diff --git a/docs/unfinished.md b/docs/unfinished.md index 5b20d66..7d2b960 100644 --- a/docs/unfinished.md +++ b/docs/unfinished.md @@ -116,10 +116,16 @@ Cabrillo one. for its own columns gets them, in N1MM's shape — the operator's callsign in the first 13 columns, then every column padded to the width the file gives with one space after it, and a value longer than its column cut rather than pushed. The -numbered `CabrilloFormat` layouts are not read: 2, 3, 4 and 5 name the NAQP, -NA Sprint, Sweepstakes and RFC lines, and a file asking for one of those gets -the default line instead. `CabrilloFormat = 0` means the sponsor takes no -Cabrillo log at all, and nothing here stops the operator writing one. The session, off-time and band-change settings +numbered `CabrilloFormat` layouts 2, 3, 4 and 5 — the NAQP, NA Sprint, +Sweepstakes and section-and-serial lines — are read as well, in N1MM's columns. +The exchange those layouts split into columns is built from the boxes the file +defines, in the order it defines them; N1MM builds it from the section box +alone, which writes only half of a two-box exchange. Layout 6, the ARRL RTTY +Roundup line, is not read, and a file asking for it gets the default line; no +published `.udc` file asks for it, because the Roundup has a contest class of +its own. `CabrilloFormat = 0` means the sponsor takes no Cabrillo log at all, +and nothing here stops the operator writing one. The +session, off-time and band-change settings (`MultipleSessions`, `MinimumOffTime`, the `…BandChange…` family, `DupeQSOMinutesAgo`) are read by nothing, so a contest with periods is logged as one long session. diff --git a/src/Nonemm.Contests/QsoExchange.cs b/src/Nonemm.Contests/QsoExchange.cs new file mode 100644 index 0000000..c2bae3d --- /dev/null +++ b/src/Nonemm.Contests/QsoExchange.cs @@ -0,0 +1,30 @@ +using Nonemm.Core; + +namespace Nonemm.Contests; + +/// Reading the exchange back out of a logged contact, one entry box at a time. +/// `ExchangeSlots.Apply` in `Nonemm.Session` puts the values in. +public static class QsoExchange +{ + /// What the contact holds for one exchange box, or an empty string for a + /// box it holds nothing for. + public static string ValueOf(Qso qso, ExchangeSlot slot) => slot switch + { + ExchangeSlot.ReceivedReport => qso.ReceivedReport, + ExchangeSlot.SerialNumber => Digits(qso.ReceivedNumber), + ExchangeSlot.Zone => Digits(qso.Zone), + ExchangeSlot.Section => qso.Section, + ExchangeSlot.Check => Digits(qso.Check), + ExchangeSlot.Precedence => qso.Precedence, + ExchangeSlot.Exchange1 => qso.Exchange1, + ExchangeSlot.MiscText => qso.MiscText, + ExchangeSlot.Name => qso.Name, + ExchangeSlot.Qth => qso.Qth, + ExchangeSlot.GridSquare => qso.GridSquare, + ExchangeSlot.Power => qso.Power, + ExchangeSlot.Comment => qso.Comment, + _ => "", + }; + + private static string Digits(int value) => value > 0 ? value.ToString() : ""; +} diff --git a/src/Nonemm.Contests/Udc/UdcCabrilloFormat.cs b/src/Nonemm.Contests/Udc/UdcCabrilloFormat.cs new file mode 100644 index 0000000..20e25c6 --- /dev/null +++ b/src/Nonemm.Contests/Udc/UdcCabrilloFormat.cs @@ -0,0 +1,96 @@ +using Nonemm.Core; + +namespace Nonemm.Contests.Udc; + +/// The numbered `CabrilloFormat` layouts. A `.udc` file that names one of +/// these asks for a QSO line the default layout cannot write: +/// +/// | Format | Line | What the exchange looks like | +/// |---|---|---| +/// | 2 | NAQP | two columns: `SAM` and `OH` | +/// | 3 | NA Sprint | the same two columns, taken from the second and third words | +/// | 4 | Sweepstakes | one column: `0034 B 77 OH` | +/// | 5 | section and serial | one column: `OH034` | +/// +/// Each width below is N1MM's width less one. N1MM separates two columns by +/// padding the first one; `CabrilloWriter` pads the first one and then adds a +/// space. So a column N1MM writes 14 characters wide is 13 characters here. +public sealed class UdcCabrilloFormat(int format) +{ + public bool IsNumbered => format is 2 or 3 or 4 or 5; + + public CabrilloExchange Line(Qso qso, StationInfo me, string sent, string received) => + format is 2 or 3 + ? ExchangeInTwoColumns(qso, me, sent, received) + : ExchangeInOneColumn(qso, me, sent); + + /// Formats 2 and 3. Format 3 starts at the second word of the exchange: + /// the NA Sprint exchange begins with the serial number, and the column + /// before these two already holds it. + /// + /// N1MM leaves both columns empty when the exchange is one word long, + /// which looks like a slip. That one word goes in the first column here. + private CabrilloExchange ExchangeInTwoColumns( + Qso qso, + StationInfo me, + string sent, + string received) + { + int firstWord = format == 3 ? 1 : 0; + string[] sentWords = Words(sent); + string[] receivedWords = Words(received); + return new CabrilloExchange( + [ + new CabrilloField(me.Callsign, 13), + new CabrilloField($"{qso.SentNumber}".PadLeft(4), 5), + new CabrilloField(Cut(Word(sentWords, firstWord), 10), 11), + new CabrilloField(Word(sentWords, firstWord + 1), 11), + ], + [ + new CabrilloField(qso.Call.Text, 13), + new CabrilloField($"{qso.ReceivedNumber}".PadLeft(4), 5), + new CabrilloField(Cut(Word(receivedWords, firstWord), 10), 11), + new CabrilloField(Word(receivedWords, firstWord + 1), 11), + ]); + } + + /// Formats 4 and 5. Neither line ends in a transmitter number. + /// + /// The last column is 12 wide rather than 11, because a Sweepstakes + /// exchange is 12 characters long and a value wider than its column is + /// cut. Nothing follows it on the line, so the space the writer adds after + /// it falls at the end of the line. + private CabrilloExchange ExchangeInOneColumn(Qso qso, StationInfo me, string sent) => + new( + [ + new CabrilloField(me.Callsign, 13), + new CabrilloField( + format == 5 + ? sent + $"{qso.SentNumber:000}" + : $"{qso.SentNumber:0000} {SingleSpaced(sent)}", + 13), + ], + [ + new CabrilloField(qso.Call.Text, 13), + new CabrilloField( + format == 5 + ? qso.Section + $"{qso.ReceivedNumber:000}" + : $"{qso.ReceivedNumber:0000} {qso.Precedence} {qso.Check:00} {Cut(qso.Section.PadRight(3), 3)}", + 12), + ]) + { + AddsTransmitter = false, + }; + + private static string[] Words(string exchange) => + exchange.Split(' ', StringSplitOptions.RemoveEmptyEntries); + + private static string Word(string[] words, int at) => words.Length > at ? words[at] : ""; + + private static string Cut(string value, int width) => + value.Length > width ? value[..width] : value; + + /// One space between words. An operator can line the sent exchange up with + /// extra spaces, and N1MM takes them out before writing this line. + private static string SingleSpaced(string exchange) => string.Join(' ', Words(exchange)); +} diff --git a/src/Nonemm.Contests/Udc/UserDefinedContest.cs b/src/Nonemm.Contests/Udc/UserDefinedContest.cs index e0e9ffc..e8d4d0b 100644 --- a/src/Nonemm.Contests/Udc/UserDefinedContest.cs +++ b/src/Nonemm.Contests/Udc/UserDefinedContest.cs @@ -15,6 +15,7 @@ public sealed class UserDefinedContest : Contest private readonly IReadOnlyList exchangeFields; private readonly IReadOnlyList workableStations; private readonly UdcCabrillo cabrillo; + private readonly UdcCabrilloFormat cabrilloFormat; public UserDefinedContest(UdcFile file) { @@ -26,6 +27,7 @@ public sealed class UserDefinedContest : Contest exchangeFields = ReadExchangeFields(file); workableStations = file.List("IsWorkable"); cabrillo = new UdcCabrillo(file.Text("CabrilloString")); + cabrilloFormat = new UdcCabrilloFormat(file.Number("CabrilloFormat", 1)); } public static UserDefinedContest Load(string path) => @@ -103,6 +105,17 @@ public sealed class UserDefinedContest : Contest private string SentExchange(QsoContext qso) => qso.Entry.SentExchange.Length > 0 ? qso.Entry.SentExchange : SentExchangeFor(qso.Me); + /// The exchange values of one contact, in the order the file lists the + /// entry boxes. The report is left out because no numbered layout has a + /// column for it. + private string ReceivedExchange(Qso qso) => + string.Join( + ' ', + exchangeFields + .Where(field => field.Slot != ExchangeSlot.ReceivedReport) + .Select(field => QsoExchange.ValueOf(qso, field.Slot)) + .Where(value => value.Length > 0)); + public IReadOnlyList MultipliersFor(QsoContext qso) => IsWorkable(qso) ? multipliers.Select(m => m.For(qso)).OfType().ToList() @@ -119,15 +132,22 @@ public sealed class UserDefinedContest : Contest return (int)Math.Round(total * pointsMultiplier.ForEntry(entry)); } - /// `CabrilloString` lays out the whole line, which is how a contest whose - /// sponsor asks for its own columns is written. Without it the line is the - /// usual one: each station, its report and its exchange. + /// A sponsor who asks for columns of their own gets them two ways: + /// `CabrilloString` names every column, and a numbered `CabrilloFormat` + /// names one of the four layouts N1MM has built in. A file with neither + /// gets the usual line: each station, its report and its exchange. public CabrilloExchange CabrilloExchange(Qso qso, StationInfo me, ContestEntry entry) => cabrillo.IsDefined ? new CabrilloExchange(cabrillo.Fields(qso, me, entry), []) { AddsTransmitter = cabrillo.HasTransmitter, } + : cabrilloFormat.IsNumbered + ? cabrilloFormat.Line( + qso, + me, + entry.SentExchange.Length > 0 ? entry.SentExchange : SentExchangeFor(me), + ReceivedExchange(qso)) : new CabrilloExchange( [ new CabrilloField(me.Callsign, 13), diff --git a/src/Nonemm.Session/ExchangeSlots.cs b/src/Nonemm.Session/ExchangeSlots.cs index 8b43bdd..c8a7beb 100644 --- a/src/Nonemm.Session/ExchangeSlots.cs +++ b/src/Nonemm.Session/ExchangeSlots.cs @@ -26,26 +26,6 @@ public static class ExchangeSlots _ => qso, }; - /// What a logged contact holds for one exchange box. The inverse of - /// `Apply`, used to load a contact back into the entry boxes. - public static string ValueOf(Qso qso, ExchangeSlot slot) => slot switch - { - ExchangeSlot.ReceivedReport => qso.ReceivedReport, - ExchangeSlot.SerialNumber => Digits(qso.ReceivedNumber), - ExchangeSlot.Zone => Digits(qso.Zone), - ExchangeSlot.Section => qso.Section, - ExchangeSlot.Check => Digits(qso.Check), - ExchangeSlot.Precedence => qso.Precedence, - ExchangeSlot.Exchange1 => qso.Exchange1, - ExchangeSlot.MiscText => qso.MiscText, - ExchangeSlot.Name => qso.Name, - ExchangeSlot.Qth => qso.Qth, - ExchangeSlot.GridSquare => qso.GridSquare, - ExchangeSlot.Power => qso.Power, - ExchangeSlot.Comment => qso.Comment, - _ => "", - }; - /// What the call history file says this station sends in one exchange box, /// or an empty string when the file holds nothing for it. public static string ValueFrom(CallHistoryEntry known, ExchangeField field) => field.Slot switch diff --git a/src/Nonemm.Session/OperatingPosition.cs b/src/Nonemm.Session/OperatingPosition.cs index 3d166d0..720e63a 100644 --- a/src/Nonemm.Session/OperatingPosition.cs +++ b/src/Nonemm.Session/OperatingPosition.cs @@ -361,7 +361,7 @@ public sealed class OperatingPosition Entry.Call = qso.Call.Text; for (int at = 0; at < Entry.Exchange.Count; at++) { - Entry[at + 1] = ExchangeSlots.ValueOf(qso, Entry.Exchange[at].Slot); + Entry[at + 1] = QsoExchange.ValueOf(qso, Entry.Exchange[at].Slot); } OtherName = Entry.Exchange.Any(f => f.Slot == ExchangeSlot.Name) ? "" : qso.Name; Comment = qso.Comment; diff --git a/tests/Nonemm.Formats.Tests/CabrilloWriterTests.cs b/tests/Nonemm.Formats.Tests/CabrilloWriterTests.cs index 500fccf..9b27333 100644 --- a/tests/Nonemm.Formats.Tests/CabrilloWriterTests.cs +++ b/tests/Nonemm.Formats.Tests/CabrilloWriterTests.cs @@ -120,6 +120,97 @@ public class CabrilloWriterTests Assert.EndsWith("ABC ", writer.QsoLine(Contact() with { Exchange1 = "ABCDEF" })); } + /// `CabrilloFormat = 2` is the NAQP line: the serial number and the two + /// exchange values, each in a column of its own. + [Fact] + public void ANumberedCabrilloFormatLaysOutTheNaqpLine() + { + UserDefinedContest contest = new(UdcFile.Parse(""" + [Contest] + Name=NAQP_TEST + CabrilloFormat=2 + EntryWindowInfo=NameText, 500, Exchange1Text, 500 + FrameText=Name QTH + """)); + CabrilloWriter writer = new(contest, Me, new ContestEntry { SentExchange = "JOE OH" }); + + string line = writer.QsoLine(Contact() with { Name = "SAM", Exchange1 = "OH" }); + + Assert.Equal( + "QSO: 14025 CW 2026-05-30 1234 " + + "DL1ABC 12 JOE OH " + + "JA1XYZ 34 SAM OH 0", + line); + } + + /// `CabrilloFormat = 3` is the NA Sprint line, which steps over the serial + /// number in each exchange because the column before it holds it. + [Fact] + public void TheSprintLineStepsOverTheSerialNumberInTheExchange() + { + UserDefinedContest contest = new(UdcFile.Parse(""" + [Contest] + Name=SPRINT_TEST + CabrilloFormat=3 + EntryWindowInfo=RcvNrText, 500, NameText, 500, Exchange1Text, 500 + FrameText=Nr Name QTH + """)); + CabrilloWriter writer = new(contest, Me, new ContestEntry { SentExchange = "001 JOE OH" }); + + string line = writer.QsoLine(Contact() with { Name = "SAM", Exchange1 = "OH" }); + + Assert.Equal( + "QSO: 14025 CW 2026-05-30 1234 " + + "DL1ABC 12 JOE OH " + + "JA1XYZ 34 SAM OH 0", + line); + } + + /// `CabrilloFormat = 4` is the Sweepstakes line: the whole exchange in one + /// column after each callsign, and no transmitter column. + [Fact] + public void TheSweepstakesLineHoldsTheWholeExchangeInOneColumn() + { + UserDefinedContest contest = new(UdcFile.Parse(""" + [Contest] + Name=SS_TEST + CabrilloFormat=4 + EntryWindowInfo=RcvNrText, 500, PrecText, 500, CheckText, 500, SectionText, 500 + """)); + CabrilloWriter writer = new(contest, Me, new ContestEntry { SentExchange = "A 55 OH" }); + + string line = writer.QsoLine( + Contact() with { Precedence = "B", Check = 77, Section = "OH" }); + + Assert.Equal( + "QSO: 14025 CW 2026-05-30 1234 " + + "DL1ABC 0012 A 55 OH " + + "JA1XYZ 0034 B 77 OH ", + line); + } + + /// `CabrilloFormat = 5` writes the section and the serial number as one + /// value, without a space between them. + [Fact] + public void TheSectionAndSerialLineJoinsTheTwoValues() + { + UserDefinedContest contest = new(UdcFile.Parse(""" + [Contest] + Name=RFC_TEST + CabrilloFormat=5 + EntryWindowInfo=SectionText, 500, RcvNrText, 500 + """)); + CabrilloWriter writer = new(contest, Me, new ContestEntry { SentExchange = "OH" }); + + string line = writer.QsoLine(Contact() with { Section = "OH" }); + + Assert.Equal( + "QSO: 14025 CW 2026-05-30 1234 " + + "DL1ABC OH012 " + + "JA1XYZ OH034 ", + line); + } + [Fact] public void TheCabrilloVersionComesFromTheContest() {