Skip to content

Commit 7061dd1

Browse files
committed
feat(ww-digi): isolate contest logs and refine workflow
1 parent f6e1f51 commit 7061dd1

63 files changed

Lines changed: 1985 additions & 290 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/builtin-plugins/src/standard-qso/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ export const standardQSOStrategyPlugin = definePlugin({
112112
name: BUILTIN_STANDARD_QSO_PLUGIN_NAME,
113113
version: '1.0.0',
114114
type: 'strategy',
115-
description: 'Built-in FT8/FT4 standard QSO automation strategy',
115+
description: 'Built-in FT8/FT4 standard QSO strategy',
116116

117117
settings: standardQSOSettings,
118118

packages/builtin-plugins/src/standard-qso/locales/en.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"pluginName": "Standard QSO",
3-
"pluginDescription": "Built-in FT8/FT4 standard QSO automation strategy",
3+
"pluginDescription": "Built-in FT8/FT4 standard QSO strategy",
44
"strategyOverview": "Strategy Overview",
55
"strategyOverviewDesc": "This strategy plugin drives the standard FT8/FT4 QSO flow, including answering others' CQ, automatic target picking, timeout handling, and retry behavior.",
66
"autoReplyToCQ": "Answer others' CQ",

packages/builtin-plugins/src/standard-qso/locales/ja.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"pluginName": "標準交信",
3-
"pluginDescription": "FT8/FT4標準交信自動化戦略を内蔵",
3+
"pluginDescription": "FT8/FT4標準交信ストラテジーを内蔵",
44
"strategyOverview": "戦略の説明",
55
"strategyOverviewDesc": "このポリシー プラグインは、他局の CQ への応答、自動選局、タイムアウトと再試行動作の制御など、標準の FT8/FT4 交信プロセスを担当します。",
66
"autoReplyToCQ": "他局の CQ に自動応答",

packages/builtin-plugins/src/standard-qso/locales/zh.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"pluginName": "标准通联",
3-
"pluginDescription": "内置 FT8/FT4 标准通联自动化策略",
3+
"pluginDescription": "内置 FT8/FT4 标准通联策略",
44
"strategyOverview": "策略说明",
55
"strategyOverviewDesc": "该策略插件负责标准 FT8/FT4 通联流程,包括回应他人 CQ、自动选台以及控制超时与重试行为。",
66
"autoReplyToCQ": "自动回应他人 CQ",

packages/builtin-plugins/src/ww-digi/WWDigiProtocolLane.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,14 @@ export class WWDigiProtocolLane implements ProtocolLane<WWDigiEntryData> {
441441
completion.emitted = true;
442442
return { qsoCompletions: [structuredClone(completion.effect)] };
443443
}
444+
if (actionId === 'retry-log') {
445+
if (!this.completion || this.completion.settled !== 'failed') {
446+
throw new Error('log_retry_not_available');
447+
}
448+
this.completion.settled = undefined;
449+
this.completion.emitted = true;
450+
return { qsoCompletions: [structuredClone(this.completion.effect)] };
451+
}
444452
if (actionId === 'end-qso') {
445453
this.releaseRequested = true;
446454
return { requestDecision: true };
@@ -597,6 +605,14 @@ export class WWDigiProtocolLane implements ProtocolLane<WWDigiEntryData> {
597605
];
598606
}
599607
if (!this.active) return [];
608+
if (this.completion?.settled === 'failed') {
609+
return [{
610+
id: 'retry-log', label: 'actionRetry', icon: 'rotate-right', tone: 'primary', presentation: 'primary',
611+
}, {
612+
id: 'end-qso', label: 'actionEndQso', icon: 'xmark', tone: 'danger', presentation: 'menu',
613+
confirmation: { title: 'confirmEndQso', description: 'confirmEndQsoDesc', confirmLabel: 'actionEndQso' },
614+
}];
615+
}
600616
const actions: StrategyActionDescriptor[] = [
601617
this.paused
602618
? { id: 'resume', label: 'actionResume', icon: 'play', tone: 'primary', presentation: 'primary' }

packages/builtin-plugins/src/ww-digi/WWDigiStrategyRuntime.test.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ function createRuntime(options: {
9494
workedCallsigns?: string[];
9595
busyCallsigns?: string[];
9696
transmitBlocked?: boolean;
97+
sessionId?: string;
9798
} = {}) {
9899
let transmitting = options.transmitting ?? false;
99100
const busy = new Set((options.busyCallsigns ?? []).map((callsign) => callsign.toUpperCase()));
@@ -125,7 +126,9 @@ function createRuntime(options: {
125126
config.frequency + 300,
126127
], undefined, () => options.transmitBlocked
127128
? { transmitGate: { allowed: false, reason: 'confirmSettings' } }
128-
: {}),
129+
: {}, () => options.sessionId
130+
? { kind: 'plugin-session', sessionId: options.sessionId }
131+
: undefined),
129132
setTransmitting(value: boolean) { transmitting = value; },
130133
config,
131134
};
@@ -533,7 +536,7 @@ describe('WWDigiStrategyRuntime protocol flows', () => {
533536
});
534537

535538
it('completes the outbound grid, R-grid, RR73 sequence after physical TX', async () => {
536-
const { runtime } = createRuntime({ transmitting: true });
539+
const { runtime } = createRuntime({ transmitting: true, sessionId: 'ww-digi-session' });
537540
runtime.enqueueTarget({ callsign: 'JA1AAA', lastMessage: selected('CQ WW JA1AAA PM95') });
538541

539542
const grid = await runtime.decide([], decision());
@@ -552,6 +555,7 @@ describe('WWDigiStrategyRuntime protocol flows', () => {
552555
expect(completion.qsoCompletions?.[0]).toMatchObject({
553556
streamId: 'stream-1',
554557
persistencePolicy: 'preserve-distinct',
558+
destination: { kind: 'plugin-session', sessionId: 'ww-digi-session' },
555559
record: {
556560
callsign: 'JA1AAA',
557561
grid: 'PM95',
@@ -841,7 +845,7 @@ describe('WWDigiStrategyRuntime settlement and refill', () => {
841845
});
842846
});
843847

844-
it('projects a failed settlement as review without re-emitting completion', async () => {
848+
it('keeps a failed settlement in review until the operator retries the same completion', async () => {
845849
const { runtime } = createRuntime({ transmitting: true });
846850
const started = await activateInbound(runtime, 'JA1AAA', 'PM95');
847851
confirmTransmissions(runtime, started);
@@ -867,5 +871,18 @@ describe('WWDigiStrategyRuntime settlement and refill', () => {
867871
tone: 'danger',
868872
});
869873
expect(reviewed.transmissions).toEqual([]);
874+
875+
const stream = runtime.getSnapshot().streams![0]!;
876+
expect(stream.actions?.map((action) => action.id)).toContain('retry-log');
877+
const retry = await runtime.invokeAction({
878+
target: { kind: 'stream', streamId: stream.streamId, lifecycleEpoch: stream.qsoLifecycleEpoch },
879+
actionId: 'retry-log',
880+
});
881+
expect(retry?.qsoCompletions).toEqual([
882+
expect.objectContaining({
883+
lifecycleEpoch: effect!.lifecycleEpoch,
884+
record: expect.objectContaining({ id: effect!.record.id }),
885+
}),
886+
]);
870887
});
871888
});

packages/builtin-plugins/src/ww-digi/WWDigiStrategyRuntime.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {
1111
QueuedStrategyTargetRequest,
1212
StrategyDecisionMetaV2,
1313
StrategyDecisionResult,
14+
StrategyQSOCompletionEffect,
1415
StrategyQSOCompletionSettlement,
1516
StrategyRuntimeCheckpoint,
1617
StrategyRuntimeContext,
@@ -125,6 +126,7 @@ export class WWDigiStrategyRuntime implements QueuedStrategyRuntime {
125126
mode: 'FT8' | 'FT4',
126127
) => Promise<{ encodable: boolean; error?: string; reason?: string }> = async () => ({ encodable: true }),
127128
private readonly snapshotExtension: () => WWDigiSnapshotExtension = () => ({}),
129+
private readonly completionDestination: () => StrategyQSOCompletionEffect['destination'] = () => undefined,
128130
) {
129131
const resolveFrequencies = typeof audioFrequenciesHz === 'function'
130132
? audioFrequenciesHz
@@ -721,11 +723,15 @@ export class WWDigiStrategyRuntime implements QueuedStrategyRuntime {
721723
requestedTransmitCycle?: number;
722724
stop?: boolean;
723725
} = {}): StrategyDecisionResult {
726+
const qsoCompletions = options.qsoCompletions?.map((effect) => {
727+
const destination = this.completionDestination();
728+
return { ...effect, ...(destination ? { destination } : {}) };
729+
});
724730
return {
725731
transmission: null,
726732
transmissions: this.getTransmissions(),
727733
snapshot: this.getSnapshot(),
728-
qsoCompletions: options.qsoCompletions,
734+
qsoCompletions,
729735
qsoFailures: options.qsoFailures,
730736
requestedTransmitCycle: options.requestedTransmitCycle,
731737
stop: options.stop ?? false,

packages/builtin-plugins/src/ww-digi/index.test.ts

Lines changed: 98 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -41,31 +41,40 @@ function createContestContext(options: {
4141
contestYear?: number;
4242
logBookId?: string | null;
4343
records?: QSORecord[];
44+
awaitReady?: () => Promise<void>;
45+
simulation?: boolean;
4446
} = {}) {
45-
const queryQSOs = vi.fn(async () => options.records ?? []);
47+
const queryQSOs = vi.fn(async (_filter?: unknown) => options.records ?? []);
4648
const ctx = createMockContext({
47-
permissions: ['logbook:read', 'operator:transmit-control', 'plugin:event-bus'] as const,
49+
permissions: ['logbook:session', 'operator:transmit-control', 'plugin:event-bus'] as const,
4850
callsign: 'BG5DRB',
4951
grid: 'OL32',
52+
radio: { isSimulation: options.simulation === true },
5053
config: {
5154
contestYear: options.contestYear ?? 2026,
5255
location: 'DX',
5356
categoryBand: 'ALL',
5457
categoryPower: 'LOW',
5558
},
56-
logbook: {
57-
forCallsign: () => ({
58-
callsign: 'BG5DRB',
59-
getLogBookId: async () => options.logBookId === undefined ? 'logbook-BG5DRB' : options.logBookId,
60-
queryQSOs,
61-
readQsoSnapshot: async () => ({ revision: 'revision-1', records: [] }),
62-
countQSOs: async () => 0,
63-
getStatistics: async () => null,
64-
addQSO: async (record: QSORecord) => record,
65-
updateQSO: async (_id: string, updates: Partial<QSORecord>) => qsoRecord('updated', 0, updates.contestId),
66-
applyQsoBatch: async () => ({ revision: 'revision-1', outcomes: [] }),
67-
notifyUpdated: async () => {},
68-
}),
59+
logbookSessions: {
60+
open: async (descriptor) => {
61+
if (options.logBookId === null) throw new Error('WW Digi logbook is unavailable');
62+
return {
63+
id: options.logBookId ?? 'plugin-session-ww-digi-2026',
64+
title: descriptor.title,
65+
callsign: 'BG5DRB',
66+
getLogBookId: async () => options.logBookId ?? 'plugin-session-ww-digi-2026',
67+
awaitReady: options.awaitReady ?? (async () => {}),
68+
queryQSOs,
69+
readQsoSnapshot: async () => ({ revision: 'revision-1', records: [] }),
70+
countQSOs: async () => 0,
71+
getStatistics: async () => null,
72+
addQSO: async (record: QSORecord) => record,
73+
updateQSO: async (_id: string, updates: Partial<QSORecord>) => qsoRecord('updated', 0, updates.contestId),
74+
applyQsoBatch: async () => ({ revision: 'revision-1', outcomes: [] }),
75+
notifyUpdated: async () => {},
76+
};
77+
},
6978
},
7079
});
7180
return { ctx, queryQSOs };
@@ -80,7 +89,27 @@ describe('WW Digi contest edition persistence', () => {
8089
});
8190
});
8291

83-
it('reconciles all eligible FT4/FT8 records into a shared callsign/year session', async () => {
92+
it('waits for the host logbook readiness signal before startup reconciliation', async () => {
93+
let releaseReady!: () => void;
94+
const awaitReady = vi.fn()
95+
.mockImplementationOnce(() => new Promise<void>((resolve) => { releaseReady = resolve; }))
96+
.mockResolvedValue(undefined);
97+
const { ctx, queryQSOs } = createContestContext({ awaitReady });
98+
const loading = wwDigiStrategyPlugin.onLoad!(ctx as never);
99+
100+
await vi.waitFor(() => expect(awaitReady).toHaveBeenCalledOnce());
101+
expect(queryQSOs).not.toHaveBeenCalled();
102+
103+
releaseReady();
104+
await loading;
105+
expect(queryQSOs).toHaveBeenCalledOnce();
106+
expect(ctx.store.operator.get(wwDigiTestables.runtimeLogbookIdKey(2026)))
107+
.toBe('plugin-session-ww-digi-2026');
108+
expect(ctx.store.global.get<{ health?: { state?: string } }>(wwDigiTestables.sessionKey('BG5DRB', 2026))?.health)
109+
.toMatchObject({ state: 'healthy' });
110+
});
111+
112+
it('refreshes the contest projection only from its independent session', async () => {
84113
const in2026 = qsoRecord('qso-2026', Date.UTC(2026, 7, 29, 12, 0));
85114
const in2025 = qsoRecord('qso-2025', Date.UTC(2025, 7, 30, 12, 0));
86115
const nonContest = qsoRecord('not-ww-digi', Date.UTC(2026, 7, 29, 12, 1), 'OTHER');
@@ -90,7 +119,7 @@ describe('WW Digi contest edition persistence', () => {
90119
[contestQso('retained-2025', Date.UTC(2025, 7, 30, 12))],
91120
);
92121

93-
await expect(wwDigiTestables.reconcileLedger(ctx, 2026)).resolves.toEqual({ imported: 1, total: 2 });
122+
await expect(wwDigiTestables.refreshContestProjection(ctx, 2026)).resolves.toEqual({ total: 1 });
94123

95124
expect(queryQSOs).toHaveBeenCalledWith(expect.objectContaining({
96125
orderDirection: 'asc',
@@ -104,23 +133,52 @@ describe('WW Digi contest edition persistence', () => {
104133
expect(await wwDigiTestables.readContestRecords(ctx, 2026))
105134
.toEqual(expect.arrayContaining([
106135
expect.objectContaining({ qsoId: 'qso-2026', source: 'ww-digi' }),
107-
expect.objectContaining({ qsoId: 'not-ww-digi', source: 'reconciled' }),
108136
]));
109137
expect(ctx.store.operator.get<ContestQso[]>(wwDigiTestables.ledgerKey(2025)))
110138
.toEqual([expect.objectContaining({ qsoId: 'retained-2025' })]);
111139
expect(ctx.store.global.get<{ health?: { state?: string } }>(wwDigiTestables.sessionKey('BG5DRB', 2026))?.health)
112140
.toEqual(expect.objectContaining({ state: 'healthy' }));
113141
});
114142

143+
it('includes out-of-period FT4/FT8 records only for a virtual radio', async () => {
144+
const outsidePeriod = qsoRecord('virtual-qso', Date.UTC(2026, 7, 28, 9, 20, 15));
145+
const physical = createContestContext({ records: [outsidePeriod] });
146+
const virtual = createContestContext({ records: [outsidePeriod], simulation: true });
147+
148+
await expect(wwDigiTestables.refreshContestProjection(physical.ctx, 2026))
149+
.resolves.toEqual({ total: 0 });
150+
expect(physical.queryQSOs).toHaveBeenCalledWith(expect.objectContaining({
151+
timeRange: expect.any(Object),
152+
}));
153+
154+
await expect(wwDigiTestables.refreshContestProjection(virtual.ctx, 2026))
155+
.resolves.toEqual({ total: 1 });
156+
expect(virtual.queryQSOs.mock.calls[0]?.[0]).not.toHaveProperty('timeRange');
157+
await expect(wwDigiTestables.readContestRecords(virtual.ctx, 2026))
158+
.resolves.toEqual([expect.objectContaining({ qsoId: 'virtual-qso' })]);
159+
});
160+
115161
it('marks an unavailable logbook degraded and refuses Cabrillo rendering', async () => {
116162
const { ctx } = createContestContext({ logBookId: null });
117163

118-
await expect(wwDigiTestables.reconcileLedgerWithHealth(ctx, 2026))
164+
await expect(wwDigiTestables.refreshContestProjectionWithHealth(ctx, 2026))
119165
.rejects.toThrow(/logbook is unavailable/);
120166
expect(ctx.store.global.get<{ health?: { state?: string } }>(wwDigiTestables.sessionKey('BG5DRB', 2026))?.health)
121167
.toEqual(expect.objectContaining({ state: 'degraded' }));
122168
await expect(wwDigiTestables.renderCabrillo(ctx, 2026))
123-
.rejects.toThrow(/reconcile it before download/);
169+
.rejects.toThrow(/logbook is unavailable/);
170+
});
171+
172+
it('exports only independent WW Digi session records as standard ADIF', async () => {
173+
const contest = qsoRecord('contest', Date.UTC(2026, 7, 29, 12, 0));
174+
const unrelated = { ...qsoRecord('other', Date.UTC(2026, 7, 29, 12, 1), 'OTHER'), callsign: 'K1OTHER' };
175+
const { ctx } = createContestContext({ records: [contest, unrelated] });
176+
177+
const adif = await wwDigiTestables.renderADIF(ctx, 2026);
178+
expect(adif).toContain('<programid:13>TX5DR-WW-DIGI');
179+
expect(adif).toContain('<call:6>JA1AAA');
180+
expect(adif).toContain('<station_callsign:6>BG5DRB');
181+
expect(adif).not.toContain('K1OTHER');
124182
});
125183

126184
it('migrates a schema v1 session to an unconfirmed v2 session without losing overrides', async () => {
@@ -138,7 +196,7 @@ describe('WW Digi contest edition persistence', () => {
138196
operatorTransmitters: {}, migratedOperators: {}, health: { state: 'healthy' },
139197
});
140198

141-
await wwDigiTestables.reconcileLedger(ctx, 2026);
199+
await wwDigiTestables.refreshContestProjection(ctx, 2026);
142200
expect(ctx.store.global.get<Record<string, unknown>>(key)).toMatchObject({
143201
schemaVersion: 2,
144202
setup: { status: 'unconfirmed' },
@@ -161,6 +219,25 @@ describe('WW Digi contest edition persistence', () => {
161219
.toEqual(expect.objectContaining({ 'qso-2026': expect.objectContaining({ operatorId: 'operator-0', transmitterId: 0 }) }));
162220
});
163221

222+
it('records an out-of-period completion only when the active radio is virtual', async () => {
223+
const record = qsoRecord('local-simulation-qso', Date.UTC(2026, 7, 28, 9, 20, 15));
224+
const physical = createContestContext({ records: [record] });
225+
const virtual = createContestContext({ records: [record], simulation: true });
226+
const hook = wwDigiStrategyPlugin.hooks?.onQSOComplete;
227+
228+
await hook!(record, physical.ctx);
229+
expect(physical.ctx.store.global.get<{ overrides?: Record<string, unknown> }>(
230+
wwDigiTestables.sessionKey('BG5DRB', 2026),
231+
)?.overrides).toBeUndefined();
232+
233+
await hook!(record, virtual.ctx);
234+
expect(virtual.ctx.store.global.get<{ overrides?: Record<string, unknown> }>(
235+
wwDigiTestables.sessionKey('BG5DRB', 2026),
236+
)?.overrides).toEqual(expect.objectContaining({
237+
'local-simulation-qso': expect.objectContaining({ operatorId: 'operator-0' }),
238+
}));
239+
});
240+
164241
it('defaults the setting to the current UTC year with bounded input', () => {
165242
const descriptor = wwDigiStrategyPlugin.settings?.contestYear;
166243
expect(descriptor).toMatchObject({

0 commit comments

Comments
 (0)