1// SPDX-License-Identifier: GPL-2.0-only
2// Copyright (c) 2026-present FERS Contributors (see AUTHORS.md).
4import { beforeEach, describe, expect, test } from 'bun:test';
7 enqueueFullSyncDetached,
9 type GranularSyncFailure,
10 registerGranularSyncFailureHandler,
11 resetSyncQueueForTests,
12 setSyncQueueInvokerForTests,
16type InvokeFn = typeof import('@tauri-apps/api/core').invoke;
18function delay(ms: number): Promise<void> {
19 return new Promise((resolve) => setTimeout(resolve, ms));
22function createDeferred() {
23 let resolve!: () => void;
24 const promise = new Promise<void>((res) => {
27 return { promise, resolve };
30describe('syncQueue granular recovery', () => {
32 resetSyncQueueForTests();
35 test('does not invoke recovery when granular sync succeeds', async () => {
36 const invocations: string[] = [];
37 const failures: GranularSyncFailure[] = [];
39 setSyncQueueInvokerForTests((async (
41 args?: Record<string, unknown>
43 invocations.push(`${command}:${String(args?.itemId ?? '')}`);
45 registerGranularSyncFailureHandler((failure) => {
46 failures.push(failure);
49 await enqueueGranularSync('Waveform', '10', '{"id":"10"}');
50 await waitForSyncIdle();
52 expect(invocations).toEqual(['update_item_from_json:10']);
53 expect(failures).toHaveLength(0);
56 test('coalesces granular edits into a pending full sync', async () => {
57 const invocations: string[] = [];
58 let snapshot = '{"simulation":{"name":"before"}}';
60 setSyncQueueInvokerForTests((async (
62 args?: Record<string, unknown>
65 `${command}:${String(args?.itemId ?? '')}:${String(args?.json ?? '')}`
69 const fullSync = enqueueFullSync(() => snapshot);
70 snapshot = '{"simulation":{"name":"after"}}';
71 const granularSync = enqueueGranularSync(
74 '{"id":"281474976710657","name":"after"}'
79 await waitForSyncIdle();
81 expect(invocations).toEqual([
82 'update_scenario_from_json::{"simulation":{"name":"after"}}',
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');
94 enqueueFullSync(() => '{"simulation":{"name":"bad"}}')
95 ).rejects.toThrow('backend rejected scenario');
96 await waitForSyncIdle();
99 test('detached full sync consumes backend rejection and leaves queue usable', async () => {
100 const invocations: string[] = [];
102 setSyncQueueInvokerForTests((async (
104 args?: Record<string, unknown>
106 invocations.push(`${command}:${String(args?.itemId ?? '')}`);
107 if (command === 'update_scenario_from_json') {
108 throw new Error('backend rejected scenario');
112 enqueueFullSyncDetached(() => '{"simulation":{"name":"bad"}}');
113 await waitForSyncIdle();
115 await enqueueGranularSync('Waveform', '10', '{"id":"10"}');
116 await waitForSyncIdle();
118 expect(invocations).toEqual([
119 'update_scenario_from_json:',
120 'update_item_from_json:10',
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;
134 setSyncQueueInvokerForTests((async (
136 args?: Record<string, unknown>
138 const itemId = String(args?.itemId ?? '');
139 invocations.push(`${command}:${itemId}`);
141 if (command === 'update_item_from_json' && itemId === '1') {
142 await firstFailureGate.promise;
143 throw new Error('backend rejected update');
146 registerGranularSyncFailureHandler(async (failure) => {
147 failures.push(failure);
148 signalRecoveryStarted();
149 await recoveryGate.promise;
152 const failingFlush = enqueueGranularSync('Waveform', '1', '{"id":"1"}');
155 const staleFlush = enqueueGranularSync('Waveform', '2', '{"id":"2"}');
158 firstFailureGate.resolve();
159 await recoveryStarted;
161 const freshFlush = enqueueGranularSync('Waveform', '3', '{"id":"3"}');
164 recoveryGate.resolve();
166 await expect(failingFlush).rejects.toThrow('backend rejected update');
169 await waitForSyncIdle();
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',