Two entry windows and an SO2R box

LoggingSession mixed two things: the contest, which one station has one of,
and what the operator is typing, which belongs to a radio. It is now
ContestSession and RadioPosition. Two positions share one log, one score and
one run of serial numbers, so a station worked on radio 1 is a dupe on radio 2.
Frequency, mode, split, what is typed and whether you are running belong to
each radio on its own.

A second radio opens a second entry window. It has no menu of its own: it is
another view of the same contest, not another program. Only the window for
radio 1 opens the database and the connections.

Ctrl+Tab moves the operator to the other radio and the keyboard follows.
Ctrl+Shift+Tab puts both radios in the headphones.

OtrspBox speaks OTRSP to an SO2R box over a serial port: TX1/TX2 for the key,
RX1/RX2 and RX1S for the headphones, AUXnnn for an output line. It follows the
active radio, and every message points the box at this radio before keying, so
a message cannot go out of the radio the operator has just left. A command that
would change nothing is not sent, because the box works relays; N1MM does the
same. The box takes a Stream, so what it sends is tested without a serial port.

Still missing: alternating CQ, and voice keying on the second radio.

Looked at under Xvfb with two fake radios, one of them split: both windows up,
radio 1 showing 14008.00 into 14020.0, radio 2 on 14030.00, Ctrl+Tab moving the
keyboard between them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 16:49:24 +00:00
parent 43be3816c3
commit 1185faed78
22 changed files with 714 additions and 208 deletions

View File

@@ -0,0 +1,116 @@
using Nonemm.Contests;
using Nonemm.Contests.Rules;
using Nonemm.Core;
using Nonemm.Core.Country;
using Nonemm.Storage;
namespace Nonemm.Session.Tests;
/// Two radios sharing one contest.
public class ContestSessionTests
{
private static readonly StationInfo Me = new()
{
Callsign = "DL1ABC",
CqZone = 14,
Continent = "EU",
CountryPrefix = "DL",
};
private static ContestSession Session()
{
FakeLogStore store = new();
ContestInstance instance = store.AddContest(new ContestInstance
{
ContestNumber = 0,
ContestName = "CQWPXCW",
});
return new ContestSession(store, new CqWpx(ModeCategory.Cw), instance, Me, null);
}
private static void Type(RadioPosition radio, string call, string number)
{
radio.Entry.Call = call;
radio.Entry.Set(ExchangeSlot.ReceivedReport, "599");
radio.Entry.Set(ExchangeSlot.SerialNumber, number);
}
[Fact]
public void BothRadiosLogIntoTheSameLog()
{
ContestSession session = Session();
RadioPosition first = new(session, 1);
RadioPosition second = new(session, 2);
Type(first, "JA1XYZ", "1");
first.LogContact();
Type(second, "W3LPL", "2");
second.LogContact();
Assert.Equal(2, session.Log.Qsos.Count);
Assert.Equal([1, 2], session.Log.Qsos.Select(q => q.RadioNumber));
}
/// Serial numbers belong to the station, not to a radio, so the second
/// radio carries on where the first left off.
[Fact]
public void SerialNumbersCountUpAcrossBothRadios()
{
ContestSession session = Session();
RadioPosition first = new(session, 1);
RadioPosition second = new(session, 2);
Type(first, "JA1XYZ", "1");
Assert.Equal(1, first.LogContact().SentNumber);
Type(second, "W3LPL", "2");
Assert.Equal(2, second.LogContact().SentNumber);
}
/// A station worked on radio 1 is a dupe on radio 2, or the second radio
/// spends the contest working stations again.
[Fact]
public void AStationWorkedOnOneRadioIsADupeOnTheOther()
{
ContestSession session = Session();
RadioPosition first = new(session, 1);
RadioPosition second = new(session, 2);
Type(first, "JA1XYZ", "1");
first.LogContact();
Type(second, "JA1XYZ", "2");
Assert.True(second.Verdict()?.IsDupe);
}
/// The two radios are on different bands and are running or searching
/// independently, so neither of those can be shared.
[Fact]
public void EachRadioKeepsItsOwnFrequencyAndRunState()
{
ContestSession session = Session();
RadioPosition first = new(session, 1);
RadioPosition second = new(session, 2);
first.Tune(Frequency.FromKilohertz(14_025));
first.IsRunning = true;
second.Tune(Frequency.FromKilohertz(7_025));
Assert.Equal(14_025_000, first.Frequency.Hertz);
Assert.Equal(7_025_000, second.Frequency.Hertz);
Assert.True(first.IsRunning);
Assert.False(second.IsRunning);
}
[Fact]
public void WhatIsTypedOnOneRadioStaysThere()
{
ContestSession session = Session();
RadioPosition first = new(session, 1);
RadioPosition second = new(session, 2);
Type(first, "JA1XYZ", "1");
Assert.Equal("", second.Entry.Call);
}
}

View File

@@ -7,7 +7,7 @@ namespace Nonemm.Session.Tests;
public class MessageExpanderTests
{
private static LoggingSession Session()
private static RadioPosition Session()
{
FakeLogStore store = new();
ContestInstance instance = store.AddContest(new ContestInstance
@@ -16,18 +16,18 @@ public class MessageExpanderTests
ContestName = "CQWW",
SentExchange = "14",
});
return new LoggingSession(
return new RadioPosition(new ContestSession(
store,
new CqWorldWide(ModeCategory.Cw),
instance,
new StationInfo { Callsign = "DL1ABC", CqZone = 14, Name = "Erik" },
null);
null));
}
[Fact]
public void MyCallAndTheirCallAreFilledIn()
{
LoggingSession session = Session();
RadioPosition session = Session();
session.Entry.Call = "JA1XYZ";
Assert.Equal("JA1XYZ DE DL1ABC", MessageExpander.Expand("{CALL} DE {MYCALL}", session));
}

View File

@@ -6,7 +6,7 @@ using Nonemm.Storage;
namespace Nonemm.Session.Tests;
public class LoggingSessionTests
public class RadioPositionTests
{
private const string Countries = """
Germany: 14: 28: EU: 51.00: -10.00: -1.0: DL:
@@ -24,15 +24,17 @@ public class LoggingSessionTests
CountryPrefix = "DL",
};
private static LoggingSession Session(Contest? contest = null)
private static RadioPosition Session(Contest? contest = null) =>
new(Shared(new FakeLogStore(), contest));
private static ContestSession Shared(FakeLogStore store, Contest? contest = null)
{
FakeLogStore store = new();
ContestInstance instance = store.AddContest(new ContestInstance
{
ContestNumber = 0,
ContestName = "CQWW",
});
return new LoggingSession(
return new ContestSession(
store,
contest ?? new CqWorldWide(ModeCategory.Cw),
instance,
@@ -40,7 +42,7 @@ public class LoggingSessionTests
CountryFile.Parse(Countries));
}
private static void Type(LoggingSession session, string call, string zone)
private static void Type(RadioPosition session, string call, string zone)
{
session.Entry.Call = call;
session.Entry.Set(ExchangeSlot.ReceivedReport, "599");
@@ -50,7 +52,7 @@ public class LoggingSessionTests
[Fact]
public void LoggingAContactPutsItInTheLogAndClearsTheEntry()
{
LoggingSession session = Session();
RadioPosition session = Session();
Type(session, "JA1XYZ", "25");
Qso logged = session.LogContact();
@@ -64,7 +66,7 @@ public class LoggingSessionTests
[Fact]
public void CountryAndPrefixAreFilledInFromTheCountryFile()
{
LoggingSession session = Session();
RadioPosition session = Session();
Type(session, "JA1XYZ", "25");
Qso logged = session.LogContact();
@@ -76,7 +78,7 @@ public class LoggingSessionTests
[Fact]
public void AnIncompleteExchangeIsNotLogged()
{
LoggingSession session = Session();
RadioPosition session = Session();
session.Entry.Call = "JA1XYZ";
Assert.Throws<InvalidOperationException>(() => session.LogContact());
}
@@ -84,7 +86,7 @@ public class LoggingSessionTests
[Fact]
public void TheSameStationOnTheSameBandReadsAsADupe()
{
LoggingSession session = Session();
RadioPosition session = Session();
Type(session, "JA1XYZ", "25");
session.LogContact();
Type(session, "JA1XYZ", "25");
@@ -94,7 +96,7 @@ public class LoggingSessionTests
[Fact]
public void MovingBandClearsTheDupe()
{
LoggingSession session = Session();
RadioPosition session = Session();
Type(session, "JA1XYZ", "25");
session.LogContact();
session.Tune(Frequency.FromKilohertz(7_025));
@@ -105,7 +107,7 @@ public class LoggingSessionTests
[Fact]
public void SerialNumbersCountUp()
{
LoggingSession session = Session(new CqWpx(ModeCategory.Cw));
RadioPosition session = Session(new CqWpx(ModeCategory.Cw));
Assert.Equal(1, session.SentNumber);
session.Entry.Call = "JA1XYZ";
session.Entry.Set(ExchangeSlot.ReceivedReport, "599");
@@ -117,7 +119,7 @@ public class LoggingSessionTests
[Fact]
public void SpaceMovesThroughTheBoxesAndBackToTheCall()
{
LoggingSession session = Session();
RadioPosition session = Session();
Assert.Equal(0, session.Entry.Focus);
session.Entry.Advance();
Assert.Equal(1, session.Entry.Focus);
@@ -132,7 +134,7 @@ public class LoggingSessionTests
[InlineData("7025.5", 7_025_500)]
public void AFrequencyTypedIntoTheCallBoxIsRecognised(string typed, long hertz)
{
LoggingSession session = Session();
RadioPosition session = Session();
session.Entry.Call = typed;
Assert.Equal(hertz, session.PendingQsy()?.Hertz);
}
@@ -142,7 +144,7 @@ public class LoggingSessionTests
[InlineData("12000")]
public void ACallOrAnOutOfBandNumberIsNotAQsy(string typed)
{
LoggingSession session = Session();
RadioPosition session = Session();
session.Entry.Call = typed;
Assert.Null(session.PendingQsy());
}
@@ -150,14 +152,14 @@ public class LoggingSessionTests
[Fact]
public void AContactDeletedFromTheLogGivesItsMultiplierBack()
{
LoggingSession session = Session();
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.Delete(first.Id);
session.Session.Delete(first.Id);
Assert.Single(session.Log.Qsos);
Assert.Equal(2, session.Log.Tally.TotalMultipliers);
Assert.True(session.Log.Qsos.Single().IsMultiplier1);
@@ -166,7 +168,7 @@ public class LoggingSessionTests
[Fact]
public void AContactWorkedSplitRecordsWhereWeTransmitted()
{
LoggingSession session = Session();
RadioPosition session = Session();
session.Tune(
Frequency.FromKilohertz(14_025),
Modes.Cw,
@@ -182,8 +184,7 @@ public class LoggingSessionTests
[Fact]
public void AContactRecordsWhichRadioMadeIt()
{
LoggingSession session = Session();
session.RadioNumber = 2;
RadioPosition session = new(Shared(new FakeLogStore()), 2);
Type(session, "JA1XYZ", "25");
Assert.Equal(2, session.LogContact().RadioNumber);
@@ -194,7 +195,7 @@ public class LoggingSessionTests
[Fact]
public void TuningWithoutASplitLeavesTheOneAlreadySet()
{
LoggingSession session = Session();
RadioPosition session = Session();
session.Tune(Frequency.FromKilohertz(14_025), null, Frequency.FromKilohertz(14_200));
session.Tune(Frequency.FromKilohertz(14_030));
@@ -205,12 +206,12 @@ public class LoggingSessionTests
[Fact]
public void EditingAContactRescoresTheLog()
{
LoggingSession session = Session();
RadioPosition 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");
QsoEdit edit = session.Session.Edit(logged.Id, QsoField.Call, "DL2XYZ");
Assert.True(edit.IsAccepted);
Assert.Equal("DL2XYZ", session.Log.Qsos.Single().Call.Text);
@@ -220,11 +221,11 @@ public class LoggingSessionTests
[Fact]
public void ARefusedEditLeavesTheLogAlone()
{
LoggingSession session = Session();
RadioPosition session = Session();
Type(session, "JA1XYZ", "25");
Qso logged = session.LogContact();
QsoEdit edit = session.Edit(logged.Id, QsoField.Zone, "41");
QsoEdit edit = session.Session.Edit(logged.Id, QsoField.Zone, "41");
Assert.False(edit.IsAccepted);
Assert.Equal(25, session.Log.Qsos.Single().Zone);
@@ -235,13 +236,13 @@ public class LoggingSessionTests
[Fact]
public void AnEditReportsWhatTheContactUsedToBe()
{
LoggingSession session = Session();
RadioPosition session = Session();
Type(session, "JA1XYZ", "25");
Qso logged = session.LogContact();
QsoChange? change = null;
session.Edited += (_, c) => change = c;
session.Session.Edited += (_, c) => change = c;
session.Edit(logged.Id, QsoField.Call, "JA2XYZ");
session.Session.Edit(logged.Id, QsoField.Call, "JA2XYZ");
Assert.Equal("JA1XYZ", change?.OldCall);
Assert.Equal(logged.TimestampUtc, change?.OldTimestampUtc);
@@ -253,12 +254,12 @@ public class LoggingSessionTests
[Fact]
public void CorrectingTheCountryChangesTheScore()
{
LoggingSession session = Session();
RadioPosition session = Session();
Type(session, "JA1XYZ", "25");
Qso logged = session.LogContact();
Assert.Equal(3, session.Log.Tally.Points);
session.Edit(logged.Id, QsoField.CountryPrefix, "DL");
session.Session.Edit(logged.Id, QsoField.CountryPrefix, "DL");
Assert.Equal(0, session.Log.Tally.Points);
}
@@ -266,13 +267,13 @@ public class LoggingSessionTests
[Fact]
public void ADeleteReportsTheContactThatWent()
{
LoggingSession session = Session();
RadioPosition session = Session();
Type(session, "JA1XYZ", "25");
Qso logged = session.LogContact();
Qso? gone = null;
session.Deleted += (_, q) => gone = q;
session.Session.Deleted += (_, q) => gone = q;
session.Delete(logged.Id);
session.Session.Delete(logged.Id);
Assert.Equal("JA1XYZ", gone?.Call.Text);
}
@@ -281,12 +282,16 @@ public class LoggingSessionTests
public void StoredContactsAreReadBackWhenTheSessionStarts()
{
FakeLogStore store = new();
ContestInstance instance = store.AddContest(new ContestInstance { ContestNumber = 0, ContestName = "CQWW" });
LoggingSession first = new(store, new CqWorldWide(ModeCategory.Cw), instance, Me, CountryFile.Parse(Countries));
RadioPosition first = new(Shared(store));
Type(first, "JA1XYZ", "25");
first.LogContact();
LoggingSession second = new(store, new CqWorldWide(ModeCategory.Cw), instance, Me, CountryFile.Parse(Countries));
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);
}