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
277 lines
9.6 KiB
C#
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);
|
|
}
|