Stack the calls that answer a CQ, the way N1MM does

A CQ is answered by more than one station. N1MM lets the operator put the
others on a stack and work them one after another; this is that, with N1MM's
keys and macros.

Ctrl+Alt+G and {STACKANOTHER} put the call being typed on the stack and clear
the boxes. Alt+G brings the top call back. Alt+D drops it. {SOCALLSTACK} both
stacks and unstacks in one key: it swaps the typed call for the top one, stacks
it when the stack is empty, and takes the top call when the boxes are.
{LOGTHENPOP} and {LOGTHENNEXT} log the contact and bring the next caller in.
{CLRSTACK} empties it.

The rules are N1MM's, and CallStack and the entry actions hold them without a
window, so they are unit-tested: stacking only while running, nothing shorter
than two characters, no dupe, no frequency typed into the callsign box, a call
already on the stack moved rather than repeated, and a multiplier to the front.
{SOCALLSTACK} takes the stacked call off before it puts the typed one on, so a
multiplier just typed cannot come straight back.

Two places differ. N1MM refuses your own call only while the stack is empty;
it is never a station to work, so it is refused either way here. And the order
is multipliers first in every mode: N1MM's digital window offers first-in and
last-in as well, and there is no digital window here.

The stack window opens by itself with the first call and closes with the last,
or Window > Call Stack opens it. It does not take the keyboard when it opens,
because the operator is typing the next call. The next call out is ringed and
each is coloured by what working it would bring, as in every other window.

OperatingPosition.JudgeCall now answers what a call alone is worth, which the
stack window and the check window both need; it was private to the check
window before.

Driven under Xvfb: four calls stacked with Ctrl+Alt+G and listed in the window,
Alt+G bringing the top one into the boxes, Alt+D dropping the next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoGtneMQaz4M9w7Kk49AVD
This commit is contained in:
2026-08-30 22:23:30 +00:00
parent bb35848e11
commit ec8cdb88d7
16 changed files with 757 additions and 26 deletions

View File

@@ -0,0 +1,84 @@
namespace Nonemm.Session.Tests;
public class CallStackTests
{
[Fact]
public void ACallThatIsNotAMultiplierGoesToTheBack()
{
CallStack stack = new();
stack.Add("DL1ABC", isMultiplier: false);
stack.Add("G4ABC", isMultiplier: false);
Assert.Equal(["DL1ABC", "G4ABC"], stack.Calls);
}
[Fact]
public void AMultiplierGoesToTheFront()
{
CallStack stack = new();
stack.Add("DL1ABC", isMultiplier: false);
stack.Add("G4ABC", isMultiplier: false);
stack.Add("JA1XYZ", isMultiplier: true);
Assert.Equal(["JA1XYZ", "DL1ABC", "G4ABC"], stack.Calls);
}
[Fact]
public void ACallAlreadyOnTheStackIsMovedRatherThanRepeated()
{
CallStack stack = new();
stack.Add("DL1ABC", isMultiplier: false);
stack.Add("G4ABC", isMultiplier: false);
stack.Add("DL1ABC", isMultiplier: true);
Assert.Equal(["DL1ABC", "G4ABC"], stack.Calls);
}
[Fact]
public void ACallTooShortToBeOneIsNotStacked()
{
CallStack stack = new();
stack.Add("D", isMultiplier: false);
Assert.True(stack.IsEmpty);
}
[Fact]
public void TheCallsComeOffTheFront()
{
CallStack stack = new();
stack.Add("DL1ABC", isMultiplier: false);
stack.Add("G4ABC", isMultiplier: false);
Assert.Equal("DL1ABC", stack.Next());
Assert.Equal("G4ABC", stack.Next());
Assert.Equal("", stack.Next());
}
[Fact]
public void MoveToTopBringsACallForward()
{
CallStack stack = new();
stack.Add("DL1ABC", isMultiplier: false);
stack.Add("G4ABC", isMultiplier: false);
stack.MoveToTop("G4ABC");
Assert.Equal(["G4ABC", "DL1ABC"], stack.Calls);
}
[Fact]
public void MoveToTopLeavesACallThatIsNotThereAlone()
{
CallStack stack = new();
stack.Add("DL1ABC", isMultiplier: false);
stack.MoveToTop("G4ABC");
Assert.Equal(["DL1ABC"], stack.Calls);
}
}

View File

@@ -0,0 +1,190 @@
using Nonemm.Contests;
using Nonemm.Contests.Rules;
using Nonemm.Core;
using Nonemm.Core.Country;
using Nonemm.Storage;
namespace Nonemm.Session.Tests;
/// What the stacking keys do to the entry: N1MM's Ctrl+Alt+G, Alt+G and
/// {SOCALLSTACK}.
public class CallStackingTests
{
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 OperatingPosition Running()
{
FakeLogStore store = new();
ContestInstance instance = store.AddContest(new ContestInstance
{
ContestNumber = 0,
ContestName = "CQWW",
});
ContestSession session = new(
store,
new CqWorldWide(ModeCategory.Cw),
instance,
Me,
CountryFile.Parse(Countries));
return new OperatingPosition(session) { IsRunning = true };
}
private static void Type(OperatingPosition position, string call, string zone = "25")
{
position.Entry.Call = call;
position.Entry.Set(ExchangeSlot.ReceivedReport, "599");
position.Entry.Set(ExchangeSlot.Zone, zone);
}
[Fact]
public void StackAnotherPutsTheCallOnTheStackAndClearsTheEntry()
{
OperatingPosition position = Running();
Type(position, "JA1XYZ");
Assert.True(position.StackAnother());
Assert.Equal(["JA1XYZ"], position.Stack.Calls);
Assert.Equal("", position.Entry.Call);
}
[Fact]
public void NothingIsStackedWhileSearching()
{
OperatingPosition position = Running();
position.IsRunning = false;
Type(position, "JA1XYZ");
Assert.False(position.StackAnother());
Assert.True(position.Stack.IsEmpty);
Assert.Equal("JA1XYZ", position.Entry.Call);
}
[Fact]
public void ADupeIsNotStacked()
{
OperatingPosition position = Running();
Type(position, "JA1XYZ");
position.LogContact();
Type(position, "JA1XYZ");
Assert.False(position.StackAnother());
Assert.True(position.Stack.IsEmpty);
}
[Fact]
public void AFrequencyTypedIntoTheCallsignBoxIsNotStacked()
{
OperatingPosition position = Running();
position.Entry.Call = "14025";
Assert.False(position.StackAnother());
Assert.True(position.Stack.IsEmpty);
}
/// N1MM refuses your own call only while the stack is empty. It is never
/// a station to work, so it is refused either way here.
[Fact]
public void OurOwnCallIsNotStacked()
{
OperatingPosition position = Running();
position.Stack.Add("JA1XYZ", isMultiplier: false);
Type(position, "DL1ABC", "14");
Assert.False(position.StackAnother());
Assert.Equal(["JA1XYZ"], position.Stack.Calls);
}
[Fact]
public void GrabbingPutsTheTopCallInTheBox()
{
OperatingPosition position = Running();
position.Stack.Add("JA1XYZ", isMultiplier: false);
position.Stack.Add("G4ABC", isMultiplier: false);
Assert.True(position.GrabStacked());
Assert.Equal("JA1XYZ", position.Entry.Call);
Assert.Equal(["G4ABC"], position.Stack.Calls);
}
[Fact]
public void GrabbingFromAnEmptyStackLeavesTheEntryAlone()
{
OperatingPosition position = Running();
Type(position, "JA1XYZ");
Assert.False(position.GrabStacked());
Assert.Equal("JA1XYZ", position.Entry.Call);
}
[Fact]
public void SwappingExchangesTheTypedCallForTheStackedOne()
{
OperatingPosition position = Running();
position.Stack.Add("G4ABC", isMultiplier: false);
Type(position, "JA1XYZ");
Assert.True(position.SwapWithStack());
Assert.Equal("G4ABC", position.Entry.Call);
Assert.Equal(["JA1XYZ"], position.Stack.Calls);
}
/// The stacked call comes off before the typed one goes on, so a typed
/// multiplier cannot come straight back into the box.
[Fact]
public void SwappingTakesTheStackedCallEvenWhenTheTypedOneIsAMultiplier()
{
OperatingPosition position = Running();
position.Stack.Add("G4ABC", isMultiplier: false);
Type(position, "JA1XYZ");
position.SwapWithStack();
Assert.Equal("G4ABC", position.Entry.Call);
}
[Fact]
public void SwappingWithAnEmptyStackJustStacksTheTypedCall()
{
OperatingPosition position = Running();
Type(position, "JA1XYZ");
Assert.False(position.SwapWithStack());
Assert.Equal(["JA1XYZ"], position.Stack.Calls);
Assert.Equal("", position.Entry.Call);
}
[Fact]
public void SwappingWithAnEmptyBoxTakesTheNextCall()
{
OperatingPosition position = Running();
position.Stack.Add("G4ABC", isMultiplier: false);
Assert.True(position.SwapWithStack());
Assert.Equal("G4ABC", position.Entry.Call);
Assert.True(position.Stack.IsEmpty);
}
[Fact]
public void SwappingWithNothingTypedAndNothingStackedDoesNothing()
{
OperatingPosition position = Running();
Assert.False(position.SwapWithStack());
Assert.True(position.Stack.IsEmpty);
Assert.Equal("", position.Entry.Call);
}
}

View File

@@ -83,6 +83,24 @@ public class MessagePlanTests
Assert.Equal([new MessageAction(MessageCommand.SendOnOtherRadio, "9")], plan.Before);
}
[Fact]
public void TheCallStackingMacrosAreRead()
{
MessagePlan plan = MessagePlan.Read(
"{STACKANOTHER}{SOCALLSTACK}{LOGTHENPOP}{LOGTHENNEXT}{CLRSTACK}", Session());
Assert.Equal(
[
new MessageAction(MessageCommand.StackAnother),
new MessageAction(MessageCommand.SwapWithStack),
new MessageAction(MessageCommand.LogThenPop),
new MessageAction(MessageCommand.LogThenPop),
new MessageAction(MessageCommand.ClearStack),
],
plan.Before);
Assert.Equal("", plan.Text);
}
[Fact]
public void AMacroThisProgramDoesNotActOnIsPassedOver()
{