@tx5dr/contracts 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 (255) hide show
  1. package/dist/index.d.ts +7 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +6 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/schema/__tests__/android-operator-audio.schema.test.d.ts +2 -0
  6. package/dist/schema/__tests__/android-operator-audio.schema.test.d.ts.map +1 -0
  7. package/dist/schema/__tests__/android-operator-audio.schema.test.js +27 -0
  8. package/dist/schema/__tests__/android-operator-audio.schema.test.js.map +1 -0
  9. package/dist/schema/__tests__/assisted-queue.schema.test.d.ts +2 -0
  10. package/dist/schema/__tests__/assisted-queue.schema.test.d.ts.map +1 -0
  11. package/dist/schema/__tests__/assisted-queue.schema.test.js +114 -0
  12. package/dist/schema/__tests__/assisted-queue.schema.test.js.map +1 -0
  13. package/dist/schema/__tests__/audio-sidecar.schema.test.d.ts +2 -0
  14. package/dist/schema/__tests__/audio-sidecar.schema.test.d.ts.map +1 -0
  15. package/dist/schema/__tests__/audio-sidecar.schema.test.js +42 -0
  16. package/dist/schema/__tests__/audio-sidecar.schema.test.js.map +1 -0
  17. package/dist/schema/__tests__/audio.schema.test.js +92 -1
  18. package/dist/schema/__tests__/audio.schema.test.js.map +1 -1
  19. package/dist/schema/__tests__/diagnostics.schema.test.d.ts +2 -0
  20. package/dist/schema/__tests__/diagnostics.schema.test.d.ts.map +1 -0
  21. package/dist/schema/__tests__/diagnostics.schema.test.js +31 -0
  22. package/dist/schema/__tests__/diagnostics.schema.test.js.map +1 -0
  23. package/dist/schema/__tests__/logbook-health.schema.test.d.ts +2 -0
  24. package/dist/schema/__tests__/logbook-health.schema.test.d.ts.map +1 -0
  25. package/dist/schema/__tests__/logbook-health.schema.test.js +104 -0
  26. package/dist/schema/__tests__/logbook-health.schema.test.js.map +1 -0
  27. package/dist/schema/__tests__/meter.schema.test.d.ts +2 -0
  28. package/dist/schema/__tests__/meter.schema.test.d.ts.map +1 -0
  29. package/dist/schema/__tests__/meter.schema.test.js +27 -0
  30. package/dist/schema/__tests__/meter.schema.test.js.map +1 -0
  31. package/dist/schema/__tests__/observability.schema.test.d.ts +2 -0
  32. package/dist/schema/__tests__/observability.schema.test.d.ts.map +1 -0
  33. package/dist/schema/__tests__/observability.schema.test.js +12 -0
  34. package/dist/schema/__tests__/observability.schema.test.js.map +1 -0
  35. package/dist/schema/__tests__/operator-context-report.schema.test.d.ts +2 -0
  36. package/dist/schema/__tests__/operator-context-report.schema.test.d.ts.map +1 -0
  37. package/dist/schema/__tests__/operator-context-report.schema.test.js +70 -0
  38. package/dist/schema/__tests__/operator-context-report.schema.test.js.map +1 -0
  39. package/dist/schema/__tests__/operator.schema.test.js +9 -3
  40. package/dist/schema/__tests__/operator.schema.test.js.map +1 -1
  41. package/dist/schema/__tests__/plugin-market.schema.test.js +149 -3
  42. package/dist/schema/__tests__/plugin-market.schema.test.js.map +1 -1
  43. package/dist/schema/__tests__/qso.schema.test.d.ts +2 -0
  44. package/dist/schema/__tests__/qso.schema.test.d.ts.map +1 -0
  45. package/dist/schema/__tests__/qso.schema.test.js +70 -0
  46. package/dist/schema/__tests__/qso.schema.test.js.map +1 -0
  47. package/dist/schema/__tests__/radio-power-support.test.js +15 -0
  48. package/dist/schema/__tests__/radio-power-support.test.js.map +1 -1
  49. package/dist/schema/__tests__/radio-profile.schema.test.d.ts +2 -0
  50. package/dist/schema/__tests__/radio-profile.schema.test.d.ts.map +1 -0
  51. package/dist/schema/__tests__/radio-profile.schema.test.js +36 -0
  52. package/dist/schema/__tests__/radio-profile.schema.test.js.map +1 -0
  53. package/dist/schema/__tests__/radio.schema.test.js +92 -1
  54. package/dist/schema/__tests__/radio.schema.test.js.map +1 -1
  55. package/dist/schema/__tests__/realtime-settings.schema.test.js +2 -1
  56. package/dist/schema/__tests__/realtime-settings.schema.test.js.map +1 -1
  57. package/dist/schema/__tests__/remote-access.schema.test.d.ts +2 -0
  58. package/dist/schema/__tests__/remote-access.schema.test.d.ts.map +1 -0
  59. package/dist/schema/__tests__/remote-access.schema.test.js +18 -0
  60. package/dist/schema/__tests__/remote-access.schema.test.js.map +1 -0
  61. package/dist/schema/__tests__/spectrum.schema.test.d.ts +2 -0
  62. package/dist/schema/__tests__/spectrum.schema.test.d.ts.map +1 -0
  63. package/dist/schema/__tests__/spectrum.schema.test.js +107 -0
  64. package/dist/schema/__tests__/spectrum.schema.test.js.map +1 -0
  65. package/dist/schema/__tests__/strategy-transmission.schema.test.d.ts +2 -0
  66. package/dist/schema/__tests__/strategy-transmission.schema.test.d.ts.map +1 -0
  67. package/dist/schema/__tests__/strategy-transmission.schema.test.js +95 -0
  68. package/dist/schema/__tests__/strategy-transmission.schema.test.js.map +1 -0
  69. package/dist/schema/android-operator-audio.schema.d.ts +174 -0
  70. package/dist/schema/android-operator-audio.schema.d.ts.map +1 -0
  71. package/dist/schema/android-operator-audio.schema.js +38 -0
  72. package/dist/schema/android-operator-audio.schema.js.map +1 -0
  73. package/dist/schema/api-response.schema.d.ts +503 -44
  74. package/dist/schema/api-response.schema.d.ts.map +1 -1
  75. package/dist/schema/api-response.schema.js +9 -0
  76. package/dist/schema/api-response.schema.js.map +1 -1
  77. package/dist/schema/audio-sidecar.schema.d.ts +75 -0
  78. package/dist/schema/audio-sidecar.schema.d.ts.map +1 -1
  79. package/dist/schema/audio-sidecar.schema.js +32 -0
  80. package/dist/schema/audio-sidecar.schema.js.map +1 -1
  81. package/dist/schema/audio.schema.d.ts +4749 -3
  82. package/dist/schema/audio.schema.d.ts.map +1 -1
  83. package/dist/schema/audio.schema.js +101 -2
  84. package/dist/schema/audio.schema.js.map +1 -1
  85. package/dist/schema/auth.schema.d.ts +173 -14
  86. package/dist/schema/auth.schema.d.ts.map +1 -1
  87. package/dist/schema/auth.schema.js +65 -1
  88. package/dist/schema/auth.schema.js.map +1 -1
  89. package/dist/schema/cpu-profile.schema.d.ts +2 -2
  90. package/dist/schema/cw-decoder.schema.d.ts +74 -74
  91. package/dist/schema/cw-keyer.schema.d.ts +20 -14
  92. package/dist/schema/cw-keyer.schema.d.ts.map +1 -1
  93. package/dist/schema/cw-keyer.schema.js +3 -0
  94. package/dist/schema/cw-keyer.schema.js.map +1 -1
  95. package/dist/schema/desktop-https.schema.d.ts +6 -6
  96. package/dist/schema/device-ui.schema.d.ts +16 -16
  97. package/dist/schema/diagnostics.schema.d.ts +163 -0
  98. package/dist/schema/diagnostics.schema.d.ts.map +1 -0
  99. package/dist/schema/diagnostics.schema.js +46 -0
  100. package/dist/schema/diagnostics.schema.js.map +1 -0
  101. package/dist/schema/ft8.schema.d.ts +12 -2
  102. package/dist/schema/ft8.schema.d.ts.map +1 -1
  103. package/dist/schema/ft8.schema.js +8 -0
  104. package/dist/schema/ft8.schema.js.map +1 -1
  105. package/dist/schema/image-radio.schema.d.ts +2597 -0
  106. package/dist/schema/image-radio.schema.d.ts.map +1 -0
  107. package/dist/schema/image-radio.schema.js +283 -0
  108. package/dist/schema/image-radio.schema.js.map +1 -0
  109. package/dist/schema/logbook.schema.d.ts +2128 -18
  110. package/dist/schema/logbook.schema.d.ts.map +1 -1
  111. package/dist/schema/logbook.schema.js +159 -1
  112. package/dist/schema/logbook.schema.js.map +1 -1
  113. package/dist/schema/mode.schema.d.ts +4 -6
  114. package/dist/schema/mode.schema.d.ts.map +1 -1
  115. package/dist/schema/mode.schema.js +18 -6
  116. package/dist/schema/mode.schema.js.map +1 -1
  117. package/dist/schema/observability.schema.d.ts +66 -0
  118. package/dist/schema/observability.schema.d.ts.map +1 -0
  119. package/dist/schema/observability.schema.js +20 -0
  120. package/dist/schema/observability.schema.js.map +1 -0
  121. package/dist/schema/openwebrx.schema.d.ts +2 -2
  122. package/dist/schema/operator.schema.d.ts +2682 -6
  123. package/dist/schema/operator.schema.d.ts.map +1 -1
  124. package/dist/schema/operator.schema.js +10 -20
  125. package/dist/schema/operator.schema.js.map +1 -1
  126. package/dist/schema/plugin.schema.d.ts +1977 -492
  127. package/dist/schema/plugin.schema.d.ts.map +1 -1
  128. package/dist/schema/plugin.schema.js +165 -14
  129. package/dist/schema/plugin.schema.js.map +1 -1
  130. package/dist/schema/process-monitor.schema.d.ts +132 -132
  131. package/dist/schema/pskreporter.schema.d.ts +1 -3
  132. package/dist/schema/pskreporter.schema.d.ts.map +1 -1
  133. package/dist/schema/pskreporter.schema.js +1 -3
  134. package/dist/schema/pskreporter.schema.js.map +1 -1
  135. package/dist/schema/qso.schema.d.ts +246 -1
  136. package/dist/schema/qso.schema.d.ts.map +1 -1
  137. package/dist/schema/qso.schema.js +68 -0
  138. package/dist/schema/qso.schema.js.map +1 -1
  139. package/dist/schema/radio-capability.schema.d.ts +31 -40
  140. package/dist/schema/radio-capability.schema.d.ts.map +1 -1
  141. package/dist/schema/radio-capability.schema.js +8 -15
  142. package/dist/schema/radio-capability.schema.js.map +1 -1
  143. package/dist/schema/radio-power-support.d.ts.map +1 -1
  144. package/dist/schema/radio-power-support.js +3 -0
  145. package/dist/schema/radio-power-support.js.map +1 -1
  146. package/dist/schema/radio-power.schema.d.ts +102 -13
  147. package/dist/schema/radio-power.schema.d.ts.map +1 -1
  148. package/dist/schema/radio-power.schema.js +15 -8
  149. package/dist/schema/radio-power.schema.js.map +1 -1
  150. package/dist/schema/radio-profile.schema.d.ts +1491 -97
  151. package/dist/schema/radio-profile.schema.d.ts.map +1 -1
  152. package/dist/schema/radio-profile.schema.js +7 -0
  153. package/dist/schema/radio-profile.schema.js.map +1 -1
  154. package/dist/schema/radio.schema.d.ts +442 -48
  155. package/dist/schema/radio.schema.d.ts.map +1 -1
  156. package/dist/schema/radio.schema.js +75 -7
  157. package/dist/schema/radio.schema.js.map +1 -1
  158. package/dist/schema/realtime.schema.d.ts +60 -52
  159. package/dist/schema/realtime.schema.d.ts.map +1 -1
  160. package/dist/schema/realtime.schema.js +9 -1
  161. package/dist/schema/realtime.schema.js.map +1 -1
  162. package/dist/schema/rigctld.schema.d.ts +6 -6
  163. package/dist/schema/slot-info.schema.d.ts +28 -9
  164. package/dist/schema/slot-info.schema.d.ts.map +1 -1
  165. package/dist/schema/slot-info.schema.js +17 -10
  166. package/dist/schema/slot-info.schema.js.map +1 -1
  167. package/dist/schema/spectrum-config.schema.d.ts +638 -0
  168. package/dist/schema/spectrum-config.schema.d.ts.map +1 -0
  169. package/dist/schema/spectrum-config.schema.js +129 -0
  170. package/dist/schema/spectrum-config.schema.js.map +1 -0
  171. package/dist/schema/spectrum.schema.d.ts +1323 -30
  172. package/dist/schema/spectrum.schema.d.ts.map +1 -1
  173. package/dist/schema/spectrum.schema.js +72 -0
  174. package/dist/schema/spectrum.schema.js.map +1 -1
  175. package/dist/schema/station-info.schema.d.ts +5 -0
  176. package/dist/schema/station-info.schema.d.ts.map +1 -1
  177. package/dist/schema/station-info.schema.js +5 -0
  178. package/dist/schema/station-info.schema.js.map +1 -1
  179. package/dist/schema/system.schema.d.ts +56 -6
  180. package/dist/schema/system.schema.d.ts.map +1 -1
  181. package/dist/schema/system.schema.js +22 -0
  182. package/dist/schema/system.schema.js.map +1 -1
  183. package/dist/schema/transmission.schema.d.ts +8 -7
  184. package/dist/schema/transmission.schema.d.ts.map +1 -1
  185. package/dist/schema/transmission.schema.js +2 -7
  186. package/dist/schema/transmission.schema.js.map +1 -1
  187. package/dist/schema/tx-audio-input.schema.d.ts +8 -0
  188. package/dist/schema/tx-audio-input.schema.d.ts.map +1 -0
  189. package/dist/schema/tx-audio-input.schema.js +19 -0
  190. package/dist/schema/tx-audio-input.schema.js.map +1 -0
  191. package/dist/schema/voice-keyer.schema.d.ts +16 -16
  192. package/dist/schema/voice.schema.d.ts +1 -1
  193. package/dist/schema/voice.schema.d.ts.map +1 -1
  194. package/dist/schema/voice.schema.js +1 -1
  195. package/dist/schema/voice.schema.js.map +1 -1
  196. package/dist/schema/websocket.schema.d.ts +35811 -7829
  197. package/dist/schema/websocket.schema.d.ts.map +1 -1
  198. package/dist/schema/websocket.schema.js +453 -9
  199. package/dist/schema/websocket.schema.js.map +1 -1
  200. package/package.json +6 -1
  201. package/src/index.ts +7 -0
  202. package/src/schema/__tests__/android-operator-audio.schema.test.ts +31 -0
  203. package/src/schema/__tests__/assisted-queue.schema.test.ts +122 -0
  204. package/src/schema/__tests__/audio-sidecar.schema.test.ts +48 -0
  205. package/src/schema/__tests__/audio.schema.test.ts +102 -0
  206. package/src/schema/__tests__/diagnostics.schema.test.ts +32 -0
  207. package/src/schema/__tests__/logbook-health.schema.test.ts +117 -0
  208. package/src/schema/__tests__/meter.schema.test.ts +28 -0
  209. package/src/schema/__tests__/observability.schema.test.ts +15 -0
  210. package/src/schema/__tests__/operator-context-report.schema.test.ts +77 -0
  211. package/src/schema/__tests__/operator.schema.test.ts +11 -3
  212. package/src/schema/__tests__/plugin-market.schema.test.ts +165 -2
  213. package/src/schema/__tests__/qso.schema.test.ts +91 -0
  214. package/src/schema/__tests__/radio-power-support.test.ts +17 -0
  215. package/src/schema/__tests__/radio-profile.schema.test.ts +44 -0
  216. package/src/schema/__tests__/radio.schema.test.ts +108 -1
  217. package/src/schema/__tests__/realtime-settings.schema.test.ts +2 -1
  218. package/src/schema/__tests__/remote-access.schema.test.ts +22 -0
  219. package/src/schema/__tests__/spectrum.schema.test.ts +119 -0
  220. package/src/schema/__tests__/strategy-transmission.schema.test.ts +106 -0
  221. package/src/schema/android-operator-audio.schema.ts +49 -0
  222. package/src/schema/api-response.schema.ts +11 -0
  223. package/src/schema/audio-sidecar.schema.ts +35 -0
  224. package/src/schema/audio.schema.ts +115 -2
  225. package/src/schema/auth.schema.ts +79 -1
  226. package/src/schema/cw-keyer.schema.ts +5 -0
  227. package/src/schema/diagnostics.schema.ts +55 -0
  228. package/src/schema/ft8.schema.ts +8 -0
  229. package/src/schema/image-radio.schema.ts +342 -0
  230. package/src/schema/logbook.schema.ts +205 -1
  231. package/src/schema/mode.schema.ts +18 -6
  232. package/src/schema/observability.schema.ts +26 -0
  233. package/src/schema/operator.schema.ts +11 -20
  234. package/src/schema/plugin.schema.ts +186 -15
  235. package/src/schema/pskreporter.schema.ts +1 -3
  236. package/src/schema/qso.schema.ts +78 -0
  237. package/src/schema/radio-capability.schema.ts +8 -15
  238. package/src/schema/radio-power-support.ts +3 -0
  239. package/src/schema/radio-power.schema.ts +15 -8
  240. package/src/schema/radio-profile.schema.ts +7 -0
  241. package/src/schema/radio.schema.ts +90 -7
  242. package/src/schema/realtime.schema.ts +9 -1
  243. package/src/schema/slot-info.schema.ts +18 -11
  244. package/src/schema/spectrum-config.schema.ts +156 -0
  245. package/src/schema/spectrum.schema.ts +89 -0
  246. package/src/schema/station-info.schema.ts +5 -0
  247. package/src/schema/system.schema.ts +29 -0
  248. package/src/schema/transmission.schema.ts +2 -7
  249. package/src/schema/tx-audio-input.schema.ts +22 -0
  250. package/src/schema/voice.schema.ts +1 -1
  251. package/src/schema/websocket.schema.ts +601 -13
  252. package/test/image-radio-schema.test.ts +107 -0
  253. package/test/plugin-slot-schema.test.ts +31 -0
  254. package/test/radio-capability-schema.test.ts +44 -0
  255. package/CLAUDE.md +0 -41
@@ -0,0 +1,117 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ CreateLogbookBackupRequestSchema,
5
+ LogBookInfoSchema,
6
+ LogbookHealthSchema,
7
+ RestoreLogbookRequestSchema,
8
+ WSMessageSchema,
9
+ WSMessageType,
10
+ } from '../../index.js';
11
+
12
+ const loadingHealth = {
13
+ state: 'loading' as const,
14
+ readable: false,
15
+ writable: false,
16
+ issues: [],
17
+ updatedAt: 1_775_394_000_000,
18
+ };
19
+
20
+ describe('logbook health contracts', () => {
21
+ it('requires every logbook listing to expose health', () => {
22
+ const info = {
23
+ id: 'logbook-N0CALL',
24
+ name: 'N0CALL QSO Log',
25
+ fileName: 'N0CALL.adi',
26
+ storageKind: 'managed' as const,
27
+ createdAt: 1,
28
+ lastUsed: 2,
29
+ isActive: true,
30
+ };
31
+
32
+ expect(LogBookInfoSchema.safeParse(info).success).toBe(false);
33
+ const parsed = LogBookInfoSchema.parse({ ...info, health: loadingHealth });
34
+ expect(parsed.health).toEqual(loadingHealth);
35
+ expect(parsed).not.toHaveProperty('filePath');
36
+ });
37
+
38
+ it('rejects path-bearing fields in backup and restore mutations', () => {
39
+ expect(CreateLogbookBackupRequestSchema.safeParse({
40
+ filePath: '/tmp/escape.adi',
41
+ }).success).toBe(false);
42
+ expect(RestoreLogbookRequestSchema.safeParse({
43
+ preflightToken: 'preflight-token-1',
44
+ confirmation: 'logbook-N0CALL',
45
+ artifactPath: '../another.adi',
46
+ }).success).toBe(false);
47
+ });
48
+
49
+ it('rejects negative affected record and byte counts', () => {
50
+ expect(LogbookHealthSchema.safeParse({
51
+ ...loadingHealth,
52
+ issues: [{
53
+ code: 'OPAQUE_RECORDS',
54
+ message: 'One record was skipped',
55
+ affectedRecords: -1,
56
+ affectedBytes: -1,
57
+ occurredAt: loadingHealth.updatedAt,
58
+ }],
59
+ }).success).toBe(false);
60
+ });
61
+
62
+ it('validates health and write-failure websocket payloads', () => {
63
+ expect(WSMessageSchema.safeParse({
64
+ type: WSMessageType.LOGBOOK_HEALTH_CHANGED,
65
+ timestamp: new Date().toISOString(),
66
+ data: { logBookId: 'logbook-N0CALL', health: loadingHealth },
67
+ }).success).toBe(true);
68
+ expect(WSMessageSchema.safeParse({
69
+ type: WSMessageType.LOGBOOK_WRITE_FAILED,
70
+ timestamp: new Date().toISOString(),
71
+ data: {
72
+ logBookId: 'logbook-N0CALL',
73
+ operatorId: 'operator-1',
74
+ attemptId: 'attempt-12345678',
75
+ unsavedCount: 1,
76
+ error: {
77
+ code: 'LOGBOOK_WRITE_FAILED',
78
+ message: 'No space left on device',
79
+ systemCode: 'ENOSPC',
80
+ occurredAt: loadingHealth.updatedAt,
81
+ },
82
+ },
83
+ }).success).toBe(true);
84
+ expect(WSMessageSchema.safeParse({
85
+ type: WSMessageType.LOGBOOK_WRITE_FAILED,
86
+ timestamp: new Date().toISOString(),
87
+ data: {
88
+ logBookId: 'logbook-N0CALL',
89
+ qsoRecord: {
90
+ id: 'private-qso',
91
+ callsign: 'N0CALL',
92
+ frequency: 14_074_000,
93
+ mode: 'FT8',
94
+ startTime: loadingHealth.updatedAt,
95
+ messageHistory: ['CQ N0CALL AA00'],
96
+ },
97
+ error: {
98
+ code: 'LOGBOOK_WRITE_FAILED',
99
+ message: 'write failed',
100
+ occurredAt: loadingHealth.updatedAt,
101
+ },
102
+ },
103
+ }).success).toBe(false);
104
+ expect(WSMessageSchema.safeParse({
105
+ type: WSMessageType.LOGBOOK_WRITE_FAILED,
106
+ timestamp: new Date().toISOString(),
107
+ data: {
108
+ logBookId: 'logbook-N0CALL',
109
+ error: {
110
+ code: 'ARBITRARY_ERROR',
111
+ message: 'Unstable error code',
112
+ occurredAt: loadingHealth.updatedAt,
113
+ },
114
+ },
115
+ }).success).toBe(false);
116
+ });
117
+ });
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { MeterDataSchema } from '../websocket.schema.js';
3
+
4
+ describe('MeterDataSchema', () => {
5
+ it('accepts watts without an invented normalized percentage', () => {
6
+ expect(MeterDataSchema.parse({
7
+ swr: null,
8
+ alc: null,
9
+ level: null,
10
+ power: { raw: 12.5, percent: null, watts: 12.5, maxWatts: null },
11
+ }).power?.percent).toBeNull();
12
+ });
13
+
14
+ it('accepts native dBFS ALC while keeping legacy percent messages compatible', () => {
15
+ expect(MeterDataSchema.parse({
16
+ swr: null,
17
+ alc: { raw: -2.5, percent: 87.5, alert: true, unit: 'dbfs' },
18
+ level: null,
19
+ power: null,
20
+ }).alc?.unit).toBe('dbfs');
21
+ expect(MeterDataSchema.parse({
22
+ swr: null,
23
+ alc: { raw: 120, percent: 100, alert: true },
24
+ level: null,
25
+ power: null,
26
+ }).alc?.unit).toBeUndefined();
27
+ });
28
+ });
@@ -0,0 +1,15 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ OBSERVABILITY_NOTICE_VERSION,
4
+ UpdateObservabilitySettingsSchema,
5
+ } from '../observability.schema.js';
6
+
7
+ describe('observability settings schema', () => {
8
+ it('requires acknowledgment of the current notice', () => {
9
+ expect(UpdateObservabilitySettingsSchema.parse({
10
+ enabled: true,
11
+ noticeVersion: OBSERVABILITY_NOTICE_VERSION,
12
+ })).toEqual({ enabled: true, noticeVersion: 1 });
13
+ expect(() => UpdateObservabilitySettingsSchema.parse({ enabled: true, noticeVersion: 0 })).toThrow();
14
+ });
15
+ });
@@ -0,0 +1,77 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ StrategyRuntimeSnapshotSchema,
4
+ WSMessageSchema,
5
+ WSMessageType,
6
+ } from '../../index.js';
7
+
8
+ describe('operator context report contracts', () => {
9
+ it('accepts null only as an explicit WebSocket clear command', () => {
10
+ expect(WSMessageSchema.safeParse({
11
+ type: WSMessageType.SET_OPERATOR_CONTEXT,
12
+ timestamp: new Date(0).toISOString(),
13
+ data: {
14
+ operatorId: 'operator-1',
15
+ context: {
16
+ reportSent: null,
17
+ reportReceived: null,
18
+ },
19
+ },
20
+ }).success).toBe(true);
21
+ });
22
+
23
+ it('keeps normalized strategy snapshots free of nullable reports', () => {
24
+ expect(StrategyRuntimeSnapshotSchema.safeParse({
25
+ currentState: 'TX6',
26
+ context: {
27
+ reportSent: null,
28
+ },
29
+ }).success).toBe(false);
30
+
31
+ expect(StrategyRuntimeSnapshotSchema.safeParse({
32
+ currentState: 'TX6',
33
+ context: {
34
+ reportSent: 0,
35
+ reportReceived: 0,
36
+ },
37
+ }).success).toBe(true);
38
+ });
39
+
40
+ it('accepts protocol-neutral history presentation, navigation and transmit gates', () => {
41
+ expect(StrategyRuntimeSnapshotSchema.safeParse({
42
+ currentState: 'idle',
43
+ actions: [{
44
+ id: 'open-settings',
45
+ label: 'openSettings',
46
+ navigation: { kind: 'plugin-page', pageId: 'settings' },
47
+ }],
48
+ messagePresentation: {
49
+ revision: 2,
50
+ mode: 'replace-logbook',
51
+ subject: 'sender-callsign',
52
+ partitionBy: 'band',
53
+ eligiblePartitions: ['20M'],
54
+ defaultClass: 'new',
55
+ classes: {
56
+ new: {
57
+ badges: [{ label: 'newCall', tone: 'secondary' }],
58
+ row: { tone: 'secondary', background: 'soft', accent: true },
59
+ emphasisWhen: [
60
+ { firstTokenIn: ['CQ'] },
61
+ { anyTokenIn: ['RR73', 'RRR', '73'] },
62
+ ],
63
+ },
64
+ worked: { textDecoration: 'line-through', opacity: 'muted' },
65
+ },
66
+ assignments: [{ subject: 'JA1AAA', partition: '20M', classId: 'worked' }],
67
+ noveltyRules: [{
68
+ fact: 'grid-field-2', knownValuesByPartition: { '20M': ['PM'] }, classId: 'new',
69
+ }],
70
+ tagRules: [{
71
+ id: 'cq', match: { firstTokenIn: ['CQ'] }, badge: { label: 'CQ', tone: 'primary' },
72
+ }],
73
+ },
74
+ transmitGate: { allowed: false, reason: 'confirmSettings', actionId: 'open-settings' },
75
+ }).success).toBe(true);
76
+ });
77
+ });
@@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest';
2
2
  import {
3
3
  CreateRadioOperatorRequestSchema,
4
4
  UpdateRadioOperatorRequestSchema,
5
- sanitizeCallsignInput
5
+ sanitizeCallsignInput,
6
+ sanitizeGridInput,
6
7
  } from '../../index.js';
7
8
  import { MODES } from '../mode.schema.js';
8
9
 
@@ -11,19 +12,26 @@ describe('operator callsign normalization', () => {
11
12
  expect(sanitizeCallsignInput(' bg5abc/p ')).toBe('BG5ABC/P');
12
13
  });
13
14
 
14
- it('normalizes callsigns in create and update payloads', () => {
15
+ it('uppercases grids and removes whitespace', () => {
16
+ expect(sanitizeGridInput(' ol 63aa ')).toBe('OL63AA');
17
+ });
18
+
19
+ it('normalizes callsigns and grids in create and update payloads', () => {
15
20
  const createPayload = CreateRadioOperatorRequestSchema.parse({
16
21
  myCallsign: 'bg5abc/p',
17
- myGrid: 'OL63',
22
+ myGrid: 'ol63aa',
18
23
  frequency: 1000,
19
24
  transmitCycles: [0],
20
25
  mode: MODES.FT8,
21
26
  });
22
27
  const updatePayload = UpdateRadioOperatorRequestSchema.parse({
23
28
  myCallsign: 'vk9/bg3yza',
29
+ myGrid: 'pm01',
24
30
  });
25
31
 
26
32
  expect(createPayload.myCallsign).toBe('BG5ABC/P');
33
+ expect(createPayload.myGrid).toBe('OL63AA');
27
34
  expect(updatePayload.myCallsign).toBe('VK9/BG3YZA');
35
+ expect(updatePayload.myGrid).toBe('PM01');
28
36
  });
29
37
  });
@@ -1,16 +1,20 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import {
3
+ PluginArtifactManifestSchema,
3
4
  PluginMarketCatalogResponseSchema,
4
5
  PluginMarketCatalogSchema,
5
6
  PluginMarketChannelSchema,
7
+ PluginManifestSchema,
6
8
  PluginPermissionSchema,
9
+ PluginsConfigSchema,
7
10
  PluginSourceSchema,
11
+ PluginStatusSchema,
8
12
  } from '../../index.js';
9
13
 
10
14
  describe('plugin market schema', () => {
11
15
  it('accepts a valid stable catalog', () => {
12
16
  const catalog = PluginMarketCatalogSchema.parse({
13
- schemaVersion: 1,
17
+ schemaVersion: 2,
14
18
  generatedAt: '2026-04-22T12:00:00.000Z',
15
19
  channel: 'stable',
16
20
  plugins: [
@@ -18,6 +22,8 @@ describe('plugin market schema', () => {
18
22
  name: 'heartbeat-demo',
19
23
  title: 'Heartbeat Demo',
20
24
  description: 'Example timer and quick-action plugin.',
25
+ readmeMarkdown: '# Heartbeat Demo\n\nExample timer and quick-action plugin.',
26
+ readmeSourceUrl: 'https://github.com/boybook/tx-5dr-plugins/blob/main/heartbeat-demo/README.md',
21
27
  locales: {
22
28
  en: {
23
29
  pluginName: 'Heartbeat Demo',
@@ -29,7 +35,8 @@ describe('plugin market schema', () => {
29
35
  },
30
36
  },
31
37
  latestVersion: '1.2.3',
32
- minHostVersion: '1.0.0',
38
+ minPluginApiVersion: '1.2.0',
39
+ artifactManifestVersion: 1,
33
40
  author: 'TX-5DR',
34
41
  license: 'GPL-3.0-only',
35
42
  repository: 'https://github.com/boybook/tx-5dr-plugins',
@@ -53,16 +60,172 @@ describe('plugin market schema', () => {
53
60
 
54
61
  expect(catalog.channel).toBe('stable');
55
62
  expect(catalog.plugins[0]?.name).toBe('heartbeat-demo');
63
+ expect(catalog.plugins[0]?.readmeMarkdown).toContain('# Heartbeat Demo');
64
+ expect(catalog.plugins[0]?.readmeSourceUrl).toContain('/heartbeat-demo/README.md');
65
+ expect(catalog.plugins[0]?.minPluginApiVersion).toBe('1.2.0');
66
+ });
67
+
68
+ it('normalizes the catalog v1 minimum Host field as a Plugin API floor', () => {
69
+ const catalog = PluginMarketCatalogSchema.parse({
70
+ schemaVersion: 1,
71
+ generatedAt: '2026-04-22T12:00:00.000Z',
72
+ channel: 'nightly',
73
+ plugins: [{
74
+ name: 'legacy-plugin',
75
+ title: 'Legacy Plugin',
76
+ description: 'Legacy catalog entry.',
77
+ latestVersion: '1.0.0',
78
+ minHostVersion: '1.7.11',
79
+ artifactUrl: 'https://cdn.example.com/plugins/legacy-plugin-1.0.0.zip',
80
+ sha256: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
81
+ size: 123,
82
+ publishedAt: '2026-04-22T12:00:00.000Z',
83
+ }],
84
+ });
85
+
86
+ expect(catalog.plugins[0]?.minPluginApiVersion).toBe('1.7.11');
87
+ expect('minHostVersion' in catalog.plugins[0]!).toBe(false);
88
+ });
89
+
90
+ it('requires a static artifact manifest declaration in catalog v2', () => {
91
+ const result = PluginMarketCatalogSchema.safeParse({
92
+ schemaVersion: 2,
93
+ generatedAt: '2026-04-22T12:00:00.000Z',
94
+ channel: 'nightly',
95
+ plugins: [{
96
+ name: 'manifestless-plugin',
97
+ title: 'Manifestless Plugin',
98
+ description: 'Invalid v2 catalog entry.',
99
+ latestVersion: '1.0.0',
100
+ minPluginApiVersion: '2.1.0',
101
+ artifactUrl: 'https://cdn.example.com/plugins/manifestless-plugin-1.0.0.zip',
102
+ sha256: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
103
+ size: 123,
104
+ publishedAt: '2026-04-22T12:00:00.000Z',
105
+ }],
106
+ });
107
+
108
+ expect(result.success).toBe(false);
109
+ if (!result.success) {
110
+ expect(result.error.issues).toContainEqual(expect.objectContaining({
111
+ path: ['plugins', 0, 'artifactManifestVersion'],
112
+ }));
113
+ }
114
+ });
115
+
116
+ it('keeps minHostVersion limited to catalog v1 compatibility', () => {
117
+ const result = PluginMarketCatalogSchema.safeParse({
118
+ schemaVersion: 2,
119
+ generatedAt: '2026-04-22T12:00:00.000Z',
120
+ channel: 'nightly',
121
+ plugins: [{
122
+ name: 'legacy-field-plugin',
123
+ title: 'Legacy Field Plugin',
124
+ description: 'Invalid v2 catalog entry.',
125
+ latestVersion: '1.0.0',
126
+ minHostVersion: '2.1.0',
127
+ artifactManifestVersion: 1,
128
+ artifactUrl: 'https://cdn.example.com/plugins/legacy-field-plugin-1.0.0.zip',
129
+ sha256: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
130
+ size: 123,
131
+ publishedAt: '2026-04-22T12:00:00.000Z',
132
+ }],
133
+ });
134
+
135
+ expect(result.success).toBe(false);
136
+ if (!result.success) {
137
+ expect(result.error.issues).toContainEqual(expect.objectContaining({
138
+ path: ['plugins', 0, 'minHostVersion'],
139
+ }));
140
+ }
56
141
  });
57
142
 
58
143
  it('rejects unsupported channels', () => {
59
144
  expect(() => PluginMarketChannelSchema.parse('beta')).toThrow();
60
145
  });
61
146
 
147
+ it('rejects a catalog with an invalid minimum Plugin API version', () => {
148
+ const result = PluginMarketCatalogSchema.safeParse({
149
+ schemaVersion: 1,
150
+ generatedAt: '2026-04-22T12:00:00.000Z',
151
+ channel: 'stable',
152
+ plugins: [{
153
+ name: 'heartbeat-demo',
154
+ title: 'Heartbeat Demo',
155
+ description: 'Example plugin.',
156
+ latestVersion: '1.2.3',
157
+ minPluginApiVersion: 'current',
158
+ artifactUrl: 'https://cdn.example.com/plugins/heartbeat-demo-1.2.3.zip',
159
+ sha256: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
160
+ size: 12345,
161
+ publishedAt: '2026-04-22T12:00:00.000Z',
162
+ }],
163
+ });
164
+
165
+ expect(result.success).toBe(false);
166
+ });
167
+
168
+ it('validates a static artifact manifest', () => {
169
+ expect(PluginArtifactManifestSchema.parse({
170
+ schemaVersion: 1,
171
+ name: 'heartbeat-demo',
172
+ version: '1.0.0',
173
+ minPluginApiVersion: '1.2.0',
174
+ type: 'utility',
175
+ permissions: [],
176
+ })).toMatchObject({
177
+ instanceScope: 'operator',
178
+ minPluginApiVersion: '1.2.0',
179
+ });
180
+ });
181
+
62
182
  it('accepts host settings permissions', () => {
183
+ expect(PluginPermissionSchema.parse('plugin:event-bus')).toBe('plugin:event-bus');
63
184
  expect(PluginPermissionSchema.parse('host:hamlib')).toBe('host:hamlib');
185
+ expect(PluginPermissionSchema.parse('operator:transmit-control')).toBe('operator:transmit-control');
186
+ expect(PluginPermissionSchema.parse('logbook:session')).toBe('logbook:session');
64
187
  expect(PluginPermissionSchema.parse('settings:ft8')).toBe('settings:ft8');
65
188
  expect(PluginPermissionSchema.parse('settings:ntp')).toBe('settings:ntp');
189
+ expect(() => PluginPermissionSchema.parse('operator:transmit')).toThrow();
190
+ });
191
+
192
+ it('accepts transmit-control permission and auto-call status in plugin snapshots', () => {
193
+ const manifest = PluginManifestSchema.parse({
194
+ name: 'autocall-demo',
195
+ version: '1.0.0',
196
+ type: 'utility',
197
+ permissions: ['operator:transmit-control'],
198
+ });
199
+ expect(manifest.permissions).toEqual(['operator:transmit-control']);
200
+
201
+ const status = PluginStatusSchema.parse({
202
+ name: 'autocall-demo',
203
+ version: '1.0.0',
204
+ type: 'utility',
205
+ isBuiltIn: false,
206
+ loaded: true,
207
+ enabled: true,
208
+ errorCount: 0,
209
+ permissions: ['operator:transmit-control'],
210
+ capabilities: ['auto_call_control'],
211
+ autoCallEnabledOperatorIds: ['operator-1'],
212
+ pausedOperatorIds: ['operator-2'],
213
+ });
214
+ expect(status.autoCallEnabledOperatorIds).toEqual(['operator-1']);
215
+ expect(status.pausedOperatorIds).toEqual(['operator-2']);
216
+ expect(status.capabilities).toContain('auto_call_control');
217
+ });
218
+
219
+ it('accepts persisted operator plugin pause state with defaults', () => {
220
+ const emptyConfig = PluginsConfigSchema.parse({});
221
+ expect(emptyConfig.operatorPluginPauses).toEqual({});
222
+
223
+ const config = PluginsConfigSchema.parse({
224
+ operatorPluginPauses: {
225
+ 'operator-1': ['scheduled-cq-autocall'],
226
+ },
227
+ });
228
+ expect(config.operatorPluginPauses['operator-1']).toEqual(['scheduled-cq-autocall']);
66
229
  });
67
230
 
68
231
  it('accepts the route response envelope', () => {
@@ -0,0 +1,91 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ CONTEST_QSO_ENVELOPE_MAX_BYTES,
5
+ ContestQsoEnvelopeSchema,
6
+ QSORecordSchema,
7
+ parseContestQsoEnvelope,
8
+ serializeContestQsoEnvelope,
9
+ } from '../qso.schema.js';
10
+
11
+ function envelope() {
12
+ return {
13
+ schemaVersion: 1 as const,
14
+ contestId: 'FT-CHALLENGE',
15
+ editionId: '2026-weekend-1',
16
+ rulesetVersion: '2026.1',
17
+ sent: { grid: 'PL05', snr: '-09' },
18
+ received: { grid: 'PM96', snr: '-12' },
19
+ annotations: { status: 'included', transmitter: 1, reviewed: false, note: '台北' },
20
+ };
21
+ }
22
+
23
+ describe('ContestQsoEnvelopeSchema', () => {
24
+ it('accepts shallow typed facts and round-trips Unicode through durable JSON', () => {
25
+ const parsed = ContestQsoEnvelopeSchema.parse(envelope());
26
+ const serialized = serializeContestQsoEnvelope(parsed);
27
+
28
+ expect(serialized).toContain('\\u53f0\\u5317');
29
+ expect(new TextEncoder().encode(serialized).byteLength).toBeLessThanOrEqual(
30
+ CONTEST_QSO_ENVELOPE_MAX_BYTES,
31
+ );
32
+ expect(parseContestQsoEnvelope(serialized)).toEqual(parsed);
33
+ });
34
+
35
+ it('rejects arbitrary nested values and unknown envelope fields', () => {
36
+ expect(ContestQsoEnvelopeSchema.safeParse({
37
+ ...envelope(),
38
+ annotations: { nested: { value: true } },
39
+ }).success).toBe(false);
40
+ expect(ContestQsoEnvelopeSchema.safeParse({
41
+ ...envelope(),
42
+ privateState: 'not part of the contract',
43
+ }).success).toBe(false);
44
+ });
45
+
46
+ it.each([Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY])(
47
+ 'rejects non-finite annotation number %s',
48
+ (value) => {
49
+ expect(ContestQsoEnvelopeSchema.safeParse({
50
+ ...envelope(),
51
+ annotations: { distance: value },
52
+ }).success).toBe(false);
53
+ },
54
+ );
55
+
56
+ it('enforces the UTF-8 JSON byte limit', () => {
57
+ const result = ContestQsoEnvelopeSchema.safeParse({
58
+ ...envelope(),
59
+ received: { payload: '台'.repeat(Math.ceil(CONTEST_QSO_ENVELOPE_MAX_BYTES / 6)) },
60
+ });
61
+
62
+ expect(result.success).toBe(false);
63
+ });
64
+ });
65
+
66
+ describe('QSORecordSchema contest identity', () => {
67
+ const qso = {
68
+ id: 'qso-1',
69
+ callsign: 'N0CALL',
70
+ frequency: 14_074_000,
71
+ mode: 'FT8',
72
+ startTime: 1,
73
+ messageHistory: [],
74
+ };
75
+
76
+ it('accepts a matching standard and atomic contest identity', () => {
77
+ expect(QSORecordSchema.safeParse({
78
+ ...qso,
79
+ contestId: 'FT-CHALLENGE',
80
+ contestEntry: envelope(),
81
+ }).success).toBe(true);
82
+ });
83
+
84
+ it('rejects conflicting standard and atomic contest identities', () => {
85
+ expect(QSORecordSchema.safeParse({
86
+ ...qso,
87
+ contestId: 'WW-DIGI',
88
+ contestEntry: envelope(),
89
+ }).success).toBe(false);
90
+ });
91
+ });
@@ -23,4 +23,21 @@ describe('radio power support', () => {
23
23
  }),
24
24
  ).not.toThrow();
25
25
  });
26
+
27
+ it('does not expose physical power controls for TCI radios', () => {
28
+ const decision = decidePowerSupport({
29
+ type: 'tci',
30
+ tci: {
31
+ host: '127.0.0.1', port: 40001, dialect: 'auto', autoDiscoverPorts: true,
32
+ receiver: 0, trx: 0, vfo: 0, audioEnabled: true, audioSampleRate: 12000,
33
+ },
34
+ });
35
+
36
+ expect(decision).toMatchObject({
37
+ canPowerOn: false,
38
+ canPowerOff: false,
39
+ supportedStates: [],
40
+ reason: 'model-unsupported',
41
+ });
42
+ });
26
43
  });
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ CreateProfileRequestSchema,
4
+ RadioProfileSchema,
5
+ UpdateProfileRequestSchema,
6
+ } from '../radio-profile.schema.js';
7
+
8
+ const audio = {
9
+ inputDeviceName: 'Headset microphone',
10
+ inputRouteKey: 'android:wired-headset:input',
11
+ outputDeviceName: 'Headset headphones',
12
+ outputRouteKey: 'android:wired-headset:output',
13
+ inputSampleRate: 48000,
14
+ outputSampleRate: 48000,
15
+ };
16
+
17
+ describe('radio profile audio route identity', () => {
18
+ it('preserves stable route keys through create and update requests', () => {
19
+ expect(CreateProfileRequestSchema.parse({
20
+ name: 'Android 3.5 mm',
21
+ radio: { type: 'none' },
22
+ audio,
23
+ }).audio).toMatchObject(audio);
24
+
25
+ expect(UpdateProfileRequestSchema.parse({ audio }).audio).toMatchObject(audio);
26
+ });
27
+
28
+ it('loads stable route keys while accepting legacy name-only profiles', () => {
29
+ const baseProfile = {
30
+ id: 'profile-1',
31
+ name: 'Android 3.5 mm',
32
+ radio: { type: 'none' as const },
33
+ audioLockedToRadio: false,
34
+ createdAt: 1,
35
+ updatedAt: 2,
36
+ };
37
+
38
+ expect(RadioProfileSchema.parse({ ...baseProfile, audio }).audio).toMatchObject(audio);
39
+ expect(RadioProfileSchema.parse({
40
+ ...baseProfile,
41
+ audio: { inputDeviceName: 'Legacy USB Audio' },
42
+ }).audio).toEqual({ inputDeviceName: 'Legacy USB Audio' });
43
+ });
44
+ });