Take a contact from another station without reopening the contest
AppSession.TakeFromNetwork wrote the contact to the store and then called OpenContest to see it, which built a new ContestSession, read the whole log back and built new entry positions. Anything the operator was typing at either radio went with them, and the entry boxes were rebuilt under the other operator's hands. It also ran on the socket thread, so it was changing the log while the windows were reading it. ContestSession now applies the three messages itself: AddFromNetwork, ReplaceFromNetwork and DeleteFromNetwork write the store, change the log in place and raise Changed. None of them raises Logged, Edited or Deleted, so nothing goes back out to the other stations. Points and multipliers are still worked out here from the rules rather than trusted. A contact that arrives goes in where its timestamp says it belongs, through the new ContestLog.Insert, and the log is scored again: of two stations that worked the same multiplier, the one that worked it first keeps it, however late the message turns up. Add would have appended it at the end and given the multiplier to the wrong contact. The socket thread now hands the update to the thread the windows run on, which is where every other change to the log is made. The matching rule moved with the code, so ContestSession answers what a message from N1MM refers to: the contact id, then the call and time it had before the edit. Still not tested against a second station. Two of these cannot run on one host to try it, because they share one UDP port. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD
This commit is contained in:
@@ -657,6 +657,12 @@ 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
|
our ids, so the call and the timestamp are the fallback — that is the pair N1MM
|
||||||
itself keys a contact on.
|
itself keys a contact on.
|
||||||
|
|
||||||
|
A contact that arrives is put into the log where its timestamp says it belongs
|
||||||
|
and the log is scored again, so of two stations that worked the same multiplier
|
||||||
|
the one that worked it first keeps it, however late the message turns up.
|
||||||
|
Nothing else moves: what is being typed at either radio is left alone, and the
|
||||||
|
windows redraw where they stand.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
| Project | What it holds |
|
| Project | What it holds |
|
||||||
|
|||||||
@@ -114,11 +114,6 @@ beyond country, prefix, zone, section, exchange, grid and continent.
|
|||||||
|
|
||||||
## Known rough edges
|
## Known rough edges
|
||||||
|
|
||||||
**Contacts arriving over the station network** are applied from the socket
|
|
||||||
thread, and `AppSession.TakeFromNetwork` reopens the whole contest to do it.
|
|
||||||
That is correct but heavy, and it discards what is typed in an entry window on
|
|
||||||
another radio.
|
|
||||||
|
|
||||||
**The band plan covers 160M to 2M only.** 60M and everything above 2M get no
|
**The band plan covers 160M to 2M only.** 60M and everything above 2M get no
|
||||||
CW, digital or phone shading on the bandmap, because N1MM's own numbers for
|
CW, digital or phone shading on the bandmap, because N1MM's own numbers for
|
||||||
those bands contradict themselves — its CW top for 1.25M is below its phone
|
those bands contradict themselves — its CW top for 1.25M is below its phone
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Avalonia.Threading;
|
||||||
using Nonemm.App.Configuration;
|
using Nonemm.App.Configuration;
|
||||||
using Nonemm.Contests;
|
using Nonemm.Contests;
|
||||||
using Nonemm.Core;
|
using Nonemm.Core;
|
||||||
@@ -225,60 +226,37 @@ public sealed class AppSession : IDisposable
|
|||||||
Settings.NetworkPort,
|
Settings.NetworkPort,
|
||||||
Settings.NetworkStationName.Length > 0 ? Settings.NetworkStationName : Environment.MachineName,
|
Settings.NetworkStationName.Length > 0 ? Settings.NetworkStationName : Environment.MachineName,
|
||||||
Settings.NetworkPeers);
|
Settings.NetworkPeers);
|
||||||
network.UpdateArrived += (_, update) => TakeFromNetwork(update);
|
// the socket thread must not touch the log: every other change to it is
|
||||||
|
// made where the windows read it
|
||||||
|
network.UpdateArrived += (_, update) =>
|
||||||
|
Dispatcher.UIThread.Post(() => TakeFromNetwork(update));
|
||||||
network.Start();
|
network.Start();
|
||||||
Changed?.Invoke(this, EventArgs.Empty);
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What another station did to its log, applied to ours. The store is
|
/// What another station did to its log, applied to ours. Nothing goes back
|
||||||
/// written directly rather than through `Logging`, so the change is not
|
/// out to the network, and the contact is scored here from the rules
|
||||||
/// broadcast back out again. Points and multipliers are worked out here
|
/// instead of trusting what the sender put in the message.
|
||||||
/// from the rules instead of trusting what the sender put in the message.
|
|
||||||
private void TakeFromNetwork(ContactUpdate update)
|
private void TakeFromNetwork(ContactUpdate update)
|
||||||
{
|
{
|
||||||
if (Logging is null || store is null)
|
if (Logging is null)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
int contestNumber = Logging.Instance.ContestNumber;
|
|
||||||
switch (update)
|
switch (update)
|
||||||
{
|
{
|
||||||
case ContactLogged logged when !Logging.Log.Qsos.Any(q => q.Id == logged.Qso.Id):
|
case ContactLogged logged:
|
||||||
store.Add(logged.Qso with { ContestNumber = contestNumber, IsOriginal = false });
|
Logging.AddFromNetwork(logged.Qso);
|
||||||
break;
|
break;
|
||||||
case ContactReplaced replaced:
|
case ContactReplaced replaced:
|
||||||
if (FindLocal(replaced.Qso.Id, replaced.OldCall, replaced.OldTimestampUtc) is not { } old)
|
Logging.ReplaceFromNetwork(replaced.Qso, replaced.OldCall, replaced.OldTimestampUtc);
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
store.Update(replaced.Qso with
|
|
||||||
{
|
|
||||||
Id = old.Id,
|
|
||||||
ContestNumber = contestNumber,
|
|
||||||
IsOriginal = false,
|
|
||||||
});
|
|
||||||
break;
|
break;
|
||||||
case ContactDeleted deleted:
|
case ContactDeleted deleted:
|
||||||
if (FindLocal(deleted.Id, deleted.Call, deleted.TimestampUtc) is not { } gone)
|
Logging.DeleteFromNetwork(deleted.Id, deleted.Call, deleted.TimestampUtc);
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
store.Delete(gone.Id);
|
|
||||||
break;
|
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.
|
/// Starts, restarts or stops the keyer, following what the settings say.
|
||||||
/// A keyer that will not open is reported; the program keeps running
|
/// A keyer that will not open is reported; the program keeps running
|
||||||
/// without one.
|
/// without one.
|
||||||
|
|||||||
@@ -71,6 +71,15 @@ public sealed class ContestLog
|
|||||||
Rebuild();
|
Rebuild();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Adds a contact that may be older than ones already logged, which is what
|
||||||
|
/// a contact from another station of the entry can be, and scores the log
|
||||||
|
/// again: the earlier of two contacts is the one that claims a multiplier.
|
||||||
|
public void Insert(Qso qso)
|
||||||
|
{
|
||||||
|
qsos.Add(qso);
|
||||||
|
Rebuild();
|
||||||
|
}
|
||||||
|
|
||||||
public void Remove(string id)
|
public void Remove(string id)
|
||||||
{
|
{
|
||||||
qsos.RemoveAll(q => q.Id == id);
|
qsos.RemoveAll(q => q.Id == id);
|
||||||
|
|||||||
@@ -107,6 +107,64 @@ public sealed class ContestSession
|
|||||||
|
|
||||||
public void NotifyChanged() => Changed?.Invoke(this, EventArgs.Empty);
|
public void NotifyChanged() => Changed?.Invoke(this, EventArgs.Empty);
|
||||||
|
|
||||||
|
/// A contact worked by another station of a multi-operator entry. It is
|
||||||
|
/// scored here from the rules rather than trusting what the sender worked
|
||||||
|
/// out, and it raises no `Logged`, so it does not go back out to the other
|
||||||
|
/// stations. False when we already hold it.
|
||||||
|
public bool AddFromNetwork(Qso qso)
|
||||||
|
{
|
||||||
|
if (Log.Qsos.Any(q => q.Id == qso.Id))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Log.Insert(store.Add(FromNetwork(qso)));
|
||||||
|
SentNumber = NextSentNumber();
|
||||||
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An edit made at another station. False when we do not hold the contact
|
||||||
|
/// it changes.
|
||||||
|
public bool ReplaceFromNetwork(Qso qso, string oldCall, DateTime oldTimestampUtc)
|
||||||
|
{
|
||||||
|
if (Find(qso.Id, oldCall, oldTimestampUtc) is not { } old)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Qso replacement = FromNetwork(qso) with { Id = old.Id };
|
||||||
|
store.Update(replacement);
|
||||||
|
Log.Replace(replacement);
|
||||||
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A contact deleted at another station. False when we do not hold it.
|
||||||
|
public bool DeleteFromNetwork(string id, string call, DateTime timestampUtc)
|
||||||
|
{
|
||||||
|
if (Find(id, call, timestampUtc) is not { } gone)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
store.Delete(gone.Id);
|
||||||
|
Log.Remove(gone.Id);
|
||||||
|
Changed?.Invoke(this, EventArgs.Empty);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Qso FromNetwork(Qso qso) => qso with
|
||||||
|
{
|
||||||
|
ContestNumber = Instance.ContestNumber,
|
||||||
|
IsOriginal = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// 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? Find(string id, string call, DateTime timestampUtc) =>
|
||||||
|
Log.Qsos.FirstOrDefault(q => id.Length > 0 && q.Id == id)
|
||||||
|
?? Log.Qsos.FirstOrDefault(q =>
|
||||||
|
string.Equals(q.Call.Text, call, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& q.TimestampUtc == timestampUtc);
|
||||||
|
|
||||||
private QsoEdit EditWith(string id, Func<Qso, QsoEdit> change)
|
private QsoEdit EditWith(string id, Func<Qso, QsoEdit> change)
|
||||||
{
|
{
|
||||||
Qso before = Log.Qsos.FirstOrDefault(q => q.Id == id)
|
Qso before = Log.Qsos.FirstOrDefault(q => q.Id == id)
|
||||||
|
|||||||
202
tests/Nonemm.Session.Tests/NetworkedContactsTests.cs
Normal file
202
tests/Nonemm.Session.Tests/NetworkedContactsTests.cs
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
using Nonemm.Contests;
|
||||||
|
using Nonemm.Contests.Rules;
|
||||||
|
using Nonemm.Core;
|
||||||
|
using Nonemm.Core.Country;
|
||||||
|
using Nonemm.Storage;
|
||||||
|
|
||||||
|
namespace Nonemm.Session.Tests;
|
||||||
|
|
||||||
|
/// What a contact from another station of a multi-operator entry does to this
|
||||||
|
/// log.
|
||||||
|
public class NetworkedContactsTests
|
||||||
|
{
|
||||||
|
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 readonly DateTime Start = new(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
private static (ContestSession Session, FakeLogStore Store) Station()
|
||||||
|
{
|
||||||
|
FakeLogStore store = new();
|
||||||
|
ContestInstance instance = store.AddContest(new ContestInstance
|
||||||
|
{
|
||||||
|
ContestNumber = 0,
|
||||||
|
ContestName = "CQWW",
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
new ContestSession(
|
||||||
|
store,
|
||||||
|
new CqWorldWide(ModeCategory.Cw),
|
||||||
|
instance,
|
||||||
|
Me,
|
||||||
|
CountryFile.Parse(Countries)),
|
||||||
|
store);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Qso Arriving(string call, int minute, int zone = 25) => new()
|
||||||
|
{
|
||||||
|
Id = $"{call}-{minute}",
|
||||||
|
TimestampUtc = Start.AddMinutes(minute),
|
||||||
|
Call = Callsign.Parse(call),
|
||||||
|
Frequency = Frequency.FromKilohertz(14_030),
|
||||||
|
Mode = Modes.Cw,
|
||||||
|
ContestName = "CQWW",
|
||||||
|
SentReport = "599",
|
||||||
|
ReceivedReport = "599",
|
||||||
|
Zone = zone,
|
||||||
|
// what the sending station worked out, which is not trusted
|
||||||
|
Points = 99,
|
||||||
|
IsMultiplier1 = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A contact worked at this station, which `Add` scores from the rules like
|
||||||
|
/// any other.
|
||||||
|
private static Qso WorkHere(ContestSession session, string call, int minute, int zone = 25) =>
|
||||||
|
session.Add(Arriving(call, minute, zone) with
|
||||||
|
{
|
||||||
|
Id = Qso.NewId(),
|
||||||
|
ContestNumber = session.Instance.ContestNumber,
|
||||||
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AContactFromAnotherStationIsLoggedAndScoredHere()
|
||||||
|
{
|
||||||
|
(ContestSession session, _) = Station();
|
||||||
|
|
||||||
|
Assert.True(session.AddFromNetwork(Arriving("JA1XYZ", 1)));
|
||||||
|
|
||||||
|
Qso stored = Assert.Single(session.Log.Qsos);
|
||||||
|
Assert.Equal("JA1XYZ", stored.Call.Text);
|
||||||
|
Assert.Equal(3, stored.Points);
|
||||||
|
Assert.False(stored.IsOriginal);
|
||||||
|
Assert.Equal(session.Instance.ContestNumber, stored.ContestNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ItDoesNotGoBackOutToTheOtherStations()
|
||||||
|
{
|
||||||
|
(ContestSession session, _) = Station();
|
||||||
|
int sent = 0;
|
||||||
|
session.Logged += (_, _) => sent++;
|
||||||
|
|
||||||
|
session.AddFromNetwork(Arriving("JA1XYZ", 1));
|
||||||
|
|
||||||
|
Assert.Equal(0, sent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TheSameContactArrivingTwiceIsTakenOnce()
|
||||||
|
{
|
||||||
|
(ContestSession session, _) = Station();
|
||||||
|
session.AddFromNetwork(Arriving("JA1XYZ", 1));
|
||||||
|
|
||||||
|
Assert.False(session.AddFromNetwork(Arriving("JA1XYZ", 1)));
|
||||||
|
Assert.Single(session.Log.Qsos);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The station that worked it first keeps the multiplier, however late the
|
||||||
|
/// message about it turns up.
|
||||||
|
[Fact]
|
||||||
|
public void AContactThatArrivesLateStillTakesTheMultiplierItWorkedFirst()
|
||||||
|
{
|
||||||
|
(ContestSession session, _) = Station();
|
||||||
|
WorkHere(session, "JA9ZZZ", minute: 5);
|
||||||
|
|
||||||
|
session.AddFromNetwork(Arriving("JA1XYZ", minute: 1));
|
||||||
|
|
||||||
|
Assert.True(session.Log.Qsos[0].IsMultiplier1);
|
||||||
|
Assert.False(session.Log.Qsos[1].IsMultiplier1);
|
||||||
|
Assert.Equal("JA1XYZ", session.Log.Qsos[0].Call.Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WhatIsBeingTypedIsLeftAlone()
|
||||||
|
{
|
||||||
|
(ContestSession session, _) = Station();
|
||||||
|
OperatingPosition position = new(session);
|
||||||
|
position.Entry.Call = "G4AB";
|
||||||
|
position.Entry.Set(ExchangeSlot.Zone, "14");
|
||||||
|
|
||||||
|
session.AddFromNetwork(Arriving("JA1XYZ", 1));
|
||||||
|
|
||||||
|
Assert.Equal("G4AB", position.Entry.Call);
|
||||||
|
Assert.Equal("14", position.Entry.ValueOf(ExchangeSlot.Zone));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnEditFromAnotherStationIsAppliedById()
|
||||||
|
{
|
||||||
|
(ContestSession session, _) = Station();
|
||||||
|
session.AddFromNetwork(Arriving("JA1XYZ", 1));
|
||||||
|
|
||||||
|
Assert.True(session.ReplaceFromNetwork(
|
||||||
|
Arriving("JA1XYZ", 1) with { Zone = 24 },
|
||||||
|
"JA1XYZ",
|
||||||
|
Start.AddMinutes(1)));
|
||||||
|
|
||||||
|
Assert.Equal(24, Assert.Single(session.Log.Qsos).Zone);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// N1MM does not know our ids, so a message from N1MM is matched on the
|
||||||
|
/// call and time the contact had before the edit.
|
||||||
|
[Fact]
|
||||||
|
public void AnEditWithNoIdIsMatchedOnTheOldCallAndTime()
|
||||||
|
{
|
||||||
|
(ContestSession session, _) = Station();
|
||||||
|
Qso worked = WorkHere(session, "JA1XYZ", minute: 1);
|
||||||
|
|
||||||
|
Assert.True(session.ReplaceFromNetwork(
|
||||||
|
Arriving("JA1XYY", 1) with { Id = "" },
|
||||||
|
"JA1XYZ",
|
||||||
|
worked.TimestampUtc));
|
||||||
|
|
||||||
|
Qso changed = Assert.Single(session.Log.Qsos);
|
||||||
|
Assert.Equal("JA1XYY", changed.Call.Text);
|
||||||
|
Assert.Equal(worked.Id, changed.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AnEditToAContactWeDoNotHoldIsRefused()
|
||||||
|
{
|
||||||
|
(ContestSession session, _) = Station();
|
||||||
|
|
||||||
|
Assert.False(session.ReplaceFromNetwork(Arriving("JA1XYZ", 1), "JA1XYZ", Start));
|
||||||
|
Assert.Empty(session.Log.Qsos);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ADeleteFromAnotherStationRemovesTheContactAndRescores()
|
||||||
|
{
|
||||||
|
(ContestSession session, FakeLogStore store) = Station();
|
||||||
|
session.AddFromNetwork(Arriving("JA1XYZ", 1));
|
||||||
|
WorkHere(session, "JA9ZZZ", minute: 5);
|
||||||
|
|
||||||
|
Assert.True(session.DeleteFromNetwork("JA1XYZ-1", "JA1XYZ", Start.AddMinutes(1)));
|
||||||
|
|
||||||
|
Qso left = Assert.Single(session.Log.Qsos);
|
||||||
|
Assert.Equal("JA9ZZZ", left.Call.Text);
|
||||||
|
Assert.True(left.IsMultiplier1);
|
||||||
|
Assert.Single(store.Qsos(session.Instance.ContestNumber));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ADeleteOfAContactWeDoNotHoldIsRefused()
|
||||||
|
{
|
||||||
|
(ContestSession session, _) = Station();
|
||||||
|
|
||||||
|
Assert.False(session.DeleteFromNetwork("nothing", "JA1XYZ", Start));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user