Move and batch spots the way N1MM does

Two differences from N1MM in the spot path, both found by reading Packet.cs.

Randomising moved every CW spot. PacketSpot.Randomize moves a spot only when it
is not a dupe, not a self spot, not on a CQ frequency, is on CW and is not
working split, and Packet skips it altogether for a station passed to another
band. Moving a dupe or a split station makes it harder to find, not easier, so
this now leaves the same ones alone. Whether a station has been worked comes
from the log, so AppSession asks it before moving a spot.

Split needed knowing where a station is listening, which is in the comment. Qsx
reads it by N1MM's rules: QSX, UP, DOWN, DN, U or D, the word standing alone or
after a space, a bare number after QSX meaning kilohertz inside the band and
anything else an offset from the spot, an offset over a hundred kilohertz
refused unless it says QSX, DN70 read as a grid square rather than five down,
and a listening frequency outside the band thrown away.

Spots reached the bandmap one at a time, each one redrawing every window that
watches it. N1MM queues them and flushes the queue once a second, which is what
this does now: the filters and the randomiser run over the batch, and the
bandmap takes it in one call and raises one change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-28 10:15:40 +00:00
parent f102e0a304
commit ac63987e0c
9 changed files with 267 additions and 19 deletions

View File

@@ -0,0 +1,60 @@
using Nonemm.Core;
namespace Nonemm.Spotting.Tests;
public class QsxTests
{
private static readonly Frequency Spot = Frequency.FromKilohertz(14_025);
private static double Kilohertz(string comment) => Qsx.Parse(comment, Spot).Kilohertz;
[Fact]
public void AWholeFrequencyAfterQsxIsTakenAsItIs() =>
Assert.Equal(14_205, Kilohertz("QSX 14205"));
[Fact]
public void ABareNumberAfterQsxIsKilohertzInTheBand() =>
Assert.Equal(14_205, Kilohertz("QSX 205"));
[Fact]
public void UpAndDownAreOffsetsFromTheSpot()
{
Assert.Equal(14_027, Kilohertz("UP 2"));
Assert.Equal(14_020, Kilohertz("DN 5"));
Assert.Equal(14_020, Kilohertz("DOWN 5"));
}
[Fact]
public void ACommentThatSaysNothingAboutSplitGivesNothing()
{
Assert.Equal(0, Kilohertz("CQ TEST"));
Assert.Equal(0, Kilohertz(""));
}
[Fact]
public void AGridSquareIsNotAnOffset() =>
Assert.Equal(0, Kilohertz("TNX QSO DN70"));
[Fact]
public void AWordThatOnlyHoldsTheLettersIsNotAnOffset() =>
Assert.Equal(0, Kilohertz("GROUP 5"));
[Fact]
public void ABigNumberAfterAnythingButQsxIsNotAnOffset() =>
Assert.Equal(0, Kilohertz("UP 14205"));
[Fact]
public void AListeningFrequencyOutsideTheBandIsDropped() =>
Assert.Equal(0, Kilohertz("QSX 7205"));
[Fact]
public void TheSpotLineCarriesTheListeningFrequency()
{
Spot? spot = SpotLine.Parse(
"DX de W3LPL: 14025.0 JA1XYZ UP 2 1234Z",
new DateTime(2026, 8, 28, 12, 0, 0, DateTimeKind.Utc));
Assert.Equal(14_027_000, Assert.IsType<Spot>(spot).Qsx.Hertz);
Assert.True(spot.IsSplit);
}
}

View File

@@ -4,8 +4,12 @@ namespace Nonemm.Spotting.Tests;
public class SpotJitterTests
{
private static Spot At(double kilohertz) =>
new(Callsign.Parse("OM5M"), Frequency.FromKilohertz(kilohertz), DateTime.UtcNow, SpotSource.Cluster);
private static Spot At(double kilohertz, SpotSource source = SpotSource.Cluster, string comment = "") =>
new(Callsign.Parse("OM5M"), Frequency.FromKilohertz(kilohertz), DateTime.UtcNow, source, "W3LPL", comment);
private static long Offset(Spot spot, bool isDupe = false) =>
SpotJitter.Shifted(spot, BandPlan.Default, new Random(1), isDupe).Frequency.Hertz
- spot.Frequency.Hertz;
[Fact]
public void ACwSpotMovesByThirtyOrSixtyHertz()
@@ -14,15 +18,30 @@ public class SpotJitterTests
for (int run = 0; run < 20; run++)
{
Spot moved = SpotJitter.Shifted(At(14_025), BandPlan.Default, random);
long offset = moved.Frequency.Hertz - 14_025_000;
Assert.Contains(offset, (long[])[-60, -30, 30, 60]);
Assert.Contains(moved.Frequency.Hertz - 14_025_000, (long[])[-60, -30, 30, 60]);
}
}
[Fact]
public void APhoneSpotIsLeftWhereItIs()
public void APhoneSpotIsLeftWhereItIs() => Assert.Equal(0, Offset(At(14_250)));
[Fact]
public void AStationAlreadyWorkedIsLeftWhereItIs() =>
Assert.Equal(0, Offset(At(14_025), isDupe: true));
[Fact]
public void AStationWorkingSplitIsLeftWhereItIs()
{
Spot spot = At(14_250);
Assert.Equal(spot, SpotJitter.Shifted(spot, BandPlan.Default, new Random(1)));
Spot split = At(14_025, comment: "UP 2") with { Qsx = Frequency.FromKilohertz(14_027) };
Assert.Equal(0, Offset(split));
}
[Fact]
public void OurOwnSpotIsLeftWhereItIs() =>
Assert.Equal(0, Offset(At(14_025, SpotSource.Operator)));
[Fact]
public void AStationPassedToAnotherBandIsLeftWhereItIs() =>
Assert.Equal(0, Offset(At(14_025, comment: "Passed Station 21025")));
}