Edit and delete contacts in the log

Double-click a cell in the log window to change it. The columns follow the
contest exchange instead of being fixed in the XAML, so CQ WW gets a Zone
column and Sweepstakes gets Nr, Prec, Ck and Sec.

QsoEditor holds the column list and applies one field edit. Validation comes
from the ExchangeFieldKind the contest declared: a CQ zone is 1 to 40, an ITU
zone 1 to 90, a section is looked up in the ARRL list, a grid must parse.
A refused edit returns the reason and leaves the log alone, so the cell
reverts. Changing the callsign runs the country lookup again.

Delete, or the right-click menu, removes the selected contact after a
confirmation. Either way the log is rescored, so a multiplier the removed
contact held passes to the next contact that claims it.

Both go out to the other stations in N1MM's own messages: contactreplace
carries oldcall and oldtimestamp, contactdelete names the contact. An incoming
edit or delete is matched by contact id first, falling back to call plus
timestamp because N1MM does not know our ids.

Country, continent and the two prefixes were filled in twice, once when
logging and once when editing. They now come from CountryFields.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 15:32:32 +00:00
parent 328a40b27c
commit eb31fa5ae1
23 changed files with 953 additions and 112 deletions

1
.gitignore vendored
View File

@@ -2,3 +2,4 @@ bin/
obj/
*.user
/N1MM/
/N1MMsln/

View File

@@ -45,6 +45,7 @@ scorer: red for a dupe, green for a new multiplier, blue for points.
| Log | N1MM `.s3db`, Cabrillo 3.0 out, ADIF in and out |
| While typing | dupe check, multiplier check, points, country and zone from the country file |
| Windows | entry, log, check, bandmap, score summary, packet |
| Editing | double-click a cell in the log to change it, Delete to remove the contact; both go out to the other stations |
| Radio | hamlib `rigctld`, reconnecting on its own |
| Cluster | DX cluster over telnet, spots feeding the bandmap |
| Network | contacts shared with the other stations of a multi-operator entry, in N1MM's own contact message |
@@ -94,13 +95,30 @@ messages for CW and for phone. The macros are N1MM's: `{MYCALL}`, `{CALL}`,
`{EXCH}`, `{SENTRST}`, `{SENTNR}`, `#` for the serial number, and `{SENTRSTCUT}`
for cut numbers. Escape stops sending.
### Editing the log
Double-click a cell in the log window to change it. The columns follow the
contest exchange, so CQ WW shows a Zone column and Sweepstakes shows Nr, Prec,
Ck and Sec. A value the contest does not accept — zone 41, an ARRL section that
does not exist, a callsign with a space in it — is refused and the reason
appears under the log. Changing the callsign looks the country up again.
Delete, or the right-click menu, removes the selected contact after a
confirmation. Either way the whole log is scored again, so a multiplier the
removed contact was holding passes to the next contact that claims it.
### Networked stations
**Config → Network** names this station and lists the others. Each contact is
sent to them as it is logged, in N1MM's `contactinfo` message, so an N1MM
station on the same network sees them too. With no addresses listed the contacts
are broadcast. A contact that arrives is scored again here from the rules rather
than trusted.
station on the same network sees them too. Editing a contact sends
`contactreplace` and deleting one sends `contactdelete`, the same as N1MM. With
no addresses listed the messages are broadcast. A contact that arrives is scored
again here from the rules rather than trusted.
An incoming edit or delete is matched by contact id first. N1MM does not know
our ids, so the call and the timestamp are the fallback — that is the pair N1MM
itself keys a contact on.
## Layout
@@ -125,8 +143,9 @@ covered by plain unit tests.
Working: logging a contest end to end, live dupe and multiplier checking, eight
built-in contests plus user-defined ones, Cabrillo and ADIF export, ADIF import,
the log, check, bandmap, score and packet windows, radio control, DX cluster
spots, contacts shared between networked stations, and CW keying.
the log, check, bandmap, score and packet windows, editing and deleting logged
contacts, radio control, DX cluster spots, contacts shared between networked
stations, and CW keying.
Checked against N1MM 1.0.11031: a log this program wrote opens in N1MM, which
reads the contest, its categories and the contacts. See

View File

@@ -112,6 +112,9 @@ public sealed class AppSession : IDisposable
Logging.Logged += (_, qso) => Bandmap.Add(new Spot(
qso.Call, qso.Frequency, qso.TimestampUtc, SpotSource.Log));
Logging.Logged += (_, qso) => _ = network?.SendAsync(qso, Settings.Station.Callsign);
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);
Save(Settings with { ContestNumber = contestNumber });
ContestChanged?.Invoke(this, EventArgs.Empty);
@@ -132,28 +135,59 @@ public sealed class AppSession : IDisposable
Settings.NetworkPort,
Settings.NetworkStationName.Length > 0 ? Settings.NetworkStationName : Environment.MachineName,
Settings.NetworkPeers);
network.ContactArrived += (_, qso) => TakeFromNetwork(qso);
network.UpdateArrived += (_, update) => TakeFromNetwork(update);
network.Start();
Changed?.Invoke(this, EventArgs.Empty);
}
/// A contact another station logged. It goes into the same log under the
/// contest that is open, and is scored here rather than trusting the
/// points the sender put in the message.
private void TakeFromNetwork(Qso qso)
/// What another station did to its log, applied to ours. The store is
/// written directly rather than through `Logging`, so the change is not
/// broadcast back out again. Points and multipliers are worked out here
/// from the rules instead of trusting what the sender put in the message.
private void TakeFromNetwork(ContactUpdate update)
{
if (Logging is null || store is null)
{
return;
}
if (Logging.Log.Qsos.Any(q => q.Id == qso.Id))
int contestNumber = Logging.Instance.ContestNumber;
switch (update)
{
case ContactLogged logged when !Logging.Log.Qsos.Any(q => q.Id == logged.Qso.Id):
store.Add(logged.Qso with { ContestNumber = contestNumber, IsOriginal = false });
break;
case ContactReplaced replaced:
if (FindLocal(replaced.Qso.Id, replaced.OldCall, replaced.OldTimestampUtc) is not { } old)
{
return;
}
Qso mine = qso with { ContestNumber = Logging.Instance.ContestNumber, IsOriginal = false };
store.Add(mine);
OpenContest(Logging.Instance.ContestNumber);
store.Update(replaced.Qso with
{
Id = old.Id,
ContestNumber = contestNumber,
IsOriginal = false,
});
break;
case ContactDeleted deleted:
if (FindLocal(deleted.Id, deleted.Call, deleted.TimestampUtc) is not { } gone)
{
return;
}
store.Delete(gone.Id);
break;
default:
return;
}
OpenContest(contestNumber);
}
/// N1MM keys a contact on its call and time, so a message from N1MM carries
/// no id we would recognise. Fall back to that pair when the id misses.
private Qso? FindLocal(string id, string call, DateTime timestampUtc) =>
Logging?.Log.Qsos.FirstOrDefault(q => id.Length > 0 && q.Id == id)
?? Logging?.Log.Qsos.FirstOrDefault(q =>
string.Equals(q.Call.Text, call, StringComparison.OrdinalIgnoreCase)
&& q.TimestampUtc == timestampUtc);
/// Starts, restarts or stops the keyer, following what the settings say.
/// A keyer that will not open is reported; the program keeps running

View File

@@ -0,0 +1,14 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Nonemm.App.Dialogs.ConfirmDialog"
Width="380" SizeToContent="Height"
WindowStartupLocation="CenterOwner" CanResize="False">
<DockPanel Margin="14">
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right"
Spacing="6" Margin="0,12,0,0">
<Button Content="Cancel" Click="OnCancel" IsCancel="True" />
<Button Name="ConfirmButton" Click="OnConfirm" IsDefault="True" />
</StackPanel>
<TextBlock Name="QuestionText" TextWrapping="Wrap" />
</DockPanel>
</Window>

View File

@@ -0,0 +1,20 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace Nonemm.App.Dialogs;
/// Asks a yes or no question before something that cannot be undone.
public sealed partial class ConfirmDialog : Window
{
public ConfirmDialog(string title, string question, string confirmLabel)
{
InitializeComponent();
Title = title;
QuestionText.Text = question;
ConfirmButton.Content = confirmLabel;
}
private void OnConfirm(object? sender, RoutedEventArgs e) => Close(true);
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
}

View File

@@ -1,24 +1,36 @@
using Avalonia.Media;
using Nonemm.Contests;
using Nonemm.Core;
using Nonemm.Session;
namespace Nonemm.App.Windows;
/// One line of the log window. It carries the colour so the log is coloured by
/// the same scorer as the entry window and the bandmap.
public sealed record LogRow(Qso Qso, Verdict? Verdict)
/// One line of the log window. The grid reaches the editable fields through the
/// indexer, so the columns can follow the contest exchange instead of being
/// fixed in the XAML. The colour comes from the same scorer as the entry window
/// and the bandmap.
public sealed class LogRow
{
public string Time => Qso.TimestampUtc.ToString("MM-dd HH:mm");
private readonly Func<Qso, QsoField, string, bool> edit;
public string Call => Qso.Call.Text;
public LogRow(Qso qso, Verdict? verdict, Func<Qso, QsoField, string, bool> edit)
{
Qso = qso;
Verdict = verdict;
this.edit = edit;
}
public string Frequency => Qso.Frequency.Kilohertz.ToString("0.0");
public Qso Qso { get; }
public string Mode => Qso.Mode.Name;
public Verdict? Verdict { get; }
public string Sent => $"{Qso.SentReport} {SentExchange()}".Trim();
public string Received => $"{Qso.ReceivedReport} {ReceivedExchange()}".Trim();
/// `name` is a `QsoField` name. A refused edit leaves the contact alone, so
/// the grid reads the old value back and the cell reverts.
public string this[string name]
{
get => QsoEditor.Read(Qso, Enum.Parse<QsoField>(name));
set => edit(Qso, Enum.Parse<QsoField>(name), value);
}
public string Country => Qso.CountryPrefix;
@@ -29,31 +41,5 @@ public sealed record LogRow(Qso Qso, Verdict? Verdict)
Qso.IsMultiplier2 ? "2" : "",
Qso.IsMultiplier3 ? "3" : "");
public string Operator => Qso.Operator;
public IBrush Colour => Verdicts.Colour(Verdict);
private string SentExchange() =>
Qso.SentNumber > 0 ? Qso.SentNumber.ToString() : "";
private string ReceivedExchange()
{
List<string> parts = [];
if (Qso.ReceivedNumber > 0)
{
parts.Add(Qso.ReceivedNumber.ToString());
}
if (Qso.Zone > 0)
{
parts.Add(Qso.Zone.ToString());
}
foreach (string value in new[] { Qso.Section, Qso.Exchange1, Qso.Name, Qso.GridSquare, Qso.MiscText })
{
if (value.Length > 0)
{
parts.Add(value);
}
}
return string.Join(' ', parts);
}
}

View File

@@ -5,22 +5,13 @@
Title="Log" Width="1000" Height="420">
<DockPanel>
<TextBlock DockPanel.Dock="Bottom" Name="SummaryText" Margin="8,4" FontSize="11" Opacity="0.75" />
<DataGrid Name="Rows" IsReadOnly="True" GridLinesVisibility="Horizontal"
CanUserSortColumns="False" FontSize="12" FontFamily="monospace"
x:DataType="local:LogRow">
<DataGrid.Columns>
<DataGridTextColumn Header="Time" Binding="{Binding Time}" Width="125" />
<DataGridTextColumn Header="Freq" Binding="{Binding Frequency}" Width="95" />
<DataGridTextColumn Header="Mode" Binding="{Binding Mode}" Width="60" />
<DataGridTextColumn Header="Call" Binding="{Binding Call}" Width="120"
Foreground="{Binding Colour}" />
<DataGridTextColumn Header="Sent" Binding="{Binding Sent}" Width="100" />
<DataGridTextColumn Header="Received" Binding="{Binding Received}" Width="160" />
<DataGridTextColumn Header="Cty" Binding="{Binding Country}" Width="70" />
<DataGridTextColumn Header="Pts" Binding="{Binding Points}" Width="60" />
<DataGridTextColumn Header="Mult" Binding="{Binding Multipliers}" Width="70" />
<DataGridTextColumn Header="Op" Binding="{Binding Operator}" Width="*" />
</DataGrid.Columns>
<DataGrid Name="Rows" GridLinesVisibility="Horizontal" CanUserSortColumns="False"
FontSize="12" FontFamily="monospace">
<DataGrid.ContextMenu>
<ContextMenu>
<MenuItem Header="Delete contact" Click="OnDelete" InputGesture="Delete" />
</ContextMenu>
</DataGrid.ContextMenu>
</DataGrid>
</DockPanel>
</local:RefreshableWindow>

View File

@@ -1,20 +1,39 @@
using Avalonia.Controls;
using Avalonia.Data;
using Avalonia.Input;
using Avalonia.Interactivity;
using Nonemm.App.Dialogs;
using Nonemm.Core;
using Nonemm.Session;
namespace Nonemm.App.Windows;
/// The contacts of the contest in progress, newest last.
/// The contacts of the contest in progress, newest last. Double-click a cell to
/// change it; the Delete key removes the contact.
public sealed partial class LogWindow : RefreshableWindow
{
/// Roughly the width of one character of the grid font, in device pixels.
private const double CharacterWidth = 8;
private readonly AppSession session;
private string builtFor = "";
private string message = "";
public LogWindow(AppSession session)
{
this.session = session;
InitializeComponent();
Rows.KeyDown += OnKeyDown;
Rows.LoadingRow += (_, e) =>
{
if (e.Row.DataContext is LogRow row)
{
e.Row.Foreground = row.Colour;
}
};
Refresh();
}
public override void Refresh()
{
if (session.Logging is null)
@@ -23,17 +42,100 @@ public sealed partial class LogWindow : RefreshableWindow
SummaryText.Text = "no contest is open";
return;
}
BuildColumns(session.Logging.Editor);
List<LogRow> rows = session.Logging.Log.Qsos
.Select(q => new LogRow(q, VerdictFor(q)))
.Select(q => new LogRow(q, VerdictFor(q), Edit))
.ToList();
Rows.ItemsSource = rows;
if (rows.Count > 0)
{
Rows.ScrollIntoView(rows[^1], null);
}
SummaryText.Text =
$"{session.Logging.Log.Tally.Qsos} contacts · {session.Logging.Log.Tally.Points} points · " +
$"{session.Logging.Log.Tally.TotalMultipliers} multipliers · score {session.Logging.Log.TotalScore:N0}";
SummaryText.Text = message.Length > 0 ? message : Summary(session.Logging);
message = "";
}
private static string Summary(LoggingSession logging) =>
$"{logging.Log.Tally.Qsos} contacts · {logging.Log.Tally.Points} points · " +
$"{logging.Log.Tally.TotalMultipliers} multipliers · score {logging.Log.TotalScore:N0}";
/// The columns follow the contest exchange, so they are rebuilt when a
/// different contest is opened.
private void BuildColumns(QsoEditor editor)
{
string wanted = string.Join('|', editor.Columns.Select(c => c.Label));
if (wanted == builtFor)
{
return;
}
builtFor = wanted;
Rows.Columns.Clear();
foreach (QsoColumn column in editor.Columns)
{
Rows.Columns.Add(new DataGridTextColumn
{
Header = column.Label,
Width = new DataGridLength(column.Width * CharacterWidth),
Binding = new Binding($"[{column.Field}]") { Mode = BindingMode.TwoWay },
});
}
Rows.Columns.Add(ReadOnlyColumn("Cty", nameof(LogRow.Country), 6));
Rows.Columns.Add(ReadOnlyColumn("Pts", nameof(LogRow.Points), 4));
Rows.Columns.Add(ReadOnlyColumn("Mult", nameof(LogRow.Multipliers), 5));
}
private static DataGridTextColumn ReadOnlyColumn(string header, string property, int characters) =>
new()
{
Header = header,
IsReadOnly = true,
Width = new DataGridLength(characters * CharacterWidth),
Binding = new Binding(property),
};
/// True when the edit was taken. A refused edit puts the reason in the
/// summary line and the cell falls back to what it held before.
private bool Edit(Qso qso, QsoField field, string text)
{
if (session.Logging is null)
{
return false;
}
QsoEdit edit = session.Logging.Edit(qso.Id, field, text);
if (edit.IsAccepted)
{
return true;
}
SummaryText.Text = edit.Error;
return false;
}
private void OnKeyDown(object? sender, KeyEventArgs e)
{
if (e.Key == Key.Delete && e.Source is not TextBox)
{
OnDelete(sender, new RoutedEventArgs());
e.Handled = true;
}
}
private async void OnDelete(object? sender, RoutedEventArgs e)
{
if (session.Logging is null || Rows.SelectedItem is not LogRow row)
{
return;
}
ConfirmDialog dialog = new(
"Delete contact",
$"Delete {row.Qso.Call.Text} at {row.Qso.TimestampUtc:yyyy-MM-dd HH:mm:ss}? " +
"The other stations are told to delete it too.",
"Delete");
if (await dialog.ShowDialog<bool>(this))
{
session.Logging.Delete(row.Qso.Id);
message = $"{row.Qso.Call.Text} deleted";
Refresh();
}
}
/// A logged contact is coloured by what it turned out to be worth, not by

View File

@@ -34,6 +34,18 @@ public sealed record Callsign
/// prefix, so most contests score them zero.
public bool CountsForEntity => !IsMaritimeMobile && !IsAeronauticalMobile;
/// True when the text is shaped like a callsign: letters and digits, with
/// at least one of each, plus the usual portable prefixes and modifiers.
/// It cannot say whether the call was issued, only that it could have been.
public bool IsPlausible =>
Station.Length >= 3 &&
Station.Length <= 10 &&
Station.All(char.IsLetterOrDigit) &&
Station.Any(char.IsDigit) &&
Station.Any(char.IsLetter) &&
(PortablePrefix is null ||
(PortablePrefix.Length <= 6 && PortablePrefix.All(char.IsLetterOrDigit)));
public static Callsign Parse(string text)
{
string cleaned = text.Trim().ToUpperInvariant();

View File

@@ -4,22 +4,55 @@ using Nonemm.Core;
namespace Nonemm.Network;
/// One contact as N1MM broadcasts it: a `contactinfo` XML document. Writing
/// N1MM's own message means a Nonemm station and an N1MM station can sit on the
/// same network and see each other's contacts.
/// The contact messages N1MM broadcasts: `contactinfo` when a contact is
/// logged, `contactreplace` when one is edited and `contactdelete` when one is
/// removed. Writing N1MM's own messages lets a Nonemm station and an N1MM
/// station sit on the same network and see each other's contacts.
public static class ContactMessage
{
/// N1MM sends the frequencies in units of ten hertz.
private const long FrequencyUnit = 10;
public static string Write(Qso qso, string myCallsign, string stationName)
private const string TimeFormat = "yyyy-MM-dd HH:mm:ss";
public static string Write(Qso qso, string myCallsign, string stationName) =>
Contact("contactinfo", qso, myCallsign, stationName).ToString();
/// An edited contact. The old call and time tell the other stations which
/// of their rows to replace.
public static string WriteReplace(
Qso qso,
string myCallsign,
string stationName,
string oldCall,
DateTime oldTimestampUtc)
{
XElement root = Contact("contactreplace", qso, myCallsign, stationName);
root.Add(new XElement("oldtimestamp", Stamp(oldTimestampUtc)));
root.Add(new XElement("oldcall", oldCall));
return root.ToString();
}
public static string WriteDelete(Qso qso, string myCallsign, string stationName) =>
new XElement(
"contactdelete",
new XElement("app", "Nonemm"),
new XElement("timestamp", Stamp(qso.TimestampUtc)),
new XElement("call", qso.Call.Text),
new XElement("mycall", myCallsign),
new XElement("band", qso.Band?.MegahertzLabel ?? 0),
new XElement("contestnr", qso.ContestNumber),
new XElement("StationName", stationName),
new XElement("ID", qso.Id)).ToString();
private static XElement Contact(string element, Qso qso, string myCallsign, string stationName)
{
XElement root = new(
"contactinfo",
element,
new XElement("app", "Nonemm"),
new XElement("contestname", qso.ContestName),
new XElement("contestnr", qso.ContestNumber),
new XElement("timestamp", qso.TimestampUtc.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)),
new XElement("timestamp", Stamp(qso.TimestampUtc)),
new XElement("mycall", myCallsign),
new XElement("band", qso.Band?.MegahertzLabel ?? 0),
new XElement("rxfreq", qso.Frequency.Hertz / FrequencyUnit),
@@ -60,12 +93,12 @@ public static class ContactMessage
new XElement("StationName", stationName),
new XElement("ID", qso.Id),
new XElement("IsClaimedQso", qso.IsClaimed ? 1 : 0));
return root.ToString();
return root;
}
/// Null for a message that is not a contact, which is how the other N1MM
/// message kinds on the same port are passed over.
public static Qso? Read(string xml)
/// Null for a message that is not about a contact. The other N1MM message
/// kinds share the port, so they arrive here too.
public static ContactUpdate? Read(string xml)
{
XElement root;
try
@@ -76,19 +109,39 @@ public static class ContactMessage
{
return null;
}
if (root.Name.LocalName != "contactinfo")
{
return null;
}
string call = Text(root, "call");
if (call.Length == 0)
{
return null;
}
return new Qso
string station = Station(root);
switch (root.Name.LocalName)
{
case "contactinfo":
return new ContactLogged(ReadContact(root, call), station);
case "contactreplace":
return new ContactReplaced(
ReadContact(root, call),
Text(root, "oldcall") is { Length: > 0 } old ? old : call,
Timestamp(root, "oldtimestamp"),
station);
case "contactdelete":
return new ContactDeleted(
Text(root, "ID"),
call,
Timestamp(root, "timestamp"),
(int)Integer(root, "contestnr"),
station);
default:
return null;
}
}
private static Qso ReadContact(XElement root, string call) =>
new Qso
{
Id = Text(root, "ID") is { Length: > 0 } id ? id : Qso.NewId(),
TimestampUtc = Timestamp(root),
TimestampUtc = Timestamp(root, "timestamp"),
Call = Callsign.Parse(call),
Frequency = Frequency.FromHertz(Integer(root, "rxfreq") * FrequencyUnit),
QsxFrequency = Frequency.FromHertz(Integer(root, "txfreq") * FrequencyUnit),
@@ -124,14 +177,11 @@ public static class ContactMessage
RadioNumber = (int)Integer(root, "radionr"),
IsRadioInterfaced = Flag(root, "RadioInterfaced"),
NetworkedComputerNumber = (int)Integer(root, "NetworkedCompNr"),
StationName = Text(root, "StationName") is { Length: > 0 } station
? station
: Text(root, "NetBiosName"),
StationName = Station(root),
// a contact that arrived over the network was made somewhere else
IsOriginal = false,
IsClaimed = Flag(root, "IsClaimedQso"),
};
}
private static string Text(XElement root, string name) =>
root.Element(name)?.Value.Trim() ?? "";
@@ -147,9 +197,15 @@ public static class ContactMessage
return text.Equals("true", StringComparison.OrdinalIgnoreCase) || text == "1";
}
private static DateTime Timestamp(XElement root) =>
private static string Stamp(DateTime when) =>
when.ToString(TimeFormat, CultureInfo.InvariantCulture);
private static string Station(XElement root) =>
Text(root, "StationName") is { Length: > 0 } name ? name : Text(root, "NetBiosName");
private static DateTime Timestamp(XElement root, string name) =>
DateTime.TryParse(
Text(root, "timestamp"),
Text(root, name),
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out DateTime when)

View File

@@ -0,0 +1,23 @@
using Nonemm.Core;
namespace Nonemm.Network;
/// Something another station did to its log. `StationName` is the computer that
/// sent it, so a station can ignore the echo of its own message.
public abstract record ContactUpdate(string StationName);
public sealed record ContactLogged(Qso Qso, string StationName)
: ContactUpdate(StationName);
/// An edited contact. The receiver finds its copy by the old call and time,
/// because that is the pair N1MM keys a contact on.
public sealed record ContactReplaced(Qso Qso, string OldCall, DateTime OldTimestampUtc, string StationName)
: ContactUpdate(StationName);
public sealed record ContactDeleted(
string Id,
string Call,
DateTime TimestampUtc,
int ContestNumber,
string StationName)
: ContactUpdate(StationName);

View File

@@ -31,17 +31,34 @@ public sealed class StationNetwork : IDisposable
public IReadOnlyList<IPEndPoint> Peers => peers;
/// A contact another station logged. It arrives already scored; the log
/// works its points and multipliers out again from the rules.
public event EventHandler<Qso>? ContactArrived;
/// A contact another station logged, edited or deleted. A logged or edited
/// contact arrives already scored; the log works its points and multipliers
/// out again from the rules.
public event EventHandler<ContactUpdate>? UpdateArrived;
public event EventHandler<string>? Failed;
public void Start() => loop ??= Task.Run(() => ListenAsync(stopping.Token));
public async Task SendAsync(Qso qso, string myCallsign, CancellationToken cancellation = default)
public Task SendAsync(Qso qso, string myCallsign, CancellationToken cancellation = default) =>
SendTextAsync(ContactMessage.Write(qso, myCallsign, stationName), cancellation);
public Task SendEditAsync(
Qso qso,
string myCallsign,
string oldCall,
DateTime oldTimestampUtc,
CancellationToken cancellation = default) =>
SendTextAsync(
ContactMessage.WriteReplace(qso, myCallsign, stationName, oldCall, oldTimestampUtc),
cancellation);
public Task SendDeleteAsync(Qso qso, string myCallsign, CancellationToken cancellation = default) =>
SendTextAsync(ContactMessage.WriteDelete(qso, myCallsign, stationName), cancellation);
private async Task SendTextAsync(string xml, CancellationToken cancellation)
{
byte[] message = Encoding.UTF8.GetBytes(ContactMessage.Write(qso, myCallsign, stationName));
byte[] message = Encoding.UTF8.GetBytes(xml);
foreach (IPEndPoint peer in Destinations())
{
try
@@ -75,10 +92,10 @@ public sealed class StationNetwork : IDisposable
try
{
UdpReceiveResult received = await listener.ReceiveAsync(cancellation).ConfigureAwait(false);
Qso? qso = ContactMessage.Read(Encoding.UTF8.GetString(received.Buffer));
if (qso is not null && qso.StationName != stationName)
ContactUpdate? update = ContactMessage.Read(Encoding.UTF8.GetString(received.Buffer));
if (update is not null && update.StationName != stationName)
{
ContactArrived?.Invoke(this, qso);
UpdateArrived?.Invoke(this, update);
}
}
catch (OperationCanceledException)

View File

@@ -0,0 +1,22 @@
using Nonemm.Core;
using Nonemm.Core.Country;
namespace Nonemm.Session;
/// Fills in the contact fields that come from the callsign: country,
/// continent and the two prefixes. Used when a contact is logged and when its
/// call is edited later.
public static class CountryFields
{
public static Qso Apply(Qso qso, CountryFile? countries)
{
CountryLookup? country = countries?.Find(qso.Call);
return qso with
{
CountryPrefix = country?.Entity.PrimaryPrefix ?? "",
Continent = country?.Continent ?? "",
StationPrefix = qso.Call.PortablePrefix ?? "",
WpxPrefix = qso.Call.WpxPrefix() ?? "",
};
}
}

View File

@@ -29,6 +29,7 @@ public sealed class LoggingSession
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();
}
@@ -42,6 +43,8 @@ public sealed class LoggingSession
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;
@@ -58,6 +61,12 @@ public sealed class LoggingSession
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;
public void Tune(Frequency frequency, Mode? mode = null)
{
Frequency = frequency;
@@ -112,11 +121,31 @@ public sealed class LoggingSession
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)
{
Qso before = Log.Qsos.FirstOrDefault(q => q.Id == id)
?? throw new InvalidOperationException($"no contact with id {id} in the log");
QsoEdit edit = Editor.Apply(before, field, text);
if (edit.Result is not null)
{
Update(edit.Result);
Edited?.Invoke(this, new QsoChange(edit.Result, before.Call.Text, before.TimestampUtc));
}
return edit;
}
public void Update(Qso qso)
{
store.Update(qso);
@@ -130,7 +159,6 @@ public sealed class LoggingSession
private Qso BuildQso(string call)
{
Callsign parsed = Callsign.Parse(call);
CountryLookup? country = countries?.Find(parsed);
Qso qso = new()
{
Id = Qso.NewId(),
@@ -142,14 +170,10 @@ public sealed class LoggingSession
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);
return ApplyExchange(CountryFields.Apply(qso, countries));
}
private Qso ApplyExchange(Qso qso)

View File

@@ -0,0 +1,7 @@
using Nonemm.Core;
namespace Nonemm.Session;
/// An edited contact, plus the call and time it had before the edit. Other
/// stations look their copy up by that pair.
public sealed record QsoChange(Qso Qso, string OldCall, DateTime OldTimestampUtc);

View File

@@ -0,0 +1,5 @@
namespace Nonemm.Session;
/// One editable column of the log window.
/// `Width` is in characters.
public sealed record QsoColumn(string Label, QsoField Field, int Width);

View File

@@ -0,0 +1,13 @@
using Nonemm.Core;
namespace Nonemm.Session;
/// The result of an edit: either the changed contact or an error message.
public sealed record QsoEdit(Qso? Result, string Error)
{
public static QsoEdit Accept(Qso qso) => new(qso, "");
public static QsoEdit Refuse(string error) => new(null, error);
public bool IsAccepted => Result is not null;
}

View File

@@ -0,0 +1,227 @@
using System.Globalization;
using Nonemm.Contests;
using Nonemm.Contests.Multipliers;
using Nonemm.Core;
using Nonemm.Core.Country;
namespace Nonemm.Session;
/// Reads and changes one field of a logged contact. The contest decides which
/// columns there are and what each one accepts. No UI framework here, so the
/// checks are unit-tested.
public sealed class QsoEditor
{
private const string TimeFormat = "yyyy-MM-dd HH:mm:ss";
private readonly CountryFile? countries;
private readonly Dictionary<QsoField, ExchangeFieldKind> exchangeKinds = [];
public QsoEditor(Contest contest, StationInfo me, CountryFile? countries)
{
this.countries = countries;
Columns = BuildColumns(contest, me, exchangeKinds);
}
/// Log columns, left to right.
public IReadOnlyList<QsoColumn> Columns { get; }
public static string Read(Qso qso, QsoField field) => field switch
{
QsoField.Time => qso.TimestampUtc.ToString(TimeFormat, CultureInfo.InvariantCulture),
QsoField.Frequency => qso.Frequency.Kilohertz.ToString("0.0", CultureInfo.InvariantCulture),
QsoField.Mode => qso.Mode.Name,
QsoField.Call => qso.Call.Text,
QsoField.SentReport => qso.SentReport,
QsoField.SentNumber => Digits(qso.SentNumber),
QsoField.ReceivedReport => qso.ReceivedReport,
QsoField.ReceivedNumber => Digits(qso.ReceivedNumber),
QsoField.Zone => Digits(qso.Zone),
QsoField.Section => qso.Section,
QsoField.Precedence => qso.Precedence,
QsoField.Check => Digits(qso.Check),
QsoField.Exchange1 => qso.Exchange1,
QsoField.MiscText => qso.MiscText,
QsoField.Name => qso.Name,
QsoField.Qth => qso.Qth,
QsoField.GridSquare => qso.GridSquare,
QsoField.Power => qso.Power,
QsoField.Comment => qso.Comment,
QsoField.Operator => qso.Operator,
_ => "",
};
/// Returns the contact with the field changed, or an error message. An empty
/// value clears the field, except for the call, time, mode and frequency.
public QsoEdit Apply(Qso qso, QsoField field, string text)
{
string value = Normalize(field, text);
if (KindError(field, value) is { } refused)
{
return QsoEdit.Refuse(refused);
}
return field switch
{
QsoField.Time => WithTime(qso, value),
QsoField.Frequency => WithFrequency(qso, value),
QsoField.Mode => WithMode(qso, value),
QsoField.Call => WithCall(qso, value),
QsoField.SentReport => QsoEdit.Accept(qso with { SentReport = value }),
QsoField.SentNumber => Count(value, n => qso with { SentNumber = n }),
QsoField.ReceivedReport => QsoEdit.Accept(qso with { ReceivedReport = value }),
QsoField.ReceivedNumber => Count(value, n => qso with { ReceivedNumber = n }),
QsoField.Zone => Count(value, n => qso with { Zone = n }),
QsoField.Section => QsoEdit.Accept(qso with { Section = value }),
QsoField.Precedence => QsoEdit.Accept(qso with { Precedence = value }),
QsoField.Check => Count(value, n => qso with { Check = n }),
QsoField.Exchange1 => QsoEdit.Accept(qso with { Exchange1 = value }),
QsoField.MiscText => QsoEdit.Accept(qso with { MiscText = value }),
QsoField.Name => QsoEdit.Accept(qso with { Name = value }),
QsoField.Qth => QsoEdit.Accept(qso with { Qth = value }),
QsoField.GridSquare => QsoEdit.Accept(qso with { GridSquare = value }),
QsoField.Power => QsoEdit.Accept(qso with { Power = value }),
QsoField.Comment => QsoEdit.Accept(qso with { Comment = value }),
QsoField.Operator => QsoEdit.Accept(qso with { Operator = value }),
_ => QsoEdit.Refuse($"{field} cannot be edited"),
};
}
// exchange fields are stored upper case, the same as the entry window does
// it; names, QTHs, power and comments keep the case they were typed in
private static string Normalize(QsoField field, string text)
{
string trimmed = text.Trim();
return field is QsoField.Name or QsoField.Qth or QsoField.Power or QsoField.Comment
? trimmed
: trimmed.ToUpperInvariant();
}
private QsoEdit WithCall(Qso qso, string value)
{
Callsign call = Callsign.Parse(value);
if (!call.IsPlausible)
{
return QsoEdit.Refuse($"{value} is not shaped like a callsign");
}
return QsoEdit.Accept(CountryFields.Apply(qso with { Call = call }, countries));
}
private static QsoEdit WithTime(Qso qso, string value)
{
if (!DateTime.TryParse(
value,
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out DateTime when))
{
return QsoEdit.Refuse($"{value} is not a date and time; write it as {TimeFormat}");
}
return QsoEdit.Accept(qso with { TimestampUtc = when });
}
private static QsoEdit WithFrequency(Qso qso, string value)
{
if (!double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out double kilohertz)
|| kilohertz <= 0)
{
return QsoEdit.Refuse($"{value} is not a frequency in kilohertz");
}
return QsoEdit.Accept(qso with { Frequency = Frequency.FromKilohertz(kilohertz) });
}
private static QsoEdit WithMode(Qso qso, string value)
{
Mode? mode = Modes.Parse(value);
return mode is null
? QsoEdit.Refuse($"{value} is not a mode this logger knows")
: QsoEdit.Accept(qso with { Mode = mode });
}
private static QsoEdit Count(string value, Func<int, Qso> set)
{
if (value.Length == 0)
{
return QsoEdit.Accept(set(0));
}
return int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int number)
&& number >= 0
? QsoEdit.Accept(set(number))
: QsoEdit.Refuse($"{value} is not a whole number");
}
/// Checks the value against the exchange field kind the contest defined.
/// Returns null if it is acceptable. An empty value always is.
private string? KindError(QsoField field, string value)
{
if (value.Length == 0 || !exchangeKinds.TryGetValue(field, out ExchangeFieldKind kind))
{
return null;
}
return kind switch
{
ExchangeFieldKind.Check => InRange(value, 0, 99) ? null : "the check is a two-digit year",
ExchangeFieldKind.CqZone => InRange(value, 1, 40) ? null : "CQ zones run from 1 to 40",
ExchangeFieldKind.ItuZone => InRange(value, 1, 90) ? null : "ITU zones run from 1 to 90",
ExchangeFieldKind.ArrlSection =>
ArrlSections.IsSection(value) ? null : $"{value} is not an ARRL section",
ExchangeFieldKind.UsStateOrCanadianProvince =>
StatesAndProvinces.IsStateOrProvince(value) ? null : $"{value} is not a state or province",
ExchangeFieldKind.Grid =>
GridSquare.TryParse(value, out _) ? null : $"{value} is not a grid square",
ExchangeFieldKind.Precedence =>
value.Length == 1 && char.IsLetter(value[0]) ? null : "the precedence is one letter",
_ => null,
};
}
private static bool InRange(string value, int low, int high) =>
int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int number)
&& number >= low
&& number <= high;
private static string Digits(int value) => value > 0 ? value.ToString(CultureInfo.InvariantCulture) : "";
private static IReadOnlyList<QsoColumn> BuildColumns(
Contest contest,
StationInfo me,
Dictionary<QsoField, ExchangeFieldKind> kinds)
{
List<QsoColumn> columns =
[
new QsoColumn("Time", QsoField.Time, TimeFormat.Length),
new QsoColumn("Freq", QsoField.Frequency, 8),
new QsoColumn("Mode", QsoField.Mode, 5),
new QsoColumn("Call", QsoField.Call, 12),
new QsoColumn("Snt", QsoField.SentReport, 4),
];
if (contest.HasSerialNumbers)
{
columns.Add(new QsoColumn("SntNr", QsoField.SentNumber, 5));
}
foreach (ExchangeField field in contest.ExchangeFieldsFor(me))
{
QsoField mapped = FieldFor(field.Slot);
kinds[mapped] = field.Kind;
columns.Add(new QsoColumn(field.Label, mapped, Math.Max(field.Width, field.Label.Length)));
}
columns.Add(new QsoColumn("Op", QsoField.Operator, 8));
return columns;
}
private static QsoField FieldFor(ExchangeSlot slot) => slot switch
{
ExchangeSlot.ReceivedReport => QsoField.ReceivedReport,
ExchangeSlot.SerialNumber => QsoField.ReceivedNumber,
ExchangeSlot.Zone => QsoField.Zone,
ExchangeSlot.Section => QsoField.Section,
ExchangeSlot.Check => QsoField.Check,
ExchangeSlot.Precedence => QsoField.Precedence,
ExchangeSlot.Exchange1 => QsoField.Exchange1,
ExchangeSlot.MiscText => QsoField.MiscText,
ExchangeSlot.Name => QsoField.Name,
ExchangeSlot.Qth => QsoField.Qth,
ExchangeSlot.GridSquare => QsoField.GridSquare,
ExchangeSlot.Power => QsoField.Power,
ExchangeSlot.Comment => QsoField.Comment,
_ => throw new ArgumentOutOfRangeException(nameof(slot), slot, "no log column for this exchange slot"),
};
}

View File

@@ -0,0 +1,27 @@
namespace Nonemm.Session;
/// A field of a logged contact that can be edited in the log window. The names
/// match the log columns.
public enum QsoField
{
Time,
Frequency,
Mode,
Call,
SentReport,
SentNumber,
ReceivedReport,
ReceivedNumber,
Zone,
Section,
Precedence,
Check,
Exchange1,
MiscText,
Name,
Qth,
GridSquare,
Power,
Comment,
Operator,
}

View File

@@ -52,4 +52,21 @@ public class CallsignTests
[Fact]
public void PortablePrefixIsWhatTheCountryFileGetsAsked() =>
Assert.Equal("KH9", Callsign.Parse("KH9/N8BJQ").EntityLookupText());
[Theory]
[InlineData("JA1XYZ")]
[InlineData("DL1ABC/P")]
[InlineData("KH9/N8BJQ")]
[InlineData("9A1A")]
public void ACallThatCouldHaveBeenIssuedIsPlausible(string text) =>
Assert.True(Callsign.Parse(text).IsPlausible);
[Theory]
[InlineData("12345")]
[InlineData("ABCDE")]
[InlineData("AB")]
[InlineData("JA1XY Z")]
[InlineData("")]
public void ATextThatCouldNotBeACallIsNot(string text) =>
Assert.False(Callsign.Parse(text).IsPlausible);
}

View File

@@ -24,12 +24,14 @@ public class ContactMessageTests
IsRunQso = true,
};
private static Qso ReadBack(string xml) =>
Assert.IsType<ContactLogged>(ContactMessage.Read(xml)).Qso;
[Fact]
public void ContactSurvivesTheRoundTrip()
{
Qso? read = ContactMessage.Read(ContactMessage.Write(Contact(), "DL1ABC", "PC1"));
Qso read = ReadBack(ContactMessage.Write(Contact(), "DL1ABC", "PC1"));
Assert.NotNull(read);
Assert.Equal("0123456789abcdef0123456789abcdef", read.Id);
Assert.Equal("JA1XYZ", read.Call.Text);
Assert.Equal(14_025_500, read.Frequency.Hertz);
@@ -44,12 +46,39 @@ public class ContactMessageTests
/// the sender put in the field.
[Fact]
public void AContactFromTheNetworkIsNotOriginal() =>
Assert.False(ContactMessage.Read(ContactMessage.Write(Contact(), "DL1ABC", "PC1"))!.IsOriginal);
Assert.False(ReadBack(ContactMessage.Write(Contact(), "DL1ABC", "PC1")).IsOriginal);
[Fact]
public void FrequenciesAreSentInUnitsOfTenHertz() =>
Assert.Contains("<rxfreq>1402550</rxfreq>", ContactMessage.Write(Contact(), "DL1ABC", "PC1"));
[Fact]
public void AnEditCarriesTheCallAndTimeTheContactHadBefore()
{
Qso edited = Contact() with { Call = Callsign.Parse("JA1XYZ/2") };
DateTime before = new(2026, 5, 30, 12, 30, 0, DateTimeKind.Utc);
ContactReplaced read = Assert.IsType<ContactReplaced>(ContactMessage.Read(
ContactMessage.WriteReplace(edited, "DL1ABC", "PC1", "JA1XYZ", before)));
Assert.Equal("JA1XYZ/2", read.Qso.Call.Text);
Assert.Equal("JA1XYZ", read.OldCall);
Assert.Equal(before, read.OldTimestampUtc);
}
[Fact]
public void ADeleteNamesTheContactByIdCallAndTime()
{
ContactDeleted read = Assert.IsType<ContactDeleted>(ContactMessage.Read(
ContactMessage.WriteDelete(Contact(), "DL1ABC", "PC1")));
Assert.Equal("0123456789abcdef0123456789abcdef", read.Id);
Assert.Equal("JA1XYZ", read.Call);
Assert.Equal(new DateTime(2026, 5, 30, 12, 34, 56, DateTimeKind.Utc), read.TimestampUtc);
Assert.Equal(3, read.ContestNumber);
Assert.Equal("PC1", read.StationName);
}
[Theory]
[InlineData("<radioinfo><app>N1MM</app></radioinfo>")]
[InlineData("not xml at all")]

View File

@@ -163,6 +163,66 @@ public class LoggingSessionTests
Assert.True(session.Log.Qsos.Single().IsMultiplier1);
}
[Fact]
public void EditingAContactRescoresTheLog()
{
LoggingSession 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");
Assert.True(edit.IsAccepted);
Assert.Equal("DL2XYZ", session.Log.Qsos.Single().Call.Text);
Assert.Equal(0, session.Log.Tally.Points);
}
[Fact]
public void ARefusedEditLeavesTheLogAlone()
{
LoggingSession session = Session();
Type(session, "JA1XYZ", "25");
Qso logged = session.LogContact();
QsoEdit edit = session.Edit(logged.Id, QsoField.Zone, "41");
Assert.False(edit.IsAccepted);
Assert.Equal(25, session.Log.Qsos.Single().Zone);
}
/// The other stations need the call and time the contact had before, because
/// that is how they find their own copy of it.
[Fact]
public void AnEditReportsWhatTheContactUsedToBe()
{
LoggingSession session = Session();
Type(session, "JA1XYZ", "25");
Qso logged = session.LogContact();
QsoChange? change = null;
session.Edited += (_, c) => change = c;
session.Edit(logged.Id, QsoField.Call, "JA2XYZ");
Assert.Equal("JA1XYZ", change?.OldCall);
Assert.Equal(logged.TimestampUtc, change?.OldTimestampUtc);
Assert.Equal("JA2XYZ", change?.Qso.Call.Text);
}
[Fact]
public void ADeleteReportsTheContactThatWent()
{
LoggingSession session = Session();
Type(session, "JA1XYZ", "25");
Qso logged = session.LogContact();
Qso? gone = null;
session.Deleted += (_, q) => gone = q;
session.Delete(logged.Id);
Assert.Equal("JA1XYZ", gone?.Call.Text);
}
[Fact]
public void StoredContactsAreReadBackWhenTheSessionStarts()
{

View File

@@ -0,0 +1,135 @@
using Nonemm.Contests.Rules;
using Nonemm.Core;
using Nonemm.Core.Country;
namespace Nonemm.Session.Tests;
public class QsoEditorTests
{
private const string Countries = """
Germany: 14: 28: EU: 51.00: -10.00: -1.0: DL:
DL,DK,DJ;
Japan: 25: 45: AS: 36.40: -138.38: -9.0: JA:
JA,JH,JR;
""";
private static readonly StationInfo Me = new()
{
Callsign = "DL1ABC",
CqZone = 14,
ItuZone = 28,
Continent = "EU",
CountryPrefix = "DL",
};
private static QsoEditor CqWw() =>
new(new CqWorldWide(ModeCategory.Cw), Me, CountryFile.Parse(Countries));
private static QsoEditor Sweeps() =>
new(new Sweepstakes(ModeCategory.Cw), Me, CountryFile.Parse(Countries));
private static Qso Contact() => new()
{
Id = "1",
TimestampUtc = new DateTime(2026, 5, 30, 12, 34, 56, DateTimeKind.Utc),
Call = Callsign.Parse("JA1XYZ"),
Frequency = Frequency.FromKilohertz(14_025),
Mode = Modes.Cw,
ContestName = "CQWW",
SentReport = "599",
ReceivedReport = "599",
Zone = 25,
CountryPrefix = "JA",
Continent = "AS",
};
[Fact]
public void TheColumnsFollowTheContestExchange()
{
IReadOnlyList<QsoField> fields = [.. CqWw().Columns.Select(c => c.Field)];
Assert.Contains(QsoField.Zone, fields);
Assert.DoesNotContain(QsoField.Section, fields);
Assert.DoesNotContain(QsoField.SentNumber, fields);
}
/// Sweepstakes counts serial numbers, so the log shows the one we sent.
[Fact]
public void AContestWithSerialNumbersGetsASentNumberColumn() =>
Assert.Contains(QsoField.SentNumber, Sweeps().Columns.Select(c => c.Field));
[Fact]
public void ChangingTheCallLooksTheCountryUpAgain()
{
QsoEdit edit = CqWw().Apply(Contact(), QsoField.Call, "dl2xyz");
Assert.True(edit.IsAccepted);
Assert.Equal("DL2XYZ", edit.Result!.Call.Text);
Assert.Equal("DL", edit.Result.CountryPrefix);
Assert.Equal("EU", edit.Result.Continent);
Assert.Equal("DL2", edit.Result.WpxPrefix);
}
[Theory]
[InlineData("12345")]
[InlineData("AB")]
[InlineData("JA1XY Z")]
public void ATextThatIsNotShapedLikeACallIsRefused(string text)
{
QsoEdit edit = CqWw().Apply(Contact(), QsoField.Call, text);
Assert.False(edit.IsAccepted);
Assert.Contains("callsign", edit.Error);
}
[Fact]
public void ARefusedEditReturnsNoContact() =>
Assert.Null(CqWw().Apply(Contact(), QsoField.Zone, "41").Result);
[Theory]
[InlineData("40", true)]
[InlineData("1", true)]
[InlineData("41", false)]
[InlineData("0", false)]
[InlineData("two", false)]
public void CqZonesRunFromOneToForty(string text, bool accepted) =>
Assert.Equal(accepted, CqWw().Apply(Contact(), QsoField.Zone, text).IsAccepted);
[Fact]
public void AnUnknownSectionIsRefused() =>
Assert.False(Sweeps().Apply(Contact(), QsoField.Section, "ZZ").IsAccepted);
[Fact]
public void ASectionIsStoredUpperCase() =>
Assert.Equal("STX", Sweeps().Apply(Contact(), QsoField.Section, "stx").Result!.Section);
[Fact]
public void ACommentKeepsTheCaseItWasTypedIn() =>
Assert.Equal("Nice signal", CqWw().Apply(Contact(), QsoField.Comment, "Nice signal").Result!.Comment);
[Fact]
public void ClearingAFieldIsAllowed() =>
Assert.Equal(0, CqWw().Apply(Contact(), QsoField.Zone, "").Result!.Zone);
[Fact]
public void TheTimeIsReadAsUtc()
{
QsoEdit edit = CqWw().Apply(Contact(), QsoField.Time, "2026-05-30 13:00:00");
Assert.Equal(new DateTime(2026, 5, 30, 13, 0, 0, DateTimeKind.Utc), edit.Result!.TimestampUtc);
}
[Fact]
public void AFrequencyIsTypedInKilohertz() =>
Assert.Equal(
21_005_000,
CqWw().Apply(Contact(), QsoField.Frequency, "21005").Result!.Frequency.Hertz);
[Fact]
public void AModeTheLoggerDoesNotKnowIsRefused() =>
Assert.False(CqWw().Apply(Contact(), QsoField.Mode, "SSTV").IsAccepted);
[Fact]
public void AnEmptyNumberReadsBackAsAnEmptyColumn() =>
Assert.Equal("", QsoEditor.Read(Contact() with { Zone = 0 }, QsoField.Zone));
}