Add the core, contest, storage and format layers

Frequencies, bands, modes, callsigns, grid squares and the country file live in
Nonemm.Core. Nonemm.Contests holds the scoring engine and CQ WW and CQ WPX.
Nonemm.Storage writes N1MM's DXLOG schema, and Nonemm.Formats writes Cabrillo
3.0 and reads and writes ADIF.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik
2026-08-27 10:27:51 +00:00
commit 0f84bc8972
68 changed files with 3823 additions and 0 deletions

View File

@@ -0,0 +1,87 @@
using Nonemm.Contests.Rules;
using Nonemm.Core;
namespace Nonemm.Contests.Tests;
public class CqWorldWideTests
{
private static ContestLog LogFor(StationInfo me) =>
new(new CqWorldWide(ModeCategory.Cw), me, TestLog.CountryFile);
[Fact]
public void ContactWithOwnCountryScoresNothing()
{
ContestLog log = LogFor(TestLog.Germany);
Assert.Equal(0, log.Judge(TestLog.Contact("DL9XYZ", zone: 14)).Points);
}
[Fact]
public void SameContinentDifferentCountryScoresOne()
{
ContestLog log = LogFor(TestLog.Germany);
Assert.Equal(1, log.Judge(TestLog.Contact("IK2XYZ", zone: 15)).Points);
}
[Fact]
public void DifferentContinentScoresThree()
{
ContestLog log = LogFor(TestLog.Germany);
Assert.Equal(3, log.Judge(TestLog.Contact("JA1XYZ", zone: 25)).Points);
}
[Fact]
public void NorthAmericansScoreTwoWithinNorthAmerica()
{
ContestLog log = LogFor(TestLog.UnitedStates);
Assert.Equal(2, log.Judge(TestLog.Contact("VE3XYZ", zone: 5)).Points);
}
[Fact]
public void OwnCountryStillCountsAsAMultiplier()
{
ContestLog log = LogFor(TestLog.Germany);
Verdict verdict = log.Judge(TestLog.Contact("DL9XYZ", zone: 14));
Assert.Equal(2, verdict.NewMultipliers.Count);
Assert.Equal(0, verdict.Points);
}
[Fact]
public void ZoneAndCountryCountOncePerBand()
{
ContestLog log = LogFor(TestLog.Germany);
log.Add(TestLog.Contact("JA1XYZ", zone: 25));
Assert.Empty(log.Judge(TestLog.Contact("JA2XYZ", zone: 25)).NewMultipliers);
Assert.Equal(2, log.Judge(TestLog.Contact("JA2XYZ", 21_025, zone: 25)).NewMultipliers.Count);
}
[Fact]
public void SameStationOnAnotherBandIsNotADupe()
{
ContestLog log = LogFor(TestLog.Germany);
log.Add(TestLog.Contact("JA1XYZ", zone: 25));
Assert.True(log.Judge(TestLog.Contact("JA1XYZ", zone: 25)).IsDupe);
Assert.False(log.Judge(TestLog.Contact("JA1XYZ", 21_025, zone: 25)).IsDupe);
}
[Fact]
public void ScoreIsPointsTimesMultipliers()
{
ContestLog log = LogFor(TestLog.Germany);
log.Add(TestLog.Contact("JA1XYZ", zone: 25));
log.Add(TestLog.Contact("PY2XYZ", zone: 11));
Assert.Equal(6, log.Tally.Points);
Assert.Equal(4, log.Tally.TotalMultipliers);
Assert.Equal(24, log.TotalScore);
}
[Fact]
public void RemovingAContactHandsItsMultiplierToTheNextOne()
{
ContestLog log = LogFor(TestLog.Germany);
Qso first = log.Add(TestLog.Contact("JA1XYZ", zone: 25, at: new DateTime(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc)));
log.Add(TestLog.Contact("JA2XYZ", zone: 25, at: new DateTime(2026, 5, 30, 12, 1, 0, DateTimeKind.Utc)));
log.Remove(first.Id);
Assert.True(log.Qsos.Single().IsMultiplier1);
Assert.Equal(2, log.Tally.TotalMultipliers);
}
}

View File

@@ -0,0 +1,69 @@
using Nonemm.Contests.Rules;
using Nonemm.Core;
namespace Nonemm.Contests.Tests;
public class CqWpxTests
{
private static ContestLog LogFor(StationInfo me, ModeCategory mode = ModeCategory.Cw) =>
new(new CqWpx(mode), me, TestLog.CountryFile);
[Theory]
[InlineData(14_025, 3)]
[InlineData(21_025, 3)]
[InlineData(7_025, 6)]
[InlineData(3_525, 6)]
public void DifferentContinentDoublesBelowFourteenMegahertz(double kilohertz, int expected)
{
ContestLog log = LogFor(TestLog.Germany);
Assert.Equal(expected, log.Judge(TestLog.Contact("JA1XYZ", kilohertz)).Points);
}
[Fact]
public void SameContinentScoresOneOnTheHighBands()
{
ContestLog log = LogFor(TestLog.Germany);
Assert.Equal(1, log.Judge(TestLog.Contact("IK2XYZ")).Points);
Assert.Equal(2, log.Judge(TestLog.Contact("IK2XYZ", 7_025)).Points);
}
[Fact]
public void NorthAmericansScoreDoubleWithinNorthAmerica()
{
ContestLog log = LogFor(TestLog.UnitedStates);
Assert.Equal(2, log.Judge(TestLog.Contact("VE3XYZ")).Points);
Assert.Equal(4, log.Judge(TestLog.Contact("VE3XYZ", 7_025)).Points);
}
[Fact]
public void OwnCountryScoresOneOnEveryBand()
{
ContestLog log = LogFor(TestLog.Germany);
Assert.Equal(1, log.Judge(TestLog.Contact("DL9XYZ")).Points);
Assert.Equal(1, log.Judge(TestLog.Contact("DL9XYZ", 3_525)).Points);
}
[Fact]
public void RttyScoresOwnCountryTwiceOnTheLowBands()
{
ContestLog log = LogFor(TestLog.Germany, ModeCategory.Digital);
Assert.Equal(1, log.Judge(TestLog.Contact("DL9XYZ", mode: Modes.Rtty)).Points);
Assert.Equal(2, log.Judge(TestLog.Contact("DL9XYZ", 3_525, Modes.Rtty)).Points);
}
[Fact]
public void PrefixCountsOnceForTheWholeContest()
{
ContestLog log = LogFor(TestLog.Germany);
log.Add(TestLog.Contact("JA1XYZ"));
Assert.Empty(log.Judge(TestLog.Contact("JA1ZZZ", 7_025)).NewMultipliers);
Assert.Single(log.Judge(TestLog.Contact("JA2ZZZ", 7_025)).NewMultipliers);
}
[Fact]
public void MaritimeMobileBringsNoPrefix()
{
ContestLog log = LogFor(TestLog.Germany);
Assert.Empty(log.Judge(TestLog.Contact("DL1ABC/MM")).NewMultipliers);
}
}

View 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.Contests\Nonemm.Contests.csproj" />
<ProjectReference Include="..\..\src\Nonemm.Core\Nonemm.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,69 @@
using Nonemm.Core;
using Nonemm.Core.Country;
namespace Nonemm.Contests.Tests;
/// A country file and QSO builder small enough to read, so a scoring test says
/// what it is testing rather than how to build a contact.
public static class TestLog
{
public const string Countries = """
Germany: 14: 28: EU: 51.00: -10.00: -1.0: DL:
DA,DB,DC,DD,DE,DF,DG,DH,DJ,DK,DL,DM,DO,DP,DQ,DR;
Italy: 15: 28: EU: 42.83: -12.83: -1.0: I:
I,IK,IZ;
United States: 05: 08: NA: 37.60: 91.87: 5.0: K:
K,W,N,AA,K6(3)[6];
Canada: 05: 09: NA: 44.35: 78.75: 5.0: VE:
VE,VA;
Japan: 25: 45: AS: 36.40: -138.38: -9.0: JA:
JA,JH,JR,7K;
Brazil: 11: 15: SA: -10.00: 55.00: 3.0: PY:
PY,PP;
""";
public static readonly CountryFile CountryFile = CountryFile.Parse(Countries);
public static readonly StationInfo Germany = new()
{
Callsign = "DL1ABC",
CqZone = 14,
ItuZone = 28,
Continent = "EU",
CountryPrefix = "DL",
};
public static readonly StationInfo UnitedStates = new()
{
Callsign = "K1ABC",
CqZone = 5,
ItuZone = 8,
Continent = "NA",
CountryPrefix = "K",
State = "CT",
ArrlSection = "CT",
};
public static Qso Contact(
string call,
double kilohertz = 14_025,
Mode? mode = null,
int zone = 0,
int number = 0,
string exchange = "",
string section = "",
DateTime? at = null) =>
new()
{
Id = Qso.NewId(),
TimestampUtc = at ?? new DateTime(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc),
Call = Callsign.Parse(call),
Frequency = Frequency.FromKilohertz(kilohertz),
Mode = mode ?? Modes.Cw,
ContestName = "TEST",
Zone = zone,
ReceivedNumber = number,
Exchange1 = exchange,
Section = section,
};
}

View File

@@ -0,0 +1,34 @@
using Nonemm.Core;
namespace Nonemm.Core.Tests;
public class BandsTests
{
[Theory]
[InlineData(1830, "160M")]
[InlineData(3510.5, "80M")]
[InlineData(14025, "20M")]
[InlineData(28499, "10M")]
[InlineData(50110, "6M")]
[InlineData(144200, "2M")]
public void FrequencyLandsOnItsBand(double kilohertz, string expected) =>
Assert.Equal(expected, Bands.ForFrequency(Frequency.FromKilohertz(kilohertz))?.Name);
[Fact]
public void FrequencyOutsideEveryAllocationHasNoBand() =>
Assert.Null(Bands.ForFrequency(Frequency.FromKilohertz(12000)));
[Fact]
public void BandEdgesAreIncluded()
{
Assert.Equal("20M", Bands.ForFrequency(Frequency.FromKilohertz(14000))?.Name);
Assert.Equal("20M", Bands.ForFrequency(Frequency.FromKilohertz(14350))?.Name);
}
[Theory]
[InlineData(1.8, "160M")]
[InlineData(5, "60M")]
[InlineData(76000, "4MM")]
public void N1mmBandLabelFindsTheBand(double label, string expected) =>
Assert.Equal(expected, Bands.ByLabel(label)?.Name);
}

View File

@@ -0,0 +1,55 @@
using Nonemm.Core;
namespace Nonemm.Core.Tests;
public class CallsignTests
{
[Theory]
[InlineData("N8BJQ", "N8")]
[InlineData("9A1AA", "9A1")]
[InlineData("K1ABC", "K1")]
[InlineData("VP2EXX", "VP2")]
[InlineData("LZ1400PS", "LZ1400")]
public void WpxPrefixEndsAtTheLastDigit(string call, string expected) =>
Assert.Equal(expected, Callsign.Parse(call).WpxPrefix());
[Fact]
public void CallWithNoDigitTakesAZeroAfterTwoLetters() =>
Assert.Equal("RA0", Callsign.Parse("RAEM").WpxPrefix());
[Theory]
[InlineData("KH9/N8BJQ")]
[InlineData("N8BJQ/KH9")]
public void PortablePrefixBecomesTheWpxPrefix(string call) =>
Assert.Equal("KH9", Callsign.Parse(call).WpxPrefix());
[Fact]
public void PortablePrefixWithNoDigitGainsAZero() =>
Assert.Equal("PA0", Callsign.Parse("PA/N8BJQ").WpxPrefix());
[Fact]
public void SingleDigitModifierReplacesTheDigit() =>
Assert.Equal("N9", Callsign.Parse("N8BJQ/9").WpxPrefix());
[Fact]
public void PortableModifierChangesNothing()
{
Callsign call = Callsign.Parse("DL1ABC/P");
Assert.Equal("DL1", call.WpxPrefix());
Assert.Null(call.PortablePrefix);
Assert.Equal("DL1ABC", call.Station);
}
[Fact]
public void MaritimeMobileCountsForNothing()
{
Callsign call = Callsign.Parse("DL1ABC/MM");
Assert.True(call.IsMaritimeMobile);
Assert.False(call.CountsForEntity);
Assert.Null(call.WpxPrefix());
}
[Fact]
public void PortablePrefixIsWhatTheCountryFileGetsAsked() =>
Assert.Equal("KH9", Callsign.Parse("KH9/N8BJQ").EntityLookupText());
}

View File

@@ -0,0 +1,70 @@
using Nonemm.Core;
using Nonemm.Core.Country;
namespace Nonemm.Core.Tests.Country;
public class CountryFileTests
{
private const string Sample = """
Sicily: 15: 28: EU: 37.50: -14.00: -1.0: *IT9:
IT9,IW9,ID9;
Italy: 15: 28: EU: 42.83: -12.83: -1.0: I:
I,IK,IZ,=IG9ABC;
United States: 05: 08: NA: 37.60: 91.87: 5.0: K:
K,W,N,K6(3)[6],=W1AW;
Fiji: 32: 56: OC: -17.78: -177.92: -12.0: 3D2:
3D2;
""";
private static readonly CountryFile File = CountryFile.Parse(Sample);
[Fact]
public void LongestPrefixWins() =>
Assert.Equal("IT9", File.Find("IT9ABC")?.Entity.PrimaryPrefix);
[Fact]
public void ShorterPrefixStillMatches() =>
Assert.Equal("I", File.Find("IK2XYZ")?.Entity.PrimaryPrefix);
[Fact]
public void WaeOnlyEntitiesAreMarked() =>
Assert.True(File.Find("IT9ABC")?.Entity.IsWaeOnly);
[Fact]
public void WholeCallEntryBeatsThePrefix() =>
Assert.Equal("I", File.Find("IG9ABC")?.Entity.PrimaryPrefix);
[Fact]
public void PrefixOverrideChangesTheZone()
{
CountryLookup? found = File.Find("K6XYZ");
Assert.Equal(3, found?.CqZone);
Assert.Equal(6, found?.ItuZone);
}
[Fact]
public void PrefixWithoutOverrideKeepsTheEntityZone() =>
Assert.Equal(5, File.Find("W1XYZ")?.CqZone);
[Fact]
public void PortableStationIsLookedUpByItsPortablePrefix() =>
Assert.Equal("3D2", File.Find("3D2/K1ABC")?.Entity.PrimaryPrefix);
[Fact]
public void MaritimeMobileBelongsToNoEntity() =>
Assert.Null(File.Find("K1ABC/MM"));
[Fact]
public void LongitudeComesBackEastPositive() =>
Assert.Equal(-91.87, File.Find("W1XYZ")!.Longitude, 2);
[Fact]
public void UnknownCallHasNoEntity() =>
Assert.Null(File.Find("XX9ZZZ"));
[Theory]
[InlineData("")]
[InlineData("<html>404 not found</html>")]
public void TextThatIsNotACountryFileIsRejected(string text) =>
Assert.Throws<FormatException>(() => CountryFile.Parse(text));
}

View File

@@ -0,0 +1,46 @@
using Nonemm.Core;
namespace Nonemm.Core.Tests;
public class GridSquareTests
{
[Fact]
public void FourCharacterSquareCentresOnTheSquare()
{
Assert.True(GridSquare.TryParse("JN88", out GridSquare grid));
Assert.Equal(48.5, grid.Latitude, 3);
Assert.Equal(17.0, grid.Longitude, 3);
}
[Fact]
public void SixCharacterSquareIsMorePrecise()
{
Assert.True(GridSquare.TryParse("FN31pr", out GridSquare grid));
Assert.Equal(41.7, grid.Latitude, 1);
Assert.Equal(-72.7, grid.Longitude, 1);
}
[Theory]
[InlineData("")]
[InlineData("JN8")]
[InlineData("SN89")]
[InlineData("JNXY")]
public void MalformedLocatorIsRejected(string text) =>
Assert.False(GridSquare.TryParse(text, out _));
[Fact]
public void DistanceIsTheGreatCircleBetweenSquareCentres()
{
Assert.True(GridSquare.TryParse("JO99", out GridSquare jo99));
Assert.True(GridSquare.TryParse("IO91", out GridSquare io91));
Assert.InRange(jo99.DistanceTo(io91), 1525, 1540);
}
[Fact]
public void BearingIsZeroDueNorth()
{
Assert.True(GridSquare.TryParse("JO99", out GridSquare south));
Assert.True(GridSquare.TryParse("JP90", out GridSquare north));
Assert.Equal(0, south.BearingTo(north), 6);
}
}

View File

@@ -0,0 +1,25 @@
<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.Core\Nonemm.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,61 @@
using Nonemm.Core;
using Nonemm.Formats.Adif;
namespace Nonemm.Formats.Tests;
public class AdifTests
{
private static readonly StationInfo Me = new() { Callsign = "DL1ABC" };
private static Qso Contact() => new()
{
Id = Qso.NewId(),
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 = "579",
Zone = 25,
ReceivedNumber = 34,
};
[Fact]
public void ContactSurvivesAWriteAndRead()
{
string text = new AdifWriter(Me).Write([Contact()]);
Qso read = AdifReader.Read(text).Single();
Assert.Equal("JA1XYZ", read.Call.Text);
Assert.Equal(new DateTime(2026, 5, 30, 12, 34, 56, DateTimeKind.Utc), read.TimestampUtc);
Assert.Equal(14_025_000, read.Frequency.Hertz);
Assert.Equal(Modes.Cw, read.Mode);
Assert.Equal("579", read.ReceivedReport);
Assert.Equal(25, read.Zone);
Assert.Equal(34, read.ReceivedNumber);
Assert.Equal("CQWW", read.ContestName);
}
[Fact]
public void HeaderIsSkipped()
{
const string text = "Exported by something\r\n<ADIF_VER:5>3.1.4 <EOH>\r\n" +
"<CALL:6>JA1XYZ <QSO_DATE:8>20260530 <TIME_ON:6>123456 <MODE:2>CW <EOR>";
Assert.Equal("JA1XYZ", AdifReader.Read(text).Single().Call.Text);
}
[Fact]
public void RecordWithNoCallIsDropped()
{
const string text = "<EOH>\r\n<QSO_DATE:8>20260530 <EOR>\r\n<CALL:6>JA1XYZ <EOR>";
Assert.Single(AdifReader.Read(text));
}
[Fact]
public void BandStandsInWhenThereIsNoFrequency()
{
const string text = "<EOH>\r\n<CALL:6>JA1XYZ <BAND:3>20m <EOR>";
Assert.Equal(Bands.Band20M, AdifReader.Read(text).Single().Band);
}
}

View File

@@ -0,0 +1,83 @@
using Nonemm.Contests.Rules;
using Nonemm.Core;
using Nonemm.Formats.Cabrillo;
namespace Nonemm.Formats.Tests;
public class CabrilloWriterTests
{
private static readonly StationInfo Me = new()
{
Callsign = "DL1ABC",
CqZone = 14,
Continent = "EU",
CountryPrefix = "DL",
};
private static Qso Contact(double kilohertz = 14_025, Mode? mode = null) => new()
{
Id = Qso.NewId(),
TimestampUtc = new DateTime(2026, 5, 30, 12, 34, 56, DateTimeKind.Utc),
Call = Callsign.Parse("JA1XYZ"),
Frequency = Frequency.FromKilohertz(kilohertz),
Mode = mode ?? Modes.Cw,
ContestName = "CQWW",
SentReport = "599",
ReceivedReport = "599",
Zone = 25,
SentNumber = 12,
ReceivedNumber = 34,
};
[Fact]
public void QsoLineCarriesBothExchanges()
{
CabrilloWriter writer = new(new CqWorldWide(ModeCategory.Cw), Me);
string line = writer.QsoLine(Contact());
Assert.StartsWith("QSO: 14025 CW 2026-05-30 1234 DL1ABC", line);
Assert.Contains("JA1XYZ", line);
Assert.EndsWith("0", line);
Assert.Contains(" 14 ", line);
Assert.Contains(" 25 ", line);
}
[Fact]
public void WpxNumbersArePaddedToFour()
{
CabrilloWriter writer = new(new CqWpx(ModeCategory.Cw), Me);
string line = writer.QsoLine(Contact());
Assert.Contains("0012", line);
Assert.Contains("0034", line);
}
[Fact]
public void VhfContactsUseTheBandDesignator()
{
CabrilloWriter writer = new(new CqWorldWide(ModeCategory.Cw), Me);
Assert.Contains(" 144 ", writer.QsoLine(Contact(144_200)));
Assert.Contains(" 1.2G ", writer.QsoLine(Contact(1_296_100)));
}
[Fact]
public void HeaderAndFooterWrapTheContacts()
{
CabrilloWriter writer = new(new CqWorldWide(ModeCategory.Cw), Me);
string log = writer.Write(
new CabrilloHeader { Contest = "CQ-WW-CW", Callsign = "DL1ABC", ClaimedScore = 1234 },
[Contact()]);
Assert.StartsWith("START-OF-LOG: 3.0\r\n", log);
Assert.Contains("CONTEST: CQ-WW-CW\r\n", log);
Assert.Contains("CLAIMED-SCORE: 1234\r\n", log);
Assert.EndsWith("END-OF-LOG:\r\n", log);
}
[Fact]
public void ContactsLeftOutOfTheClaimedScoreAreNotSent()
{
CabrilloWriter writer = new(new CqWorldWide(ModeCategory.Cw), Me);
string log = writer.Write(
new CabrilloHeader { Contest = "CQ-WW-CW", Callsign = "DL1ABC" },
[Contact() with { IsClaimed = false }]);
Assert.DoesNotContain("QSO:", log);
}
}

View File

@@ -0,0 +1,27 @@
<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.Formats\Nonemm.Formats.csproj" />
<ProjectReference Include="..\..\src\Nonemm.Core\Nonemm.Core.csproj" />
<ProjectReference Include="..\..\src\Nonemm.Contests\Nonemm.Contests.csproj" />
</ItemGroup>
</Project>

View 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.Storage\Nonemm.Storage.csproj" />
<ProjectReference Include="..\..\src\Nonemm.Core\Nonemm.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,145 @@
using Microsoft.Data.Sqlite;
using Nonemm.Core;
namespace Nonemm.Storage.Tests;
public class SqliteLogStoreTests : IDisposable
{
private readonly string path = Path.Combine(Path.GetTempPath(), $"nonemm-{Guid.NewGuid():N}.s3db");
public void Dispose()
{
SqliteConnection.ClearAllPools();
File.Delete(path);
GC.SuppressFinalize(this);
}
private static Qso Contact(string call, DateTime at, int contestNumber = 1) => new()
{
Id = Qso.NewId(),
TimestampUtc = at,
Call = Callsign.Parse(call),
Frequency = Frequency.FromKilohertz(14_025.5),
Mode = Modes.Cw,
ContestName = "CQWW",
ContestNumber = contestNumber,
SentReport = "599",
ReceivedReport = "599",
Zone = 14,
Points = 3,
IsMultiplier1 = true,
CountryPrefix = "DL",
Continent = "EU",
WpxPrefix = "DL1",
};
[Fact]
public void ContactSurvivesARoundTrip()
{
DateTime at = new(2026, 5, 30, 12, 34, 56, DateTimeKind.Utc);
using SqliteLogStore store = SqliteLogStore.Open(path);
Qso written = store.Add(Contact("DL1ABC", at));
Qso read = store.Qsos(1).Single();
Assert.Equal(written.Id, read.Id);
Assert.Equal("DL1ABC", read.Call.Text);
Assert.Equal(at, read.TimestampUtc);
Assert.Equal(14_025_500, read.Frequency.Hertz);
Assert.Equal(Modes.Cw, read.Mode);
Assert.Equal(14, read.Zone);
Assert.Equal("EU", read.Continent);
}
[Fact]
public void SecondContactInTheSameSecondMovesOnASecond()
{
DateTime at = new(2026, 5, 30, 12, 34, 56, DateTimeKind.Utc);
using SqliteLogStore store = SqliteLogStore.Open(path);
store.Add(Contact("DL1ABC", at));
Qso second = store.Add(Contact("DL1ABC", at));
Assert.Equal(at.AddSeconds(1), second.TimestampUtc);
Assert.Equal(2, store.Qsos(1).Count);
}
[Fact]
public void EditedContactKeepsItsIdentity()
{
using SqliteLogStore store = SqliteLogStore.Open(path);
Qso written = store.Add(Contact("DL1ABC", new DateTime(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc)));
store.Update(written with { ReceivedReport = "579", Zone = 15 });
Qso read = store.Qsos(1).Single();
Assert.Equal(written.Id, read.Id);
Assert.Equal("579", read.ReceivedReport);
Assert.Equal(15, read.Zone);
}
[Fact]
public void DeletedContactIsGone()
{
using SqliteLogStore store = SqliteLogStore.Open(path);
Qso written = store.Add(Contact("DL1ABC", new DateTime(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc)));
store.Delete(written.Id);
Assert.Empty(store.Qsos(1));
}
[Fact]
public void ContestsAreNumberedInTurn()
{
using SqliteLogStore store = SqliteLogStore.Open(path);
ContestInstance first = store.AddContest(new ContestInstance { ContestNumber = 0, ContestName = "CQWW" });
ContestInstance second = store.AddContest(new ContestInstance { ContestNumber = 0, ContestName = "CQWPX" });
Assert.Equal(1, first.ContestNumber);
Assert.Equal(2, second.ContestNumber);
Assert.Equal(2, store.Contests().Count);
}
[Fact]
public void ContactsBelongToTheirOwnContest()
{
using SqliteLogStore store = SqliteLogStore.Open(path);
store.Add(Contact("DL1ABC", new DateTime(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc), contestNumber: 1));
store.Add(Contact("DL2ABC", new DateTime(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc), contestNumber: 2));
Assert.Equal("DL1ABC", store.Qsos(1).Single().Call.Text);
Assert.Equal("DL2ABC", store.Qsos(2).Single().Call.Text);
}
[Fact]
public void NewDatabaseCarriesN1mmTablesAndVersion()
{
using (SqliteLogStore store = SqliteLogStore.Open(path))
{
}
using SqliteConnection connection = new($"Data Source={path}");
connection.Open();
using SqliteCommand command = connection.CreateCommand();
command.CommandText = "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name";
using SqliteDataReader row = command.ExecuteReader();
List<string> tables = [];
while (row.Read())
{
tables.Add(row.GetString(0));
}
Assert.Contains("DXLOG", tables);
Assert.Contains("ContestInstance", tables);
Assert.Contains("Contest", tables);
Assert.Contains("Station", tables);
using SqliteCommand version = connection.CreateCommand();
version.CommandText = "PRAGMA user_version";
Assert.Equal(4L, version.ExecuteScalar());
}
[Fact]
public void ContactIdCannotBeChanged()
{
using SqliteLogStore store = SqliteLogStore.Open(path);
store.Add(Contact("DL1ABC", new DateTime(2026, 5, 30, 12, 0, 0, DateTimeKind.Utc)));
using SqliteConnection connection = new($"Data Source={path}");
connection.Open();
using SqliteCommand command = connection.CreateCommand();
command.CommandText = "UPDATE DXLOG SET ID = 'zzzz'";
Assert.Throws<SqliteException>(() => command.ExecuteNonQuery());
}
}