@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
@@ -39,21 +39,49 @@ function createMockHostDependencies() {
39
39
  };
40
40
  }
41
41
  // ===== Factory: KVStore =====
42
+ function cloneJsonValue(value) {
43
+ const serialized = JSON.stringify(value);
44
+ if (serialized === undefined)
45
+ return undefined;
46
+ return JSON.parse(serialized);
47
+ }
48
+ function cloneStructuredValue(value) {
49
+ if (Buffer.isBuffer(value))
50
+ return Buffer.from(value);
51
+ return structuredClone(value);
52
+ }
42
53
  export function createMockKVStore(initial) {
43
- const data = new Map(initial ? Object.entries(initial) : []);
54
+ const clonedInitial = initial ? cloneJsonValue(initial) : {};
55
+ const data = new Map(Object.entries(clonedInitial));
44
56
  return {
45
57
  _data: data,
46
58
  get(key, defaultValue) {
47
- return (data.has(key) ? data.get(key) : defaultValue);
59
+ return (data.has(key) ? cloneJsonValue(data.get(key)) : defaultValue);
48
60
  },
49
61
  set(key, value) {
50
- data.set(key, value);
62
+ const clonedEntry = cloneJsonValue({ [key]: value });
63
+ if (!Object.prototype.hasOwnProperty.call(clonedEntry, key)) {
64
+ data.delete(key);
65
+ }
66
+ else {
67
+ data.set(key, clonedEntry[key]);
68
+ }
69
+ },
70
+ update(key, reducer) {
71
+ const current = data.has(key) ? cloneJsonValue(data.get(key)) : undefined;
72
+ const next = reducer(current);
73
+ const clonedEntry = cloneJsonValue({ [key]: next });
74
+ if (!Object.prototype.hasOwnProperty.call(clonedEntry, key))
75
+ data.delete(key);
76
+ else
77
+ data.set(key, clonedEntry[key]);
78
+ return data.has(key) ? cloneJsonValue(data.get(key)) : undefined;
51
79
  },
52
80
  delete(key) {
53
81
  data.delete(key);
54
82
  },
55
83
  getAll() {
56
- return Object.fromEntries(data);
84
+ return cloneJsonValue(Object.fromEntries(data));
57
85
  },
58
86
  async flush() {
59
87
  // no-op in mock
@@ -95,7 +123,7 @@ export function createMockTimers() {
95
123
  },
96
124
  };
97
125
  }
98
- // ===== Factory: OperatorControl =====
126
+ // ===== Factory: Operator snapshot and commands =====
99
127
  const DEFAULT_MODE = {
100
128
  name: 'FT8',
101
129
  slotMs: 15000,
@@ -104,79 +132,125 @@ const DEFAULT_MODE = {
104
132
  transmitTiming: 1180,
105
133
  encodeAdvance: 400,
106
134
  };
107
- export function createMockOperatorControl(overrides) {
135
+ export function createMockOperatorSnapshot(overrides) {
136
+ const mode = cloneStructuredValue(overrides?.mode ?? DEFAULT_MODE);
137
+ const transmitCycles = cloneStructuredValue(overrides?.transmitCycles ?? [0]);
138
+ const automation = cloneStructuredValue(overrides?.automation ?? null);
139
+ const getOtherOperators = overrides?.getOtherOperators ?? (() => []);
108
140
  return {
109
- id: 'operator-0',
110
- isTransmitting: false,
111
- callsign: 'W1AW',
112
- grid: 'FN31',
113
- frequency: 1500,
114
- mode: DEFAULT_MODE,
115
- transmitCycles: [0],
116
- automation: null,
117
- getOtherOperators: () => [],
118
- startTransmitting() { },
119
- stopTransmitting() { },
120
- call() { },
121
- replyToDecode() { },
122
- setTransmitCycles() { },
123
- clearDecodes() { },
124
- haltTransmission() { },
125
- setFreeText() { },
126
- sendFreeText() { },
127
- setTemporaryLocation() { },
128
- highlightCallsign() { },
129
- hasWorkedCallsign: async () => false,
130
- isTargetBeingWorkedByOthers: () => false,
131
- recordQSO() { },
132
- notifySlotsUpdated() { },
133
- notifyStateChanged() { },
134
- ...overrides,
141
+ id: overrides?.id ?? 'operator-0',
142
+ isTransmitting: overrides?.isTransmitting ?? false,
143
+ callsign: overrides?.callsign ?? 'W1AW',
144
+ grid: overrides?.grid ?? 'FN31',
145
+ frequency: overrides?.frequency ?? 1500,
146
+ maxConcurrentStreams: overrides?.maxConcurrentStreams ?? 3,
147
+ get mode() { return cloneStructuredValue(mode); },
148
+ get transmitCycles() { return cloneStructuredValue(transmitCycles); },
149
+ get automation() { return cloneStructuredValue(automation); },
150
+ getOtherOperators: () => cloneStructuredValue(getOtherOperators()),
151
+ hasWorkedCallsign: overrides?.hasWorkedCallsign ?? (async () => false),
152
+ isTargetBeingWorkedByOthers: overrides?.isTargetBeingWorkedByOthers ?? (() => false),
153
+ };
154
+ }
155
+ export function createMockOperatorCommandPort(submit) {
156
+ const commands = [];
157
+ return {
158
+ _commands: commands,
159
+ async submit(command) {
160
+ const snapshot = cloneStructuredValue(command);
161
+ commands.push(snapshot);
162
+ await submit?.(cloneStructuredValue(snapshot));
163
+ return { epoch: commands.length, outcome: 'completed' };
164
+ },
135
165
  };
136
166
  }
137
167
  // ===== Factory: RadioControl =====
138
- export function createMockRadioControl(overrides) {
139
- const capabilitySnapshot = { descriptors: [], capabilities: [] };
140
- const powerState = { state: 'awake', stage: 'idle' };
168
+ export function createMockRadioView(overrides) {
169
+ const mode = cloneStructuredValue(overrides?.mode ?? {
170
+ engineMode: 'digital',
171
+ mode: 'FT8',
172
+ radioMode: 'USB',
173
+ descriptor: DEFAULT_MODE,
174
+ });
141
175
  return {
142
- frequency: 14074000,
143
- band: '20m',
144
- mode: {
145
- engineMode: 'digital',
146
- mode: 'FT8',
147
- radioMode: 'USB',
148
- descriptor: DEFAULT_MODE,
149
- },
150
- isConnected: true,
151
- capabilities: {
152
- getSnapshot: () => capabilitySnapshot,
153
- getState: (id) => capabilitySnapshot.capabilities.find((capability) => capability.id === id) ?? null,
154
- refresh: async () => capabilitySnapshot,
155
- write: async () => { },
156
- },
157
- power: {
158
- getSupport: async (profileId = 'mock-profile') => ({
159
- profileId,
160
- canPowerOn: true,
161
- canPowerOff: true,
162
- supportedStates: ['off', 'standby', 'operate'],
163
- }),
164
- getState: () => powerState,
165
- set: async (state) => ({ success: true, target: state, state: state === 'off' ? 'off' : 'awake' }),
166
- },
167
- setFrequency: async () => { },
168
- ...overrides,
176
+ frequency: overrides?.frequency ?? 14074000,
177
+ band: overrides?.band ?? '20m',
178
+ get mode() { return cloneStructuredValue(mode); },
179
+ isConnected: overrides?.isConnected ?? true,
180
+ isSimulation: overrides?.isSimulation ?? false,
181
+ };
182
+ }
183
+ export function createMockRadioCapabilitiesView() {
184
+ const snapshot = { descriptors: [], capabilities: [] };
185
+ return {
186
+ getSnapshot: () => cloneStructuredValue(snapshot),
187
+ getState: (id) => cloneStructuredValue(snapshot.capabilities.find((capability) => capability.id === id) ?? null),
188
+ refresh: async () => cloneStructuredValue(snapshot),
189
+ };
190
+ }
191
+ export function createMockRadioCommandPort() {
192
+ return { submit: async () => { } };
193
+ }
194
+ export function createMockRadioTunerCommandPort() {
195
+ return { submit: async () => { } };
196
+ }
197
+ export function createMockRadioPowerView() {
198
+ const state = { state: 'awake', stage: 'idle' };
199
+ return {
200
+ getSupport: async (profileId = 'mock-profile') => ({
201
+ profileId,
202
+ canPowerOn: true,
203
+ canPowerOff: true,
204
+ supportedStates: ['off', 'standby', 'operate'],
205
+ }),
206
+ getState: () => cloneStructuredValue(state),
207
+ };
208
+ }
209
+ export function createMockRadioPowerCommandPort() {
210
+ return {
211
+ submit: async (command) => ({
212
+ success: true,
213
+ target: command.state,
214
+ state: command.state === 'off' ? 'off' : 'awake',
215
+ }),
169
216
  };
170
217
  }
171
218
  // ===== Factory: LogbookAccess =====
172
219
  export function createMockLogbookAccess(overrides) {
220
+ const commit = async (record) => cloneStructuredValue(record);
221
+ const update = async (qsoId, updates) => commit({
222
+ callsign: updates.callsign ?? 'N0CALL',
223
+ frequency: updates.frequency ?? 14_074_000,
224
+ mode: updates.mode ?? 'FT8',
225
+ startTime: updates.startTime ?? 0,
226
+ messageHistory: updates.messageHistory ?? [],
227
+ ...updates,
228
+ id: qsoId,
229
+ });
230
+ const readQsoSnapshot = async () => ({ revision: 'mock-revision', records: [] });
231
+ const applyQsoBatch = async (mutations) => ({
232
+ revision: 'mock-revision',
233
+ outcomes: await Promise.all(mutations.map(async (mutation, inputIndex) => {
234
+ if (mutation.type === 'add') {
235
+ return { inputIndex, status: 'added', record: await commit(mutation.record) };
236
+ }
237
+ return {
238
+ inputIndex,
239
+ status: 'updated',
240
+ record: await update(mutation.qsoId, mutation.updates),
241
+ };
242
+ })),
243
+ });
173
244
  const callsignAccess = {
174
245
  callsign: 'N0CALL',
175
246
  getLogBookId: async () => 'logbook-N0CALL',
247
+ awaitReady: async () => { },
176
248
  queryQSOs: async () => [],
249
+ readQsoSnapshot,
177
250
  countQSOs: async () => 0,
178
- addQSO: async () => { },
179
- updateQSO: async () => { },
251
+ addQSO: commit,
252
+ updateQSO: update,
253
+ applyQsoBatch,
180
254
  getStatistics: async () => null,
181
255
  notifyUpdated: async () => { },
182
256
  };
@@ -185,10 +259,12 @@ export function createMockLogbookAccess(overrides) {
185
259
  hasWorkedDXCC: async () => false,
186
260
  hasWorkedGrid: async () => false,
187
261
  queryQSOs: async () => [],
262
+ readQsoSnapshot,
188
263
  countQSOs: async () => 0,
189
264
  forCallsign: () => callsignAccess,
190
- addQSO: async () => { },
191
- updateQSO: async () => { },
265
+ addQSO: commit,
266
+ updateQSO: update,
267
+ applyQsoBatch,
192
268
  notifyUpdated: async () => { },
193
269
  ...overrides,
194
270
  };
@@ -206,33 +282,38 @@ export function createMockBandAccess(overrides) {
206
282
  // ===== Factory: UIBridge =====
207
283
  export function createMockUIBridge() {
208
284
  const sentData = new Map();
285
+ const events = [];
209
286
  return {
210
287
  _sentData: sentData,
288
+ _events: events,
211
289
  send(panelId, data) {
212
290
  const existing = sentData.get(panelId) ?? [];
213
- existing.push(data);
291
+ existing.push(cloneJsonValue(data));
214
292
  sentData.set(panelId, existing);
215
293
  },
216
- setPanelMeta(_panelId, _meta) {
217
- // no-op in mock
294
+ setPanelMeta(panelId, meta) {
295
+ events.push({ type: 'panel-meta', id: panelId, data: cloneJsonValue(meta) });
218
296
  },
219
- setPanelContributions(_groupId, _panels) {
220
- // no-op in mock
297
+ setPanelContributions(groupId, panels) {
298
+ events.push({ type: 'panel-contributions', id: groupId, data: cloneJsonValue(panels) });
221
299
  },
222
- clearPanelContributions(_groupId) {
223
- // no-op in mock
300
+ clearPanelContributions(groupId) {
301
+ events.push({ type: 'panel-contributions', id: groupId, data: [] });
302
+ },
303
+ refreshOperatorProjection() {
304
+ events.push({ type: 'operator-projection-refresh', id: 'operator' });
224
305
  },
225
306
  registerPageHandler(_handler) {
226
307
  // no-op in mock
227
308
  },
228
- pushToSession(_pageSessionId, _action, _data) {
229
- // no-op in mock
309
+ pushToSession(pageSessionId, action, data) {
310
+ events.push({ type: 'session-push', id: `${pageSessionId}:${action}`, data: cloneJsonValue(data) });
230
311
  },
231
312
  listActivePageSessions(_pageId) {
232
313
  return [];
233
314
  },
234
- pushToPage(_pageId, _action, _data) {
235
- // no-op in mock
315
+ pushToPage(pageId, action, data) {
316
+ events.push({ type: 'page-push', id: `${pageId}:${action}`, data: cloneJsonValue(data) });
236
317
  },
237
318
  };
238
319
  }
@@ -240,8 +321,11 @@ export function createMockUIBridge() {
240
321
  export function createMockFileStore() {
241
322
  const storage = new Map();
242
323
  return {
243
- async write(p, data) { storage.set(p, data); },
244
- async read(p) { return storage.get(p) ?? null; },
324
+ async write(p, data) { storage.set(p, Buffer.from(data)); },
325
+ async read(p) {
326
+ const data = storage.get(p);
327
+ return data ? Buffer.from(data) : null;
328
+ },
245
329
  async delete(p) { return storage.delete(p); },
246
330
  async list(prefix) {
247
331
  const keys = Array.from(storage.keys());
@@ -268,7 +352,9 @@ export function createMockNetworkControl() {
268
352
  async _emitMessage(data, remote) {
269
353
  if (!messageHandler)
270
354
  return;
271
- const payload = typeof data === 'string' ? Buffer.from(data, 'utf8') : data;
355
+ const payload = typeof data === 'string'
356
+ ? Buffer.from(data, 'utf8')
357
+ : new Uint8Array(data);
272
358
  await messageHandler(payload, {
273
359
  address: remote?.address ?? '127.0.0.1',
274
360
  port: remote?.port ?? 2237,
@@ -283,7 +369,11 @@ export function createMockNetworkControl() {
283
369
  binds.push(options);
284
370
  },
285
371
  async send(data, port, host) {
286
- sent.push({ data, port, host });
372
+ sent.push({
373
+ data: typeof data === 'string' ? data : new Uint8Array(data),
374
+ port,
375
+ host,
376
+ });
287
377
  },
288
378
  onMessage(handler) {
289
379
  messageHandler = handler;
@@ -304,6 +394,77 @@ export function createMockNetworkControl() {
304
394
  },
305
395
  };
306
396
  }
397
+ /**
398
+ * Creates a mock {@link PluginEventBus} for unit testing plugin code.
399
+ *
400
+ * All published messages are recorded in `_published` for assertion.
401
+ * The internal `_subscriptions` map lets tests inspect active subscriptions.
402
+ *
403
+ * @param options - Optional configuration for publisher metadata.
404
+ * @returns A mock event bus with inspection helpers.
405
+ *
406
+ * @example
407
+ * ```ts
408
+ * const bus = createMockEventBus({ owner: { pluginName: 'my-plugin' } });
409
+ * bus.subscribe('topic', handler);
410
+ * bus.publish('topic', { value: 42 });
411
+ * expect(bus._published).toHaveLength(1);
412
+ * expect(bus._published[0].publisher.pluginName).toBe('my-plugin');
413
+ * ```
414
+ */
415
+ export function createMockEventBus(options) {
416
+ const subscriptions = new Map();
417
+ const published = [];
418
+ const ownerName = options?.owner?.pluginName ?? 'mock-plugin';
419
+ const ownerScope = options?.owner?.instanceScope ?? 'operator';
420
+ const ownerId = ownerScope === 'operator'
421
+ ? (options?.owner?.operatorId ?? 'operator-0')
422
+ : undefined;
423
+ return {
424
+ _subscriptions: subscriptions,
425
+ _published: published,
426
+ publish(topic, payload) {
427
+ const message = cloneStructuredValue({
428
+ topic,
429
+ payload: cloneStructuredValue(payload),
430
+ timestamp: Date.now(),
431
+ publisher: {
432
+ pluginName: ownerName,
433
+ instanceScope: ownerScope,
434
+ operatorId: ownerId,
435
+ },
436
+ });
437
+ published.push(cloneStructuredValue(message));
438
+ const handlers = [...(subscriptions.get(topic) ?? [])];
439
+ for (const handler of handlers) {
440
+ try {
441
+ void Promise.resolve(handler(cloneStructuredValue(message))).catch(() => undefined);
442
+ }
443
+ catch {
444
+ // Match the production bus: subscriber failures do not reach publishers.
445
+ }
446
+ }
447
+ },
448
+ subscribe(topic, handler) {
449
+ const handlers = subscriptions.get(topic) ?? [];
450
+ if (!handlers.includes(handler)) {
451
+ handlers.push(handler);
452
+ subscriptions.set(topic, handlers);
453
+ }
454
+ return () => {
455
+ const current = subscriptions.get(topic);
456
+ if (!current)
457
+ return;
458
+ const next = current.filter((candidate) => candidate !== handler);
459
+ if (next.length === 0) {
460
+ subscriptions.delete(topic);
461
+ return;
462
+ }
463
+ subscriptions.set(topic, next);
464
+ };
465
+ },
466
+ };
467
+ }
307
468
  // ===== Factory: HostSettingsControl =====
308
469
  export function createMockHostSettingsControl(overrides) {
309
470
  const ft8 = {
@@ -337,33 +498,58 @@ export function createMockHostSettingsControl(overrides) {
337
498
  const ntp = { servers: ['pool.ntp.org'], defaultServers: ['pool.ntp.org'] };
338
499
  return {
339
500
  ft8: {
340
- async get() { return ft8; },
341
- async update(patch) { Object.assign(ft8, patch); return ft8; },
501
+ async get() { return cloneStructuredValue(ft8); },
502
+ async update(patch) {
503
+ Object.assign(ft8, cloneStructuredValue(patch));
504
+ return cloneStructuredValue(ft8);
505
+ },
342
506
  },
343
507
  decodeWindows: {
344
- async get() { return decodeWindows; },
345
- async update(settings) { Object.assign(decodeWindows, settings); return decodeWindows; },
508
+ async get() { return cloneStructuredValue(decodeWindows); },
509
+ async update(settings) {
510
+ Object.assign(decodeWindows, cloneStructuredValue(settings));
511
+ return cloneStructuredValue(decodeWindows);
512
+ },
346
513
  },
347
514
  realtime: {
348
- async get() { return realtime; },
349
- async update(settings) { Object.assign(realtime, settings); return realtime; },
515
+ async get() { return cloneStructuredValue(realtime); },
516
+ async update(settings) {
517
+ Object.assign(realtime, cloneStructuredValue(settings));
518
+ return cloneStructuredValue(realtime);
519
+ },
350
520
  },
351
521
  frequencyPresets: {
352
- async get() { return frequencyPresets; },
353
- async update(presets) { frequencyPresets.presets = presets; frequencyPresets.isCustomized = true; return frequencyPresets; },
354
- async reset() { frequencyPresets.isCustomized = false; return frequencyPresets; },
522
+ async get() { return cloneStructuredValue(frequencyPresets); },
523
+ async update(presets) {
524
+ frequencyPresets.presets = cloneStructuredValue(presets);
525
+ frequencyPresets.isCustomized = true;
526
+ return cloneStructuredValue(frequencyPresets);
527
+ },
528
+ async reset() {
529
+ frequencyPresets.isCustomized = false;
530
+ return cloneStructuredValue(frequencyPresets);
531
+ },
355
532
  },
356
533
  station: {
357
- async get() { return station; },
358
- async update(patch) { Object.assign(station, patch); return station; },
534
+ async get() { return cloneStructuredValue(station); },
535
+ async update(patch) {
536
+ Object.assign(station, cloneStructuredValue(patch));
537
+ return cloneStructuredValue(station);
538
+ },
359
539
  },
360
540
  pskReporter: {
361
- async get() { return pskReporter; },
362
- async update(patch) { Object.assign(pskReporter, patch); return pskReporter; },
541
+ async get() { return cloneStructuredValue(pskReporter); },
542
+ async update(patch) {
543
+ Object.assign(pskReporter, cloneStructuredValue(patch));
544
+ return cloneStructuredValue(pskReporter);
545
+ },
363
546
  },
364
547
  ntp: {
365
- async get() { return ntp; },
366
- async update(request) { ntp.servers = request.servers; return ntp; },
548
+ async get() { return cloneStructuredValue(ntp); },
549
+ async update(request) {
550
+ ntp.servers = cloneStructuredValue(request.servers);
551
+ return cloneStructuredValue(ntp);
552
+ },
367
553
  },
368
554
  ...overrides,
369
555
  };
@@ -378,7 +564,7 @@ export function createMockContext(options) {
378
564
  const mode = opts.mode
379
565
  ? { ...DEFAULT_MODE, ...opts.mode }
380
566
  : DEFAULT_MODE;
381
- const operator = createMockOperatorControl({
567
+ const operator = createMockOperatorSnapshot({
382
568
  id: opts.operatorId ?? 'operator-0',
383
569
  callsign: opts.callsign ?? 'W1AW',
384
570
  grid: opts.grid ?? 'FN31',
@@ -386,33 +572,114 @@ export function createMockContext(options) {
386
572
  mode,
387
573
  ...opts.operator,
388
574
  });
389
- const radio = createMockRadioControl(opts.radio);
575
+ const radio = createMockRadioView(opts.radio);
390
576
  const logbook = createMockLogbookAccess(opts.logbook);
577
+ const logbookSessions = opts.logbookSessions ?? {
578
+ async open(descriptor) {
579
+ const access = logbook.forCallsign(descriptor.stationCallsign);
580
+ return {
581
+ ...access,
582
+ id: `plugin-session-${descriptor.sessionKey}`,
583
+ title: descriptor.title,
584
+ async destroy() { },
585
+ };
586
+ },
587
+ async destroy() { },
588
+ };
589
+ const readOnlyLogbook = {
590
+ hasWorked: logbook.hasWorked,
591
+ hasWorkedDXCC: logbook.hasWorkedDXCC,
592
+ hasWorkedGrid: logbook.hasWorkedGrid,
593
+ queryQSOs: logbook.queryQSOs,
594
+ readQsoSnapshot: logbook.readQsoSnapshot,
595
+ countQSOs: logbook.countQSOs,
596
+ forCallsign(callsign) {
597
+ const access = logbook.forCallsign(callsign);
598
+ return {
599
+ callsign: access.callsign,
600
+ getLogBookId: access.getLogBookId,
601
+ awaitReady: access.awaitReady,
602
+ queryQSOs: access.queryQSOs,
603
+ readQsoSnapshot: access.readQsoSnapshot,
604
+ countQSOs: access.countQSOs,
605
+ getStatistics: access.getStatistics,
606
+ };
607
+ },
608
+ };
391
609
  const band = createMockBandAccess(opts.band);
392
610
  const settings = createMockHostSettingsControl(opts.settings);
393
611
  const files = createMockFileStore();
394
612
  const network = opts.network ?? createMockNetworkControl();
613
+ const eventBus = opts.eventBus ?? createMockEventBus();
395
614
  const hostDependencies = opts.hostDependencies
396
615
  ?? (opts.permissions?.includes('host:hamlib') ? createMockHostDependencies() : {});
397
616
  const logbookSync = { register() { } };
617
+ const configState = cloneJsonValue(opts.config ?? {});
398
618
  return {
399
- config: opts.config ?? {},
619
+ pluginApiVersion: opts.pluginApiVersion ?? '2.5.0',
620
+ get config() {
621
+ return cloneStructuredValue(configState);
622
+ },
400
623
  async updateConfig(patch) {
401
- // no-op in mock
624
+ Object.assign(configState, cloneJsonValue(patch));
402
625
  },
403
626
  store: { global: globalStore, operator: operatorStore },
627
+ digitalMessagePreflight: {
628
+ async check(request) {
629
+ const text = request.text.trim().toUpperCase().replace(/\s+/g, ' ');
630
+ return text
631
+ ? { encodable: true, requestedText: text, transmittedText: text }
632
+ : { encodable: false, requestedText: text, reason: 'empty' };
633
+ },
634
+ },
404
635
  log,
405
636
  timers,
406
637
  operator,
407
638
  radio,
408
- logbook,
409
639
  band,
410
640
  ui,
411
- settings,
412
- hostDependencies,
413
641
  files,
414
- network,
415
- logbookSync,
642
+ ...((opts.permissions?.includes('logbook:read')
643
+ || opts.permissions?.includes('logbook:write')
644
+ || opts.permissions?.includes('logbook:session')) ? {
645
+ logbook: {
646
+ ...(opts.permissions?.includes('logbook:write')
647
+ ? logbook
648
+ : opts.permissions?.includes('logbook:read') ? readOnlyLogbook : {}),
649
+ ...(opts.permissions?.includes('logbook:session') ? { sessions: logbookSessions } : {}),
650
+ },
651
+ } : {}),
652
+ ...(opts.permissions?.includes('logbook:sync') ? { logbookSync } : {}),
653
+ ...((opts.permissions ?? []).some(permission => permission.startsWith('settings:')) ? {
654
+ settings: {
655
+ ...(opts.permissions?.includes('settings:ft8') ? { ft8: settings.ft8 } : {}),
656
+ ...(opts.permissions?.includes('settings:decode-windows') ? { decodeWindows: settings.decodeWindows } : {}),
657
+ ...(opts.permissions?.includes('settings:realtime') ? { realtime: settings.realtime } : {}),
658
+ ...(opts.permissions?.includes('settings:frequency-presets') ? { frequencyPresets: settings.frequencyPresets } : {}),
659
+ ...(opts.permissions?.includes('settings:station') ? { station: settings.station } : {}),
660
+ ...(opts.permissions?.includes('settings:psk-reporter') ? { pskReporter: settings.pskReporter } : {}),
661
+ ...(opts.permissions?.includes('settings:ntp') ? { ntp: settings.ntp } : {}),
662
+ },
663
+ } : {}),
664
+ ...(opts.permissions?.includes('operator:transmit-control') ? {
665
+ operatorCommands: opts.operatorCommands ?? createMockOperatorCommandPort(),
666
+ } : {}),
667
+ ...(opts.permissions?.includes('radio:read') ? {
668
+ radioCapabilities: opts.radioCapabilities ?? createMockRadioCapabilitiesView(),
669
+ radioPower: opts.radioPower ?? createMockRadioPowerView(),
670
+ } : {}),
671
+ ...(opts.permissions?.includes('radio:control') ? {
672
+ radioCommands: opts.radioCommands ?? createMockRadioCommandPort(),
673
+ } : {}),
674
+ ...(opts.permissions?.includes('radio:tuner-control') ? {
675
+ radioTunerCommands: opts.radioTunerCommands ?? createMockRadioTunerCommandPort(),
676
+ } : {}),
677
+ ...(opts.permissions?.includes('radio:power') ? {
678
+ radioPowerCommands: opts.radioPowerCommands ?? createMockRadioPowerCommandPort(),
679
+ } : {}),
680
+ ...(opts.permissions?.includes('host:hamlib') ? { hostDependencies } : {}),
681
+ ...(opts.permissions?.includes('network') ? { network, fetch: globalThis.fetch } : {}),
682
+ ...(opts.permissions?.includes('plugin:event-bus') ? { eventBus } : {}),
416
683
  };
417
684
  }
418
685
  // ===== Data factories =====