diff --git a/Nonemm.slnx b/Nonemm.slnx
index 320d791..2dbf99f 100644
--- a/Nonemm.slnx
+++ b/Nonemm.slnx
@@ -15,5 +15,7 @@
+
+
diff --git a/src/Nonemm.Core/Calls/CallDatabase.cs b/src/Nonemm.Core/Calls/CallDatabase.cs
new file mode 100644
index 0000000..4fab637
--- /dev/null
+++ b/src/Nonemm.Core/Calls/CallDatabase.cs
@@ -0,0 +1,60 @@
+namespace Nonemm.Core.Calls;
+
+/// The super check partial database (`MASTER.SCP`): the callsigns heard in
+/// contests, used to finish a call the operator has half copied.
+public sealed class CallDatabase
+{
+ private readonly IReadOnlyList calls;
+
+ private CallDatabase(IReadOnlyList calls) => this.calls = calls;
+
+ public static readonly CallDatabase Empty = new([]);
+
+ public int Count => calls.Count;
+
+ /// Throws `FormatException` when the text is not a callsign list, so a
+ /// download that returned an error page cannot replace a good file.
+ public static CallDatabase Parse(string text)
+ {
+ List found = [];
+ foreach (string rawLine in text.Split('\n'))
+ {
+ string line = rawLine.Trim();
+ if (line.Length == 0 || line.StartsWith('#') || line.StartsWith('!'))
+ {
+ continue;
+ }
+ if (!LooksLikeCall(line))
+ {
+ throw new FormatException($"'{line}' is not a callsign, so this is not a MASTER.SCP");
+ }
+ found.Add(line.ToUpperInvariant());
+ }
+ if (found.Count == 0)
+ {
+ throw new FormatException("the callsign database holds no callsigns");
+ }
+ return new CallDatabase(found);
+ }
+
+ public IReadOnlyList Matches(string query, int limit = 40)
+ {
+ List found = [];
+ foreach (string call in calls)
+ {
+ PartialMatchQuality? quality = PartialMatching.Judge(query, call);
+ if (quality is not null)
+ {
+ found.Add(new PartialMatch(call, quality.Value));
+ }
+ }
+ return found
+ .OrderBy(m => m.Quality)
+ .ThenBy(m => m.Call, StringComparer.Ordinal)
+ .Take(limit)
+ .ToList();
+ }
+
+ private static bool LooksLikeCall(string text) =>
+ text.Length is >= 3 and <= 16 && text.All(c => char.IsAsciiLetterOrDigit(c) || c == '/');
+}
diff --git a/src/Nonemm.Core/Calls/PartialMatch.cs b/src/Nonemm.Core/Calls/PartialMatch.cs
new file mode 100644
index 0000000..10926b3
--- /dev/null
+++ b/src/Nonemm.Core/Calls/PartialMatch.cs
@@ -0,0 +1,3 @@
+namespace Nonemm.Core.Calls;
+
+public sealed record PartialMatch(string Call, PartialMatchQuality Quality);
diff --git a/src/Nonemm.Core/Calls/PartialMatchQuality.cs b/src/Nonemm.Core/Calls/PartialMatchQuality.cs
new file mode 100644
index 0000000..88479f3
--- /dev/null
+++ b/src/Nonemm.Core/Calls/PartialMatchQuality.cs
@@ -0,0 +1,11 @@
+namespace Nonemm.Core.Calls;
+
+/// How well a candidate call answers what the operator has typed. The order is
+/// the order the check window lists them in.
+public enum PartialMatchQuality
+{
+ Exact,
+ StartsOrEnds,
+ Contains,
+ OneCharacterOut,
+}
diff --git a/src/Nonemm.Core/Calls/PartialMatching.cs b/src/Nonemm.Core/Calls/PartialMatching.cs
new file mode 100644
index 0000000..6226cd7
--- /dev/null
+++ b/src/Nonemm.Core/Calls/PartialMatching.cs
@@ -0,0 +1,90 @@
+namespace Nonemm.Core.Calls;
+
+/// Judges how well a callsign answers a partly copied one. `?` stands for a
+/// character that was not copied.
+public static class PartialMatching
+{
+ /// Two characters match a large share of any callsign list, so the check
+ /// windows say nothing until there are three.
+ public const int ShortestUsefulQuery = 3;
+
+ public static PartialMatchQuality? Judge(string query, string candidate)
+ {
+ if (query.Length < ShortestUsefulQuery || candidate.Length == 0)
+ {
+ return null;
+ }
+ if (Same(query, candidate))
+ {
+ return PartialMatchQuality.Exact;
+ }
+ if (query.Length < candidate.Length)
+ {
+ if (Same(query, candidate[..query.Length]) ||
+ Same(query, candidate[^query.Length..]))
+ {
+ return PartialMatchQuality.StartsOrEnds;
+ }
+ for (int at = 1; at + query.Length < candidate.Length; at++)
+ {
+ if (Same(query, candidate.Substring(at, query.Length)))
+ {
+ return PartialMatchQuality.Contains;
+ }
+ }
+ }
+ return IsOneCharacterOut(query, candidate) ? PartialMatchQuality.OneCharacterOut : null;
+ }
+
+ /// Same length with one character different, or two next to each other the
+ /// wrong way round.
+ private static bool IsOneCharacterOut(string query, string candidate)
+ {
+ if (query.Length != candidate.Length)
+ {
+ return false;
+ }
+ int differences = 0;
+ int firstDifference = -1;
+ for (int at = 0; at < query.Length; at++)
+ {
+ if (!SameCharacter(query[at], candidate[at]))
+ {
+ differences++;
+ if (firstDifference < 0)
+ {
+ firstDifference = at;
+ }
+ }
+ }
+ if (differences == 1)
+ {
+ return true;
+ }
+ if (differences != 2 || firstDifference + 1 >= query.Length)
+ {
+ return false;
+ }
+ return SameCharacter(query[firstDifference], candidate[firstDifference + 1]) &&
+ SameCharacter(query[firstDifference + 1], candidate[firstDifference]);
+ }
+
+ private static bool Same(string query, string candidate)
+ {
+ if (query.Length != candidate.Length)
+ {
+ return false;
+ }
+ for (int at = 0; at < query.Length; at++)
+ {
+ if (!SameCharacter(query[at], candidate[at]))
+ {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static bool SameCharacter(char query, char candidate) =>
+ query == '?' || char.ToUpperInvariant(query) == char.ToUpperInvariant(candidate);
+}
diff --git a/src/Nonemm.Rig/Nonemm.Rig.csproj b/src/Nonemm.Rig/Nonemm.Rig.csproj
index b760144..4464042 100644
--- a/src/Nonemm.Rig/Nonemm.Rig.csproj
+++ b/src/Nonemm.Rig/Nonemm.Rig.csproj
@@ -1,5 +1,9 @@
+
+
+
+
net10.0
enable
diff --git a/src/Nonemm.Rig/Radio.cs b/src/Nonemm.Rig/Radio.cs
new file mode 100644
index 0000000..47173d5
--- /dev/null
+++ b/src/Nonemm.Rig/Radio.cs
@@ -0,0 +1,23 @@
+using Nonemm.Core;
+
+namespace Nonemm.Rig;
+
+/// A radio the logger can read and tune. With nothing connected the logger
+/// leaves frequency and mode wherever they were last typed, so every method
+/// here is allowed to do nothing.
+public interface Radio : IDisposable
+{
+ bool IsConnected { get; }
+
+ RadioState? State { get; }
+
+ /// Raised when the radio has moved.
+ event EventHandler? Moved;
+
+ /// Raised when the connection comes up or goes down.
+ event EventHandler? ConnectionChanged;
+
+ Task TuneAsync(Frequency frequency, CancellationToken cancellation = default);
+
+ Task SetModeAsync(Mode mode, CancellationToken cancellation = default);
+}
diff --git a/src/Nonemm.Rig/RadioState.cs b/src/Nonemm.Rig/RadioState.cs
new file mode 100644
index 0000000..c5fe7c7
--- /dev/null
+++ b/src/Nonemm.Rig/RadioState.cs
@@ -0,0 +1,6 @@
+using Nonemm.Core;
+
+namespace Nonemm.Rig;
+
+/// Where the radio is now.
+public sealed record RadioState(Frequency Frequency, Mode Mode);
diff --git a/src/Nonemm.Rig/RigctldRadio.cs b/src/Nonemm.Rig/RigctldRadio.cs
new file mode 100644
index 0000000..a666608
--- /dev/null
+++ b/src/Nonemm.Rig/RigctldRadio.cs
@@ -0,0 +1,198 @@
+using System.Globalization;
+using System.Net.Sockets;
+using Nonemm.Core;
+
+namespace Nonemm.Rig;
+
+/// Talks to hamlib's `rigctld`, which is started separately for whichever radio
+/// is on the desk. The connection is retried on its own, so a radio switched on
+/// late or a `rigctld` restarted mid-contest comes back without the operator
+/// touching anything.
+public sealed class RigctldRadio : Radio
+{
+ private readonly string host;
+ private readonly int port;
+ private readonly TimeSpan pollInterval;
+ private readonly TimeSpan retryInterval;
+ private readonly CancellationTokenSource stopping = new();
+ private readonly SemaphoreSlim writing = new(1, 1);
+ private TcpClient? client;
+ private StreamReader? reader;
+ private StreamWriter? writer;
+ private Task? loop;
+
+ public RigctldRadio(
+ string host = "127.0.0.1",
+ int port = 4532,
+ TimeSpan? pollInterval = null,
+ TimeSpan? retryInterval = null)
+ {
+ this.host = host;
+ this.port = port;
+ this.pollInterval = pollInterval ?? TimeSpan.FromMilliseconds(200);
+ this.retryInterval = retryInterval ?? TimeSpan.FromSeconds(3);
+ }
+
+ public bool IsConnected { get; private set; }
+
+ public RadioState? State { get; private set; }
+
+ public event EventHandler? Moved;
+
+ public event EventHandler? ConnectionChanged;
+
+ public void Start() => loop ??= Task.Run(() => RunAsync(stopping.Token));
+
+ public async Task TuneAsync(Frequency frequency, CancellationToken cancellation = default) =>
+ await SendAsync($"F {frequency.Hertz}", cancellation).ConfigureAwait(false);
+
+ public async Task SetModeAsync(Mode mode, CancellationToken cancellation = default) =>
+ await SendAsync($"M {RigctldMode(mode)} 0", cancellation).ConfigureAwait(false);
+
+ public void Dispose()
+ {
+ stopping.Cancel();
+ client?.Dispose();
+ stopping.Dispose();
+ writing.Dispose();
+ }
+
+ private async Task RunAsync(CancellationToken cancellation)
+ {
+ while (!cancellation.IsCancellationRequested)
+ {
+ try
+ {
+ await ConnectAsync(cancellation).ConfigureAwait(false);
+ await PollAsync(cancellation).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ return;
+ }
+ catch (Exception e) when (e is IOException or SocketException)
+ {
+ SetConnected(false);
+ await Task.Delay(retryInterval, cancellation).ConfigureAwait(false);
+ }
+ }
+ }
+
+ private async Task ConnectAsync(CancellationToken cancellation)
+ {
+ client?.Dispose();
+ client = new TcpClient();
+ await client.ConnectAsync(host, port, cancellation).ConfigureAwait(false);
+ NetworkStream stream = client.GetStream();
+ reader = new StreamReader(stream);
+ writer = new StreamWriter(stream) { AutoFlush = true };
+ SetConnected(true);
+ }
+
+ private async Task PollAsync(CancellationToken cancellation)
+ {
+ while (!cancellation.IsCancellationRequested)
+ {
+ Frequency? frequency = ReadFrequency(await AskAsync("f", cancellation).ConfigureAwait(false));
+ Mode? mode = ReadMode(await AskAsync("m", cancellation).ConfigureAwait(false));
+ if (frequency is not null)
+ {
+ RadioState state = new(frequency.Value, mode ?? State?.Mode ?? Modes.Cw);
+ if (state != State)
+ {
+ State = state;
+ Moved?.Invoke(this, state);
+ }
+ }
+ await Task.Delay(pollInterval, cancellation).ConfigureAwait(false);
+ }
+ }
+
+ private async Task> AskAsync(string command, CancellationToken cancellation)
+ {
+ await writing.WaitAsync(cancellation).ConfigureAwait(false);
+ try
+ {
+ if (writer is null || reader is null)
+ {
+ throw new IOException("the connection to rigctld is not open");
+ }
+ await writer.WriteAsync(command + "\n").ConfigureAwait(false);
+ List lines = [];
+ // rigctld answers one value per line and ends with RPRT on an error
+ string? line = await reader.ReadLineAsync(cancellation).ConfigureAwait(false)
+ ?? throw new IOException($"rigctld closed the connection during '{command}'");
+ lines.Add(line);
+ if (command == "m")
+ {
+ string? passband = await reader.ReadLineAsync(cancellation).ConfigureAwait(false);
+ if (passband is not null)
+ {
+ lines.Add(passband);
+ }
+ }
+ return lines;
+ }
+ finally
+ {
+ writing.Release();
+ }
+ }
+
+ private async Task SendAsync(string command, CancellationToken cancellation)
+ {
+ if (!IsConnected)
+ {
+ return;
+ }
+ await writing.WaitAsync(cancellation).ConfigureAwait(false);
+ try
+ {
+ if (writer is null || reader is null)
+ {
+ return;
+ }
+ await writer.WriteAsync(command + "\n").ConfigureAwait(false);
+ await reader.ReadLineAsync(cancellation).ConfigureAwait(false);
+ }
+ catch (Exception e) when (e is IOException or SocketException)
+ {
+ SetConnected(false);
+ }
+ finally
+ {
+ writing.Release();
+ }
+ }
+
+ private void SetConnected(bool connected)
+ {
+ if (IsConnected != connected)
+ {
+ IsConnected = connected;
+ ConnectionChanged?.Invoke(this, connected);
+ }
+ }
+
+ private static Frequency? ReadFrequency(IReadOnlyList answer) =>
+ answer.Count > 0 &&
+ long.TryParse(answer[0].Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out long hertz) &&
+ hertz > 0
+ ? Frequency.FromHertz(hertz)
+ : null;
+
+ private static Mode? ReadMode(IReadOnlyList answer) =>
+ answer.Count > 0 ? Modes.Parse(answer[0].Trim()) : null;
+
+ /// hamlib names the modes CW, USB, LSB, RTTY, FM, AM and PKTUSB.
+ private static string RigctldMode(Mode mode) => mode.Name switch
+ {
+ "CW" => "CW",
+ "USB" => "USB",
+ "LSB" => "LSB",
+ "AM" => "AM",
+ "FM" => "FM",
+ "RTTY" => "RTTY",
+ _ => "PKTUSB",
+ };
+}
diff --git a/src/Nonemm.Session/EntryFields.cs b/src/Nonemm.Session/EntryFields.cs
new file mode 100644
index 0000000..9c37805
--- /dev/null
+++ b/src/Nonemm.Session/EntryFields.cs
@@ -0,0 +1,87 @@
+using Nonemm.Contests;
+
+namespace Nonemm.Session;
+
+/// What the operator has typed: the callsign, a value per exchange box, and
+/// which box the cursor is in. The callsign box is field 0.
+public sealed class EntryFields
+{
+ private readonly List values;
+
+ public EntryFields(IReadOnlyList exchange)
+ {
+ Exchange = exchange;
+ values = [.. Enumerable.Repeat("", exchange.Count)];
+ }
+
+ public IReadOnlyList Exchange { get; }
+
+ public string Call { get; set; } = "";
+
+ /// 0 is the callsign box; 1 and up are the exchange boxes in order.
+ public int Focus { get; private set; }
+
+ public int FieldCount => Exchange.Count + 1;
+
+ public string this[int field]
+ {
+ get => field == 0 ? Call : values[field - 1];
+ set
+ {
+ if (field == 0)
+ {
+ Call = value;
+ }
+ else
+ {
+ values[field - 1] = value;
+ }
+ }
+ }
+
+ public string ValueOf(ExchangeSlot slot)
+ {
+ for (int at = 0; at < Exchange.Count; at++)
+ {
+ if (Exchange[at].Slot == slot)
+ {
+ return values[at];
+ }
+ }
+ return "";
+ }
+
+ public void Set(ExchangeSlot slot, string value)
+ {
+ for (int at = 0; at < Exchange.Count; at++)
+ {
+ if (Exchange[at].Slot == slot)
+ {
+ values[at] = value;
+ }
+ }
+ }
+
+ public void FocusOn(int field) => Focus = Math.Clamp(field, 0, FieldCount - 1);
+
+ /// What space does: move to the next box, and round to the callsign again
+ /// from the last one.
+ public void Advance() => Focus = (Focus + 1) % FieldCount;
+
+ public void Retreat() => Focus = (Focus + FieldCount - 1) % FieldCount;
+
+ /// True when everything the contest asks for has been typed.
+ public bool IsComplete =>
+ Call.Trim().Length > 0 &&
+ Exchange.Where((f, at) => f.IsRequired && values[at].Trim().Length == 0).Any() == false;
+
+ public void Clear()
+ {
+ Call = "";
+ for (int at = 0; at < values.Count; at++)
+ {
+ values[at] = "";
+ }
+ Focus = 0;
+ }
+}
diff --git a/src/Nonemm.Session/FrequencyEntry.cs b/src/Nonemm.Session/FrequencyEntry.cs
new file mode 100644
index 0000000..c6d74fc
--- /dev/null
+++ b/src/Nonemm.Session/FrequencyEntry.cs
@@ -0,0 +1,29 @@
+using System.Globalization;
+using Nonemm.Core;
+
+namespace Nonemm.Session;
+
+/// Reads a frequency typed into the callsign box, which is how an operator
+/// changes band without touching the radio.
+public static class FrequencyEntry
+{
+ /// Kilohertz when the number is 1000 or more, megahertz when it is less, so
+ /// both `14025` and `14.025` reach 20 metres. Null when the text is not a
+ /// frequency or lands outside every band.
+ public static Frequency? Parse(string text)
+ {
+ string trimmed = text.Trim();
+ if (trimmed.Length == 0 || !trimmed.All(c => char.IsAsciiDigit(c) || c == '.'))
+ {
+ return null;
+ }
+ if (!double.TryParse(trimmed, NumberStyles.Float, CultureInfo.InvariantCulture, out double value))
+ {
+ return null;
+ }
+ Frequency frequency = value >= 1000
+ ? Frequency.FromKilohertz(value)
+ : Frequency.FromMegahertz(value);
+ return Bands.ForFrequency(frequency) is null ? null : frequency;
+ }
+}
diff --git a/src/Nonemm.Session/LoggingSession.cs b/src/Nonemm.Session/LoggingSession.cs
new file mode 100644
index 0000000..ebfec31
--- /dev/null
+++ b/src/Nonemm.Session/LoggingSession.cs
@@ -0,0 +1,189 @@
+using Nonemm.Contests;
+using Nonemm.Core;
+using Nonemm.Core.Country;
+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
+{
+ private readonly LogStore store;
+ private readonly CountryFile? countries;
+
+ public LoggingSession(
+ LogStore store,
+ Contest contest,
+ ContestInstance instance,
+ StationInfo me,
+ CountryFile? countries)
+ {
+ 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));
+ SentNumber = NextSentNumber();
+ }
+
+ public Contest Contest { get; }
+
+ public ContestInstance Instance { get; }
+
+ public StationInfo Me { get; }
+
+ public ContestLog Log { get; }
+
+ public EntryFields Entry { get; }
+
+ /// With no radio connected this stays where it was last typed.
+ public Frequency Frequency { get; private set; } = Bands.Band20M.Low;
+
+ public Mode Mode { get; private set; } = Modes.Cw;
+
+ public int SentNumber { get; private set; }
+
+ public string Operator { get; set; } = "";
+
+ /// True while the operator is calling CQ rather than searching.
+ public bool IsRunning { get; set; }
+
+ public event EventHandler? Changed;
+
+ public event EventHandler? Logged;
+
+ public void Tune(Frequency frequency, Mode? mode = null)
+ {
+ Frequency = frequency;
+ Mode = mode ?? Mode;
+ Changed?.Invoke(this, EventArgs.Empty);
+ }
+
+ /// What the log says about what is typed now. Null when there is no call to
+ /// judge yet.
+ public Verdict? Verdict()
+ {
+ string call = Entry.Call.Trim();
+ return call.Length == 0 ? null : Log.Judge(BuildQso(call));
+ }
+
+ public CountryLookup? Country() =>
+ Entry.Call.Trim().Length == 0 ? null : countries?.Find(Entry.Call.Trim());
+
+ /// Every earlier contact with the call being typed, newest first.
+ public IReadOnlyList WorkedBefore() =>
+ Entry.Call.Trim().Length == 0 ? [] : Log.WorkedBefore(Entry.Call.Trim());
+
+ /// The frequency typed into the callsign box, if that is what it holds.
+ public Frequency? PendingQsy() => FrequencyEntry.Parse(Entry.Call);
+
+ /// Logs what is typed and clears the entry. Throws when the entry is not
+ /// complete, so a caller that has not checked cannot lose an exchange.
+ public Qso LogContact()
+ {
+ if (!Entry.IsComplete)
+ {
+ 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);
+ }
+ 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);
+ }
+
+ public void Delete(string id)
+ {
+ store.Delete(id);
+ Log.Remove(id);
+ Changed?.Invoke(this, EventArgs.Empty);
+ }
+
+ public void Update(Qso qso)
+ {
+ store.Update(qso);
+ Log.Replace(qso);
+ Changed?.Invoke(this, EventArgs.Empty);
+ }
+
+ private int NextSentNumber() =>
+ Log.Qsos.Count == 0 ? 1 : Log.Qsos.Max(q => q.SentNumber) + 1;
+
+ private Qso BuildQso(string call)
+ {
+ Callsign parsed = Callsign.Parse(call);
+ CountryLookup? country = countries?.Find(parsed);
+ Qso qso = new()
+ {
+ Id = Qso.NewId(),
+ TimestampUtc = DateTime.UtcNow.AddTicks(-(DateTime.UtcNow.Ticks % TimeSpan.TicksPerSecond)),
+ Call = parsed,
+ Frequency = Frequency,
+ Mode = Mode,
+ ContestName = Contest.Name,
+ ContestNumber = Instance.ContestNumber,
+ SentReport = DefaultReport(),
+ SentNumber = SentNumber,
+ CountryPrefix = country?.Entity.PrimaryPrefix ?? "",
+ Continent = country?.Continent ?? "",
+ StationPrefix = parsed.PortablePrefix ?? "",
+ WpxPrefix = parsed.WpxPrefix() ?? "",
+ Operator = Operator.Length > 0 ? Operator : Me.Callsign,
+ IsRunQso = IsRunning,
+ };
+ return ApplyExchange(qso);
+ }
+
+ private Qso ApplyExchange(Qso qso)
+ {
+ foreach (ExchangeField field in Entry.Exchange)
+ {
+ string value = Entry.ValueOf(field.Slot).Trim();
+ qso = field.Slot switch
+ {
+ ExchangeSlot.ReceivedReport => qso with { ReceivedReport = value },
+ ExchangeSlot.SerialNumber => qso with { ReceivedNumber = Number(value) },
+ ExchangeSlot.Zone => qso with { Zone = Number(value) },
+ ExchangeSlot.Section => qso with { Section = value.ToUpperInvariant() },
+ ExchangeSlot.Check => qso with { Check = Number(value) },
+ ExchangeSlot.Precedence => qso with { Precedence = value.ToUpperInvariant() },
+ ExchangeSlot.Exchange1 => qso with { Exchange1 = value.ToUpperInvariant() },
+ ExchangeSlot.MiscText => qso with { MiscText = value.ToUpperInvariant() },
+ ExchangeSlot.Name => qso with { Name = value },
+ ExchangeSlot.Qth => qso with { Qth = value },
+ ExchangeSlot.GridSquare => qso with { GridSquare = value.ToUpperInvariant() },
+ ExchangeSlot.Power => qso with { Power = value },
+ ExchangeSlot.Comment => qso with { Comment = value },
+ _ => qso,
+ };
+ }
+ return qso;
+ }
+
+ private 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;
+}
diff --git a/src/Nonemm.Session/Nonemm.Session.csproj b/src/Nonemm.Session/Nonemm.Session.csproj
index b760144..e5b1bc7 100644
--- a/src/Nonemm.Session/Nonemm.Session.csproj
+++ b/src/Nonemm.Session/Nonemm.Session.csproj
@@ -1,5 +1,12 @@
+
+
+
+
+
+
+
net10.0
enable
diff --git a/src/Nonemm.Spotting/Bandmap.cs b/src/Nonemm.Spotting/Bandmap.cs
new file mode 100644
index 0000000..d6ee40c
--- /dev/null
+++ b/src/Nonemm.Spotting/Bandmap.cs
@@ -0,0 +1,77 @@
+using Nonemm.Core;
+
+namespace Nonemm.Spotting;
+
+/// The stations on the band, in frequency order. A spot ages out after an hour
+/// because a bandmap is a picture of the last hour, and a stale spot costs a
+/// move to an empty frequency.
+public sealed class Bandmap
+{
+ private readonly Dictionary spots = new(StringComparer.OrdinalIgnoreCase);
+ private readonly TimeSpan lifetime;
+
+ public Bandmap(TimeSpan? lifetime = null) => this.lifetime = lifetime ?? TimeSpan.FromHours(1);
+
+ public event EventHandler? Changed;
+
+ public void Add(Spot spot)
+ {
+ spots[Key(spot)] = spot;
+ Changed?.Invoke(this, EventArgs.Empty);
+ }
+
+ public void Remove(Callsign call, Band band)
+ {
+ if (spots.Remove($"{call.Text}|{band.Name}"))
+ {
+ Changed?.Invoke(this, EventArgs.Empty);
+ }
+ }
+
+ public void DropOlderThan(DateTime nowUtc)
+ {
+ List stale = spots
+ .Where(pair => nowUtc - pair.Value.AtUtc > lifetime)
+ .Select(pair => pair.Key)
+ .ToList();
+ foreach (string key in stale)
+ {
+ spots.Remove(key);
+ }
+ if (stale.Count > 0)
+ {
+ Changed?.Invoke(this, EventArgs.Empty);
+ }
+ }
+
+ public IReadOnlyList On(Band band) =>
+ spots.Values
+ .Where(s => s.Band == band)
+ .OrderBy(s => s.Frequency.Hertz)
+ .ToList();
+
+ public IReadOnlyList All() =>
+ spots.Values.OrderBy(s => s.Frequency.Hertz).ToList();
+
+ /// The spot nearest the frequency, within the window either side. Used when
+ /// the radio lands on a spot so the entry window can fill the call in.
+ public Spot? Near(Frequency frequency, Frequency window)
+ {
+ Spot? best = null;
+ long bestDistance = long.MaxValue;
+ foreach (Spot spot in spots.Values)
+ {
+ long distance = Math.Abs(spot.Frequency.Hertz - frequency.Hertz);
+ if (distance <= window.Hertz && distance < bestDistance)
+ {
+ best = spot;
+ bestDistance = distance;
+ }
+ }
+ return best;
+ }
+
+ /// One spot per station per band: a station that moves within the band keeps
+ /// one entry rather than leaving a trail.
+ private static string Key(Spot spot) => $"{spot.Call.Text}|{spot.Band?.Name}";
+}
diff --git a/src/Nonemm.Spotting/ClusterClient.cs b/src/Nonemm.Spotting/ClusterClient.cs
new file mode 100644
index 0000000..244c462
--- /dev/null
+++ b/src/Nonemm.Spotting/ClusterClient.cs
@@ -0,0 +1,140 @@
+using System.Net.Sockets;
+using System.Text;
+
+namespace Nonemm.Spotting;
+
+/// A DX cluster node over telnet. Spots go to the bandmap; the rest of the
+/// node's traffic goes to the packet window as it arrived, because filters are
+/// the node's business and its replies are for the operator to read.
+public sealed class ClusterClient : IDisposable
+{
+ private readonly string host;
+ private readonly int port;
+ private readonly string callsign;
+ private readonly IReadOnlyList commandsAfterLogin;
+ private readonly TimeSpan retryInterval;
+ private readonly CancellationTokenSource stopping = new();
+ private TcpClient? client;
+ private StreamWriter? writer;
+ private Task? loop;
+
+ public ClusterClient(
+ string host,
+ int port,
+ string callsign,
+ IReadOnlyList? commandsAfterLogin = null,
+ TimeSpan? retryInterval = null)
+ {
+ this.host = host;
+ this.port = port;
+ this.callsign = callsign;
+ this.commandsAfterLogin = commandsAfterLogin ?? [];
+ this.retryInterval = retryInterval ?? TimeSpan.FromSeconds(10);
+ }
+
+ public bool IsConnected { get; private set; }
+
+ public event EventHandler? SpotArrived;
+
+ public event EventHandler? LineArrived;
+
+ public event EventHandler? ConnectionChanged;
+
+ public void Start() => loop ??= Task.Run(() => RunAsync(stopping.Token));
+
+ public async Task SendAsync(string line, CancellationToken cancellation = default)
+ {
+ if (writer is null)
+ {
+ return;
+ }
+ try
+ {
+ await writer.WriteAsync(line + "\r\n").ConfigureAwait(false);
+ }
+ catch (Exception e) when (e is IOException or SocketException or ObjectDisposedException)
+ {
+ SetConnected(false);
+ }
+ }
+
+ public void Dispose()
+ {
+ stopping.Cancel();
+ client?.Dispose();
+ stopping.Dispose();
+ }
+
+ private async Task RunAsync(CancellationToken cancellation)
+ {
+ while (!cancellation.IsCancellationRequested)
+ {
+ try
+ {
+ await SessionAsync(cancellation).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ return;
+ }
+ catch (Exception e) when (e is IOException or SocketException)
+ {
+ LineArrived?.Invoke(this, $"*** {host}:{port} {e.Message}");
+ }
+ SetConnected(false);
+ await Task.Delay(retryInterval, cancellation).ConfigureAwait(false);
+ }
+ }
+
+ private async Task SessionAsync(CancellationToken cancellation)
+ {
+ client?.Dispose();
+ client = new TcpClient();
+ await client.ConnectAsync(host, port, cancellation).ConfigureAwait(false);
+ NetworkStream stream = client.GetStream();
+ using StreamReader reader = new(stream, Encoding.Latin1);
+ writer = new StreamWriter(stream, Encoding.Latin1) { AutoFlush = true };
+ SetConnected(true);
+
+ bool loggedIn = false;
+ while (!cancellation.IsCancellationRequested)
+ {
+ string? line = await reader.ReadLineAsync(cancellation).ConfigureAwait(false);
+ if (line is null)
+ {
+ return;
+ }
+ LineArrived?.Invoke(this, line);
+ Spot? spot = SpotLine.Parse(line, DateTime.UtcNow);
+ if (spot is not null)
+ {
+ SpotArrived?.Invoke(this, spot);
+ continue;
+ }
+ if (!loggedIn && AsksForCallsign(line))
+ {
+ loggedIn = true;
+ await SendAsync(callsign, cancellation).ConfigureAwait(false);
+ foreach (string command in commandsAfterLogin)
+ {
+ await SendAsync(command, cancellation).ConfigureAwait(false);
+ }
+ }
+ }
+ }
+
+ /// Nodes ask in their own words; all of them use one of these.
+ private static bool AsksForCallsign(string line) =>
+ line.Contains("login", StringComparison.OrdinalIgnoreCase) ||
+ line.Contains("call", StringComparison.OrdinalIgnoreCase) ||
+ line.Contains("callsign", StringComparison.OrdinalIgnoreCase);
+
+ private void SetConnected(bool connected)
+ {
+ if (IsConnected != connected)
+ {
+ IsConnected = connected;
+ ConnectionChanged?.Invoke(this, connected);
+ }
+ }
+}
diff --git a/src/Nonemm.Spotting/Nonemm.Spotting.csproj b/src/Nonemm.Spotting/Nonemm.Spotting.csproj
index b760144..4464042 100644
--- a/src/Nonemm.Spotting/Nonemm.Spotting.csproj
+++ b/src/Nonemm.Spotting/Nonemm.Spotting.csproj
@@ -1,5 +1,9 @@
+
+
+
+
net10.0
enable
diff --git a/src/Nonemm.Spotting/Spot.cs b/src/Nonemm.Spotting/Spot.cs
new file mode 100644
index 0000000..88badeb
--- /dev/null
+++ b/src/Nonemm.Spotting/Spot.cs
@@ -0,0 +1,15 @@
+using Nonemm.Core;
+
+namespace Nonemm.Spotting;
+
+/// One station on the bandmap.
+public sealed record Spot(
+ Callsign Call,
+ Frequency Frequency,
+ DateTime AtUtc,
+ SpotSource Source,
+ string Spotter = "",
+ string Comment = "")
+{
+ public Band? Band => Bands.ForFrequency(Frequency);
+}
diff --git a/src/Nonemm.Spotting/SpotLine.cs b/src/Nonemm.Spotting/SpotLine.cs
new file mode 100644
index 0000000..05e6076
--- /dev/null
+++ b/src/Nonemm.Spotting/SpotLine.cs
@@ -0,0 +1,77 @@
+using System.Globalization;
+using Nonemm.Core;
+
+namespace Nonemm.Spotting;
+
+/// Reads the `DX de` lines a cluster node sends.
+public static class SpotLine
+{
+ private const string Marker = "DX de ";
+
+ /// Null for any other traffic from the node, which the packet window shows
+ /// as it arrived.
+ public static Spot? Parse(string line, DateTime nowUtc)
+ {
+ int start = line.IndexOf(Marker, StringComparison.OrdinalIgnoreCase);
+ if (start < 0)
+ {
+ return null;
+ }
+ string body = line[(start + Marker.Length)..];
+ int colon = body.IndexOf(':');
+ if (colon < 0)
+ {
+ return null;
+ }
+ string spotter = body[..colon].Trim();
+ string[] parts = body[(colon + 1)..].Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length < 2)
+ {
+ return null;
+ }
+ if (!double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out double kilohertz))
+ {
+ return null;
+ }
+ string comment = string.Join(' ', parts.Skip(2)).Trim();
+ return new Spot(
+ Callsign.Parse(parts[1]),
+ Frequency.FromKilohertz(kilohertz),
+ ParseTime(comment, nowUtc),
+ SpotSource.Cluster,
+ spotter,
+ TrimTime(comment));
+ }
+
+ /// The node puts the spot's time at the end as `1234Z`.
+ private static DateTime ParseTime(string comment, DateTime nowUtc)
+ {
+ string? stamp = TimeStamp(comment);
+ if (stamp is null ||
+ !int.TryParse(stamp[..2], out int hour) ||
+ !int.TryParse(stamp[2..4], out int minute))
+ {
+ return nowUtc;
+ }
+ DateTime at = new(nowUtc.Year, nowUtc.Month, nowUtc.Day, hour, minute, 0, DateTimeKind.Utc);
+ // a spot timed later than now came in just before midnight
+ return at > nowUtc.AddMinutes(5) ? at.AddDays(-1) : at;
+ }
+
+ private static string TrimTime(string comment)
+ {
+ string? stamp = TimeStamp(comment);
+ return stamp is null ? comment : comment[..^stamp.Length].TrimEnd();
+ }
+
+ private static string? TimeStamp(string comment)
+ {
+ string trimmed = comment.TrimEnd();
+ if (trimmed.Length < 5 || char.ToUpperInvariant(trimmed[^1]) != 'Z')
+ {
+ return null;
+ }
+ string candidate = trimmed[^5..];
+ return candidate[..4].All(char.IsAsciiDigit) ? candidate : null;
+ }
+}
diff --git a/src/Nonemm.Spotting/SpotSource.cs b/src/Nonemm.Spotting/SpotSource.cs
new file mode 100644
index 0000000..00ba409
--- /dev/null
+++ b/src/Nonemm.Spotting/SpotSource.cs
@@ -0,0 +1,9 @@
+namespace Nonemm.Spotting;
+
+/// Where a spot came from, which decides how much to trust it.
+public enum SpotSource
+{
+ Cluster,
+ Log,
+ Operator,
+}
diff --git a/tests/Nonemm.Session.Tests/FakeLogStore.cs b/tests/Nonemm.Session.Tests/FakeLogStore.cs
new file mode 100644
index 0000000..91d99ad
--- /dev/null
+++ b/tests/Nonemm.Session.Tests/FakeLogStore.cs
@@ -0,0 +1,57 @@
+using Nonemm.Core;
+using Nonemm.Storage;
+
+namespace Nonemm.Session.Tests;
+
+/// A log store that keeps everything in memory, so session tests do not need a
+/// file. It keeps the one rule the sqlite store enforces: no two contacts with
+/// the same call in the same second.
+public sealed class FakeLogStore : LogStore
+{
+ private readonly List qsos = [];
+ private readonly List contests = [];
+
+ public IReadOnlyList Contests() => contests;
+
+ public ContestInstance? Contest(int contestNumber) =>
+ contests.FirstOrDefault(c => c.ContestNumber == contestNumber);
+
+ public ContestInstance AddContest(ContestInstance instance)
+ {
+ ContestInstance stored = instance with { ContestNumber = contests.Count + 1 };
+ contests.Add(stored);
+ return stored;
+ }
+
+ public void UpdateContest(ContestInstance instance)
+ {
+ int at = contests.FindIndex(c => c.ContestNumber == instance.ContestNumber);
+ contests[at] = instance;
+ }
+
+ public IReadOnlyList Qsos(int contestNumber) =>
+ qsos.Where(q => q.ContestNumber == contestNumber).OrderBy(q => q.TimestampUtc).ToList();
+
+ public Qso Add(Qso qso)
+ {
+ Qso candidate = qso;
+ while (qsos.Any(q => q.Call.Text == candidate.Call.Text && q.TimestampUtc == candidate.TimestampUtc))
+ {
+ candidate = candidate with { TimestampUtc = candidate.TimestampUtc.AddSeconds(1) };
+ }
+ qsos.Add(candidate);
+ return candidate;
+ }
+
+ public void Update(Qso qso)
+ {
+ int at = qsos.FindIndex(q => q.Id == qso.Id);
+ qsos[at] = qso;
+ }
+
+ public void Delete(string id) => qsos.RemoveAll(q => q.Id == id);
+
+ public void Dispose()
+ {
+ }
+}
diff --git a/tests/Nonemm.Session.Tests/LoggingSessionTests.cs b/tests/Nonemm.Session.Tests/LoggingSessionTests.cs
new file mode 100644
index 0000000..999c8ff
--- /dev/null
+++ b/tests/Nonemm.Session.Tests/LoggingSessionTests.cs
@@ -0,0 +1,179 @@
+using Nonemm.Contests;
+using Nonemm.Contests.Rules;
+using Nonemm.Core;
+using Nonemm.Core.Country;
+using Nonemm.Storage;
+
+namespace Nonemm.Session.Tests;
+
+public class LoggingSessionTests
+{
+ 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 LoggingSession Session(Contest? contest = null)
+ {
+ FakeLogStore store = new();
+ ContestInstance instance = store.AddContest(new ContestInstance
+ {
+ ContestNumber = 0,
+ ContestName = "CQWW",
+ });
+ return new LoggingSession(
+ store,
+ contest ?? new CqWorldWide(ModeCategory.Cw),
+ instance,
+ Me,
+ CountryFile.Parse(Countries));
+ }
+
+ private static void Type(LoggingSession 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()
+ {
+ LoggingSession 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()
+ {
+ LoggingSession 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()
+ {
+ LoggingSession session = Session();
+ session.Entry.Call = "JA1XYZ";
+ Assert.Throws(() => session.LogContact());
+ }
+
+ [Fact]
+ public void TheSameStationOnTheSameBandReadsAsADupe()
+ {
+ LoggingSession session = Session();
+ Type(session, "JA1XYZ", "25");
+ session.LogContact();
+ Type(session, "JA1XYZ", "25");
+ Assert.True(session.Verdict()?.IsDupe);
+ }
+
+ [Fact]
+ public void MovingBandClearsTheDupe()
+ {
+ LoggingSession 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()
+ {
+ LoggingSession 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()
+ {
+ LoggingSession 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)
+ {
+ LoggingSession session = Session();
+ session.Entry.Call = typed;
+ Assert.Equal(hertz, session.PendingQsy()?.Hertz);
+ }
+
+ [Theory]
+ [InlineData("JA1XYZ")]
+ [InlineData("12000")]
+ public void ACallOrAnOutOfBandNumberIsNotAQsy(string typed)
+ {
+ LoggingSession session = Session();
+ session.Entry.Call = typed;
+ Assert.Null(session.PendingQsy());
+ }
+
+ [Fact]
+ public void AContactDeletedFromTheLogGivesItsMultiplierBack()
+ {
+ LoggingSession 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);
+ Assert.Single(session.Log.Qsos);
+ Assert.Equal(2, session.Log.Tally.TotalMultipliers);
+ Assert.True(session.Log.Qsos.Single().IsMultiplier1);
+ }
+
+ [Fact]
+ 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));
+ Type(first, "JA1XYZ", "25");
+ first.LogContact();
+
+ LoggingSession second = new(store, new CqWorldWide(ModeCategory.Cw), instance, Me, CountryFile.Parse(Countries));
+ Assert.Single(second.Log.Qsos);
+ Assert.Equal(3, second.Log.Tally.Points);
+ }
+}
diff --git a/tests/Nonemm.Session.Tests/Nonemm.Session.Tests.csproj b/tests/Nonemm.Session.Tests/Nonemm.Session.Tests.csproj
new file mode 100644
index 0000000..15a7c1c
--- /dev/null
+++ b/tests/Nonemm.Session.Tests/Nonemm.Session.Tests.csproj
@@ -0,0 +1,28 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Nonemm.Spotting.Tests/BandmapTests.cs b/tests/Nonemm.Spotting.Tests/BandmapTests.cs
new file mode 100644
index 0000000..223bc00
--- /dev/null
+++ b/tests/Nonemm.Spotting.Tests/BandmapTests.cs
@@ -0,0 +1,61 @@
+using Nonemm.Core;
+
+namespace Nonemm.Spotting.Tests;
+
+public class BandmapTests
+{
+ private static readonly DateTime Now = new(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc);
+
+ private static Spot At(string call, double kilohertz, DateTime? at = null) =>
+ new(Callsign.Parse(call), Frequency.FromKilohertz(kilohertz), at ?? Now, SpotSource.Cluster);
+
+ [Fact]
+ public void SpotsComeBackInFrequencyOrder()
+ {
+ Bandmap map = new();
+ map.Add(At("DL1ABC", 14_100));
+ map.Add(At("JA1XYZ", 14_010));
+ Assert.Equal(["JA1XYZ", "DL1ABC"], map.On(Bands.Band20M).Select(s => s.Call.Text));
+ }
+
+ [Fact]
+ public void AStationThatMovesKeepsOneEntryOnItsBand()
+ {
+ Bandmap map = new();
+ map.Add(At("DL1ABC", 14_010));
+ map.Add(At("DL1ABC", 14_020));
+ Spot only = Assert.Single(map.On(Bands.Band20M));
+ Assert.Equal(14_020_000, only.Frequency.Hertz);
+ }
+
+ [Fact]
+ public void TheSameStationOnAnotherBandIsASeparateEntry()
+ {
+ Bandmap map = new();
+ map.Add(At("DL1ABC", 14_010));
+ map.Add(At("DL1ABC", 7_010));
+ Assert.Equal(2, map.All().Count);
+ }
+
+ [Fact]
+ public void SpotsOlderThanAnHourAreDropped()
+ {
+ Bandmap map = new();
+ map.Add(At("DL1ABC", 14_010, Now.AddHours(-2)));
+ map.Add(At("JA1XYZ", 14_020, Now.AddMinutes(-5)));
+ map.DropOlderThan(Now);
+ Assert.Equal("JA1XYZ", Assert.Single(map.All()).Call.Text);
+ }
+
+ [Fact]
+ public void TheNearestSpotInsideTheWindowIsFound()
+ {
+ Bandmap map = new();
+ map.Add(At("DL1ABC", 14_010));
+ map.Add(At("JA1XYZ", 14_050));
+ Assert.Equal(
+ "DL1ABC",
+ map.Near(Frequency.FromKilohertz(14_010.2), Frequency.FromKilohertz(0.5))?.Call.Text);
+ Assert.Null(map.Near(Frequency.FromKilohertz(14_030), Frequency.FromKilohertz(0.5)));
+ }
+}
diff --git a/tests/Nonemm.Spotting.Tests/Nonemm.Spotting.Tests.csproj b/tests/Nonemm.Spotting.Tests/Nonemm.Spotting.Tests.csproj
new file mode 100644
index 0000000..73d2ac0
--- /dev/null
+++ b/tests/Nonemm.Spotting.Tests/Nonemm.Spotting.Tests.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/Nonemm.Spotting.Tests/SpotLineTests.cs b/tests/Nonemm.Spotting.Tests/SpotLineTests.cs
new file mode 100644
index 0000000..39e398c
--- /dev/null
+++ b/tests/Nonemm.Spotting.Tests/SpotLineTests.cs
@@ -0,0 +1,34 @@
+using Nonemm.Core;
+
+namespace Nonemm.Spotting.Tests;
+
+public class SpotLineTests
+{
+ private static readonly DateTime Now = new(2026, 5, 30, 12, 40, 0, DateTimeKind.Utc);
+
+ [Fact]
+ public void ANodeSpotIsReadIntoItsParts()
+ {
+ Spot? spot = SpotLine.Parse(
+ "DX de W3LPL: 14025.0 DL1ABC CW 20 dB 25 WPM CQ 1234Z",
+ Now);
+
+ Assert.NotNull(spot);
+ Assert.Equal("DL1ABC", spot.Call.Text);
+ Assert.Equal(14_025_000, spot.Frequency.Hertz);
+ Assert.Equal("W3LPL", spot.Spotter);
+ Assert.Equal(new DateTime(2026, 5, 30, 12, 34, 0, DateTimeKind.Utc), spot.AtUtc);
+ Assert.DoesNotContain("1234Z", spot.Comment);
+ }
+
+ [Fact]
+ public void OtherTrafficIsNotASpot() =>
+ Assert.Null(SpotLine.Parse("WWV de VE7CC <18Z> : SFI=142, A=7, K=2", Now));
+
+ [Fact]
+ public void ASpotTimedAfterNowCameInYesterday()
+ {
+ Spot? spot = SpotLine.Parse("DX de W3LPL: 14025.0 DL1ABC CQ 2350Z", Now);
+ Assert.Equal(new DateTime(2026, 5, 29, 23, 50, 0, DateTimeKind.Utc), spot?.AtUtc);
+ }
+}