@tx5dr/plugin-api 1.7.12 → 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 (181) hide show
  1. package/README.md +475 -17
  2. package/dist/__tests__/adif.test.js +107 -0
  3. package/dist/__tests__/adif.test.js.map +1 -1
  4. package/dist/__tests__/capability-context.test.d.ts +2 -0
  5. package/dist/__tests__/capability-context.test.d.ts.map +1 -0
  6. package/dist/__tests__/capability-context.test.js +99 -0
  7. package/dist/__tests__/capability-context.test.js.map +1 -0
  8. package/dist/__tests__/testing-utils.test.js +234 -15
  9. package/dist/__tests__/testing-utils.test.js.map +1 -1
  10. package/dist/capabilities.d.ts +38 -0
  11. package/dist/capabilities.d.ts.map +1 -0
  12. package/dist/capabilities.js +50 -0
  13. package/dist/capabilities.js.map +1 -0
  14. package/dist/compatibility.d.ts +15 -0
  15. package/dist/compatibility.d.ts.map +1 -0
  16. package/dist/compatibility.js +71 -0
  17. package/dist/compatibility.js.map +1 -0
  18. package/dist/contest-logbook-ui/assets/contest-log.css +1 -0
  19. package/dist/contest-logbook-ui/assets/contest-log.js +40 -0
  20. package/dist/contest-logbook-ui/contest-log.html +13 -0
  21. package/dist/context.d.ts +125 -54
  22. package/dist/context.d.ts.map +1 -1
  23. package/dist/definition.d.ts +98 -21
  24. package/dist/definition.d.ts.map +1 -1
  25. package/dist/definition.js +8 -1
  26. package/dist/definition.js.map +1 -1
  27. package/dist/ft8/StandardQSOPluginRuntime.d.ts +142 -0
  28. package/dist/ft8/StandardQSOPluginRuntime.d.ts.map +1 -0
  29. package/dist/ft8/StandardQSOPluginRuntime.js +1592 -0
  30. package/dist/ft8/StandardQSOPluginRuntime.js.map +1 -0
  31. package/dist/ft8/index.d.ts +22 -0
  32. package/dist/ft8/index.d.ts.map +1 -0
  33. package/dist/ft8/index.js +176 -0
  34. package/dist/ft8/index.js.map +1 -0
  35. package/dist/helpers.d.ts +417 -132
  36. package/dist/helpers.d.ts.map +1 -1
  37. package/dist/hooks.d.ts +56 -34
  38. package/dist/hooks.d.ts.map +1 -1
  39. package/dist/host-dependencies.d.ts +101 -0
  40. package/dist/host-dependencies.d.ts.map +1 -1
  41. package/dist/index.d.ts +20 -6
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +12 -1
  44. package/dist/index.js.map +1 -1
  45. package/dist/runtime.d.ts +539 -13
  46. package/dist/runtime.d.ts.map +1 -1
  47. package/dist/runtime.js +9 -1
  48. package/dist/runtime.js.map +1 -1
  49. package/dist/settings.d.ts +31 -0
  50. package/dist/settings.d.ts.map +1 -1
  51. package/dist/simulation.d.ts +60 -0
  52. package/dist/simulation.d.ts.map +1 -0
  53. package/dist/simulation.js +2 -0
  54. package/dist/simulation.js.map +1 -0
  55. package/dist/sync.d.ts +98 -6
  56. package/dist/sync.d.ts.map +1 -1
  57. package/dist/sync.js +6 -0
  58. package/dist/sync.js.map +1 -1
  59. package/dist/testing/index.d.ts +74 -12
  60. package/dist/testing/index.d.ts.map +1 -1
  61. package/dist/testing/index.js +372 -105
  62. package/dist/testing/index.js.map +1 -1
  63. package/dist/toolkit/contest/CabrilloBuilder.d.ts +7 -0
  64. package/dist/toolkit/contest/CabrilloBuilder.d.ts.map +1 -0
  65. package/dist/toolkit/contest/CabrilloBuilder.js +11 -0
  66. package/dist/toolkit/contest/CabrilloBuilder.js.map +1 -0
  67. package/dist/toolkit/contest/ContestCategorySchema.d.ts +11 -0
  68. package/dist/toolkit/contest/ContestCategorySchema.d.ts.map +1 -0
  69. package/dist/toolkit/contest/ContestCategorySchema.js +6 -0
  70. package/dist/toolkit/contest/ContestCategorySchema.js.map +1 -0
  71. package/dist/toolkit/contest/ContestLogbook.d.ts +223 -0
  72. package/dist/toolkit/contest/ContestLogbook.d.ts.map +1 -0
  73. package/dist/toolkit/contest/ContestLogbook.js +845 -0
  74. package/dist/toolkit/contest/ContestLogbook.js.map +1 -0
  75. package/dist/toolkit/contest/ContestQsoEnvelopeAdapter.d.ts +28 -0
  76. package/dist/toolkit/contest/ContestQsoEnvelopeAdapter.d.ts.map +1 -0
  77. package/dist/toolkit/contest/ContestQsoEnvelopeAdapter.js +79 -0
  78. package/dist/toolkit/contest/ContestQsoEnvelopeAdapter.js.map +1 -0
  79. package/dist/toolkit/contest/ContestQsoProjector.d.ts +12 -0
  80. package/dist/toolkit/contest/ContestQsoProjector.d.ts.map +1 -0
  81. package/dist/toolkit/contest/ContestQsoProjector.js +13 -0
  82. package/dist/toolkit/contest/ContestQsoProjector.js.map +1 -0
  83. package/dist/toolkit/contest/ContestSessionNotifier.d.ts +9 -0
  84. package/dist/toolkit/contest/ContestSessionNotifier.d.ts.map +1 -0
  85. package/dist/toolkit/contest/ContestSessionNotifier.js +13 -0
  86. package/dist/toolkit/contest/ContestSessionNotifier.js.map +1 -0
  87. package/dist/toolkit/contest/ContestSessionRepository.d.ts +21 -0
  88. package/dist/toolkit/contest/ContestSessionRepository.d.ts.map +1 -0
  89. package/dist/toolkit/contest/ContestSessionRepository.js +27 -0
  90. package/dist/toolkit/contest/ContestSessionRepository.js.map +1 -0
  91. package/dist/toolkit/contest/DefaultContestSession.d.ts +105 -0
  92. package/dist/toolkit/contest/DefaultContestSession.d.ts.map +1 -0
  93. package/dist/toolkit/contest/DefaultContestSession.js +387 -0
  94. package/dist/toolkit/contest/DefaultContestSession.js.map +1 -0
  95. package/dist/toolkit/contest/DefaultContestWorkbench.d.ts +100 -0
  96. package/dist/toolkit/contest/DefaultContestWorkbench.d.ts.map +1 -0
  97. package/dist/toolkit/contest/DefaultContestWorkbench.js +33 -0
  98. package/dist/toolkit/contest/DefaultContestWorkbench.js.map +1 -0
  99. package/dist/toolkit/contest/FT8ContestDefinition.d.ts +83 -0
  100. package/dist/toolkit/contest/FT8ContestDefinition.d.ts.map +1 -0
  101. package/dist/toolkit/contest/FT8ContestDefinition.js +167 -0
  102. package/dist/toolkit/contest/FT8ContestDefinition.js.map +1 -0
  103. package/dist/toolkit/contest/FT8ContestModules.d.ts +178 -0
  104. package/dist/toolkit/contest/FT8ContestModules.d.ts.map +1 -0
  105. package/dist/toolkit/contest/FT8ContestModules.js +247 -0
  106. package/dist/toolkit/contest/FT8ContestModules.js.map +1 -0
  107. package/dist/toolkit/contest/FT8ContestPlugin.d.ts +55 -0
  108. package/dist/toolkit/contest/FT8ContestPlugin.d.ts.map +1 -0
  109. package/dist/toolkit/contest/FT8ContestPlugin.js +322 -0
  110. package/dist/toolkit/contest/FT8ContestPlugin.js.map +1 -0
  111. package/dist/toolkit/contest/FT8ContestTestKit.d.ts +12 -0
  112. package/dist/toolkit/contest/FT8ContestTestKit.d.ts.map +1 -0
  113. package/dist/toolkit/contest/FT8ContestTestKit.js +60 -0
  114. package/dist/toolkit/contest/FT8ContestTestKit.js.map +1 -0
  115. package/dist/toolkit/contest/contest-toolkit.test.d.ts +2 -0
  116. package/dist/toolkit/contest/contest-toolkit.test.d.ts.map +1 -0
  117. package/dist/toolkit/contest/contest-toolkit.test.js +1196 -0
  118. package/dist/toolkit/contest/contest-toolkit.test.js.map +1 -0
  119. package/dist/toolkit/contest/index.d.ts +14 -0
  120. package/dist/toolkit/contest/index.d.ts.map +1 -0
  121. package/dist/toolkit/contest/index.js +14 -0
  122. package/dist/toolkit/contest/index.js.map +1 -0
  123. package/dist/toolkit/index.d.ts +3 -0
  124. package/dist/toolkit/index.d.ts.map +1 -0
  125. package/dist/toolkit/index.js +3 -0
  126. package/dist/toolkit/index.js.map +1 -0
  127. package/dist/toolkit/parallel-qso/AuthorizationLease.d.ts +15 -0
  128. package/dist/toolkit/parallel-qso/AuthorizationLease.d.ts.map +1 -0
  129. package/dist/toolkit/parallel-qso/AuthorizationLease.js +25 -0
  130. package/dist/toolkit/parallel-qso/AuthorizationLease.js.map +1 -0
  131. package/dist/toolkit/parallel-qso/BoundedCallSessionController.d.ts +40 -0
  132. package/dist/toolkit/parallel-qso/BoundedCallSessionController.d.ts.map +1 -0
  133. package/dist/toolkit/parallel-qso/BoundedCallSessionController.js +81 -0
  134. package/dist/toolkit/parallel-qso/BoundedCallSessionController.js.map +1 -0
  135. package/dist/toolkit/parallel-qso/BoundedCallSessionController.test.d.ts +2 -0
  136. package/dist/toolkit/parallel-qso/BoundedCallSessionController.test.d.ts.map +1 -0
  137. package/dist/toolkit/parallel-qso/BoundedCallSessionController.test.js +31 -0
  138. package/dist/toolkit/parallel-qso/BoundedCallSessionController.test.js.map +1 -0
  139. package/dist/toolkit/parallel-qso/CompletionTracker.d.ts +20 -0
  140. package/dist/toolkit/parallel-qso/CompletionTracker.d.ts.map +1 -0
  141. package/dist/toolkit/parallel-qso/CompletionTracker.js +20 -0
  142. package/dist/toolkit/parallel-qso/CompletionTracker.js.map +1 -0
  143. package/dist/toolkit/parallel-qso/LaneFrequencyController.d.ts +19 -0
  144. package/dist/toolkit/parallel-qso/LaneFrequencyController.d.ts.map +1 -0
  145. package/dist/toolkit/parallel-qso/LaneFrequencyController.js +23 -0
  146. package/dist/toolkit/parallel-qso/LaneFrequencyController.js.map +1 -0
  147. package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.d.ts +134 -0
  148. package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.d.ts.map +1 -0
  149. package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.js +554 -0
  150. package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.js.map +1 -0
  151. package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.test.d.ts +2 -0
  152. package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.test.d.ts.map +1 -0
  153. package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.test.js +470 -0
  154. package/dist/toolkit/parallel-qso/ParallelQSOCoordinator.test.js.map +1 -0
  155. package/dist/toolkit/parallel-qso/PostCompletionRecoveryLease.d.ts +20 -0
  156. package/dist/toolkit/parallel-qso/PostCompletionRecoveryLease.d.ts.map +1 -0
  157. package/dist/toolkit/parallel-qso/PostCompletionRecoveryLease.js +29 -0
  158. package/dist/toolkit/parallel-qso/PostCompletionRecoveryLease.js.map +1 -0
  159. package/dist/toolkit/parallel-qso/ProtocolLane.d.ts +50 -0
  160. package/dist/toolkit/parallel-qso/ProtocolLane.d.ts.map +1 -0
  161. package/dist/toolkit/parallel-qso/ProtocolLane.js +2 -0
  162. package/dist/toolkit/parallel-qso/ProtocolLane.js.map +1 -0
  163. package/dist/toolkit/parallel-qso/controllers.test.d.ts +2 -0
  164. package/dist/toolkit/parallel-qso/controllers.test.d.ts.map +1 -0
  165. package/dist/toolkit/parallel-qso/controllers.test.js +36 -0
  166. package/dist/toolkit/parallel-qso/controllers.test.js.map +1 -0
  167. package/dist/toolkit/parallel-qso/index.d.ts +8 -0
  168. package/dist/toolkit/parallel-qso/index.d.ts.map +1 -0
  169. package/dist/toolkit/parallel-qso/index.js +8 -0
  170. package/dist/toolkit/parallel-qso/index.js.map +1 -0
  171. package/dist/utils/adif.d.ts +1 -1
  172. package/dist/utils/adif.d.ts.map +1 -1
  173. package/dist/utils/adif.js +96 -51
  174. package/dist/utils/adif.js.map +1 -1
  175. package/dist/utils/qso-text-fields.d.ts +7 -1
  176. package/dist/utils/qso-text-fields.d.ts.map +1 -1
  177. package/dist/utils/qso-text-fields.js +74 -6
  178. package/dist/utils/qso-text-fields.js.map +1 -1
  179. package/package.json +28 -6
  180. package/src/bridge.d.ts +3 -0
  181. 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