Files
Nonemm/tests/Nonemm.Session.Tests/QsoEditorTests.cs
T
erikbrockoandClaude Opus 5 e0f307968e Mark a dupe in the contact type column, as N1MM does
N1MM writes a contact type on every contact it logs: `D` for a station
already worked, `N` for one the entry may not work at all, and empty for an
ordinary contact. Nothing here wrote the column, so a dupe counted as a
contact worth no points instead of dropping out of the score, and the two
places that read the column did nothing: the QTC pile offered dupes that
N1MM leaves out, and the statistics window could not leave dupes out either.

The marking is in one place, `ContestLog.ApplyVerdict`, and every contest is
marked the same way. What changes between contests is what counts as a dupe
and which stations are workable, and both come from the contest, as they do
in N1MM's `Contact.IsDupe`. No contest module in N1MM marks differently. Our
own callsign is not workable either, which is what N1MM adds on top of the
contest rule.

Two details follow N1MM as well: QTC traffic keeps an empty column, because
its `IsDupe` only marks a row while the program is logging contacts; and a
mark that came with the contact stands, because N1MM marks a contact when it
logs or edits one and never clears marks in a pass over the log. Editing a
contact clears the column so it is worked out again, which is what N1MM does
at the same point: an edit can move a contact to a band where it is no
longer a dupe.

A dupe is now out of the score altogether — contacts, points and multipliers
— which is N1MM's `ScoreClass.AddContact`. The QSO count still counts WAE
QTC rows as contacts; that is the other half of the scoring audit's item 14.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157i7PiZyqBkquxS9A6FTdQ
2026-09-10 19:38:57 +00:00

277 lines
9.6 KiB
C#

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));
/// Sweepstakes types the whole exchange in one box and logs it in four
/// columns, as N1MM does.
[Fact]
public void TheSweepstakesExchangeGetsAColumnPerPart()
{
IReadOnlyList<QsoField> fields = [.. Sweeps().Columns.Select(c => c.Field)];
Assert.Contains(QsoField.ReceivedNumber, fields);
Assert.Contains(QsoField.Precedence, fields);
Assert.Contains(QsoField.Check, fields);
Assert.Contains(QsoField.Section, fields);
Assert.DoesNotContain(QsoField.Exchange1, fields);
}
/// A WAE row says whether it is a contact or QTC traffic in the exchange
/// column — `QSO`, `SQTC` or `RQTC` — and N1MM's log has that column.
[Fact]
public void TheWaeLogHasAColumnForTheQtcDesignator()
{
QsoEditor editor = new(new Wae(ModeCategory.Cw), Me, CountryFile.Parse(Countries));
Assert.Equal(
["Series", "QTC?"],
editor.Columns
.Where(c => c.Field is QsoField.Exchange1 or QsoField.MiscText)
.Select(c => c.Label)
.ToList());
}
[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);
}
/// An edit can move a contact to a band where it is no longer a dupe, so
/// the mark comes off and the log works it out again. N1MM works it out
/// again at the same point.
[Fact]
public void EditingAContactClearsTheContactType()
{
QsoEdit edit = CqWw().Apply(Contact() with { ContactType = "D" }, QsoField.Frequency, "21005");
Assert.Equal("", edit.Result!.ContactType);
}
[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));
[Fact]
public void SeveralFieldsCanBeChangedAtOnce()
{
QsoEdit edit = CqWw().ApplyAll(Contact(),
[
new(QsoField.Call, "DL2XYZ"),
new(QsoField.Zone, "14"),
new(QsoField.Comment, "first QSO"),
]);
Assert.Equal("DL2XYZ", edit.Result!.Call.Text);
Assert.Equal(14, edit.Result.Zone);
Assert.Equal("first QSO", edit.Result.Comment);
Assert.Equal("DL", edit.Result.CountryPrefix);
}
[Fact]
public void TheFirstValueTheContestWillNotTakeStopsTheWholeChange()
{
QsoEdit edit = CqWw().ApplyAll(Contact(),
[
new(QsoField.Call, "DL2XYZ"),
new(QsoField.Zone, "41"),
]);
Assert.Null(edit.Result);
Assert.Equal(QsoField.Zone, edit.Field);
}
[Fact]
public void ARefusalNamesTheFieldItCameFrom() =>
Assert.Equal(QsoField.Call, CqWw().Apply(Contact(), QsoField.Call, "12345").Field);
[Fact]
public void AQsxFrequencyCanBeCleared() =>
Assert.Equal(
0,
CqWw().Apply(Contact() with { QsxFrequency = Frequency.FromKilohertz(14_200) },
QsoField.QsxFrequency, "").Result!.QsxFrequency.Hertz);
/// The working frequency is what the contact was made on, so it cannot be
/// emptied the way the QSX frequency can.
[Fact]
public void TheWorkingFrequencyCannotBeCleared() =>
Assert.False(CqWw().Apply(Contact(), QsoField.Frequency, "").IsAccepted);
[Fact]
public void ArbitraryTextInTheReportColumnIsRefused()
{
QsoEdit edit = CqWw().Apply(Contact(), QsoField.ReceivedReport, "good copy");
Assert.False(edit.IsAccepted);
Assert.Contains("signal report", edit.Error);
Assert.Equal(QsoField.ReceivedReport, edit.Field);
}
[Fact]
public void TheSentReportIsCheckedTheSameWay() =>
Assert.False(CqWw().Apply(Contact(), QsoField.SentReport, "699").IsAccepted);
[Fact]
public void AReportCanStillBeCorrected() =>
Assert.Equal("339", CqWw().Apply(Contact(), QsoField.ReceivedReport, "339").Result!.ReceivedReport);
/// FT8 exchanges a signal-to-noise ratio, so RST is the wrong thing there
/// and decibels are the wrong thing on CW.
[Fact]
public void AWeakSignalContactReportsDecibels()
{
Qso ft8 = Contact() with { Mode = Modes.Ft8, ReceivedReport = "-12" };
Assert.True(CqWw().Apply(ft8, QsoField.ReceivedReport, "-07").IsAccepted);
Assert.False(CqWw().Apply(ft8, QsoField.ReceivedReport, "599").IsAccepted);
Assert.False(CqWw().Apply(Contact(), QsoField.ReceivedReport, "-07").IsAccepted);
}
/// A WAE QTC row keeps the time sent and the other station's call in the
/// two report columns, so those are not reports to check.
[Fact]
public void TheReportColumnsOfAQtcRowAreLeftAlone()
{
QsoEditor editor = new(new Wae(ModeCategory.Cw), Me, CountryFile.Parse(Countries));
Qso traffic = Contact() with { ContestName = "WAEDC", Exchange1 = "SQTC", MiscText = "3/10" };
Assert.True(editor.Apply(traffic, QsoField.SentReport, "1234").IsAccepted);
Assert.True(editor.Apply(traffic, QsoField.ReceivedReport, "DL1ABC").IsAccepted);
}
[Fact]
public void TextThatIsNotALocatorIsRefused() =>
Assert.False(CqWw().Apply(Contact(), QsoField.GridSquare, "nowhere").IsAccepted);
/// A locator is stored the way it is written, whatever case it was typed in.
[Fact]
public void ALocatorIsStoredWithALowerCaseSubsquare() =>
Assert.Equal("JN88dd", CqWw().Apply(Contact(), QsoField.GridSquare, "jn88dd").Result!.GridSquare);
[Fact]
public void ACorrectedCountryPrefixIsKept() =>
Assert.Equal("DL", CqWw().Apply(Contact(), QsoField.CountryPrefix, "dl").Result!.CountryPrefix);
}