Add the session, radio control, bandmap and cluster
Nonemm.Session holds what the operator is typing, what the log says about it and what happens on Enter, with no UI toolkit behind it. Nonemm.Rig talks to hamlib's rigctld and reconnects on its own. Nonemm.Spotting reads DX cluster lines into a bandmap that drops spots after an hour. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
57
tests/Nonemm.Session.Tests/FakeLogStore.cs
Normal file
57
tests/Nonemm.Session.Tests/FakeLogStore.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
using Nonemm.Core;
|
||||
using Nonemm.Storage;
|
||||
|
||||
namespace Nonemm.Session.Tests;
|
||||
|
||||
/// A log store that keeps everything in memory, so session tests do not need a
|
||||
/// file. It keeps the one rule the sqlite store enforces: no two contacts with
|
||||
/// the same call in the same second.
|
||||
public sealed class FakeLogStore : LogStore
|
||||
{
|
||||
private readonly List<Qso> qsos = [];
|
||||
private readonly List<ContestInstance> contests = [];
|
||||
|
||||
public IReadOnlyList<ContestInstance> Contests() => contests;
|
||||
|
||||
public ContestInstance? Contest(int contestNumber) =>
|
||||
contests.FirstOrDefault(c => c.ContestNumber == contestNumber);
|
||||
|
||||
public ContestInstance AddContest(ContestInstance instance)
|
||||
{
|
||||
ContestInstance stored = instance with { ContestNumber = contests.Count + 1 };
|
||||
contests.Add(stored);
|
||||
return stored;
|
||||
}
|
||||
|
||||
public void UpdateContest(ContestInstance instance)
|
||||
{
|
||||
int at = contests.FindIndex(c => c.ContestNumber == instance.ContestNumber);
|
||||
contests[at] = instance;
|
||||
}
|
||||
|
||||
public IReadOnlyList<Qso> Qsos(int contestNumber) =>
|
||||
qsos.Where(q => q.ContestNumber == contestNumber).OrderBy(q => q.TimestampUtc).ToList();
|
||||
|
||||
public Qso Add(Qso qso)
|
||||
{
|
||||
Qso candidate = qso;
|
||||
while (qsos.Any(q => q.Call.Text == candidate.Call.Text && q.TimestampUtc == candidate.TimestampUtc))
|
||||
{
|
||||
candidate = candidate with { TimestampUtc = candidate.TimestampUtc.AddSeconds(1) };
|
||||
}
|
||||
qsos.Add(candidate);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
public void Update(Qso qso)
|
||||
{
|
||||
int at = qsos.FindIndex(q => q.Id == qso.Id);
|
||||
qsos[at] = qso;
|
||||
}
|
||||
|
||||
public void Delete(string id) => qsos.RemoveAll(q => q.Id == id);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
179
tests/Nonemm.Session.Tests/LoggingSessionTests.cs
Normal file
179
tests/Nonemm.Session.Tests/LoggingSessionTests.cs
Normal file
@@ -0,0 +1,179 @@
|
||||
using Nonemm.Contests;
|
||||
using Nonemm.Contests.Rules;
|
||||
using Nonemm.Core;
|
||||
using Nonemm.Core.Country;
|
||||
using Nonemm.Storage;
|
||||
|
||||
namespace Nonemm.Session.Tests;
|
||||
|
||||
public class LoggingSessionTests
|
||||
{
|
||||
private const string Countries = """
|
||||
Germany: 14: 28: EU: 51.00: -10.00: -1.0: DL:
|
||||
DL,DK,DJ;
|
||||
Japan: 25: 45: AS: 36.40: -138.38: -9.0: JA:
|
||||
JA,JH,JR;
|
||||
""";
|
||||
|
||||
private static readonly StationInfo Me = new()
|
||||
{
|
||||
Callsign = "DL1ABC",
|
||||
CqZone = 14,
|
||||
ItuZone = 28,
|
||||
Continent = "EU",
|
||||
CountryPrefix = "DL",
|
||||
};
|
||||
|
||||
private static LoggingSession Session(Contest? contest = null)
|
||||
{
|
||||
FakeLogStore store = new();
|
||||
ContestInstance instance = store.AddContest(new ContestInstance
|
||||
{
|
||||
ContestNumber = 0,
|
||||
ContestName = "CQWW",
|
||||
});
|
||||
return new LoggingSession(
|
||||
store,
|
||||
contest ?? new CqWorldWide(ModeCategory.Cw),
|
||||
instance,
|
||||
Me,
|
||||
CountryFile.Parse(Countries));
|
||||
}
|
||||
|
||||
private static void Type(LoggingSession session, string call, string zone)
|
||||
{
|
||||
session.Entry.Call = call;
|
||||
session.Entry.Set(ExchangeSlot.ReceivedReport, "599");
|
||||
session.Entry.Set(ExchangeSlot.Zone, zone);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoggingAContactPutsItInTheLogAndClearsTheEntry()
|
||||
{
|
||||
LoggingSession session = Session();
|
||||
Type(session, "JA1XYZ", "25");
|
||||
Qso logged = session.LogContact();
|
||||
|
||||
Assert.Equal("JA1XYZ", logged.Call.Text);
|
||||
Assert.Equal(25, logged.Zone);
|
||||
Assert.Equal(3, logged.Points);
|
||||
Assert.Equal("", session.Entry.Call);
|
||||
Assert.Single(session.Log.Qsos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CountryAndPrefixAreFilledInFromTheCountryFile()
|
||||
{
|
||||
LoggingSession session = Session();
|
||||
Type(session, "JA1XYZ", "25");
|
||||
Qso logged = session.LogContact();
|
||||
|
||||
Assert.Equal("JA", logged.CountryPrefix);
|
||||
Assert.Equal("AS", logged.Continent);
|
||||
Assert.Equal("JA1", logged.WpxPrefix);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnIncompleteExchangeIsNotLogged()
|
||||
{
|
||||
LoggingSession session = Session();
|
||||
session.Entry.Call = "JA1XYZ";
|
||||
Assert.Throws<InvalidOperationException>(() => session.LogContact());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheSameStationOnTheSameBandReadsAsADupe()
|
||||
{
|
||||
LoggingSession session = Session();
|
||||
Type(session, "JA1XYZ", "25");
|
||||
session.LogContact();
|
||||
Type(session, "JA1XYZ", "25");
|
||||
Assert.True(session.Verdict()?.IsDupe);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MovingBandClearsTheDupe()
|
||||
{
|
||||
LoggingSession session = Session();
|
||||
Type(session, "JA1XYZ", "25");
|
||||
session.LogContact();
|
||||
session.Tune(Frequency.FromKilohertz(7_025));
|
||||
Type(session, "JA1XYZ", "25");
|
||||
Assert.False(session.Verdict()?.IsDupe);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerialNumbersCountUp()
|
||||
{
|
||||
LoggingSession session = Session(new CqWpx(ModeCategory.Cw));
|
||||
Assert.Equal(1, session.SentNumber);
|
||||
session.Entry.Call = "JA1XYZ";
|
||||
session.Entry.Set(ExchangeSlot.ReceivedReport, "599");
|
||||
session.Entry.Set(ExchangeSlot.SerialNumber, "12");
|
||||
session.LogContact();
|
||||
Assert.Equal(2, session.SentNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpaceMovesThroughTheBoxesAndBackToTheCall()
|
||||
{
|
||||
LoggingSession session = Session();
|
||||
Assert.Equal(0, session.Entry.Focus);
|
||||
session.Entry.Advance();
|
||||
Assert.Equal(1, session.Entry.Focus);
|
||||
session.Entry.Advance();
|
||||
session.Entry.Advance();
|
||||
Assert.Equal(0, session.Entry.Focus);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("14025", 14_025_000)]
|
||||
[InlineData("14.025", 14_025_000)]
|
||||
[InlineData("7025.5", 7_025_500)]
|
||||
public void AFrequencyTypedIntoTheCallBoxIsRecognised(string typed, long hertz)
|
||||
{
|
||||
LoggingSession session = Session();
|
||||
session.Entry.Call = typed;
|
||||
Assert.Equal(hertz, session.PendingQsy()?.Hertz);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("JA1XYZ")]
|
||||
[InlineData("12000")]
|
||||
public void ACallOrAnOutOfBandNumberIsNotAQsy(string typed)
|
||||
{
|
||||
LoggingSession session = Session();
|
||||
session.Entry.Call = typed;
|
||||
Assert.Null(session.PendingQsy());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AContactDeletedFromTheLogGivesItsMultiplierBack()
|
||||
{
|
||||
LoggingSession session = Session();
|
||||
Type(session, "JA1XYZ", "25");
|
||||
Qso first = session.LogContact();
|
||||
Type(session, "JA2XYZ", "25");
|
||||
session.LogContact();
|
||||
Assert.Equal(2, session.Log.Tally.TotalMultipliers);
|
||||
|
||||
session.Delete(first.Id);
|
||||
Assert.Single(session.Log.Qsos);
|
||||
Assert.Equal(2, session.Log.Tally.TotalMultipliers);
|
||||
Assert.True(session.Log.Qsos.Single().IsMultiplier1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StoredContactsAreReadBackWhenTheSessionStarts()
|
||||
{
|
||||
FakeLogStore store = new();
|
||||
ContestInstance instance = store.AddContest(new ContestInstance { ContestNumber = 0, ContestName = "CQWW" });
|
||||
LoggingSession first = new(store, new CqWorldWide(ModeCategory.Cw), instance, Me, CountryFile.Parse(Countries));
|
||||
Type(first, "JA1XYZ", "25");
|
||||
first.LogContact();
|
||||
|
||||
LoggingSession second = new(store, new CqWorldWide(ModeCategory.Cw), instance, Me, CountryFile.Parse(Countries));
|
||||
Assert.Single(second.Log.Qsos);
|
||||
Assert.Equal(3, second.Log.Tally.Points);
|
||||
}
|
||||
}
|
||||
28
tests/Nonemm.Session.Tests/Nonemm.Session.Tests.csproj
Normal file
28
tests/Nonemm.Session.Tests/Nonemm.Session.Tests.csproj
Normal file
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Nonemm.Session\Nonemm.Session.csproj" />
|
||||
<ProjectReference Include="..\..\src\Nonemm.Core\Nonemm.Core.csproj" />
|
||||
<ProjectReference Include="..\..\src\Nonemm.Contests\Nonemm.Contests.csproj" />
|
||||
<ProjectReference Include="..\..\src\Nonemm.Storage\Nonemm.Storage.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
61
tests/Nonemm.Spotting.Tests/BandmapTests.cs
Normal file
61
tests/Nonemm.Spotting.Tests/BandmapTests.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Spotting.Tests;
|
||||
|
||||
public class BandmapTests
|
||||
{
|
||||
private static readonly DateTime Now = new(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static Spot At(string call, double kilohertz, DateTime? at = null) =>
|
||||
new(Callsign.Parse(call), Frequency.FromKilohertz(kilohertz), at ?? Now, SpotSource.Cluster);
|
||||
|
||||
[Fact]
|
||||
public void SpotsComeBackInFrequencyOrder()
|
||||
{
|
||||
Bandmap map = new();
|
||||
map.Add(At("DL1ABC", 14_100));
|
||||
map.Add(At("JA1XYZ", 14_010));
|
||||
Assert.Equal(["JA1XYZ", "DL1ABC"], map.On(Bands.Band20M).Select(s => s.Call.Text));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AStationThatMovesKeepsOneEntryOnItsBand()
|
||||
{
|
||||
Bandmap map = new();
|
||||
map.Add(At("DL1ABC", 14_010));
|
||||
map.Add(At("DL1ABC", 14_020));
|
||||
Spot only = Assert.Single(map.On(Bands.Band20M));
|
||||
Assert.Equal(14_020_000, only.Frequency.Hertz);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheSameStationOnAnotherBandIsASeparateEntry()
|
||||
{
|
||||
Bandmap map = new();
|
||||
map.Add(At("DL1ABC", 14_010));
|
||||
map.Add(At("DL1ABC", 7_010));
|
||||
Assert.Equal(2, map.All().Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpotsOlderThanAnHourAreDropped()
|
||||
{
|
||||
Bandmap map = new();
|
||||
map.Add(At("DL1ABC", 14_010, Now.AddHours(-2)));
|
||||
map.Add(At("JA1XYZ", 14_020, Now.AddMinutes(-5)));
|
||||
map.DropOlderThan(Now);
|
||||
Assert.Equal("JA1XYZ", Assert.Single(map.All()).Call.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheNearestSpotInsideTheWindowIsFound()
|
||||
{
|
||||
Bandmap map = new();
|
||||
map.Add(At("DL1ABC", 14_010));
|
||||
map.Add(At("JA1XYZ", 14_050));
|
||||
Assert.Equal(
|
||||
"DL1ABC",
|
||||
map.Near(Frequency.FromKilohertz(14_010.2), Frequency.FromKilohertz(0.5))?.Call.Text);
|
||||
Assert.Null(map.Near(Frequency.FromKilohertz(14_030), Frequency.FromKilohertz(0.5)));
|
||||
}
|
||||
}
|
||||
26
tests/Nonemm.Spotting.Tests/Nonemm.Spotting.Tests.csproj
Normal file
26
tests/Nonemm.Spotting.Tests/Nonemm.Spotting.Tests.csproj
Normal file
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Nonemm.Spotting\Nonemm.Spotting.csproj" />
|
||||
<ProjectReference Include="..\..\src\Nonemm.Core\Nonemm.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
34
tests/Nonemm.Spotting.Tests/SpotLineTests.cs
Normal file
34
tests/Nonemm.Spotting.Tests/SpotLineTests.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using Nonemm.Core;
|
||||
|
||||
namespace Nonemm.Spotting.Tests;
|
||||
|
||||
public class SpotLineTests
|
||||
{
|
||||
private static readonly DateTime Now = new(2026, 5, 30, 12, 40, 0, DateTimeKind.Utc);
|
||||
|
||||
[Fact]
|
||||
public void ANodeSpotIsReadIntoItsParts()
|
||||
{
|
||||
Spot? spot = SpotLine.Parse(
|
||||
"DX de W3LPL: 14025.0 DL1ABC CW 20 dB 25 WPM CQ 1234Z",
|
||||
Now);
|
||||
|
||||
Assert.NotNull(spot);
|
||||
Assert.Equal("DL1ABC", spot.Call.Text);
|
||||
Assert.Equal(14_025_000, spot.Frequency.Hertz);
|
||||
Assert.Equal("W3LPL", spot.Spotter);
|
||||
Assert.Equal(new DateTime(2026, 5, 30, 12, 34, 0, DateTimeKind.Utc), spot.AtUtc);
|
||||
Assert.DoesNotContain("1234Z", spot.Comment);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OtherTrafficIsNotASpot() =>
|
||||
Assert.Null(SpotLine.Parse("WWV de VE7CC <18Z> : SFI=142, A=7, K=2", Now));
|
||||
|
||||
[Fact]
|
||||
public void ASpotTimedAfterNowCameInYesterday()
|
||||
{
|
||||
Spot? spot = SpotLine.Parse("DX de W3LPL: 14025.0 DL1ABC CQ 2350Z", Now);
|
||||
Assert.Equal(new DateTime(2026, 5, 29, 23, 50, 0, DateTimeKind.Utc), spot?.AtUtc);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user