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

@@ -140,15 +140,28 @@ and the bandmap draws a red bar at the transmit frequency. A radio that cannot
do split answers `RPRT -11`; that is an answer, not a broken connection, and
everything else it reported still counts.
A second radio makes the station SO2R. Both are read and both show on the
bandmap — the one you are on in green, the other in orange — but only the one
you are on drives the entry window, so the second radio moving cannot drag you
off the station you are working. Ctrl+Tab moves you to the other radio; contacts
record which one made them.
A second radio makes the station SO2R and opens a second entry window. The two
windows share one log, one score and one run of serial numbers, so a station
worked on radio 1 is a dupe on radio 2. What is typed, the frequency, the mode
and whether you are running belong to each radio on its own.
Not there yet: a second entry window, alternating CQ, and audio switching. What
is here is two radios read and logged correctly, not a full two-radio operating
position.
| Key | What it does |
|---|---|
| Ctrl+Tab | moves you to the other radio: the keyboard, the entry window and the box all follow |
| Ctrl+Shift+Tab | puts both radios in the headphones, and back |
Both radios show on the bandmap — the one you are on in green, the other in
orange — but only the one you are on drives its entry window, so the second
radio moving cannot drag you off the station you are working.
An SO2R box speaking OTRSP routes the key and the headphones. Name its serial
port in **Config → Radios** and it follows: `TX2` when you move to radio 2,
`RX1S` when you ask for both radios, and `TX` again before every message goes
out, so a message cannot go out of the radio you have just left. A command that
would change nothing is not sent, because the box works relays. Without a box
the logger just keeps track of which radio it is on and you switch by hand.
Not there yet: alternating CQ, and voice keying on the second radio.
### The bandmap
@@ -211,11 +224,11 @@ itself keys a contact on.
| `Nonemm.Contests` | Contest rules, the scoring engine, `.udc` files |
| `Nonemm.Formats` | Cabrillo out, ADIF in and out |
| `Nonemm.Storage` | The N1MM-compatible `.s3db` |
| `Nonemm.Rig` | Radio control over `rigctld` |
| `Nonemm.Rig` | Radio control over `rigctld`, and the SO2R box |
| `Nonemm.Spotting` | Spots, the bandmap, the DX cluster client |
| `Nonemm.Network` | Contacts shared between the stations of a multi-operator entry |
| `Nonemm.Keying` | CW through `cwdaemon` or a WinKeyer |
| `Nonemm.Session` | What the operator is typing and what the log says about it — no UI toolkit |
| `Nonemm.Session` | The contest in progress, and one entry position per radio — no UI toolkit |
| `Nonemm.App` | The Avalonia windows |
The split at `Nonemm.Session` is the important one: it references no UI

View File

@@ -19,10 +19,12 @@ public sealed class AppSession : IDisposable
{
private LogStore? store;
private readonly List<RigctldRadio> radios = [];
private readonly List<RadioPosition> positions = [];
private int activeRadio;
private ClusterClient? cluster;
private StationNetwork? network;
private MessageSender? keyer;
private So2rBox? box;
public AppSession(UserPaths paths, Settings settings)
{
@@ -49,7 +51,16 @@ public sealed class AppSession : IDisposable
public Bandmap Bandmap { get; } = new();
public LoggingSession? Logging { get; private set; }
/// The contest in progress: one log and one score, however many radios.
public ContestSession? Logging { get; private set; }
/// One per radio, in radio-number order. There is always at least one, so
/// the program works with no radio connected.
public IReadOnlyList<RadioPosition> Positions => positions;
/// The radio the operator is on.
public RadioPosition? Position =>
activeRadio < positions.Count ? positions[activeRadio] : null;
public CheckWindowSources? Check { get; private set; }
@@ -60,7 +71,7 @@ public sealed class AppSession : IDisposable
/// frequency and mode stay wherever they were last typed.
public Radio? Radio => activeRadio < radios.Count ? radios[activeRadio] : null;
public int ActiveRadioNumber => Radio?.Number ?? 1;
public int ActiveRadioNumber => Position?.RadioNumber ?? 1;
public ClusterClient? Cluster => cluster;
@@ -68,6 +79,13 @@ public sealed class AppSession : IDisposable
public MessageSender? Keyer => keyer;
/// The SO2R box, or null when there is none and the operator switches the
/// transmitter and the headphones by hand.
public So2rBox? Box => box;
/// True while both radios are in the headphones.
public bool IsListeningToBoth { get; private set; }
public event EventHandler? Changed;
public event EventHandler? ContestChanged;
@@ -115,10 +133,13 @@ public sealed class AppSession : IDisposable
?? throw new InvalidOperationException($"no contest numbered {contestNumber} in the log");
Contest contest = Registry.Create(instance.ContestName, ModeCategoryOf(instance));
SaveDefinition(contest);
Logging = new LoggingSession(Store, contest, instance, Settings.Station.ToStationInfo(), Countries)
Logging = new ContestSession(Store, contest, instance, Settings.Station.ToStationInfo(), Countries);
positions.Clear();
for (int number = 1; number <= PositionCount; number++)
{
RadioNumber = ActiveRadioNumber,
};
positions.Add(new RadioPosition(Logging, number));
}
activeRadio = Math.Min(activeRadio, positions.Count - 1);
Logging.Changed += (_, _) => Changed?.Invoke(this, EventArgs.Empty);
Logging.Logged += (_, qso) => Bandmap.Add(new Spot(
qso.Call, qso.Frequency, qso.TimestampUtc, SpotSource.Log));
@@ -126,11 +147,14 @@ public sealed class AppSession : IDisposable
Logging.Edited += (_, change) => _ = network?.SendEditAsync(
change.Qso, Settings.Station.Callsign, change.OldCall, change.OldTimestampUtc);
Logging.Deleted += (_, qso) => _ = network?.SendDeleteAsync(qso, Settings.Station.Callsign);
Check = new CheckWindowSources(Logging, Calls, Bandmap);
Check = new CheckWindowSources(positions[0], Calls, Bandmap);
Save(Settings with { ContestNumber = contestNumber });
ContestChanged?.Invoke(this, EventArgs.Empty);
}
/// One entry position per radio, and one when there is no radio at all.
private int PositionCount => Math.Max(1, Settings.Radios.Count(r => r.IsEnabled));
/// Starts, restarts or stops the link to the other stations of a
/// multi-operator entry, following what the settings now say.
public void ApplyNetworkSettings()
@@ -240,7 +264,12 @@ public sealed class AppSession : IDisposable
radios.Add(opened);
opened.Start();
}
activeRadio = Math.Min(activeRadio, Math.Max(0, radios.Count - 1));
if (Logging is not null && positions.Count != PositionCount)
{
OpenContest(Logging.Instance.ContestNumber);
return;
}
activeRadio = Math.Min(activeRadio, Math.Max(0, positions.Count - 1));
Changed?.Invoke(this, EventArgs.Empty);
}
@@ -254,34 +283,71 @@ public sealed class AppSession : IDisposable
/// that radio is sitting, and contacts are logged against its number.
public void SwapRadio()
{
if (radios.Count < 2)
if (positions.Count < 2)
{
return;
}
activeRadio = (activeRadio + 1) % radios.Count;
if (Logging is not null)
{
Logging.RadioNumber = ActiveRadioNumber;
if (Radio?.State is { } state)
{
Logging.Tune(state.Frequency, state.Mode, state.TransmitFrequency);
}
}
activeRadio = (activeRadio + 1) % positions.Count;
_ = FollowActiveRadioAsync();
Changed?.Invoke(this, EventArgs.Empty);
ActiveRadioChanged?.Invoke(this, ActiveRadioNumber);
}
/// Puts both radios in the headphones, or goes back to one. An operator
/// listens to the second radio while the first is sending.
public void ToggleListenToBoth()
{
IsListeningToBoth = !IsListeningToBoth;
_ = FollowActiveRadioAsync();
Changed?.Invoke(this, EventArgs.Empty);
}
/// Points the box at the radio about to transmit. Called before keying, so
/// a message never goes out of the radio the operator has just left.
public Task PointTransmitAtAsync(int radioNumber) =>
box?.SetTransmitAsync(radioNumber) ?? Task.CompletedTask;
/// Starts, restarts or stops the SO2R box, following what the settings say.
public void ApplySo2rSettings()
{
box?.Dispose();
box = Settings.So2rBoxPort.Length > 0 ? OtrspBox.Open(Settings.So2rBoxPort) : null;
_ = FollowActiveRadioAsync();
Changed?.Invoke(this, EventArgs.Empty);
}
private async Task FollowActiveRadioAsync()
{
if (box is null)
{
return;
}
try
{
await box.SetTransmitAsync(ActiveRadioNumber).ConfigureAwait(false);
await box.SetReceiveAsync(ActiveRadioNumber, IsListeningToBoth).ConfigureAwait(false);
}
catch (Exception e) when (e is IOException or InvalidOperationException)
{
box.Dispose();
box = null;
}
}
/// Raised when the operator moves to the other radio, so the entry windows
/// can hand the keyboard over and the SO2R box can follow.
public event EventHandler<int>? ActiveRadioChanged;
/// The radio the operator is not on still moves, and the bandmap shows it,
/// but it must not drag the entry window off the contact being worked.
private void RadioMoved(Radio moved, RadioState state)
{
if (moved.Number == ActiveRadioNumber)
RadioPosition? position = positions.FirstOrDefault(p => p.RadioNumber == moved.Number);
if (position is null)
{
Logging?.Tune(state.Frequency, state.Mode, state.TransmitFrequency);
}
else
{
Changed?.Invoke(this, EventArgs.Empty);
return;
}
position.Tune(state.Frequency, state.Mode, state.TransmitFrequency);
}
private void DisposeRadios()
@@ -332,6 +398,7 @@ public sealed class AppSession : IDisposable
cluster?.Dispose();
network?.Dispose();
keyer?.Dispose();
box?.Dispose();
store?.Dispose();
}

View File

@@ -43,6 +43,10 @@ public sealed record Settings
public IReadOnlyList<string> NetworkPeers { get; init; } = [];
/// The serial port of an SO2R box speaking OTRSP, or empty for none. The
/// box routes the transmitter and the headphones between the two radios.
public string So2rBoxPort { get; init; } = "";
/// `none`, `cwdaemon` or `winkeyer`.
public string KeyerKind { get; init; } = "none";

View File

@@ -30,11 +30,11 @@ public sealed partial class EditContactDialog : Window
QsoField.CountryPrefix, QsoField.WpxPrefix,
];
private readonly LoggingSession logging;
private readonly ContestSession logging;
private readonly Dictionary<QsoField, TextBox> boxes = [];
private int at;
public EditContactDialog(LoggingSession logging, string contactId)
public EditContactDialog(ContestSession logging, string contactId)
{
this.logging = logging;
InitializeComponent();

View File

@@ -23,8 +23,11 @@
<CheckBox Grid.Row="4" Grid.Column="1" Name="SecondEnabledBox" Content="Follow this radio"
Margin="0,2,0,0" />
</Grid>
<TextBlock Text="SO2R box serial port, or empty for none" FontSize="11" Opacity="0.7"
Margin="0,10,0,1" />
<TextBox Name="BoxPortBox" />
<TextBlock TextWrapping="Wrap" FontSize="11" Opacity="0.75" Margin="0,8,0,0"
Text="With a second radio the station is SO2R: both are read and both show on the bandmap, but only the one you are on drives the entry window. Ctrl+Tab moves you to the other." />
Text="With a second radio the station is SO2R: both are read and both show on the bandmap, but only the one you are on drives the entry window. Ctrl+Tab moves you to the other, and Ctrl+Shift+Tab puts both in the headphones. An OTRSP box follows, routing the key and the headphones." />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Spacing="6" Margin="0,8,0,0">
<Button Content="Cancel" Click="OnCancel" />
<Button Content="Save" Click="OnSave" IsDefault="True" />

View File

@@ -17,6 +17,7 @@ public sealed partial class RadioDialog : Window
InitializeComponent();
Show(0, HostBox, PortBox, EnabledBox);
Show(1, SecondHostBox, SecondPortBox, SecondEnabledBox);
BoxPortBox.Text = settings.So2rBoxPort;
}
private void Show(int at, TextBox host, TextBox port, CheckBox enabled)
@@ -36,6 +37,7 @@ public sealed partial class RadioDialog : Window
Read(HostBox, PortBox, EnabledBox),
Read(SecondHostBox, SecondPortBox, SecondEnabledBox),
],
So2rBoxPort = (BoxPortBox.Text ?? "").Trim(),
});
private static StoredRadio Read(TextBox host, TextBox port, CheckBox enabled) => new()

View File

@@ -43,18 +43,18 @@ public sealed partial class BandmapWindow : RefreshableWindow
public override void Refresh()
{
session.Bandmap.DropOlderThan(DateTime.UtcNow);
Band? band = session.Logging is null ? null : Bands.ForFrequency(session.Logging.Frequency);
Band? band = session.Position is null ? null : Bands.ForFrequency(session.Position.Frequency);
BandText.Text = band?.Name ?? "no band";
if (session.Logging is null || band is null)
if (session.Position is null || band is null)
{
View.Spots = [];
View.Vfos = [];
StatusText.Text = session.Logging is null ? "no contest is open" : "off band";
StatusText.Text = session.Position is null ? "no contest is open" : "off band";
View.InvalidateVisual();
return;
}
(Frequency low, Frequency high) = Window(band, session.Logging.Frequency);
(Frequency low, Frequency high) = Window(band, session.Position.Frequency);
View.Low = low;
View.High = high;
View.TickStep = StepFor(high.Hertz - low.Hertz);
@@ -69,19 +69,19 @@ public sealed partial class BandmapWindow : RefreshableWindow
/// station.
private IReadOnlyList<VfoMarker> Vfos()
{
if (session.Logging is null)
if (session.Position is null)
{
return [];
}
Frequency width = Modes.Bandwidth(session.Logging.Mode);
Frequency width = Modes.Bandwidth(session.Position.Mode);
List<VfoMarker> markers =
[
VfoMarker.Centred(session.Logging.Frequency, width, VfoRole.Receive),
VfoMarker.Centred(session.Position.Frequency, width, VfoRole.Receive),
];
if (session.Logging.TransmitFrequency.Hertz > 0)
if (session.Position.TransmitFrequency.Hertz > 0)
{
markers.Add(VfoMarker.Centred(
session.Logging.TransmitFrequency, width, VfoRole.Transmit));
session.Position.TransmitFrequency, width, VfoRole.Transmit));
}
foreach (Radio other in session.Radios.Where(r => r.Number != session.ActiveRadioNumber))
{
@@ -119,18 +119,18 @@ public sealed partial class BandmapWindow : RefreshableWindow
private Verdict? VerdictFor(Spot spot)
{
if (session.Logging is null)
if (session.Position is null)
{
return null;
}
return session.Logging.Log.Judge(new Qso
return session.Position.Log.Judge(new Qso
{
Id = "",
TimestampUtc = DateTime.UtcNow,
Call = spot.Call,
Frequency = spot.Frequency,
Mode = session.Logging.Mode,
ContestName = session.Logging.Contest.Name,
Mode = session.Position.Mode,
ContestName = session.Position.Contest.Name,
});
}
}

View File

@@ -184,7 +184,7 @@ public sealed partial class EntryWindow
Status("there is nothing in the log yet");
return;
}
await new EditContactDialog(Logging, Logging.Log.Qsos[^1].Id).ShowDialog(this);
await new EditContactDialog(Logging.Session, Logging.Log.Qsos[^1].Id).ShowDialog(this);
}
/// Puts the call being typed on the cluster, or the last one logged when
@@ -258,6 +258,15 @@ public sealed partial class EntryWindow
}
session.Save(updated);
session.ConnectRadios();
try
{
session.ApplySo2rSettings();
}
catch (Exception error) when (error is IOException or UnauthorizedAccessException or ArgumentException)
{
Status($"could not open the SO2R box: {error.Message}");
return;
}
Status(session.Radios.Count switch
{
0 => "no radio",

View File

@@ -25,7 +25,7 @@
</Window.Styles>
<DockPanel>
<Menu DockPanel.Dock="Top">
<Menu Name="MainMenu" DockPanel.Dock="Top">
<MenuItem Header="_File">
<MenuItem Header="_New Database…" Click="OnNewDatabase" />
<MenuItem Header="_Open Database…" Click="OnOpenDatabase" />

View File

@@ -15,30 +15,54 @@ namespace Nonemm.App.Windows;
public sealed partial class EntryWindow : Window
{
private readonly AppSession session;
private readonly int radioNumber;
private EntryWindow? secondRadio;
private readonly List<TextBox> boxes = [];
private readonly DispatcherTimer clock = new() { Interval = TimeSpan.FromSeconds(1) };
private readonly Dictionary<Type, Window> openWindows = [];
private bool updating;
public EntryWindow(AppSession session)
public EntryWindow(AppSession session, int radioNumber = 1)
{
this.session = session;
this.radioNumber = radioNumber;
InitializeComponent();
BuildFunctionKeys();
session.Changed += (_, _) => Dispatcher.UIThread.Post(Refresh);
session.ContestChanged += (_, _) => Dispatcher.UIThread.Post(BuildEntryBoxes);
session.ContestChanged += (_, _) => Dispatcher.UIThread.Post(() =>
{
BuildEntryBoxes();
ShowSecondRadio();
});
clock.Tick += (_, _) => UpdateClock();
clock.Start();
session.ActiveRadioChanged += (_, number) =>
Dispatcher.UIThread.Post(() => FollowActiveRadio(number));
// only the window for radio 1 opens the log and the connections; the
// second window is another view of the same session
Opened += (_, _) => Reopen();
if (radioNumber > 1)
{
Title = "Nonemm — radio 2";
MainMenu.IsVisible = false;
}
AddHandler(KeyDownEvent, OnWindowKeyDown, RoutingStrategies.Tunnel);
}
private LoggingSession? Logging => session.Logging;
/// Resolved every time rather than held, because opening a contest builds
/// new positions.
private RadioPosition? Logging =>
session.Positions.FirstOrDefault(p => p.RadioNumber == radioNumber);
/// Reopens the database and contest the operator was last in.
private void Reopen()
{
if (radioNumber > 1)
{
BuildEntryBoxes();
return;
}
try
{
int contestNumber = session.Settings.ContestNumber;
@@ -57,6 +81,42 @@ public sealed partial class EntryWindow : Window
}
StartConnections();
BuildEntryBoxes();
ShowSecondRadio();
}
/// A second radio gets its own entry window: the two share one log and one
/// score, but what is typed on each belongs to that radio.
private void ShowSecondRadio()
{
if (radioNumber > 1)
{
return;
}
bool wanted = session.Positions.Count > 1;
if (wanted && secondRadio is null)
{
Title = "Nonemm — radio 1";
secondRadio = new EntryWindow(session, 2);
secondRadio.Closed += (_, _) => secondRadio = null;
secondRadio.Show(this);
}
else if (!wanted && secondRadio is not null)
{
Title = "Nonemm";
secondRadio.Close();
secondRadio = null;
}
}
/// The keyboard follows the operator to the other radio.
private void FollowActiveRadio(int number)
{
if (number != radioNumber || Logging is null)
{
return;
}
Activate();
boxes.ElementAtOrDefault(Logging.Entry.Focus)?.Focus();
}
/// Brings up whatever the operator had connected last time. Each is
@@ -94,6 +154,10 @@ public sealed partial class EntryWindow : Window
{
yield return ("keyer", session.ApplyKeyerSettings);
}
if (session.Settings.So2rBoxPort.Length > 0)
{
yield return ("SO2R box", session.ApplySo2rSettings);
}
}
private void BuildEntryBoxes()
@@ -175,10 +239,15 @@ public sealed partial class EntryWindow : Window
SyncBoxes();
boxes[0].Focus();
break;
case Key.Tab when e.KeyModifiers.HasFlag(KeyModifiers.Control)
&& e.KeyModifiers.HasFlag(KeyModifiers.Shift):
e.Handled = true;
session.ToggleListenToBoth();
Status(session.IsListeningToBoth ? "both radios" : $"radio {session.ActiveRadioNumber}");
break;
case Key.Tab when e.KeyModifiers.HasFlag(KeyModifiers.Control):
e.Handled = true;
session.SwapRadio();
Status($"radio {session.ActiveRadioNumber}");
break;
case Key.Tab when FocusedIsEntryBox():
e.Handled = true;
@@ -392,6 +461,8 @@ public sealed partial class EntryWindow : Window
return;
}
string text = MessageExpander.Expand(template, Logging);
// the box has to point at this radio before the key does
_ = session.PointTransmitAtAsync(radioNumber);
Status($"sending {text}");
_ = SendAsync(text);
}

View File

@@ -58,7 +58,7 @@ public sealed partial class LogWindow : RefreshableWindow
message = "";
}
private static string Summary(LoggingSession logging) =>
private static string Summary(ContestSession logging) =>
$"{logging.Log.Tally.Qsos} contacts · {logging.Log.Tally.Points} points · " +
$"{logging.Log.Tally.TotalMultipliers} multipliers · score {logging.Log.TotalScore:N0}";

View File

@@ -4,6 +4,10 @@
<ProjectReference Include="..\Nonemm.Core\Nonemm.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="System.IO.Ports" Version="10.0.11" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>

View File

@@ -0,0 +1,77 @@
using System.IO.Ports;
using System.Text;
namespace Nonemm.Rig;
/// An SO2R box speaking OTRSP, the protocol microHAM and others use: short
/// commands terminated by a carriage return. `TX1` sends on radio one, `RX2`
/// listens to radio two, `RX1S` puts both radios in the headphones and `AUX103`
/// sets an output line.
public sealed class OtrspBox : So2rBox
{
private readonly Stream stream;
private readonly IDisposable? port;
private readonly SemaphoreSlim writing = new(1, 1);
private string lastTransmit = "";
private string lastReceive = "";
public OtrspBox(Stream stream, IDisposable? port = null)
{
this.stream = stream;
this.port = port;
}
public static OtrspBox Open(string portName, int baudRate = 9600)
{
SerialPort port = new(portName, baudRate, Parity.None, 8, StopBits.One);
port.Open();
return new OtrspBox(port.BaseStream, port);
}
/// A box works relays, so a command that would change nothing is not sent.
/// N1MM does the same.
public Task SetTransmitAsync(int radioNumber, CancellationToken cancellation = default) =>
SendOnceAsync($"TX{Radio(radioNumber)}", ref lastTransmit, cancellation);
public Task SetReceiveAsync(
int radioNumber,
bool bothRadios = false,
CancellationToken cancellation = default) =>
SendOnceAsync($"RX{Radio(radioNumber)}{(bothRadios ? "S" : "")}", ref lastReceive, cancellation);
public Task SetAuxiliaryAsync(int radioNumber, int code, CancellationToken cancellation = default) =>
SendAsync($"AUX{Radio(radioNumber)}{Math.Clamp(code, 0, 99):00}", cancellation);
public void Dispose()
{
port?.Dispose();
writing.Dispose();
}
private Task SendOnceAsync(string command, ref string last, CancellationToken cancellation)
{
if (command == last)
{
return Task.CompletedTask;
}
last = command;
return SendAsync(command, cancellation);
}
private async Task SendAsync(string command, CancellationToken cancellation)
{
byte[] bytes = Encoding.ASCII.GetBytes(command + "\r");
await writing.WaitAsync(cancellation).ConfigureAwait(false);
try
{
await stream.WriteAsync(bytes, cancellation).ConfigureAwait(false);
await stream.FlushAsync(cancellation).ConfigureAwait(false);
}
finally
{
writing.Release();
}
}
private static int Radio(int radioNumber) => radioNumber == 2 ? 2 : 1;
}

19
src/Nonemm.Rig/So2rBox.cs Normal file
View File

@@ -0,0 +1,19 @@
namespace Nonemm.Rig;
/// The box between the operator and two radios: it decides which radio the
/// transmitter and the key reach, and which one the headphones hear. Without a
/// box the operator does that with switches, and the logger only has to keep
/// track of which radio it is on.
public interface So2rBox : IDisposable
{
/// Points the transmitter and the key at one radio.
Task SetTransmitAsync(int radioNumber, CancellationToken cancellation = default);
/// Points the headphones at one radio, or at both when `bothRadios` is set,
/// which is how an operator listens to the second radio while the first is
/// sending.
Task SetReceiveAsync(int radioNumber, bool bothRadios = false, CancellationToken cancellation = default);
/// Sets a box output, which is usually an antenna or band-decoder line.
Task SetAuxiliaryAsync(int radioNumber, int code, CancellationToken cancellation = default);
}

View File

@@ -8,11 +8,11 @@ namespace Nonemm.Session;
/// Answers what the callsign being typed could be, a column per source.
public sealed class CheckWindowSources
{
private readonly LoggingSession session;
private readonly RadioPosition session;
private readonly CallDatabase database;
private readonly Bandmap bandmap;
public CheckWindowSources(LoggingSession session, CallDatabase database, Bandmap bandmap)
public CheckWindowSources(RadioPosition session, CallDatabase database, Bandmap bandmap)
{
this.session = session;
this.database = database;

View File

@@ -0,0 +1,121 @@
using Nonemm.Contests;
using Nonemm.Core;
using Nonemm.Core.Country;
using Nonemm.Storage;
namespace Nonemm.Session;
/// The running contest: the log, the score and what happens when a contact is
/// logged. One of these per contest, however many radios the station has.
/// What the operator is typing lives in `RadioPosition`, one per radio.
///
/// No UI framework is referenced here, so the behaviour an operator judges the
/// logger by is tested with plain unit tests.
public sealed class ContestSession
{
private readonly LogStore store;
public ContestSession(
LogStore store,
Contest contest,
ContestInstance instance,
StationInfo me,
CountryFile? countries)
{
this.store = store;
Countries = countries;
Contest = contest;
Instance = instance;
Me = me;
Log = new ContestLog(contest, me, countries);
Log.Restore(store.Qsos(instance.ContestNumber));
Editor = new QsoEditor(contest, me, countries);
SentNumber = NextSentNumber();
}
public Contest Contest { get; }
public ContestInstance Instance { get; }
public StationInfo Me { get; }
public ContestLog Log { get; }
public QsoEditor Editor { get; }
public CountryFile? Countries { get; }
/// Serial numbers count up across the station, not per radio.
public int SentNumber { get; private set; }
public string Operator { get; set; } = "";
public event EventHandler? Changed;
public event EventHandler<Qso>? Logged;
/// A contact that was edited, with the call and time it had before. The
/// other stations need the old pair to find their copy of it.
public event EventHandler<QsoChange>? Edited;
public event EventHandler<Qso>? Deleted;
/// Scores the contact, writes it and hands back what was stored: the
/// timestamp can move by a second when another contact with the same call
/// already holds it.
public Qso Add(Qso qso)
{
Qso scored = Log.Add(qso);
Qso stored = store.Add(scored);
if (stored.TimestampUtc != scored.TimestampUtc)
{
Log.Replace(stored);
}
SentNumber = NextSentNumber();
Logged?.Invoke(this, stored);
Changed?.Invoke(this, EventArgs.Empty);
return stored;
}
public void Delete(string id)
{
Qso? gone = Log.Qsos.FirstOrDefault(q => q.Id == id);
store.Delete(id);
Log.Remove(id);
if (gone is not null)
{
Deleted?.Invoke(this, gone);
}
Changed?.Invoke(this, EventArgs.Empty);
}
/// Changes one field of a logged contact. A refused edit leaves the log
/// alone and the caller gets the reason back.
public QsoEdit Edit(string id, QsoField field, string text) =>
EditWith(id, before => Editor.Apply(before, field, text));
/// Replaces a logged contact with one the caller has already built and
/// checked. The edit contact dialog changes many fields at once and comes
/// in this way.
public void Update(Qso qso) => EditWith(qso.Id, _ => QsoEdit.Accept(qso));
public void NotifyChanged() => Changed?.Invoke(this, EventArgs.Empty);
private QsoEdit EditWith(string id, Func<Qso, QsoEdit> change)
{
Qso before = Log.Qsos.FirstOrDefault(q => q.Id == id)
?? throw new InvalidOperationException($"no contact with id {id} in the log");
QsoEdit edit = change(before);
if (edit.Result is not null)
{
store.Update(edit.Result);
Log.Replace(edit.Result);
Edited?.Invoke(this, new QsoChange(edit.Result, before.Call.Text, before.TimestampUtc));
Changed?.Invoke(this, EventArgs.Empty);
}
return edit;
}
private int NextSentNumber() =>
Log.Qsos.Count == 0 ? 1 : Log.Qsos.Max(q => q.SentNumber) + 1;
}

View File

@@ -10,7 +10,7 @@ public static class MessageExpander
/// they are shorter.
private const string CutDigits = "T12345678N";
public static string Expand(string template, LoggingSession session)
public static string Expand(string template, RadioPosition session)
{
StringBuilder text = new();
int at = 0;
@@ -41,7 +41,7 @@ public static class MessageExpander
return text.ToString();
}
private static string Macro(string name, LoggingSession session) => name.ToUpperInvariant() switch
private static string Macro(string name, RadioPosition session) => name.ToUpperInvariant() switch
{
"MYCALL" => session.Me.Callsign,
"CALL" => session.Entry.Call.Trim(),

View File

@@ -5,46 +5,25 @@ using Nonemm.Storage;
namespace Nonemm.Session;
/// The running contest: what the operator is typing, what the log says about
/// it, and what happens when the contact is logged. It holds no reference to a
/// UI toolkit, so the behaviour an operator judges the logger by is tested with
/// plain unit tests.
public sealed class LoggingSession
/// One radio and what the operator is typing on it. A single-radio station has
/// one of these; an SO2R station has two, sharing one `ContestSession` and so
/// one log, one score and one run of serial numbers.
public sealed class RadioPosition
{
private readonly LogStore store;
private readonly CountryFile? countries;
public LoggingSession(
LogStore store,
Contest contest,
ContestInstance instance,
StationInfo me,
CountryFile? countries)
public RadioPosition(ContestSession session, int radioNumber = 1)
{
this.store = store;
this.countries = countries;
Contest = contest;
Instance = instance;
Me = me;
Log = new ContestLog(contest, me, countries);
Log.Restore(store.Qsos(instance.ContestNumber));
Entry = new EntryFields(contest.ExchangeFieldsFor(me));
Editor = new QsoEditor(contest, me, countries);
SentNumber = NextSentNumber();
Session = session;
RadioNumber = radioNumber;
Entry = new EntryFields(session.Contest.ExchangeFieldsFor(session.Me));
}
public Contest Contest { get; }
public ContestSession Session { get; }
public ContestInstance Instance { get; }
public StationInfo Me { get; }
public ContestLog Log { get; }
/// 1 or 2. Contacts record which radio made them.
public int RadioNumber { get; }
public EntryFields Entry { get; }
public QsoEditor Editor { get; }
/// With no radio connected this stays where it was last typed.
public Frequency Frequency { get; private set; } = Bands.Band20M.Low;
@@ -53,32 +32,28 @@ public sealed class LoggingSession
public Mode Mode { get; private set; } = Modes.Cw;
/// Which radio of a two-radio station the operator is on.
public int RadioNumber { get; set; } = 1;
public int SentNumber { get; private set; }
public string Operator { get; set; } = "";
/// True while the operator is calling CQ rather than searching.
/// True while the operator is calling CQ on this radio rather than
/// searching. Each radio runs or searches on its own.
public bool IsRunning { get; set; }
public event EventHandler? Changed;
public Contest Contest => Session.Contest;
public event EventHandler<Qso>? Logged;
public ContestInstance Instance => Session.Instance;
/// A contact that was edited, with the call and time it had before. The
/// other stations need the old pair to find their copy of it.
public event EventHandler<QsoChange>? Edited;
public StationInfo Me => Session.Me;
public event EventHandler<Qso>? Deleted;
public ContestLog Log => Session.Log;
public QsoEditor Editor => Session.Editor;
public int SentNumber => Session.SentNumber;
public void Tune(Frequency frequency, Mode? mode = null, Frequency? transmitFrequency = null)
{
Frequency = frequency;
Mode = mode ?? Mode;
TransmitFrequency = transmitFrequency ?? TransmitFrequency;
Changed?.Invoke(this, EventArgs.Empty);
Session.NotifyChanged();
}
/// What the log says about what is typed now. Null when there is no call to
@@ -90,7 +65,7 @@ public sealed class LoggingSession
}
public CountryLookup? Country() =>
Entry.Call.Trim().Length == 0 ? null : countries?.Find(Entry.Call.Trim());
Entry.Call.Trim().Length == 0 ? null : Session.Countries?.Find(Entry.Call.Trim());
/// Every earlier contact with the call being typed, newest first.
public IReadOnlyList<Qso> WorkedBefore() =>
@@ -107,73 +82,33 @@ public sealed class LoggingSession
{
throw new InvalidOperationException($"the exchange for {Entry.Call} is not complete");
}
Qso scored = Log.Add(BuildQso(Entry.Call.Trim()));
Qso stored = store.Add(scored);
if (stored.TimestampUtc != scored.TimestampUtc)
{
Log.Replace(stored);
}
Qso stored = Session.Add(BuildQso(Entry.Call.Trim()));
Entry.Clear();
SentNumber = NextSentNumber();
Logged?.Invoke(this, stored);
Changed?.Invoke(this, EventArgs.Empty);
return stored;
}
public void Wipe()
{
Entry.Clear();
Changed?.Invoke(this, EventArgs.Empty);
Session.NotifyChanged();
}
public void Delete(string id)
/// The report an operator sends without thinking about it: 599 on CW and
/// digital modes, 59 on phone.
public string DefaultReport() => Mode.Category switch
{
Qso? gone = Log.Qsos.FirstOrDefault(q => q.Id == id);
store.Delete(id);
Log.Remove(id);
if (gone is not null)
{
Deleted?.Invoke(this, gone);
}
Changed?.Invoke(this, EventArgs.Empty);
}
/// Changes one field of a logged contact. A refused edit leaves the log
/// alone and the caller gets the reason back.
public QsoEdit Edit(string id, QsoField field, string text) =>
EditWith(id, before => Editor.Apply(before, field, text));
/// Replaces a logged contact with one the caller has already built and
/// checked. The edit contact dialog changes many fields at once and comes
/// in this way.
public void Update(Qso qso) => EditWith(qso.Id, _ => QsoEdit.Accept(qso));
private QsoEdit EditWith(string id, Func<Qso, QsoEdit> change)
{
Qso before = Log.Qsos.FirstOrDefault(q => q.Id == id)
?? throw new InvalidOperationException($"no contact with id {id} in the log");
QsoEdit edit = change(before);
if (edit.Result is not null)
{
store.Update(edit.Result);
Log.Replace(edit.Result);
Edited?.Invoke(this, new QsoChange(edit.Result, before.Call.Text, before.TimestampUtc));
Changed?.Invoke(this, EventArgs.Empty);
}
return edit;
}
private int NextSentNumber() =>
Log.Qsos.Count == 0 ? 1 : Log.Qsos.Max(q => q.SentNumber) + 1;
ModeCategory.Cw => "599",
ModeCategory.Phone => "59",
_ => "599",
};
private Qso BuildQso(string call)
{
Callsign parsed = Callsign.Parse(call);
Qso qso = new()
{
Id = Qso.NewId(),
TimestampUtc = DateTime.UtcNow.AddTicks(-(DateTime.UtcNow.Ticks % TimeSpan.TicksPerSecond)),
Call = parsed,
Call = Callsign.Parse(call),
Frequency = Frequency,
QsxFrequency = TransmitFrequency,
Mode = Mode,
@@ -182,10 +117,10 @@ public sealed class LoggingSession
ContestNumber = Instance.ContestNumber,
SentReport = DefaultReport(),
SentNumber = SentNumber,
Operator = Operator.Length > 0 ? Operator : Me.Callsign,
Operator = Session.Operator.Length > 0 ? Session.Operator : Me.Callsign,
IsRunQso = IsRunning,
};
return ApplyExchange(CountryFields.Apply(qso, countries));
return ApplyExchange(CountryFields.Apply(qso, Session.Countries));
}
private Qso ApplyExchange(Qso qso)
@@ -214,14 +149,5 @@ public sealed class LoggingSession
return qso;
}
/// The report an operator sends without thinking about it: 599 on CW and
/// digital modes, 59 on phone.
public string DefaultReport() => Mode.Category switch
{
ModeCategory.Cw => "599",
ModeCategory.Phone => "59",
_ => "599",
};
private static int Number(string value) => int.TryParse(value, out int parsed) ? parsed : 0;
}

View File

@@ -0,0 +1,69 @@
using System.Text;
namespace Nonemm.Rig.Tests;
public class OtrspBoxTests
{
private static string Written(MemoryStream stream) =>
Encoding.ASCII.GetString(stream.ToArray());
[Fact]
public async Task PointingTheTransmitterNamesTheRadio()
{
MemoryStream sent = new();
using OtrspBox box = new(sent);
await box.SetTransmitAsync(2);
Assert.Equal("TX2\r", Written(sent));
}
[Fact]
public async Task ListeningToBothRadiosAddsTheStereoFlag()
{
MemoryStream sent = new();
using OtrspBox box = new(sent);
await box.SetReceiveAsync(1, bothRadios: true);
Assert.Equal("RX1S\r", Written(sent));
}
/// A box works relays. Saying the same thing again would rattle them for
/// nothing, so it is not said.
[Fact]
public async Task TheSameCommandIsNotSentTwice()
{
MemoryStream sent = new();
using OtrspBox box = new(sent);
await box.SetTransmitAsync(1);
await box.SetTransmitAsync(1);
await box.SetTransmitAsync(2);
Assert.Equal("TX1\rTX2\r", Written(sent));
}
[Fact]
public async Task TransmitAndReceiveAreTrackedApart()
{
MemoryStream sent = new();
using OtrspBox box = new(sent);
await box.SetTransmitAsync(1);
await box.SetReceiveAsync(1);
Assert.Equal("TX1\rRX1\r", Written(sent));
}
[Fact]
public async Task AnAuxiliaryCodeIsSentAsTwoDigits()
{
MemoryStream sent = new();
using OtrspBox box = new(sent);
await box.SetAuxiliaryAsync(2, 3);
Assert.Equal("AUX203\r", Written(sent));
}
}

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);
}