diff --git a/.gitignore b/.gitignore
index 930e9f1..65f9c94 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,4 @@ bin/
obj/
*.user
/N1MM/
+/N1MMsln/
diff --git a/README.md b/README.md
index 5f4cbf3..f0cc537 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/src/Nonemm.App/AppSession.cs b/src/Nonemm.App/AppSession.cs
index a1b9b7d..0f4ba1f 100644
--- a/src/Nonemm.App/AppSession.cs
+++ b/src/Nonemm.App/AppSession.cs
@@ -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,29 +135,60 @@ 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)
{
- return;
+ 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;
+ }
+ 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;
}
- Qso mine = qso with { ContestNumber = Logging.Instance.ContestNumber, IsOriginal = false };
- store.Add(mine);
- OpenContest(Logging.Instance.ContestNumber);
+ 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
/// without one.
diff --git a/src/Nonemm.App/Dialogs/ConfirmDialog.axaml b/src/Nonemm.App/Dialogs/ConfirmDialog.axaml
new file mode 100644
index 0000000..18e86b9
--- /dev/null
+++ b/src/Nonemm.App/Dialogs/ConfirmDialog.axaml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/Nonemm.App/Dialogs/ConfirmDialog.axaml.cs b/src/Nonemm.App/Dialogs/ConfirmDialog.axaml.cs
new file mode 100644
index 0000000..5c08ea3
--- /dev/null
+++ b/src/Nonemm.App/Dialogs/ConfirmDialog.axaml.cs
@@ -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);
+}
diff --git a/src/Nonemm.App/Windows/LogRow.cs b/src/Nonemm.App/Windows/LogRow.cs
index efd26da..f8c0c65 100644
--- a/src/Nonemm.App/Windows/LogRow.cs
+++ b/src/Nonemm.App/Windows/LogRow.cs
@@ -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 edit;
- public string Call => Qso.Call.Text;
+ public LogRow(Qso qso, Verdict? verdict, Func 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(name));
+ set => edit(Qso, Enum.Parse(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 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);
- }
}
diff --git a/src/Nonemm.App/Windows/LogWindow.axaml b/src/Nonemm.App/Windows/LogWindow.axaml
index 356b8b7..f37978c 100644
--- a/src/Nonemm.App/Windows/LogWindow.axaml
+++ b/src/Nonemm.App/Windows/LogWindow.axaml
@@ -5,22 +5,13 @@
Title="Log" Width="1000" Height="420">
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
diff --git a/src/Nonemm.App/Windows/LogWindow.axaml.cs b/src/Nonemm.App/Windows/LogWindow.axaml.cs
index 7bd49b2..71a94ff 100644
--- a/src/Nonemm.App/Windows/LogWindow.axaml.cs
+++ b/src/Nonemm.App/Windows/LogWindow.axaml.cs
@@ -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 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(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
diff --git a/src/Nonemm.Core/Callsign.cs b/src/Nonemm.Core/Callsign.cs
index 6f78503..49743a6 100644
--- a/src/Nonemm.Core/Callsign.cs
+++ b/src/Nonemm.Core/Callsign.cs
@@ -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();
diff --git a/src/Nonemm.Network/ContactMessage.cs b/src/Nonemm.Network/ContactMessage.cs
index 5ae6ff0..5dc2f15 100644
--- a/src/Nonemm.Network/ContactMessage.cs
+++ b/src/Nonemm.Network/ContactMessage.cs
@@ -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)
diff --git a/src/Nonemm.Network/ContactUpdate.cs b/src/Nonemm.Network/ContactUpdate.cs
new file mode 100644
index 0000000..516bec0
--- /dev/null
+++ b/src/Nonemm.Network/ContactUpdate.cs
@@ -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);
diff --git a/src/Nonemm.Network/StationNetwork.cs b/src/Nonemm.Network/StationNetwork.cs
index 91d6e33..4802f33 100644
--- a/src/Nonemm.Network/StationNetwork.cs
+++ b/src/Nonemm.Network/StationNetwork.cs
@@ -31,17 +31,34 @@ public sealed class StationNetwork : IDisposable
public IReadOnlyList 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? 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? UpdateArrived;
public event EventHandler? 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)
diff --git a/src/Nonemm.Session/CountryFields.cs b/src/Nonemm.Session/CountryFields.cs
new file mode 100644
index 0000000..faeadc9
--- /dev/null
+++ b/src/Nonemm.Session/CountryFields.cs
@@ -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() ?? "",
+ };
+ }
+}
diff --git a/src/Nonemm.Session/LoggingSession.cs b/src/Nonemm.Session/LoggingSession.cs
index 07f6ee4..f87a0d0 100644
--- a/src/Nonemm.Session/LoggingSession.cs
+++ b/src/Nonemm.Session/LoggingSession.cs
@@ -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? 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? Edited;
+
+ public event EventHandler? 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)
diff --git a/src/Nonemm.Session/QsoChange.cs b/src/Nonemm.Session/QsoChange.cs
new file mode 100644
index 0000000..afbc254
--- /dev/null
+++ b/src/Nonemm.Session/QsoChange.cs
@@ -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);
diff --git a/src/Nonemm.Session/QsoColumn.cs b/src/Nonemm.Session/QsoColumn.cs
new file mode 100644
index 0000000..3fa998d
--- /dev/null
+++ b/src/Nonemm.Session/QsoColumn.cs
@@ -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);
diff --git a/src/Nonemm.Session/QsoEdit.cs b/src/Nonemm.Session/QsoEdit.cs
new file mode 100644
index 0000000..edb65ea
--- /dev/null
+++ b/src/Nonemm.Session/QsoEdit.cs
@@ -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;
+}
diff --git a/src/Nonemm.Session/QsoEditor.cs b/src/Nonemm.Session/QsoEditor.cs
new file mode 100644
index 0000000..5d8ec8b
--- /dev/null
+++ b/src/Nonemm.Session/QsoEditor.cs
@@ -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 exchangeKinds = [];
+
+ public QsoEditor(Contest contest, StationInfo me, CountryFile? countries)
+ {
+ this.countries = countries;
+ Columns = BuildColumns(contest, me, exchangeKinds);
+ }
+
+ /// Log columns, left to right.
+ public IReadOnlyList 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 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 BuildColumns(
+ Contest contest,
+ StationInfo me,
+ Dictionary kinds)
+ {
+ List 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"),
+ };
+}
diff --git a/src/Nonemm.Session/QsoField.cs b/src/Nonemm.Session/QsoField.cs
new file mode 100644
index 0000000..9b45aba
--- /dev/null
+++ b/src/Nonemm.Session/QsoField.cs
@@ -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,
+}
diff --git a/tests/Nonemm.Core.Tests/CallsignTests.cs b/tests/Nonemm.Core.Tests/CallsignTests.cs
index a283f99..1bf9a8f 100644
--- a/tests/Nonemm.Core.Tests/CallsignTests.cs
+++ b/tests/Nonemm.Core.Tests/CallsignTests.cs
@@ -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);
}
diff --git a/tests/Nonemm.Network.Tests/ContactMessageTests.cs b/tests/Nonemm.Network.Tests/ContactMessageTests.cs
index c8e602d..9c5f38e 100644
--- a/tests/Nonemm.Network.Tests/ContactMessageTests.cs
+++ b/tests/Nonemm.Network.Tests/ContactMessageTests.cs
@@ -24,12 +24,14 @@ public class ContactMessageTests
IsRunQso = true,
};
+ private static Qso ReadBack(string xml) =>
+ Assert.IsType(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("1402550", 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(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(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("N1MM")]
[InlineData("not xml at all")]
diff --git a/tests/Nonemm.Session.Tests/LoggingSessionTests.cs b/tests/Nonemm.Session.Tests/LoggingSessionTests.cs
index 999c8ff..e351b5e 100644
--- a/tests/Nonemm.Session.Tests/LoggingSessionTests.cs
+++ b/tests/Nonemm.Session.Tests/LoggingSessionTests.cs
@@ -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()
{
diff --git a/tests/Nonemm.Session.Tests/QsoEditorTests.cs b/tests/Nonemm.Session.Tests/QsoEditorTests.cs
new file mode 100644
index 0000000..7bc5310
--- /dev/null
+++ b/tests/Nonemm.Session.Tests/QsoEditorTests.cs
@@ -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 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));
+}