Edit and delete contacts in the log

Double-click a cell in the log window to change it. The columns follow the
contest exchange instead of being fixed in the XAML, so CQ WW gets a Zone
column and Sweepstakes gets Nr, Prec, Ck and Sec.

QsoEditor holds the column list and applies one field edit. Validation comes
from the ExchangeFieldKind the contest declared: a CQ zone is 1 to 40, an ITU
zone 1 to 90, a section is looked up in the ARRL list, a grid must parse.
A refused edit returns the reason and leaves the log alone, so the cell
reverts. Changing the callsign runs the country lookup again.

Delete, or the right-click menu, removes the selected contact after a
confirmation. Either way the log is rescored, so a multiplier the removed
contact held passes to the next contact that claims it.

Both go out to the other stations in N1MM's own messages: contactreplace
carries oldcall and oldtimestamp, contactdelete names the contact. An incoming
edit or delete is matched by contact id first, falling back to call plus
timestamp because N1MM does not know our ids.

Country, continent and the two prefixes were filled in twice, once when
logging and once when editing. They now come from CountryFields.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 15:32:32 +00:00
parent 328a40b27c
commit eb31fa5ae1
23 changed files with 953 additions and 112 deletions

View File

@@ -163,6 +163,66 @@ public class LoggingSessionTests
Assert.True(session.Log.Qsos.Single().IsMultiplier1);
}
[Fact]
public void EditingAContactRescoresTheLog()
{
LoggingSession session = Session();
Type(session, "JA1XYZ", "25");
Qso logged = session.LogContact();
Assert.Equal(3, session.Log.Tally.Points);
QsoEdit edit = session.Edit(logged.Id, QsoField.Call, "DL2XYZ");
Assert.True(edit.IsAccepted);
Assert.Equal("DL2XYZ", session.Log.Qsos.Single().Call.Text);
Assert.Equal(0, session.Log.Tally.Points);
}
[Fact]
public void ARefusedEditLeavesTheLogAlone()
{
LoggingSession session = Session();
Type(session, "JA1XYZ", "25");
Qso logged = session.LogContact();
QsoEdit edit = session.Edit(logged.Id, QsoField.Zone, "41");
Assert.False(edit.IsAccepted);
Assert.Equal(25, session.Log.Qsos.Single().Zone);
}
/// The other stations need the call and time the contact had before, because
/// that is how they find their own copy of it.
[Fact]
public void AnEditReportsWhatTheContactUsedToBe()
{
LoggingSession session = Session();
Type(session, "JA1XYZ", "25");
Qso logged = session.LogContact();
QsoChange? change = null;
session.Edited += (_, c) => change = c;
session.Edit(logged.Id, QsoField.Call, "JA2XYZ");
Assert.Equal("JA1XYZ", change?.OldCall);
Assert.Equal(logged.TimestampUtc, change?.OldTimestampUtc);
Assert.Equal("JA2XYZ", change?.Qso.Call.Text);
}
[Fact]
public void ADeleteReportsTheContactThatWent()
{
LoggingSession session = Session();
Type(session, "JA1XYZ", "25");
Qso logged = session.LogContact();
Qso? gone = null;
session.Deleted += (_, q) => gone = q;
session.Delete(logged.Id);
Assert.Equal("JA1XYZ", gone?.Call.Text);
}
[Fact]
public void StoredContactsAreReadBackWhenTheSessionStarts()
{

View File

@@ -0,0 +1,135 @@
using Nonemm.Contests.Rules;
using Nonemm.Core;
using Nonemm.Core.Country;
namespace Nonemm.Session.Tests;
public class QsoEditorTests
{
private const string Countries = """
Germany: 14: 28: EU: 51.00: -10.00: -1.0: DL:
DL,DK,DJ;
Japan: 25: 45: AS: 36.40: -138.38: -9.0: JA:
JA,JH,JR;
""";
private static readonly StationInfo Me = new()
{
Callsign = "DL1ABC",
CqZone = 14,
ItuZone = 28,
Continent = "EU",
CountryPrefix = "DL",
};
private static QsoEditor CqWw() =>
new(new CqWorldWide(ModeCategory.Cw), Me, CountryFile.Parse(Countries));
private static QsoEditor Sweeps() =>
new(new Sweepstakes(ModeCategory.Cw), Me, CountryFile.Parse(Countries));
private static Qso Contact() => new()
{
Id = "1",
TimestampUtc = new DateTime(2026, 5, 30, 12, 34, 56, DateTimeKind.Utc),
Call = Callsign.Parse("JA1XYZ"),
Frequency = Frequency.FromKilohertz(14_025),
Mode = Modes.Cw,
ContestName = "CQWW",
SentReport = "599",
ReceivedReport = "599",
Zone = 25,
CountryPrefix = "JA",
Continent = "AS",
};
[Fact]
public void TheColumnsFollowTheContestExchange()
{
IReadOnlyList<QsoField> fields = [.. CqWw().Columns.Select(c => c.Field)];
Assert.Contains(QsoField.Zone, fields);
Assert.DoesNotContain(QsoField.Section, fields);
Assert.DoesNotContain(QsoField.SentNumber, fields);
}
/// Sweepstakes counts serial numbers, so the log shows the one we sent.
[Fact]
public void AContestWithSerialNumbersGetsASentNumberColumn() =>
Assert.Contains(QsoField.SentNumber, Sweeps().Columns.Select(c => c.Field));
[Fact]
public void ChangingTheCallLooksTheCountryUpAgain()
{
QsoEdit edit = CqWw().Apply(Contact(), QsoField.Call, "dl2xyz");
Assert.True(edit.IsAccepted);
Assert.Equal("DL2XYZ", edit.Result!.Call.Text);
Assert.Equal("DL", edit.Result.CountryPrefix);
Assert.Equal("EU", edit.Result.Continent);
Assert.Equal("DL2", edit.Result.WpxPrefix);
}
[Theory]
[InlineData("12345")]
[InlineData("AB")]
[InlineData("JA1XY Z")]
public void ATextThatIsNotShapedLikeACallIsRefused(string text)
{
QsoEdit edit = CqWw().Apply(Contact(), QsoField.Call, text);
Assert.False(edit.IsAccepted);
Assert.Contains("callsign", edit.Error);
}
[Fact]
public void ARefusedEditReturnsNoContact() =>
Assert.Null(CqWw().Apply(Contact(), QsoField.Zone, "41").Result);
[Theory]
[InlineData("40", true)]
[InlineData("1", true)]
[InlineData("41", false)]
[InlineData("0", false)]
[InlineData("two", false)]
public void CqZonesRunFromOneToForty(string text, bool accepted) =>
Assert.Equal(accepted, CqWw().Apply(Contact(), QsoField.Zone, text).IsAccepted);
[Fact]
public void AnUnknownSectionIsRefused() =>
Assert.False(Sweeps().Apply(Contact(), QsoField.Section, "ZZ").IsAccepted);
[Fact]
public void ASectionIsStoredUpperCase() =>
Assert.Equal("STX", Sweeps().Apply(Contact(), QsoField.Section, "stx").Result!.Section);
[Fact]
public void ACommentKeepsTheCaseItWasTypedIn() =>
Assert.Equal("Nice signal", CqWw().Apply(Contact(), QsoField.Comment, "Nice signal").Result!.Comment);
[Fact]
public void ClearingAFieldIsAllowed() =>
Assert.Equal(0, CqWw().Apply(Contact(), QsoField.Zone, "").Result!.Zone);
[Fact]
public void TheTimeIsReadAsUtc()
{
QsoEdit edit = CqWw().Apply(Contact(), QsoField.Time, "2026-05-30 13:00:00");
Assert.Equal(new DateTime(2026, 5, 30, 13, 0, 0, DateTimeKind.Utc), edit.Result!.TimestampUtc);
}
[Fact]
public void AFrequencyIsTypedInKilohertz() =>
Assert.Equal(
21_005_000,
CqWw().Apply(Contact(), QsoField.Frequency, "21005").Result!.Frequency.Hertz);
[Fact]
public void AModeTheLoggerDoesNotKnowIsRefused() =>
Assert.False(CqWw().Apply(Contact(), QsoField.Mode, "SSTV").IsAccepted);
[Fact]
public void AnEmptyNumberReadsBackAsAnEmptyColumn() =>
Assert.Equal("", QsoEditor.Read(Contact() with { Zone = 0 }, QsoField.Zone));
}