FERS 0.1.0
The Flexible Extensible Radar Simulator
Loading...
Searching...
No Matches
syncQueue.test.ts
Go to the documentation of this file.
1// SPDX-License-Identifier: GPL-2.0-only
2// Copyright (c) 2026-present FERS Contributors (see AUTHORS.md).
3
4import { beforeEach, describe, expect, test } from 'bun:test';
5import {
6 enqueueFullSync,
7 enqueueFullSyncDetached,
8 enqueueGranularSync,
9 type GranularSyncFailure,
10 registerGranularSyncFailureHandler,
11 resetSyncQueueForTests,
12 setSyncQueueInvokerForTests,
13 waitForSyncIdle,
14} from './syncQueue';
15
16type InvokeFn = typeof import('@tauri-apps/api/core').invoke;
17
18function delay(ms: number): Promise<void> {
19 return new Promise((resolve) => setTimeout(resolve, ms));
20}
21
22function createDeferred() {
23 let resolve!: () => void;
24 const promise = new Promise<void>((res) => {
25 resolve = res;
26 });
27 return { promise, resolve };
28}
29
30describe('syncQueue granular recovery', () => {
31 beforeEach(() => {
32 resetSyncQueueForTests();
33 });
34
35 test('does not invoke recovery when granular sync succeeds', async () => {
36 const invocations: string[] = [];
37 const failures: GranularSyncFailure[] = [];
38
39 setSyncQueueInvokerForTests((async (
40 command: string,
41 args?: Record<string, unknown>
42 ) => {
43 invocations.push(`${command}:${String(args?.itemId ?? '')}`);
44 }) as InvokeFn);
45 registerGranularSyncFailureHandler((failure) => {
46 failures.push(failure);
47 });
48
49 await enqueueGranularSync('Waveform', '10', '{"id":"10"}');
50 await waitForSyncIdle();
51
52 expect(invocations).toEqual(['update_item_from_json:10']);
53 expect(failures).toHaveLength(0);
54 });
55
56 test('coalesces granular edits into a pending full sync', async () => {
57 const invocations: string[] = [];
58 let snapshot = '{"simulation":{"name":"before"}}';
59
60 setSyncQueueInvokerForTests((async (
61 command: string,
62 args?: Record<string, unknown>
63 ) => {
64 invocations.push(
65 `${command}:${String(args?.itemId ?? '')}:${String(args?.json ?? '')}`
66 );
67 }) as InvokeFn);
68
69 const fullSync = enqueueFullSync(() => snapshot);
70 snapshot = '{"simulation":{"name":"after"}}';
71 const granularSync = enqueueGranularSync(
72 'Platform',
73 '281474976710657',
74 '{"id":"281474976710657","name":"after"}'
75 );
76
77 await fullSync;
78 await granularSync;
79 await waitForSyncIdle();
80
81 expect(invocations).toEqual([
82 'update_scenario_from_json::{"simulation":{"name":"after"}}',
83 ]);
84 });
85
86 test('rejects failed full syncs', async () => {
87 setSyncQueueInvokerForTests((async (command: string) => {
88 if (command === 'update_scenario_from_json') {
89 throw new Error('backend rejected scenario');
90 }
91 }) as InvokeFn);
92
93 await expect(
94 enqueueFullSync(() => '{"simulation":{"name":"bad"}}')
95 ).rejects.toThrow('backend rejected scenario');
96 await waitForSyncIdle();
97 });
98
99 test('detached full sync consumes backend rejection and leaves queue usable', async () => {
100 const invocations: string[] = [];
101
102 setSyncQueueInvokerForTests((async (
103 command: string,
104 args?: Record<string, unknown>
105 ) => {
106 invocations.push(`${command}:${String(args?.itemId ?? '')}`);
107 if (command === 'update_scenario_from_json') {
108 throw new Error('backend rejected scenario');
109 }
110 }) as InvokeFn);
111
112 enqueueFullSyncDetached(() => '{"simulation":{"name":"bad"}}');
113 await waitForSyncIdle();
114
115 await enqueueGranularSync('Waveform', '10', '{"id":"10"}');
116 await waitForSyncIdle();
117
118 expect(invocations).toEqual([
119 'update_scenario_from_json:',
120 'update_item_from_json:10',
121 ]);
122 });
123
124 test('recovers once and discards stale queued granular flushes after a failure', async () => {
125 const invocations: string[] = [];
126 const failures: GranularSyncFailure[] = [];
127 const firstFailureGate = createDeferred();
128 const recoveryGate = createDeferred();
129 let signalRecoveryStarted!: () => void;
130 const recoveryStarted = new Promise<void>((resolve) => {
131 signalRecoveryStarted = resolve;
132 });
133
134 setSyncQueueInvokerForTests((async (
135 command: string,
136 args?: Record<string, unknown>
137 ) => {
138 const itemId = String(args?.itemId ?? '');
139 invocations.push(`${command}:${itemId}`);
140
141 if (command === 'update_item_from_json' && itemId === '1') {
142 await firstFailureGate.promise;
143 throw new Error('backend rejected update');
144 }
145 }) as InvokeFn);
146 registerGranularSyncFailureHandler(async (failure) => {
147 failures.push(failure);
148 signalRecoveryStarted();
149 await recoveryGate.promise;
150 });
151
152 const failingFlush = enqueueGranularSync('Waveform', '1', '{"id":"1"}');
153 await delay(25);
154
155 const staleFlush = enqueueGranularSync('Waveform', '2', '{"id":"2"}');
156 await delay(25);
157
158 firstFailureGate.resolve();
159 await recoveryStarted;
160
161 const freshFlush = enqueueGranularSync('Waveform', '3', '{"id":"3"}');
162 await delay(25);
163
164 recoveryGate.resolve();
165
166 await expect(failingFlush).rejects.toThrow('backend rejected update');
167 await staleFlush;
168 await freshFlush;
169 await waitForSyncIdle();
170
171 expect(failures).toHaveLength(1);
172 expect(failures[0]?.itemId).toBe('1');
173 expect(invocations).toEqual([
174 'update_item_from_json:1',
175 'update_item_from_json:3',
176 ]);
177 });
178});