@tx5dr/plugin-api 2.0.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/README.md +201 -0
  2. package/dist/__tests__/adif.test.js +67 -0
  3. package/dist/__tests__/adif.test.js.map +1 -1
  4. package/dist/__tests__/testing-utils.test.js +17 -0
  5. package/dist/__tests__/testing-utils.test.js.map +1 -1
  6. package/dist/capabilities.d.ts +2 -1
  7. package/dist/capabilities.d.ts.map +1 -1
  8. package/dist/capabilities.js +2 -0
  9. package/dist/capabilities.js.map +1 -1
  10. package/dist/compatibility.d.ts +15 -0
  11. package/dist/compatibility.d.ts.map +1 -0
  12. package/dist/compatibility.js +71 -0
  13. package/dist/compatibility.js.map +1 -0
  14. package/dist/contest-logbook-ui/assets/contest-log.css +1 -0
  15. package/dist/contest-logbook-ui/assets/contest-log.js +40 -0
  16. package/dist/contest-logbook-ui/contest-log.html +13 -0
  17. package/dist/context.d.ts +39 -7
  18. package/dist/context.d.ts.map +1 -1
  19. package/dist/definition.d.ts +25 -1
  20. package/dist/definition.d.ts.map +1 -1
  21. package/dist/definition.js.map +1 -1
  22. package/dist/ft8/StandardQSOPluginRuntime.d.ts +142 -0
  23. package/dist/ft8/StandardQSOPluginRuntime.d.ts.map +1 -0
  24. package/dist/ft8/StandardQSOPluginRuntime.js +1592 -0
  25. package/dist/ft8/StandardQSOPluginRuntime.js.map +1 -0
  26. package/dist/ft8/index.d.ts +22 -0
  27. package/dist/ft8/index.d.ts.map +1 -0
  28. package/dist/ft8/index.js +176 -0
  29. package/dist/ft8/index.js.map +1 -0
  30. package/dist/helpers.d.ts +95 -3
  31. package/dist/helpers.d.ts.map +1 -1
  32. package/dist/hooks.d.ts +2 -0
  33. package/dist/hooks.d.ts.map +1 -1
  34. package/dist/index.d.ts +13 -3
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +7 -0
  37. package/dist/index.js.map +1 -1
  38. package/dist/runtime.d.ts +377 -6
  39. package/dist/runtime.d.ts.map +1 -1
  40. package/dist/runtime.js.map +1 -1
  41. package/dist/simulation.d.ts +60 -0
  42. package/dist/simulation.d.ts.map +1 -0
  43. package/dist/simulation.js +2 -0
  44. package/dist/simulation.js.map +1 -0
  45. package/dist/testing/index.d.ts +5 -1
  46. package/dist/testing/index.d.ts.map +1 -1
  47. package/dist/testing/index.js +67 -2
  48. package/dist/testing/index.js.map +1 -1
  49. package/dist/toolkit/contest/CabrilloBuilder.d.ts +7 -0
  50. package/dist/toolkit/contest/CabrilloBuilder.d.ts.map +1 -0
  51. package/dist/toolkit/contest/CabrilloBuilder.js +11 -0
  52. package/dist/toolkit/contest/CabrilloBuilder.js.map +1 -0
  53. package/dist/toolkit/contest/ContestCategorySchema.d.ts +11 -0
  54. package/dist/toolkit/contest/ContestCategorySchema.d.ts.map +1 -0
  55. package/dist/toolkit/contest/ContestCategorySchema.js +6 -0
  56. package/dist/toolkit/contest/ContestCategorySchema.js.map +1 -0
  57. package/dist/toolkit/contest/ContestLogbook.d.ts +223 -0
  58. package/dist/toolkit/contest/ContestLogbook.d.ts.map +1 -0
  59. package/dist/toolkit/contest/ContestLogbook.js +845 -0
  60. package/dist/toolkit/contest/ContestLogbook.js.map +1 -0
  61. package/dist/toolkit/contest/ContestQsoEnvelopeAdapter.d.ts +28 -0
  62. package/dist/toolkit/contest/ContestQsoEnvelopeAdapter.d.ts.map +1 -0
  63. package/dist/toolkit/contest/ContestQsoEnvelopeAdapter.js +79 -0
  64. package/dist/toolkit/contest/ContestQsoEnvelopeAdapter.js.map +1 -0
  65. package/dist/toolkit/contest/ContestQsoProjector.d.ts +12 -0
  66. package/dist/toolkit/contest/ContestQsoProjector.d.ts.map +1 -0
  67. package/dist/toolkit/contest/ContestQsoProjector.js +13 -0
  68. package/dist/toolkit/contest/ContestQsoProjector.js.map +1 -0
  69. package/dist/toolkit/contest/ContestSessionNotifier.d.ts +9 -0
  70. package/dist/toolkit/contest/ContestSessionNotifier.d.ts.map +1 -0
  71. package/dist/toolkit/contest/ContestSessionNotifier.js +13 -0
  72. package/dist/toolkit/contest/ContestSessionNotifier.js.map +1 -0
  73. package/dist/toolkit/contest/ContestSessionRepository.d.ts +21 -0
  74. package/dist/toolkit/contest/ContestSessionRepository.d.ts.map +1 -0
  75. package/dist/toolkit/contest/ContestSessionRepository.js +27 -0
  76. package/dist/toolkit/contest/ContestSessionRepository.js.map +1 -0
  77. package/dist/toolkit/contest/DefaultContestSession.d.ts +105 -0
  78. package/dist/toolkit/contest/DefaultContestSession.d.ts.map +1 -0
  79. package/dist/toolkit/contest/DefaultContestSession.js +387 -0
  80. package/dist/toolkit/contest/DefaultContestSession.js.map +1 -0
  81. package/dist/toolkit/contest/DefaultContestWorkbench.d.ts +100 -0
  82. package/dist/toolkit/contest/DefaultContestWorkbench.d.ts.map +1 -0
  83. package/dist/toolkit/contest/DefaultContestWorkbench.js +33 -0
  84. package/dist/toolkit/contest/DefaultContestWorkbench.js.map +1 -0
  85. package/dist/toolkit/contest/FT8ContestDefinition.d.ts +83 -0
  86. package/dist/toolkit/contest/FT8ContestDefinition.d.ts.map +1 -0
  87. package/dist/toolkit/contest/FT8ContestDefinition.js +167 -0
  88. package/dist/toolkit/contest/FT8ContestDefinition.js.map +1 -0
  89. package/dist/toolkit/contest/FT8ContestModules.d.ts +178 -0
  90. package/dist/toolkit/contest/FT8ContestModules.d.ts.map +1 -0
  91. package/dist/toolkit/contest/FT8ContestModules.js +247 -0
  92. package/dist/toolkit/contest/FT8ContestModules.js.map +1 -0
  93. package/dist/toolkit/contest/FT8ContestPlugin.d.ts +55 -0
  94. package/dist/toolkit/contest/FT8ContestPlugin.d.ts.map +1 -0
  95. package/dist/toolkit/contest/FT8ContestPlugin.js +322 -0
  96. package/dist/toolkit/contest/FT8ContestPlugin.js.map +1 -0
  97. package/dist/toolkit/contest/FT8ContestTestKit.d.ts +12 -0
  98. package/dist/toolkit/contest/FT8ContestTestKit.d.ts.map +1 -0
  99. package/dist/toolkit/contest/FT8ContestTestKit.js +60 -0
  100. package/dist/toolkit/contest/FT8ContestTestKit.js.map +1 -0
  101. package/dist/toolkit/contest/contest-toolkit.test.d.ts +2 -0
  102. package/dist/toolkit/contest/contest-toolkit.test.d.ts.map +1 -0
  103. package/dist/toolkit/contest/contest-toolkit.test.js +1196 -0
  104. package/dist/toolkit/contest/contest-toolkit.test.js.map +1 -0
  105. package/dist/toolkit/contest/index.d.ts +14 -0
  106. package/dist/toolkit/contest/index.d.ts.map +1 -0
  107. package/dist/toolkit/contest/index.js +14 -0
  108. package/dist/toolkit/contest/index.js.map +1 -0
  109. package/dist/toolkit/index.d.ts +3 -0
  110. package/dist/toolkit/index.d.ts.map +1 -0
  111. package/dist/toolkit/index.js +3 -0
  112. package/dist/toolkit/index.js.map +1 -0
  113. package/dist/toolkit/parallel-qso/AuthorizationLease.d.ts +15 -0
  114. package/dist/toolkit/parallel-qso/AuthorizationLease.d.ts.map +1 -0
  115. package/dist/toolkit/parallel-qso/AuthorizationLease.js +25 -0
  116. package/dist/toolkit/parallel-qso/AuthorizationLease.js.map +1 -0
  117. package/dist/toolkit/parallel-qso/BoundedCallSessionController.d.ts +40 -0
  118. package/dist/toolkit/parallel-qso/BoundedCallSessionController.d.ts.map +1 -0
  119. package/dist/toolkit/parallel-qso/BoundedCallSessionController.js +81 -0
  120. package/dist/toolkit/parallel-qso/BoundedCallSessionController.js.map +1 -0
  121. package/dist/toolkit/parallel-qso/BoundedCallSessionController.test.d.ts +2 -0
  122. package/dist/toolkit/parallel-qso/BoundedCallSessionController.test.d.ts.map +1 -0
  123. package/dist/toolkit/parallel-qso/BoundedCallSessionController.test.js +31 -0
  124. package/dist/toolkit/parallel-qso/BoundedCallSessionController.test.js.map +1 -0
  125. package/dist/toolkit/parallel-qso/CompletionTracker.d.ts +20 -0
  126. package/dist/toolkit/parallel-qso/CompletionTracker.d.ts.map +1 -0
  127. package/dist/toolkit/parallel-qso/CompletionTracker.js +20 -0
  128. package/dist/toolkit/parallel-qso/CompletionTracker.js.map +1 -0
  129. package/dist/toolkit/parallel-qso/LaneFrequencyController.d.ts +19 -0
  130. package/dist/toolkit/parallel-qso/LaneFrequencyController.d.ts.map +1 -0
  131. package/dist/toolkit/parallel-qso/LaneFrequencyController.js +23 -0
  132. package/dist/toolkit/parallel-qso/LaneFrequencyController.js.map +1 -0
  133. package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.d.ts +134 -0
  134. package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.d.ts.map +1 -0
  135. package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.js +554 -0
  136. package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.js.map +1 -0
  137. package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.test.d.ts +2 -0
  138. package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.test.d.ts.map +1 -0
  139. package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.test.js +470 -0
  140. package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.test.js.map +1 -0
  141. package/dist/toolkit/parallel-qso/PostCompletionRecoveryLease.d.ts +20 -0
  142. package/dist/toolkit/parallel-qso/PostCompletionRecoveryLease.d.ts.map +1 -0
  143. package/dist/toolkit/parallel-qso/PostCompletionRecoveryLease.js +29 -0
  144. package/dist/toolkit/parallel-qso/PostCompletionRecoveryLease.js.map +1 -0
  145. package/dist/toolkit/parallel-qso/ProtocolLane.d.ts +50 -0
  146. package/dist/toolkit/parallel-qso/ProtocolLane.d.ts.map +1 -0
  147. package/dist/toolkit/parallel-qso/ProtocolLane.js +2 -0
  148. package/dist/toolkit/parallel-qso/ProtocolLane.js.map +1 -0
  149. package/dist/toolkit/parallel-qso/controllers.test.d.ts +2 -0
  150. package/dist/toolkit/parallel-qso/controllers.test.d.ts.map +1 -0
  151. package/dist/toolkit/parallel-qso/controllers.test.js +36 -0
  152. package/dist/toolkit/parallel-qso/controllers.test.js.map +1 -0
  153. package/dist/toolkit/parallel-qso/index.d.ts +8 -0
  154. package/dist/toolkit/parallel-qso/index.d.ts.map +1 -0
  155. package/dist/toolkit/parallel-qso/index.js +8 -0
  156. package/dist/toolkit/parallel-qso/index.js.map +1 -0
  157. package/dist/utils/adif.d.ts +1 -1
  158. package/dist/utils/adif.d.ts.map +1 -1
  159. package/dist/utils/adif.js +91 -50
  160. package/dist/utils/adif.js.map +1 -1
  161. package/package.json +27 -5
  162. package/src/bridge.d.ts +3 -0
  163. package/tokens.css +51 -0
@@ -0,0 +1,1196 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createMockContext, createMockEventBus, createMockKVStore, createMockLogbookAccess, } from '../../testing/index.js';
3
+ import { buildCabrilloDocument } from './CabrilloBuilder.js';
4
+ import { ContestSessionRepository } from './ContestSessionRepository.js';
5
+ import { defineFT8Contest, fixedWeekendEdition, formatFT8ContestSubmission, projectFT8ContestQsos, } from './FT8ContestDefinition.js';
6
+ import { cabrilloSubmission, defineCompletionModule, defineFT8ExchangeModule, distancePoints, fixedPoints, gridAndSnrExchange, gridExchange, gridFieldMultiplier, maidenheadDistanceKm, nextContestSerial, multiplierKeysFrom, oncePerBand, scoreBy, requireExchangeAndFinalAck, } from './FT8ContestModules.js';
7
+ import { composeFT8ContestPlugin, defineContestSessionModule } from './FT8ContestPlugin.js';
8
+ import { createFT8ContestTestKit } from './FT8ContestTestKit.js';
9
+ import { CONTEST_SESSION_PERMISSIONS, defaultContestSession, } from './DefaultContestSession.js';
10
+ import { defaultContestWorkbench } from './DefaultContestWorkbench.js';
11
+ import { CONTEST_WORKBENCH_ACTIONS } from './DefaultContestWorkbench.js';
12
+ import { createContestQsoEnvelopeAdapter } from './ContestQsoEnvelopeAdapter.js';
13
+ import { CONTEST_LOGBOOK_PERMISSIONS, defaultContestLogbook, standardFT8ContestLogbook, } from './ContestLogbook.js';
14
+ function createExampleContest() {
15
+ return defineFT8Contest({
16
+ id: 'example-ft8',
17
+ rulesetVersion: '2026.1',
18
+ edition: fixedWeekendEdition({
19
+ id: '2026',
20
+ startAt: '2026-08-29T00:00:00Z',
21
+ endAt: '2026-08-30T00:00:00Z',
22
+ source: { url: 'https://example.test/rules', confirmedAt: '2026-08-01T00:00:00Z' },
23
+ }),
24
+ bands: ['20m', '40m'],
25
+ exchange: gridExchange(),
26
+ completion: requireExchangeAndFinalAck(),
27
+ dupe: oncePerBand(),
28
+ scoring: distancePoints({
29
+ stepKm: 3000,
30
+ multiplierKeys: gridFieldMultiplier({
31
+ grid: (qso) => qso.receivedExchange?.grid,
32
+ band: (qso) => qso.band,
33
+ }),
34
+ }),
35
+ submission: cabrilloSubmission({
36
+ headers: () => [['CONTEST', 'EXAMPLE-FT8']],
37
+ qsoLine: (qso) => `QSO: ${qso.frequencyKhz} DG ${qso.at} ${qso.callsign}`,
38
+ }),
39
+ });
40
+ }
41
+ describe('contest toolkit', () => {
42
+ it('serializes shared session updates inside one Host process', () => {
43
+ const store = createMockKVStore();
44
+ const create = () => ({ schemaVersion: 1, revision: 0, ids: [] });
45
+ const first = new ContestSessionRepository(store, 'session', create);
46
+ const second = new ContestSessionRepository(store, 'session', create);
47
+ first.update((session) => ({ ...session, ids: [...session.ids, 'a'] }));
48
+ second.update((session) => ({ ...session, ids: [...session.ids, 'b'] }));
49
+ expect(first.read()).toEqual({ schemaVersion: 1, revision: 2, ids: ['a', 'b'] });
50
+ });
51
+ it('builds deterministic Cabrillo with CRLF endings', () => {
52
+ expect(buildCabrilloDocument({
53
+ headers: [['CONTEST', 'EXAMPLE']],
54
+ qsoLines: ['QSO: 14000 DG 2026-01-01 0000 A A B B 0'],
55
+ })).toBe('START-OF-LOG: 3.0\r\nCONTEST: EXAMPLE\r\nQSO: 14000 DG 2026-01-01 0000 A A B B 0\r\nEND-OF-LOG:\r\n');
56
+ });
57
+ it('composes safe FT8 defaults while keeping modules replaceable', () => {
58
+ const contest = createExampleContest();
59
+ expect(contest.modes).toEqual(['FT8']);
60
+ expect(contest.bands).toEqual(['20M', '40M']);
61
+ expect(contest.operating).toEqual({
62
+ humanInitiation: 'required',
63
+ maxConcurrentQsos: 1,
64
+ maxSimultaneousSignals: 1,
65
+ cycleRelation: 'single',
66
+ });
67
+ expect(contest.exchange.id).toBe('grid-4');
68
+ expect(contest.dupe.id).toBe('callsign-band');
69
+ });
70
+ it('snapshots contest identity and rule modules against caller mutation', () => {
71
+ const edition = {
72
+ id: 'mutable-edition',
73
+ startAt: '2026-08-29T00:00:00Z',
74
+ endAt: '2026-08-30T00:00:00Z',
75
+ source: { url: 'https://example.test/original' },
76
+ };
77
+ const grid = gridExchange();
78
+ const exchange = {
79
+ id: 'mutable-exchange',
80
+ decode: grid.decode,
81
+ encode: grid.encode,
82
+ validate: grid.validate,
83
+ };
84
+ const contest = defineFT8Contest({
85
+ id: 'immutable-contest',
86
+ rulesetVersion: '2026.1',
87
+ edition,
88
+ bands: ['20M'],
89
+ exchange,
90
+ completion: requireExchangeAndFinalAck(),
91
+ scoring: distancePoints({ stepKm: 3000 }),
92
+ submission: cabrilloSubmission({
93
+ headers: () => [['CONTEST', 'IMMUTABLE']],
94
+ qsoLine: (qso) => `QSO: ${qso.callsign}`,
95
+ }),
96
+ });
97
+ edition.id = 'changed-edition';
98
+ edition.source.url = 'https://example.test/changed';
99
+ exchange.id = 'changed-exchange';
100
+ expect(contest.edition).toMatchObject({
101
+ id: 'mutable-edition',
102
+ source: { url: 'https://example.test/original' },
103
+ });
104
+ expect(contest.exchange.id).toBe('mutable-exchange');
105
+ expect(Object.isFrozen(contest.edition)).toBe(true);
106
+ expect(Object.isFrozen(contest.edition.source)).toBe(true);
107
+ expect(Object.isFrozen(contest.exchange)).toBe(true);
108
+ });
109
+ it('rejects operating limits above the Host stream ceiling', () => {
110
+ const contest = createExampleContest();
111
+ const input = {
112
+ id: 'too-many-streams',
113
+ rulesetVersion: contest.rulesetVersion,
114
+ edition: contest.edition,
115
+ bands: contest.bands,
116
+ exchange: contest.exchange,
117
+ completion: contest.completion,
118
+ scoring: contest.scoring,
119
+ submission: contest.submission,
120
+ };
121
+ expect(() => defineFT8Contest({
122
+ ...input,
123
+ operating: { maxConcurrentQsos: 6 },
124
+ })).toThrow('contest_operating_invalid_concurrent_qsos');
125
+ expect(() => defineFT8Contest({
126
+ ...input,
127
+ operating: { maxConcurrentQsos: 5, maxSimultaneousSignals: 6 },
128
+ })).toThrow('contest_operating_invalid_simultaneous_signals');
129
+ });
130
+ it('maps operating safety into Host strategy capabilities and rejects contradictions', () => {
131
+ const contest = createExampleContest();
132
+ const plugin = composeFT8ContestPlugin({
133
+ name: 'safe-defaults',
134
+ version: '1.0.0',
135
+ contest,
136
+ runtime: () => ({}),
137
+ });
138
+ expect(plugin.minPluginApiVersion).toBe('2.5.0');
139
+ expect(() => plugin.createStrategyRuntime?.({
140
+ ...createMockContext(),
141
+ pluginApiVersion: '2.0.0',
142
+ })).toThrow('PLUGIN_API_VERSION_UNSUPPORTED');
143
+ expect(plugin.strategyFeatures).toMatchObject({
144
+ manualInitiation: 1,
145
+ maxConcurrentStreams: 1,
146
+ maxSimultaneousSignals: 1,
147
+ });
148
+ expect(() => composeFT8ContestPlugin({
149
+ name: 'unsafe-override',
150
+ version: '1.0.0',
151
+ contest,
152
+ strategyFeatures: { maxConcurrentStreams: 2 },
153
+ runtime: () => ({}),
154
+ })).toThrow('contest_operating_strategy_feature_conflict:maxConcurrentStreams');
155
+ const singleQueue = composeFT8ContestPlugin({
156
+ name: 'single-queue',
157
+ version: '1.0.0',
158
+ contest,
159
+ strategyFeatures: { targetQueue: 1, queueActivation: 'operator-toggle' },
160
+ runtime: () => ({}),
161
+ });
162
+ expect(singleQueue.strategyFeatures).toMatchObject({
163
+ targetQueue: 1,
164
+ queueActivation: 'operator-toggle',
165
+ maxConcurrentStreams: 1,
166
+ });
167
+ expect(() => singleQueue.createStrategyRuntime?.(createMockContext())).toThrow('contest_runtime_target_queue_requires_queued_strategy');
168
+ const parallelContest = defineFT8Contest({
169
+ id: 'parallel-contest',
170
+ rulesetVersion: contest.rulesetVersion,
171
+ edition: contest.edition,
172
+ bands: contest.bands,
173
+ exchange: contest.exchange,
174
+ completion: contest.completion,
175
+ dupe: contest.dupe,
176
+ scoring: contest.scoring,
177
+ submission: contest.submission,
178
+ operating: { maxConcurrentQsos: 2, maxSimultaneousSignals: 1 },
179
+ });
180
+ const queuedWithoutParallel = {
181
+ observeDecodedMessages() { return false; },
182
+ enqueueTarget() { return {}; },
183
+ reorderTarget() { return {}; },
184
+ removeTarget() { return {}; },
185
+ getQueueSnapshot() { return {}; },
186
+ };
187
+ const parallelPlugin = composeFT8ContestPlugin({
188
+ name: 'parallel-queue',
189
+ version: '1.0.0',
190
+ contest: parallelContest,
191
+ runtime: () => queuedWithoutParallel,
192
+ });
193
+ expect(parallelPlugin.strategyFeatures).toMatchObject({
194
+ targetQueue: 1,
195
+ parallelTargetQueue: 1,
196
+ maxConcurrentStreams: 2,
197
+ });
198
+ expect(() => parallelPlugin.createStrategyRuntime?.(createMockContext())).toThrow('contest_runtime_parallel_queue_requires_parallel_runtime');
199
+ });
200
+ it('infers the quickstart definition without generic annotations', () => {
201
+ const contest = defineFT8Contest({
202
+ id: 'quickstart',
203
+ rulesetVersion: '2026.1',
204
+ edition: fixedWeekendEdition({
205
+ id: '2026',
206
+ startAt: '2026-08-29T00:00:00Z',
207
+ endAt: '2026-08-30T00:00:00Z',
208
+ }),
209
+ bands: ['20M'],
210
+ exchange: gridExchange(),
211
+ completion: requireExchangeAndFinalAck(),
212
+ scoring: distancePoints({ stepKm: 3000 }),
213
+ submission: cabrilloSubmission({
214
+ headers: () => [['CONTEST', 'QUICKSTART']],
215
+ qsoLine: (qso) => `QSO: ${qso.callsign}`,
216
+ }),
217
+ });
218
+ expect(contest.id).toBe('quickstart');
219
+ });
220
+ it('covers exchange, completion, dupe, scoring and submission golden paths', () => {
221
+ const contest = createExampleContest();
222
+ const kit = createFT8ContestTestKit(contest);
223
+ const first = {
224
+ callsign: 'w1aw',
225
+ band: '20m',
226
+ mode: 'FT8',
227
+ startTime: Date.parse('2026-08-29T00:00:00Z'),
228
+ receivedExchange: { grid: 'FN31' },
229
+ distanceKm: 5541,
230
+ frequencyKhz: 14074,
231
+ at: '2026-08-29 0000',
232
+ };
233
+ const dupe = { ...first, callsign: 'W1AW', distanceKm: 9000 };
234
+ kit.exchange({ grid: 'fn31' }, { grid: 'FN31' }, { grid: 'FN31' });
235
+ kit.invalidExchange({ grid: 'ZZ99' }, 'invalid_grid');
236
+ kit.completion({
237
+ sentExchange: { grid: 'PL04' },
238
+ receivedExchange: { grid: 'FN31' },
239
+ receivedFinalAck: true,
240
+ }, true);
241
+ kit.dupe(first, dupe);
242
+ kit.score([first, dupe], {
243
+ qsoCount: 1,
244
+ qsoPoints: 2,
245
+ multiplierCount: 1,
246
+ total: 2,
247
+ });
248
+ kit.submission([first], undefined, 'START-OF-LOG: 3.0\r\nCONTEST: EXAMPLE-FT8\r\nQSO: 14074 DG 2026-08-29 0000 w1aw\r\nEND-OF-LOG:\r\n');
249
+ });
250
+ it('applies edition, mode, band, review and dupe rules before score or export', () => {
251
+ const contest = createExampleContest();
252
+ const first = {
253
+ callsign: 'K1ABC', band: '20M', mode: 'FT8',
254
+ startTime: Date.parse('2026-08-29T01:00:00Z'),
255
+ distanceKm: 1_000, receivedExchange: { grid: 'FN31' },
256
+ frequencyKhz: 14_074, at: '2026-08-29 0100',
257
+ };
258
+ const rows = projectFT8ContestQsos(contest, [
259
+ first,
260
+ { ...first, callsign: 'K2OUT', startTime: Date.parse('2026-08-30T01:00:00Z') },
261
+ { ...first, callsign: 'K3MODE', mode: 'FT4' },
262
+ { ...first, callsign: 'K4BAND', band: '6M' },
263
+ { ...first, callsign: 'K5REVIEW', status: 'review' },
264
+ { ...first, callsign: 'K6XQSO', status: 'x-qso' },
265
+ { ...first, startTime: first.startTime + 1_000 },
266
+ ]);
267
+ expect(rows.map((row) => ({ callsign: row.qso.callsign, eligible: row.eligible, issues: row.issues })))
268
+ .toEqual([
269
+ { callsign: 'K1ABC', eligible: true, issues: [] },
270
+ { callsign: 'K3MODE', eligible: false, issues: ['unsupported_mode'] },
271
+ { callsign: 'K4BAND', eligible: false, issues: ['unsupported_band'] },
272
+ { callsign: 'K5REVIEW', eligible: false, issues: ['review'] },
273
+ { callsign: 'K6XQSO', eligible: false, issues: ['x_qso'] },
274
+ { callsign: 'K1ABC', eligible: false, issues: ['dupe'] },
275
+ { callsign: 'K2OUT', eligible: false, issues: ['outside_edition'] },
276
+ ]);
277
+ expect(rows.find((row) => row.qso.callsign === 'K6XQSO')?.submissionEligible).toBe(true);
278
+ expect(rows.find((row) => row.dupe)?.submissionEligible).toBe(true);
279
+ expect(formatFT8ContestSubmission(contest, [first, { ...first, startTime: first.startTime + 1 }], undefined).match(/^QSO:/gm) ?? []).toHaveLength(2);
280
+ });
281
+ it('creates and validates typed contest QSO envelopes with frozen identity', () => {
282
+ const contest = createExampleContest();
283
+ const adapter = createContestQsoEnvelopeAdapter(contest);
284
+ const envelope = adapter.create({
285
+ sent: { grid: 'pl04' },
286
+ received: { grid: 'fn31' },
287
+ annotations: { status: 'review', transmitterId: 1 },
288
+ });
289
+ expect(envelope).toMatchObject({
290
+ schemaVersion: 1,
291
+ contestId: contest.id,
292
+ editionId: contest.edition.id,
293
+ rulesetVersion: contest.rulesetVersion,
294
+ sent: { grid: 'PL04' },
295
+ received: { grid: 'FN31' },
296
+ });
297
+ expect(adapter.validate(envelope)).toEqual({
298
+ ok: true,
299
+ envelope,
300
+ facts: {
301
+ sent: { grid: 'PL04' },
302
+ received: { grid: 'FN31' },
303
+ annotations: { status: 'review', transmitterId: 1 },
304
+ },
305
+ });
306
+ expect(adapter.validate({ ...envelope, rulesetVersion: '2026.2' })).toMatchObject({
307
+ ok: false,
308
+ code: 'contest_qso_envelope_identity_mismatch',
309
+ });
310
+ });
311
+ it('supports the ARRL distance rounding vector without multipliers', () => {
312
+ const scoring = distancePoints({
313
+ stepKm: 500,
314
+ rounding: 'ceil',
315
+ minimumDistanceSteps: 1,
316
+ });
317
+ const arrl = defineFT8Contest({
318
+ id: 'arrl-digital',
319
+ rulesetVersion: '2026.1',
320
+ edition: fixedWeekendEdition({
321
+ id: '2026',
322
+ startAt: '2026-06-06T00:00:00Z',
323
+ endAt: '2026-06-07T06:00:00Z',
324
+ }),
325
+ modes: ['FT8'],
326
+ bands: ['160M', '80M', '40M', '20M', '15M', '10M', '6M'],
327
+ exchange: gridExchange(),
328
+ completion: requireExchangeAndFinalAck(),
329
+ scoring,
330
+ submission: cabrilloSubmission({
331
+ headers: () => [['CONTEST', 'ARRL-DIGITAL']],
332
+ qsoLine: (qso) => `QSO: ${qso.callsign}`,
333
+ }),
334
+ });
335
+ const qso = {
336
+ callsign: 'K1ABC',
337
+ band: '6M',
338
+ mode: 'FT8',
339
+ startTime: Date.parse('2026-06-06T00:00:00Z'),
340
+ distanceKm: 1565,
341
+ };
342
+ createFT8ContestTestKit(arrl).score([qso], {
343
+ qsoCount: 1,
344
+ qsoPoints: 5,
345
+ multiplierCount: 0,
346
+ total: 5,
347
+ });
348
+ expect(scoring.score({
349
+ callsign: 'K2DEF',
350
+ band: '6M',
351
+ mode: 'FT8',
352
+ startTime: Date.parse('2026-06-06T00:01:00Z'),
353
+ distanceKm: 0,
354
+ }).points).toBe(2);
355
+ });
356
+ it('assembles fixed-point and zero-point multiplier contests from shared helpers', () => {
357
+ const sprintScoring = fixedPoints(1, {
358
+ multiplierKeys: gridFieldMultiplier({
359
+ grid: (qso) => qso.receivedExchange?.grid,
360
+ band: (qso) => qso.band,
361
+ }),
362
+ });
363
+ const sprintRows = [
364
+ {
365
+ callsign: 'K1ABC',
366
+ band: '20M',
367
+ mode: 'FT4',
368
+ startTime: Date.now(),
369
+ receivedExchange: { grid: 'FN31' },
370
+ },
371
+ {
372
+ callsign: 'K2DEF',
373
+ band: '20M',
374
+ mode: 'FT4',
375
+ startTime: Date.now(),
376
+ receivedExchange: { grid: 'FN31' },
377
+ },
378
+ ];
379
+ const sprintSummary = sprintScoring.aggregate(sprintRows.map((qso) => sprintScoring.score(qso)));
380
+ expect(sprintSummary).toMatchObject({
381
+ qsoCount: 2,
382
+ qsoPoints: 2,
383
+ multiplierCount: 1,
384
+ total: 2,
385
+ });
386
+ const bataviaScoring = scoreBy({
387
+ id: 'batavia-like',
388
+ points(qso) {
389
+ if (qso.ybMember)
390
+ return qso.sameCountry ? 5 : 5;
391
+ if (qso.sameCountry)
392
+ return 0;
393
+ return qso.dxcc === 'YB' ? 2 : 1;
394
+ },
395
+ eligible: () => true,
396
+ multiplierKeys: multiplierKeysFrom({
397
+ key: (qso) => qso.prefix,
398
+ band: (qso) => qso.band,
399
+ }),
400
+ });
401
+ const bataviaSummary = bataviaScoring.aggregate([
402
+ bataviaScoring.score({
403
+ callsign: 'YB1AAA',
404
+ band: '20M',
405
+ mode: 'FT8',
406
+ startTime: Date.now(),
407
+ prefix: 'YB',
408
+ dxcc: 'YB',
409
+ ybMember: true,
410
+ sameCountry: true,
411
+ }),
412
+ bataviaScoring.score({
413
+ callsign: 'K1ABC',
414
+ band: '20M',
415
+ mode: 'FT8',
416
+ startTime: Date.now(),
417
+ prefix: 'K',
418
+ dxcc: 'K',
419
+ ybMember: false,
420
+ sameCountry: true,
421
+ }),
422
+ ]);
423
+ expect(bataviaSummary).toMatchObject({
424
+ qsoCount: 2,
425
+ qsoPoints: 5,
426
+ multiplierCount: 2,
427
+ total: 10,
428
+ });
429
+ });
430
+ it('covers FT Challenge Grid, SNR, ZZ00 and completion rules', () => {
431
+ const exchange = gridAndSnrExchange({ missingGrid: 'ZZ00' });
432
+ const scoring = distancePoints({
433
+ stepKm: 3000,
434
+ missingDistancePoints: 1,
435
+ multiplierKeys: gridFieldMultiplier({
436
+ grid: (qso) => qso.receivedExchange?.grid,
437
+ band: (qso) => qso.band,
438
+ }),
439
+ });
440
+ const challenge = defineFT8Contest({
441
+ id: 'ft-challenge',
442
+ rulesetVersion: '2026.1',
443
+ edition: fixedWeekendEdition({
444
+ id: '2026',
445
+ startAt: '2026-12-05T00:00:00Z',
446
+ endAt: '2026-12-06T06:00:00Z',
447
+ }),
448
+ modes: ['FT4', 'FT8'],
449
+ bands: ['80M', '40M', '20M', '15M', '10M'],
450
+ exchange,
451
+ completion: requireExchangeAndFinalAck(),
452
+ scoring,
453
+ submission: cabrilloSubmission({
454
+ headers: () => [['CONTEST', 'FT-CHALLENGE']],
455
+ qsoLine: (qso) => `QSO: ${qso.callsign}`,
456
+ }),
457
+ });
458
+ const kit = createFT8ContestTestKit(challenge);
459
+ kit.exchange({ grid: 'ZZ00', snr: '-12' }, { grid: 'ZZ00', snr: -12 });
460
+ const missingGrid = scoring.score({
461
+ callsign: 'N0CALL',
462
+ band: '20M',
463
+ mode: 'FT8',
464
+ startTime: Date.parse('2026-12-05T01:00:00Z'),
465
+ receivedExchange: { grid: 'ZZ00', snr: -12 },
466
+ });
467
+ expect(missingGrid).toMatchObject({ points: 1, multiplierKeys: [] });
468
+ expect(challenge.completion.evaluate({
469
+ sentExchange: { grid: 'PL04', snr: -5 },
470
+ receivedExchange: { grid: 'ZZ00', snr: -12 },
471
+ sentFinalAck: true,
472
+ }).complete).toBe(true);
473
+ });
474
+ it('covers the FT Roundup report, regional exchange and snapshot-based serial vector', () => {
475
+ const exchange = defineFT8ExchangeModule({
476
+ id: 'region-or-serial',
477
+ decode(fields) {
478
+ const report = fields.report?.trim();
479
+ if (!report || !/^[+-]\d{2}$/.test(report)) {
480
+ return { ok: false, issues: [{ code: 'report_required', field: 'report' }] };
481
+ }
482
+ const region = fields.region?.trim().toUpperCase();
483
+ if (region && /^[A-Z]{2,3}$/.test(region)) {
484
+ return { ok: true, value: { kind: 'region', report, value: region } };
485
+ }
486
+ const serial = fields.serial?.trim();
487
+ if (serial && /^[0-9]{3,4}$/.test(serial)) {
488
+ return { ok: true, value: { kind: 'serial', report, value: serial } };
489
+ }
490
+ return { ok: false, issues: [{ code: 'region_or_serial_required' }] };
491
+ },
492
+ encode(value) {
493
+ if (value.kind === 'region')
494
+ return { report: value.report, region: value.value };
495
+ return { report: value.report, serial: value.value };
496
+ },
497
+ validate(value) {
498
+ const valid = value.kind === 'region'
499
+ ? /^[A-Z]{2,3}$/.test(value.value)
500
+ : /^[0-9]{3,4}$/.test(value.value);
501
+ return valid ? [] : [{ code: 'region_or_serial_invalid' }];
502
+ },
503
+ });
504
+ const completion = defineCompletionModule({
505
+ id: 'received-exchange',
506
+ evaluate(evidence) {
507
+ return evidence.receivedExchange
508
+ ? { complete: true, missing: [] }
509
+ : { complete: false, missing: ['received_exchange'] };
510
+ },
511
+ });
512
+ expect(exchange.decode({ report: '-10', region: 'ca' })).toEqual({
513
+ ok: true,
514
+ value: { kind: 'region', report: '-10', value: 'CA' },
515
+ });
516
+ expect(exchange.decode({ report: '-12', serial: '001' })).toEqual({
517
+ ok: true,
518
+ value: { kind: 'serial', report: '-12', value: '001' },
519
+ });
520
+ expect(nextContestSerial([{ serial: '001' }, { serial: '002' }], { serial: (record) => record.serial })).toBe('003');
521
+ expect(completion.evaluate({
522
+ receivedExchange: { kind: 'serial', report: '-12', value: '001' },
523
+ }).complete)
524
+ .toBe(true);
525
+ expect(maidenheadDistanceKm('OL32', 'FN31')).toBeGreaterThan(0);
526
+ });
527
+ it('provides a conventional versioned session facade without claiming a QSO transaction', async () => {
528
+ const contest = createExampleContest();
529
+ const session = defaultContestSession({
530
+ create: () => ({ schemaVersion: 1, revision: 0, qsoIds: [] }),
531
+ });
532
+ const plugin = composeFT8ContestPlugin({
533
+ name: 'session-example',
534
+ version: '1.0.0',
535
+ permissions: CONTEST_SESSION_PERMISSIONS,
536
+ contest,
537
+ session,
538
+ runtime: () => ({}),
539
+ });
540
+ const context = createMockContext({ permissions: CONTEST_SESSION_PERMISSIONS });
541
+ await plugin.onLoad?.(context);
542
+ session.forOperator(context.operator.id).update((current) => ({
543
+ ...current,
544
+ qsoIds: [...current.qsoIds, 'qso-1'],
545
+ }));
546
+ expect(session.forOperator(context.operator.id).read().qsoIds).toEqual(['qso-1']);
547
+ await plugin.onUnload?.(context);
548
+ expect(() => session.forOperator(context.operator.id)).toThrow('contest_session_not_open');
549
+ });
550
+ it('hides Host session conventions and retries snapshot-planned QSO transactions', async () => {
551
+ const contest = createExampleContest();
552
+ const base = createMockLogbookAccess().forCallsign('W1AW');
553
+ const descriptors = [];
554
+ const plannerRevisions = [];
555
+ const events = [];
556
+ const eventOwners = [];
557
+ let snapshotNumber = 0;
558
+ let applyCalls = 0;
559
+ let notifyCalls = 0;
560
+ const persisted = {
561
+ id: 'qso-1',
562
+ callsign: 'K1ABC',
563
+ frequency: 14_074_000,
564
+ mode: 'FT8',
565
+ startTime: 1,
566
+ messageHistory: [],
567
+ };
568
+ const access = {
569
+ ...base,
570
+ id: 'opaque-session-id',
571
+ title: 'Example session',
572
+ async queryQSOs() { return [persisted]; },
573
+ async readQsoSnapshot() {
574
+ return { revision: `r${snapshotNumber++}`, records: [] };
575
+ },
576
+ async applyQsoBatch(_mutations, _options) {
577
+ applyCalls += 1;
578
+ if (applyCalls === 1) {
579
+ throw Object.assign(new Error('conflict'), { code: 'LOGBOOK_REVISION_CONFLICT' });
580
+ }
581
+ return {
582
+ revision: 'r3',
583
+ outcomes: [{ inputIndex: 0, status: 'added', record: persisted }],
584
+ };
585
+ },
586
+ async notifyUpdated() { notifyCalls += 1; },
587
+ async destroy() { },
588
+ };
589
+ const logbookSessions = {
590
+ async open(descriptor) {
591
+ descriptors.push(descriptor);
592
+ return access;
593
+ },
594
+ async destroy() { },
595
+ };
596
+ const eventBus = createMockEventBus({
597
+ owner: { pluginName: 'application-session-example' },
598
+ });
599
+ const session = defaultContestSession({
600
+ create: () => ({ schemaVersion: 1, revision: 0 }),
601
+ maxTransactionAttempts: 3,
602
+ });
603
+ const plugin = composeFT8ContestPlugin({
604
+ name: 'application-session-example',
605
+ version: '1.0.0',
606
+ permissions: CONTEST_SESSION_PERMISSIONS,
607
+ contest,
608
+ session,
609
+ runtime: () => ({}),
610
+ });
611
+ const context = createMockContext({
612
+ permissions: CONTEST_SESSION_PERMISSIONS,
613
+ logbookSessions,
614
+ eventBus,
615
+ });
616
+ await plugin.onLoad?.(context);
617
+ const application = session.access(context);
618
+ application.subscribe((event) => {
619
+ events.push(event.reason);
620
+ eventOwners.push(`${event.pluginName}:${event.stationCallsign}`);
621
+ });
622
+ const result = await application.transact((snapshot) => {
623
+ plannerRevisions.push(snapshot.revision);
624
+ return [{ type: 'add', record: persisted }];
625
+ }, { reason: 'import' });
626
+ expect(result).toMatchObject({ attempts: 2, batch: { revision: 'r3' } });
627
+ expect(plannerRevisions).toEqual(['r1', 'r2']);
628
+ expect(await application.query()).toEqual([persisted]);
629
+ expect(application.getHealth()).toMatchObject({
630
+ state: 'healthy',
631
+ readable: true,
632
+ writable: true,
633
+ revision: 'r3',
634
+ qsoCount: 1,
635
+ });
636
+ expect(notifyCalls).toBe(1);
637
+ expect(events).toEqual(['import']);
638
+ expect(eventOwners).toEqual(['application-session-example:W1AW']);
639
+ const [topic, subscribers] = [...eventBus._subscriptions.entries()][0];
640
+ await subscribers[0]?.({
641
+ ...eventBus._published[0],
642
+ topic,
643
+ publisher: {
644
+ pluginName: 'spoofed-plugin',
645
+ instanceScope: 'operator',
646
+ operatorId: 'operator-9',
647
+ },
648
+ });
649
+ expect(events).toEqual(['import']);
650
+ expect(new Set(descriptors.map((descriptor) => descriptor.sessionKey)).size).toBe(1);
651
+ expect(descriptors.every((descriptor) => descriptor.retention === undefined)).toBe(true);
652
+ expect(eventBus._subscriptions.size).toBe(1);
653
+ await plugin.onUnload?.(context);
654
+ expect(eventBus._subscriptions.size).toBe(0);
655
+ expect(session.getHealth(context.operator.id).state).toBe('closed');
656
+ });
657
+ it('encodes contest identity segments in hidden session keys and event topics', async () => {
658
+ const baseContest = createExampleContest();
659
+ const firstContest = {
660
+ ...baseContest,
661
+ id: 'example:edition',
662
+ edition: { ...baseContest.edition, id: '2026.1' },
663
+ };
664
+ const secondContest = {
665
+ ...baseContest,
666
+ id: 'example',
667
+ edition: { ...baseContest.edition, id: 'edition:2026.1' },
668
+ };
669
+ const descriptors = [];
670
+ const bound = createMockLogbookAccess().forCallsign('W1AW');
671
+ const logbookSessions = {
672
+ async open(descriptor) {
673
+ descriptors.push(descriptor);
674
+ return { ...bound, id: descriptor.sessionKey, title: descriptor.title, async destroy() { } };
675
+ },
676
+ async destroy() { },
677
+ };
678
+ const firstBus = createMockEventBus();
679
+ const secondBus = createMockEventBus();
680
+ const firstContext = createMockContext({
681
+ permissions: CONTEST_SESSION_PERMISSIONS,
682
+ logbookSessions,
683
+ eventBus: firstBus,
684
+ });
685
+ const secondContext = createMockContext({
686
+ permissions: CONTEST_SESSION_PERMISSIONS,
687
+ logbookSessions,
688
+ eventBus: secondBus,
689
+ });
690
+ const firstSession = defaultContestSession({
691
+ create: () => ({ schemaVersion: 1, revision: 0 }),
692
+ });
693
+ const secondSession = defaultContestSession({
694
+ create: () => ({ schemaVersion: 1, revision: 0 }),
695
+ });
696
+ const firstCleanup = await firstSession.setup({
697
+ contest: firstContest,
698
+ context: firstContext,
699
+ pluginName: 'example-plugin',
700
+ });
701
+ const secondCleanup = await secondSession.setup({
702
+ contest: secondContest,
703
+ context: secondContext,
704
+ pluginName: 'example-plugin',
705
+ });
706
+ firstSession.access(firstContext).subscribe(() => { });
707
+ secondSession.access(secondContext).subscribe(() => { });
708
+ expect(descriptors[0]?.sessionKey).toContain('_3a');
709
+ expect(descriptors[0]?.sessionKey).toMatch(/^[A-Za-z0-9._:-]+$/);
710
+ expect(descriptors[0]?.sessionKey).not.toBe(descriptors[1]?.sessionKey);
711
+ expect([...firstBus._subscriptions.keys()][0]).not.toBe([...secondBus._subscriptions.keys()][0]);
712
+ if (typeof firstCleanup === 'function')
713
+ await firstCleanup(firstContext);
714
+ if (typeof secondCleanup === 'function')
715
+ await secondCleanup(secondContext);
716
+ });
717
+ it('isolates Host sessions and event topics by ruleset version', async () => {
718
+ const firstContest = createExampleContest();
719
+ const secondContest = { ...firstContest, rulesetVersion: '2026.2' };
720
+ const descriptors = [];
721
+ const bound = createMockLogbookAccess().forCallsign('W1AW');
722
+ const logbookSessions = {
723
+ async open(descriptor) {
724
+ descriptors.push(descriptor);
725
+ return { ...bound, id: descriptor.sessionKey, title: descriptor.title, async destroy() { } };
726
+ },
727
+ async destroy() { },
728
+ };
729
+ const firstBus = createMockEventBus();
730
+ const secondBus = createMockEventBus();
731
+ const firstContext = createMockContext({
732
+ permissions: CONTEST_SESSION_PERMISSIONS,
733
+ logbookSessions,
734
+ eventBus: firstBus,
735
+ });
736
+ const secondContext = createMockContext({
737
+ permissions: CONTEST_SESSION_PERMISSIONS,
738
+ logbookSessions,
739
+ eventBus: secondBus,
740
+ });
741
+ const firstSession = defaultContestSession({
742
+ create: () => ({ schemaVersion: 1, revision: 0 }),
743
+ });
744
+ const secondSession = defaultContestSession({
745
+ create: () => ({ schemaVersion: 1, revision: 0 }),
746
+ });
747
+ const firstCleanup = await firstSession.setup({
748
+ contest: firstContest,
749
+ context: firstContext,
750
+ pluginName: 'example-plugin',
751
+ });
752
+ const secondCleanup = await secondSession.setup({
753
+ contest: secondContest,
754
+ context: secondContext,
755
+ pluginName: 'example-plugin',
756
+ });
757
+ firstSession.access(firstContext).subscribe(() => { });
758
+ secondSession.access(secondContext).subscribe(() => { });
759
+ expect(descriptors[0]?.sessionKey).not.toBe(descriptors[1]?.sessionKey);
760
+ expect([...firstBus._subscriptions.keys()][0]).not.toBe([...secondBus._subscriptions.keys()][0]);
761
+ if (typeof firstCleanup === 'function')
762
+ await firstCleanup(firstContext);
763
+ if (typeof secondCleanup === 'function')
764
+ await secondCleanup(secondContext);
765
+ });
766
+ it('isolates event routes by plugin owner and station callsign', async () => {
767
+ const contest = createExampleContest();
768
+ const bound = createMockLogbookAccess().forCallsign('W1AW');
769
+ const descriptors = [];
770
+ const logbookSessions = {
771
+ async open(descriptor) {
772
+ descriptors.push(descriptor);
773
+ return { ...bound, id: descriptor.sessionKey, title: descriptor.title, async destroy() { } };
774
+ },
775
+ async destroy() { },
776
+ };
777
+ const eventBus = createMockEventBus();
778
+ const contexts = [
779
+ createMockContext({
780
+ permissions: CONTEST_SESSION_PERMISSIONS,
781
+ logbookSessions,
782
+ eventBus,
783
+ callsign: 'W1AW',
784
+ }),
785
+ createMockContext({
786
+ permissions: CONTEST_SESSION_PERMISSIONS,
787
+ logbookSessions,
788
+ eventBus,
789
+ callsign: 'W1AW',
790
+ }),
791
+ createMockContext({
792
+ permissions: CONTEST_SESSION_PERMISSIONS,
793
+ logbookSessions,
794
+ eventBus,
795
+ callsign: 'K1ABC',
796
+ }),
797
+ ];
798
+ const sessions = contexts.map(() => defaultContestSession({
799
+ create: () => ({ schemaVersion: 1, revision: 0 }),
800
+ }));
801
+ const cleanups = await Promise.all([
802
+ sessions[0].setup({ contest, context: contexts[0], pluginName: 'owner-a' }),
803
+ sessions[1].setup({ contest, context: contexts[1], pluginName: 'owner-b' }),
804
+ sessions[2].setup({ contest, context: contexts[2], pluginName: 'owner-a' }),
805
+ ]);
806
+ sessions[0].access(contexts[0]).subscribe(() => { });
807
+ sessions[1].access(contexts[1]).subscribe(() => { });
808
+ sessions[2].access(contexts[2]).subscribe(() => { });
809
+ expect(new Set(eventBus._subscriptions.keys()).size).toBe(3);
810
+ expect(descriptors.map((descriptor) => descriptor.stationCallsign))
811
+ .toEqual(['W1AW', 'W1AW', 'K1ABC']);
812
+ for (const [index, cleanup] of cleanups.entries()) {
813
+ if (typeof cleanup === 'function')
814
+ await cleanup(contexts[index]);
815
+ }
816
+ });
817
+ it('rejects runtime retention in the durable default session facade', () => {
818
+ const options = {
819
+ create: () => ({ schemaVersion: 1, revision: 0 }),
820
+ retention: 'runtime',
821
+ };
822
+ expect(() => defaultContestSession(options))
823
+ .toThrow('contest_session_runtime_retention_not_supported');
824
+ });
825
+ it('composes the default session with additional plugin permissions', () => {
826
+ const contest = createExampleContest();
827
+ const permissions = [
828
+ ...CONTEST_SESSION_PERMISSIONS,
829
+ 'network',
830
+ ];
831
+ const session = defaultContestSession({
832
+ create: () => ({ schemaVersion: 1, revision: 0 }),
833
+ });
834
+ const plugin = composeFT8ContestPlugin({
835
+ name: 'extended-permission-contest',
836
+ version: '1.0.0',
837
+ permissions,
838
+ contest,
839
+ session,
840
+ runtime: () => ({}),
841
+ });
842
+ expect(plugin.permissions).toEqual(permissions);
843
+ });
844
+ it('composes the logbook bundle and keeps public settings, ui and hooks merged', () => {
845
+ const contest = createExampleContest();
846
+ const adapter = {
847
+ settings: {
848
+ settings: {
849
+ draft: {
850
+ type: 'string',
851
+ default: '',
852
+ label: 'draft',
853
+ description: 'draft',
854
+ scope: 'operator',
855
+ },
856
+ },
857
+ seed: () => ({ schemaVersion: 1, revision: 0, settings: { draft: 'seed' } }),
858
+ validate(session) {
859
+ return session.settings.draft ? [] : [{ code: 'missing-draft', message: 'draft required' }];
860
+ },
861
+ title: () => 'contest-logbook',
862
+ },
863
+ getState: (_contest, session) => ({
864
+ schemaVersion: 1,
865
+ contest: { id: contest.id, editionId: contest.edition.id, rulesetVersion: contest.rulesetVersion },
866
+ health: { state: 'healthy', readable: true, writable: true, updatedAt: 1 },
867
+ settings: { value: session.settings, valid: true, issues: [] },
868
+ score: { claimedScore: 0, qsoPoints: 0, multiplierCount: 0 },
869
+ qsos: [],
870
+ review: { pendingCount: 0, issues: [] },
871
+ import: { state: 'idle' },
872
+ export: { formats: [] },
873
+ }),
874
+ decode(action, data) {
875
+ return { action, payload: data };
876
+ },
877
+ handle(request) {
878
+ return { action: request.action };
879
+ },
880
+ hooks: {
881
+ onConfigChange(changes, ctx) {
882
+ ctx.log.info('logbook-config', { keys: Object.keys(changes) });
883
+ },
884
+ },
885
+ panels: [{
886
+ id: 'contest-log-panel',
887
+ title: 'contestLogTitle',
888
+ component: 'iframe',
889
+ pageId: 'contest-log',
890
+ slot: 'operator-action',
891
+ openMode: 'page',
892
+ icon: 'file-lines',
893
+ }],
894
+ ui: {
895
+ dir: 'ui',
896
+ pages: [{
897
+ id: 'contest-log',
898
+ title: 'contestLogTitle',
899
+ entry: 'contest-log.html',
900
+ accessScope: 'operator',
901
+ resourceBinding: 'operator',
902
+ }],
903
+ },
904
+ };
905
+ const plugin = composeFT8ContestPlugin({
906
+ name: 'logbook-example',
907
+ version: '1.0.0',
908
+ permissions: CONTEST_LOGBOOK_PERMISSIONS,
909
+ contest,
910
+ logbook: defaultContestLogbook({
911
+ contest,
912
+ pageId: 'contest-log',
913
+ adapter,
914
+ }),
915
+ runtime: () => ({}),
916
+ settings: {
917
+ extra: {
918
+ type: 'boolean',
919
+ default: true,
920
+ label: 'extra',
921
+ description: 'extra',
922
+ scope: 'operator',
923
+ },
924
+ },
925
+ hooks: {
926
+ onConfigChange(changes) {
927
+ expect(changes).toHaveProperty('extra');
928
+ },
929
+ },
930
+ });
931
+ expect(plugin.settings).toMatchObject({
932
+ draft: expect.any(Object),
933
+ extra: expect.any(Object),
934
+ });
935
+ expect(plugin.quickSettings).toBeUndefined();
936
+ expect(plugin.panels?.map((panel) => panel.id)).toContain('contest-log-panel');
937
+ expect(plugin.ui?.pages?.map((page) => page.id)).toContain('contest-log');
938
+ expect(plugin.hooks?.onConfigChange).toBeDefined();
939
+ });
940
+ it('routes contest QSO completion effects into the contest session', async () => {
941
+ const contest = createExampleContest();
942
+ const ctx = createMockContext({ permissions: CONTEST_LOGBOOK_PERMISSIONS });
943
+ const record = {
944
+ id: 'qso-1',
945
+ callsign: 'JA1AAA',
946
+ grid: 'PM95',
947
+ frequency: 14_074_000,
948
+ mode: 'FT8',
949
+ startTime: Date.parse('2026-08-29T01:00:00Z'),
950
+ messageHistory: [],
951
+ myCallsign: 'W1AW',
952
+ myGrid: 'FN31',
953
+ };
954
+ const plugin = composeFT8ContestPlugin({
955
+ name: 'completion-contest',
956
+ version: '1.0.0',
957
+ permissions: CONTEST_LOGBOOK_PERMISSIONS,
958
+ contest,
959
+ logbook: standardFT8ContestLogbook({ contest }),
960
+ runtime: () => ({
961
+ checkpoint: () => ({}),
962
+ restore: () => { },
963
+ decide: () => ({
964
+ transmission: null,
965
+ snapshot: { currentState: 'TX6', slots: {}, context: {} },
966
+ qsoCompletion: { lifecycleEpoch: 1, record },
967
+ }),
968
+ getTransmitText: () => null,
969
+ requestCall: () => { },
970
+ getSnapshot: () => ({ currentState: 'TX6', slots: {}, context: {} }),
971
+ patchContext: () => { },
972
+ setState: () => { },
973
+ setSlotContent: () => { },
974
+ reset: () => { },
975
+ }),
976
+ });
977
+ await plugin.onLoad?.(ctx);
978
+ const runtime = plugin.createStrategyRuntime(ctx);
979
+ expect(runtime.getSnapshot().messagePresentation).toMatchObject({
980
+ mode: 'replace-logbook',
981
+ subject: 'sender-callsign',
982
+ partitionBy: 'band',
983
+ defaultClass: 'contest-new-call',
984
+ classes: {
985
+ 'contest-new-call': expect.objectContaining({
986
+ badges: [{ label: 'contestNewCallsign', tone: 'warning' }],
987
+ }),
988
+ 'contest-new-field': expect.objectContaining({
989
+ badges: [{ label: 'contestNewMultiplier', tone: 'secondary' }],
990
+ }),
991
+ },
992
+ });
993
+ const result = await runtime.decide([], {
994
+ epoch: 1,
995
+ source: 'slot-auto',
996
+ isReDecision: false,
997
+ signal: new AbortController().signal,
998
+ });
999
+ expect(result.snapshot.messagePresentation?.mode).toBe('replace-logbook');
1000
+ expect(result.qsoCompletion?.destination).toEqual({
1001
+ kind: 'plugin-session-key',
1002
+ sessionKey: expect.stringContaining('contest:'),
1003
+ });
1004
+ expect(result.qsoCompletion?.record.contestEntry).toMatchObject({
1005
+ contestId: 'example-ft8',
1006
+ sent: { grid: 'FN31' },
1007
+ received: { grid: 'PM95' },
1008
+ });
1009
+ await plugin.onUnload?.(ctx);
1010
+ });
1011
+ it('derives standard FrameTable presentation from the independent contest session', async () => {
1012
+ const contest = createExampleContest();
1013
+ const base = createMockLogbookAccess().forCallsign('W1AW');
1014
+ const existing = {
1015
+ id: 'contest-qso-1',
1016
+ callsign: 'JA1AAA',
1017
+ grid: 'PM95',
1018
+ frequency: 14_074_000,
1019
+ mode: 'FT8',
1020
+ startTime: Date.parse('2026-08-29T01:00:00Z'),
1021
+ messageHistory: [],
1022
+ myCallsign: 'W1AW',
1023
+ myGrid: 'FN31',
1024
+ };
1025
+ const access = {
1026
+ ...base,
1027
+ id: 'contest-session',
1028
+ title: 'Example contest session',
1029
+ async queryQSOs() { return [existing]; },
1030
+ async readQsoSnapshot() { return { revision: 'contest-r1', records: [existing] }; },
1031
+ async destroy() { },
1032
+ };
1033
+ const ctx = createMockContext({
1034
+ permissions: CONTEST_LOGBOOK_PERMISSIONS,
1035
+ logbookSessions: { open: async () => access, destroy: async () => { } },
1036
+ });
1037
+ const plugin = composeFT8ContestPlugin({
1038
+ name: 'contest-presentation',
1039
+ version: '1.0.0',
1040
+ permissions: CONTEST_LOGBOOK_PERMISSIONS,
1041
+ contest,
1042
+ logbook: standardFT8ContestLogbook({ contest }),
1043
+ runtime: () => ({
1044
+ checkpoint: () => ({}),
1045
+ restore: () => { },
1046
+ decide: () => ({ transmission: null, snapshot: { currentState: 'TX6' } }),
1047
+ getTransmitText: () => null,
1048
+ requestCall: () => { },
1049
+ getSnapshot: () => ({ currentState: 'TX6' }),
1050
+ patchContext: () => { },
1051
+ setState: () => { },
1052
+ setSlotContent: () => { },
1053
+ reset: () => { },
1054
+ }),
1055
+ });
1056
+ await plugin.onLoad?.(ctx);
1057
+ const presentation = plugin.createStrategyRuntime(ctx).getSnapshot().messagePresentation;
1058
+ expect(presentation?.assignments).toContainEqual({
1059
+ subject: 'JA1AAA',
1060
+ partition: '20M',
1061
+ classId: 'contest-worked',
1062
+ });
1063
+ expect(presentation?.noveltyRules?.[0]?.knownValuesByPartition['20M']).toEqual(['PM']);
1064
+ await plugin.onUnload?.(ctx);
1065
+ });
1066
+ it('rejects logbook composition without the required session permissions', () => {
1067
+ const contest = createExampleContest();
1068
+ const adapter = {
1069
+ settings: {
1070
+ settings: {},
1071
+ seed: () => ({ schemaVersion: 1, revision: 0 }),
1072
+ validate: () => [],
1073
+ },
1074
+ getState: () => ({
1075
+ schemaVersion: 1,
1076
+ contest: { id: contest.id, editionId: contest.edition.id, rulesetVersion: contest.rulesetVersion },
1077
+ health: { state: 'healthy', readable: true, writable: true, updatedAt: 1 },
1078
+ settings: { value: {}, valid: true, issues: [] },
1079
+ score: { claimedScore: 0, qsoPoints: 0, multiplierCount: 0 },
1080
+ qsos: [],
1081
+ review: { pendingCount: 0, issues: [] },
1082
+ import: { state: 'idle' },
1083
+ export: { formats: [] },
1084
+ }),
1085
+ decode(action, data) {
1086
+ return { action, payload: data };
1087
+ },
1088
+ handle() {
1089
+ return null;
1090
+ },
1091
+ };
1092
+ expect(() => composeFT8ContestPlugin({
1093
+ name: 'missing-logbook-perms',
1094
+ version: '1.0.0',
1095
+ permissions: [],
1096
+ contest,
1097
+ logbook: defaultContestLogbook({
1098
+ contest,
1099
+ pageId: 'contest-log',
1100
+ adapter: adapter,
1101
+ }),
1102
+ runtime: () => ({}),
1103
+ })).toThrow('contest_logbook_missing_permission:logbook:session');
1104
+ });
1105
+ it('composes narrow typed workbench protocols for multiple plugin pages', async () => {
1106
+ const contest = createExampleContest();
1107
+ const context = createMockContext();
1108
+ const pageHandlers = new Map();
1109
+ context.ui.registerPageHandler = (handler, registration) => {
1110
+ for (const pageId of registration?.pageIds ?? [])
1111
+ pageHandlers.set(pageId, handler);
1112
+ };
1113
+ const workbench = defaultContestWorkbench({
1114
+ pageId: 'contest-log',
1115
+ getState: () => ({
1116
+ schemaVersion: 1,
1117
+ contest: { id: contest.id, editionId: contest.edition.id, rulesetVersion: contest.rulesetVersion },
1118
+ health: { state: 'healthy', readable: true, writable: true, updatedAt: 1 },
1119
+ settings: { value: {}, valid: true, issues: [] },
1120
+ score: { claimedScore: 42, qsoPoints: 42, multiplierCount: 1 },
1121
+ qsos: [],
1122
+ review: { pendingCount: 0, issues: [] },
1123
+ import: { state: 'idle' },
1124
+ export: { formats: [] },
1125
+ }),
1126
+ decode(action, data) {
1127
+ if (action !== CONTEST_WORKBENCH_ACTIONS.setQsoStatus || typeof data !== 'string') {
1128
+ throw new Error('invalid_workbench_request');
1129
+ }
1130
+ return { action, payload: data };
1131
+ },
1132
+ handle: (request) => ({ saved: request.payload }),
1133
+ });
1134
+ const diagnostics = defaultContestWorkbench({
1135
+ pageId: 'contest-diagnostics',
1136
+ getState: () => ({
1137
+ schemaVersion: 1,
1138
+ contest: { id: contest.id, editionId: contest.edition.id, rulesetVersion: contest.rulesetVersion },
1139
+ health: { state: 'healthy', readable: true, writable: true, updatedAt: 1 },
1140
+ settings: { value: {}, valid: true, issues: [] },
1141
+ score: { claimedScore: 7, qsoPoints: 7, multiplierCount: 0 },
1142
+ qsos: [],
1143
+ review: { pendingCount: 0, issues: [] },
1144
+ import: { state: 'idle' },
1145
+ export: { formats: [] },
1146
+ }),
1147
+ decode(action, data) { return { action, payload: data }; },
1148
+ handle: (request) => ({ echoed: request.payload }),
1149
+ });
1150
+ await workbench.setup({ contest, context, pluginName: 'example-plugin' });
1151
+ await diagnostics.setup({ contest, context, pluginName: 'example-plugin' });
1152
+ const requestContext = {};
1153
+ const pageHandler = pageHandlers.get('contest-log');
1154
+ const diagnosticsHandler = pageHandlers.get('contest-diagnostics');
1155
+ expect(await pageHandler?.onMessage('contest-log', 'get-state', undefined, requestContext))
1156
+ .toMatchObject({ score: { claimedScore: 42 } });
1157
+ expect(await pageHandler?.onMessage('contest-log', CONTEST_WORKBENCH_ACTIONS.setQsoStatus, 'review', requestContext))
1158
+ .toEqual({ saved: 'review' });
1159
+ expect(await diagnosticsHandler?.onMessage('contest-diagnostics', 'get-state', undefined, requestContext)).toMatchObject({ score: { claimedScore: 7 } });
1160
+ expect([...pageHandlers.keys()]).toEqual(['contest-log', 'contest-diagnostics']);
1161
+ await expect(pageHandlers.get('contest-log')?.onMessage('contest-diagnostics', 'get-state', undefined, requestContext)).rejects.toThrow('contest_workbench_page_mismatch');
1162
+ });
1163
+ it('assembles lifecycle modules without hiding the raw modules', async () => {
1164
+ const contest = createExampleContest();
1165
+ const calls = [];
1166
+ const session = defineContestSessionModule({
1167
+ id: 'test-session',
1168
+ setup() {
1169
+ calls.push('session:setup');
1170
+ return () => { calls.push('session:cleanup'); };
1171
+ },
1172
+ });
1173
+ const plugin = composeFT8ContestPlugin({
1174
+ name: 'example-ft8-plugin',
1175
+ version: '1.0.0',
1176
+ permissions: [],
1177
+ contest,
1178
+ session,
1179
+ runtime: () => ({}),
1180
+ onLoad() { calls.push('plugin:load'); },
1181
+ onUnload() { calls.push('plugin:unload'); },
1182
+ });
1183
+ const context = createMockContext();
1184
+ await plugin.onLoad?.(context);
1185
+ await plugin.onUnload?.(context);
1186
+ expect(plugin.type).toBe('strategy');
1187
+ expect(plugin.apiVersion).toBe(2);
1188
+ expect(calls).toEqual([
1189
+ 'session:setup',
1190
+ 'plugin:load',
1191
+ 'plugin:unload',
1192
+ 'session:cleanup',
1193
+ ]);
1194
+ });
1195
+ });
1196
+ //# sourceMappingURL=contest-toolkit.test.js.map