Files
Nonemm/tests/Nonemm.Session.Tests/RadioPositionTests.cs
ericek111 1fda1696f2 Read call history files, and fill the exchange from them
N1MM's format, so a file published for a contest is read as it is: # comments,
!!Order!! naming the columns of the lines after it, semicolons in preference to
commas so user text can hold a comma, and a default column order for a file
that names none. The directives that change what is stored are acted on;
!!Order!! can appear again part way through, because these files are usually
several files stuck together.

Leaving the callsign box fills what the file knows into the exchange boxes that
are still empty. A box with something in it is left alone: what the station
actually sends beats what somebody published months ago. The zone comes from
the CqZone or the ITUZone column depending on which the contest asks for, and a
file with no Sect falls back to State.

The check window's two missing columns are in. History lists matching calls
from the file. Exchange is not about callsigns at all: it offers the values the
exchange box with the cursor in it can hold, which is what N1MM's exchange pane
does — it searches the contest's list of valid exchanges, not the log.
CheckCandidate.Call is now Text, because an exchange value is not a call.

The section-validating directives are read and passed over rather than acted
on. Acting on them means holding N1MM's section lists and its rules about
retired sections, and getting that wrong throws away good data.

Running it caught what the unit tests could not: the exchange was filled and
scored but the boxes on screen still looked empty, because nothing resynced
them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:15:41 +00:00

386 lines
12 KiB
C#

using Nonemm.Contests;
using Nonemm.Contests.Rules;
using Nonemm.Core;
using Nonemm.Core.Calls;
using Nonemm.Core.Country;
using Nonemm.Storage;
namespace Nonemm.Session.Tests;
public class RadioPositionTests
{
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 RadioPosition Session(Contest? contest = null) =>
new(Shared(new FakeLogStore(), contest));
private static ContestSession Shared(FakeLogStore store, Contest? contest = null)
{
ContestInstance instance = store.AddContest(new ContestInstance
{
ContestNumber = 0,
ContestName = "CQWW",
});
return new ContestSession(
store,
contest ?? new CqWorldWide(ModeCategory.Cw),
instance,
Me,
CountryFile.Parse(Countries));
}
private static void Type(RadioPosition session, string call, string zone)
{
session.Entry.Call = call;
session.Entry.Set(ExchangeSlot.ReceivedReport, "599");
session.Entry.Set(ExchangeSlot.Zone, zone);
}
[Fact]
public void LoggingAContactPutsItInTheLogAndClearsTheEntry()
{
RadioPosition session = Session();
Type(session, "JA1XYZ", "25");
Qso logged = session.LogContact();
Assert.Equal("JA1XYZ", logged.Call.Text);
Assert.Equal(25, logged.Zone);
Assert.Equal(3, logged.Points);
Assert.Equal("", session.Entry.Call);
Assert.Single(session.Log.Qsos);
}
[Fact]
public void CountryAndPrefixAreFilledInFromTheCountryFile()
{
RadioPosition session = Session();
Type(session, "JA1XYZ", "25");
Qso logged = session.LogContact();
Assert.Equal("JA", logged.CountryPrefix);
Assert.Equal("AS", logged.Continent);
Assert.Equal("JA1", logged.WpxPrefix);
}
[Fact]
public void AnIncompleteExchangeIsNotLogged()
{
RadioPosition session = Session();
session.Entry.Call = "JA1XYZ";
Assert.Throws<InvalidOperationException>(() => session.LogContact());
}
[Fact]
public void TheSameStationOnTheSameBandReadsAsADupe()
{
RadioPosition session = Session();
Type(session, "JA1XYZ", "25");
session.LogContact();
Type(session, "JA1XYZ", "25");
Assert.True(session.Verdict()?.IsDupe);
}
[Fact]
public void MovingBandClearsTheDupe()
{
RadioPosition session = Session();
Type(session, "JA1XYZ", "25");
session.LogContact();
session.Tune(Frequency.FromKilohertz(7_025));
Type(session, "JA1XYZ", "25");
Assert.False(session.Verdict()?.IsDupe);
}
[Fact]
public void SerialNumbersCountUp()
{
RadioPosition session = Session(new CqWpx(ModeCategory.Cw));
Assert.Equal(1, session.SentNumber);
session.Entry.Call = "JA1XYZ";
session.Entry.Set(ExchangeSlot.ReceivedReport, "599");
session.Entry.Set(ExchangeSlot.SerialNumber, "12");
session.LogContact();
Assert.Equal(2, session.SentNumber);
}
[Fact]
public void SpaceMovesThroughTheBoxesAndBackToTheCall()
{
RadioPosition session = Session();
Assert.Equal(0, session.Entry.Focus);
session.Entry.Advance();
Assert.Equal(1, session.Entry.Focus);
session.Entry.Advance();
session.Entry.Advance();
Assert.Equal(0, session.Entry.Focus);
}
[Theory]
[InlineData("14025", 14_025_000)]
[InlineData("14.025", 14_025_000)]
[InlineData("7025.5", 7_025_500)]
public void AFrequencyTypedIntoTheCallBoxIsRecognised(string typed, long hertz)
{
RadioPosition session = Session();
session.Entry.Call = typed;
Assert.Equal(hertz, session.PendingQsy()?.Hertz);
}
[Theory]
[InlineData("JA1XYZ")]
[InlineData("12000")]
public void ACallOrAnOutOfBandNumberIsNotAQsy(string typed)
{
RadioPosition session = Session();
session.Entry.Call = typed;
Assert.Null(session.PendingQsy());
}
[Fact]
public void AContactDeletedFromTheLogGivesItsMultiplierBack()
{
RadioPosition session = Session();
Type(session, "JA1XYZ", "25");
Qso first = session.LogContact();
Type(session, "JA2XYZ", "25");
session.LogContact();
Assert.Equal(2, session.Log.Tally.TotalMultipliers);
session.Session.Delete(first.Id);
Assert.Single(session.Log.Qsos);
Assert.Equal(2, session.Log.Tally.TotalMultipliers);
Assert.True(session.Log.Qsos.Single().IsMultiplier1);
}
[Fact]
public void AContactWorkedSplitRecordsWhereWeTransmitted()
{
RadioPosition session = Session();
session.Tune(
Frequency.FromKilohertz(14_025),
Modes.Cw,
Frequency.FromKilohertz(14_200));
Type(session, "JA1XYZ", "25");
Qso logged = session.LogContact();
Assert.Equal(14_025_000, logged.Frequency.Hertz);
Assert.Equal(14_200_000, logged.QsxFrequency.Hertz);
}
[Fact]
public void AContactRecordsWhichRadioMadeIt()
{
RadioPosition session = new(Shared(new FakeLogStore()), 2);
Type(session, "JA1XYZ", "25");
Assert.Equal(2, session.LogContact().RadioNumber);
}
/// Tuning without saying anything about split leaves it where it was, so a
/// frequency typed by hand does not clear what the radio reported.
[Fact]
public void TuningWithoutASplitLeavesTheOneAlreadySet()
{
RadioPosition session = Session();
session.Tune(Frequency.FromKilohertz(14_025), null, Frequency.FromKilohertz(14_200));
session.Tune(Frequency.FromKilohertz(14_030));
Assert.Equal(14_200_000, session.TransmitFrequency.Hertz);
}
[Fact]
public void EditingAContactRescoresTheLog()
{
RadioPosition session = Session();
Type(session, "JA1XYZ", "25");
Qso logged = session.LogContact();
Assert.Equal(3, session.Log.Tally.Points);
QsoEdit edit = session.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()
{
RadioPosition session = Session();
Type(session, "JA1XYZ", "25");
Qso logged = session.LogContact();
QsoEdit edit = session.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()
{
RadioPosition session = Session();
Type(session, "JA1XYZ", "25");
Qso logged = session.LogContact();
QsoChange? change = null;
session.Session.Edited += (_, c) => change = c;
session.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);
}
/// The country file gets a call wrong now and then. Correcting the country
/// by hand has to change the score, or the correction is decoration.
[Fact]
public void CorrectingTheCountryChangesTheScore()
{
RadioPosition session = Session();
Type(session, "JA1XYZ", "25");
Qso logged = session.LogContact();
Assert.Equal(3, session.Log.Tally.Points);
session.Session.Edit(logged.Id, QsoField.CountryPrefix, "DL");
Assert.Equal(0, session.Log.Tally.Points);
}
[Fact]
public void ADeleteReportsTheContactThatWent()
{
RadioPosition session = Session();
Type(session, "JA1XYZ", "25");
Qso logged = session.LogContact();
Qso? gone = null;
session.Session.Deleted += (_, q) => gone = q;
session.Session.Delete(logged.Id);
Assert.Equal("JA1XYZ", gone?.Call.Text);
}
[Fact]
public void StoredContactsAreReadBackWhenTheSessionStarts()
{
FakeLogStore store = new();
RadioPosition first = new(Shared(store));
Type(first, "JA1XYZ", "25");
first.LogContact();
RadioPosition second = new(new ContestSession(
store,
new CqWorldWide(ModeCategory.Cw),
first.Instance,
Me,
CountryFile.Parse(Countries)));
Assert.Single(second.Log.Qsos);
Assert.Equal(3, second.Log.Tally.Points);
}
private static RadioPosition WithHistory(Contest contest, string file)
{
FakeLogStore store = new();
ContestInstance instance = store.AddContest(new ContestInstance
{
ContestNumber = 0,
ContestName = contest.Name,
});
return new RadioPosition(new ContestSession(
store,
contest,
instance,
Me,
CountryFile.Parse(Countries),
CallHistory.Parse(file)));
}
[Fact]
public void TheCallHistoryFillsTheExchangeIn()
{
RadioPosition session = WithHistory(
new Sweepstakes(ModeCategory.Cw),
"!!Order!!,Call,Sect,CK\nW3LPL,MDC,56");
session.Entry.Call = "W3LPL";
Assert.True(session.FillFromHistory());
Assert.Equal("MDC", session.Entry.ValueOf(ExchangeSlot.Section));
Assert.Equal("56", session.Entry.ValueOf(ExchangeSlot.Check));
}
/// What the other station actually sends beats what was published, so a box
/// with something in it is left alone.
[Fact]
public void WhatIsAlreadyTypedIsNotOverwritten()
{
RadioPosition session = WithHistory(
new Sweepstakes(ModeCategory.Cw),
"!!Order!!,Call,Sect\nW3LPL,MDC");
session.Entry.Call = "W3LPL";
session.Entry.Set(ExchangeSlot.Section, "EPA");
session.FillFromHistory();
Assert.Equal("EPA", session.Entry.ValueOf(ExchangeSlot.Section));
}
[Fact]
public void ACallTheHistoryDoesNotHoldFillsNothing()
{
RadioPosition session = WithHistory(
new Sweepstakes(ModeCategory.Cw),
"!!Order!!,Call,Sect\nW3LPL,MDC");
session.Entry.Call = "JA1XYZ";
Assert.False(session.FillFromHistory());
Assert.Equal("", session.Entry.ValueOf(ExchangeSlot.Section));
}
/// CQ WW exchanges the CQ zone, so the CQ zone column is the one to use.
[Fact]
public void TheZoneComesFromTheColumnTheContestAsksFor()
{
RadioPosition session = WithHistory(
new CqWorldWide(ModeCategory.Cw),
"!!Order!!,Call,CqZone,ITUZone\nJA1XYZ,25,45");
session.Entry.Call = "JA1XYZ";
session.FillFromHistory();
Assert.Equal("25", session.Entry.ValueOf(ExchangeSlot.Zone));
}
/// A file for a section contest often carries the state instead.
[Fact]
public void TheStateFillsASectionWhenTheFileHasNoSection()
{
RadioPosition session = WithHistory(
new Sweepstakes(ModeCategory.Cw),
"!!Order!!,Call,Sect,State\nK1TTT,,CT");
session.Entry.Call = "K1TTT";
session.FillFromHistory();
Assert.Equal("CT", session.Entry.ValueOf(ExchangeSlot.Section));
}
}