Skip to content

Commit 980c9ba

Browse files
feat(events): backend mutation reconciliation replacing frontend optimistic updates (#41)
Add LibraryReconcileEvent with authoritative stats (total_tracks, total_duration, revision) emitted after every library mutation. Replace LibraryUpdatedEvent::deleted calls in delete, purge, dedup, scan, favorite, and watcher flows. Frontend applies backend-provided totals instead of computing them locally, eliminating count divergence and phantom tracks. - Add LibraryReconcileEvent struct with delete/scan_complete/dedup/favorite factory methods and emit_library_reconcile on EventEmitter trait - Modify library_delete_track(s), library_delete_all, library_purge_missing, run_backfill_and_dedup to emit reconcile events with DB stats + revision - Modify scan_paths_to_library and watcher to emit reconcile on completion - Modify favorites_add/remove to emit reconcile with affected_sections - Add _removeFromView to library store (filters view without recomputing totals) - Add handleLibraryReconcile handler in events.js with queue cleanup - Remove deleted-action branch from createLibraryUpdatedHandler - Remove fetchTracks from handleScanComplete (reconcile handles it) - Update context-menu-actions to use _removeFromView for optimistic UI - Add 7 Rust unit tests and 13 Vitest tests for reconcile event handling Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d42139e commit 980c9ba

File tree

13 files changed

+1294
-245
lines changed

13 files changed

+1294
-245
lines changed
Lines changed: 346 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,346 @@
1+
/**
2+
* Tests for library:reconcile event handling
3+
*
4+
* Verifies that the frontend correctly applies backend reconciliation
5+
* events instead of computing local state.
6+
*/
7+
8+
import { beforeEach, describe, expect, it, vi } from "vitest";
9+
10+
// Minimal library store stub for testing reconcile behavior
11+
function createTestLibraryStore(initialTracks = []) {
12+
const _sectionTracks = [...initialTracks];
13+
const _trackPages = {};
14+
let _dataVersion = 0;
15+
let _cacheCleared = false;
16+
17+
return {
18+
totalTracks: initialTracks.length,
19+
totalDuration: initialTracks.reduce((sum, t) => sum + (t.duration || 0), 0),
20+
currentSection: "all",
21+
_lastRevision: 0,
22+
_sectionTracks,
23+
_trackPages,
24+
_dataVersion,
25+
26+
get filteredTracks() {
27+
return this._sectionTracks;
28+
},
29+
30+
_setSectionTracks(tracks) {
31+
this._sectionTracks.length = 0;
32+
this._sectionTracks.push(...tracks);
33+
},
34+
35+
_clearCache() {
36+
_cacheCleared = true;
37+
},
38+
39+
get cacheCleared() {
40+
return _cacheCleared;
41+
},
42+
43+
_removeFromView(idSet) {
44+
if (this._sectionTracks.length > 0) {
45+
const newTracks = this._sectionTracks.filter((t) => !idSet.has(t.id));
46+
this._setSectionTracks(newTracks);
47+
this._dataVersion++;
48+
} else {
49+
for (const [pageIdx, page] of Object.entries(this._trackPages)) {
50+
this._trackPages[pageIdx] = page.filter((t) => !idSet.has(t.id));
51+
}
52+
this._dataVersion++;
53+
}
54+
this._clearCache();
55+
},
56+
57+
fetchTracks: vi.fn(),
58+
};
59+
}
60+
61+
function createTestQueueStore(initialItems = []) {
62+
return {
63+
items: [...initialItems],
64+
_originalOrder: [...initialItems],
65+
_initializing: false,
66+
_updating: false,
67+
currentIndex: 0,
68+
load: vi.fn(),
69+
};
70+
}
71+
72+
// Simulate the reconcile handler from events.js
73+
function handleLibraryReconcile(library, queue, payload) {
74+
const {
75+
mutation,
76+
affected_sections,
77+
removed_ids,
78+
total_tracks,
79+
total_duration,
80+
revision,
81+
} = payload;
82+
83+
library.totalTracks = total_tracks;
84+
library.totalDuration = total_duration;
85+
library._lastRevision = revision;
86+
87+
if (
88+
(mutation === "delete" || mutation === "dedup") &&
89+
removed_ids.length > 0
90+
) {
91+
const idSet = new Set(removed_ids);
92+
library._removeFromView(idSet);
93+
// Simplified queue cleanup for testing
94+
queue.items = queue.items.filter((t) => !idSet.has(t.id));
95+
} else if (
96+
mutation === "scan_complete" ||
97+
mutation === "delete" ||
98+
mutation === "dedup"
99+
) {
100+
library.fetchTracks();
101+
}
102+
103+
if (
104+
affected_sections.includes("liked") &&
105+
library.currentSection === "liked"
106+
) {
107+
library.fetchTracks();
108+
}
109+
}
110+
111+
describe("Library Reconcile Event Handler", () => {
112+
let library;
113+
let queue;
114+
115+
const sampleTracks = [
116+
{ id: 1, title: "Track 1", duration: 180000 },
117+
{ id: 2, title: "Track 2", duration: 240000 },
118+
{ id: 3, title: "Track 3", duration: 200000 },
119+
{ id: 4, title: "Track 4", duration: 300000 },
120+
];
121+
122+
beforeEach(() => {
123+
library = createTestLibraryStore(sampleTracks);
124+
queue = createTestQueueStore(sampleTracks);
125+
});
126+
127+
describe("delete mutation with specific IDs", () => {
128+
it("applies authoritative totals from backend", () => {
129+
handleLibraryReconcile(library, queue, {
130+
mutation: "delete",
131+
affected_sections: ["all"],
132+
removed_ids: [2, 3],
133+
added_ids: [],
134+
total_tracks: 2,
135+
total_duration: 480000,
136+
revision: 5,
137+
});
138+
139+
expect(library.totalTracks).toBe(2);
140+
expect(library.totalDuration).toBe(480000);
141+
expect(library._lastRevision).toBe(5);
142+
});
143+
144+
it("removes specified tracks from view", () => {
145+
handleLibraryReconcile(library, queue, {
146+
mutation: "delete",
147+
affected_sections: ["all"],
148+
removed_ids: [2, 3],
149+
added_ids: [],
150+
total_tracks: 2,
151+
total_duration: 480000,
152+
revision: 5,
153+
});
154+
155+
const remainingIds = library.filteredTracks.map((t) => t.id);
156+
expect(remainingIds).toEqual([1, 4]);
157+
expect(remainingIds).not.toContain(2);
158+
expect(remainingIds).not.toContain(3);
159+
});
160+
161+
it("removes deleted tracks from queue", () => {
162+
handleLibraryReconcile(library, queue, {
163+
mutation: "delete",
164+
affected_sections: ["all"],
165+
removed_ids: [1, 3],
166+
added_ids: [],
167+
total_tracks: 2,
168+
total_duration: 540000,
169+
revision: 6,
170+
});
171+
172+
const queueIds = queue.items.map((t) => t.id);
173+
expect(queueIds).toEqual([2, 4]);
174+
});
175+
176+
it("does not call fetchTracks for targeted deletes", () => {
177+
handleLibraryReconcile(library, queue, {
178+
mutation: "delete",
179+
affected_sections: ["all"],
180+
removed_ids: [2],
181+
added_ids: [],
182+
total_tracks: 3,
183+
total_duration: 680000,
184+
revision: 7,
185+
});
186+
187+
expect(library.fetchTracks).not.toHaveBeenCalled();
188+
});
189+
});
190+
191+
describe("delete mutation without IDs (bulk)", () => {
192+
it("triggers full refetch", () => {
193+
handleLibraryReconcile(library, queue, {
194+
mutation: "delete",
195+
affected_sections: ["all"],
196+
removed_ids: [],
197+
added_ids: [],
198+
total_tracks: 0,
199+
total_duration: 0,
200+
revision: 8,
201+
});
202+
203+
expect(library.fetchTracks).toHaveBeenCalled();
204+
expect(library.totalTracks).toBe(0);
205+
});
206+
});
207+
208+
describe("scan_complete mutation", () => {
209+
it("triggers full refetch with authoritative totals", () => {
210+
handleLibraryReconcile(library, queue, {
211+
mutation: "scan_complete",
212+
affected_sections: ["all", "added"],
213+
removed_ids: [],
214+
added_ids: [],
215+
total_tracks: 150,
216+
total_duration: 45000000,
217+
revision: 10,
218+
});
219+
220+
expect(library.fetchTracks).toHaveBeenCalled();
221+
expect(library.totalTracks).toBe(150);
222+
expect(library.totalDuration).toBe(45000000);
223+
expect(library._lastRevision).toBe(10);
224+
});
225+
});
226+
227+
describe("dedup mutation", () => {
228+
it("removes deduplicated tracks and updates totals", () => {
229+
handleLibraryReconcile(library, queue, {
230+
mutation: "dedup",
231+
affected_sections: ["all"],
232+
removed_ids: [3, 4],
233+
added_ids: [],
234+
total_tracks: 2,
235+
total_duration: 420000,
236+
revision: 12,
237+
});
238+
239+
const remainingIds = library.filteredTracks.map((t) => t.id);
240+
expect(remainingIds).toEqual([1, 2]);
241+
expect(library.totalTracks).toBe(2);
242+
expect(library.totalDuration).toBe(420000);
243+
});
244+
});
245+
246+
describe("favorite mutation", () => {
247+
it("refreshes liked section when viewing it", () => {
248+
library.currentSection = "liked";
249+
250+
handleLibraryReconcile(library, queue, {
251+
mutation: "favorite_add",
252+
affected_sections: ["liked"],
253+
removed_ids: [],
254+
added_ids: [],
255+
total_tracks: 4,
256+
total_duration: 920000,
257+
revision: 15,
258+
});
259+
260+
expect(library.fetchTracks).toHaveBeenCalled();
261+
});
262+
263+
it("does not refetch when not viewing liked section", () => {
264+
library.currentSection = "all";
265+
266+
handleLibraryReconcile(library, queue, {
267+
mutation: "favorite_add",
268+
affected_sections: ["liked"],
269+
removed_ids: [],
270+
added_ids: [],
271+
total_tracks: 4,
272+
total_duration: 920000,
273+
revision: 15,
274+
});
275+
276+
expect(library.fetchTracks).not.toHaveBeenCalled();
277+
});
278+
});
279+
280+
describe("authoritative totals override local state", () => {
281+
it("overrides even when local and backend disagree", () => {
282+
library.totalTracks = 999;
283+
library.totalDuration = 9999999;
284+
285+
handleLibraryReconcile(library, queue, {
286+
mutation: "delete",
287+
affected_sections: ["all"],
288+
removed_ids: [1],
289+
added_ids: [],
290+
total_tracks: 3,
291+
total_duration: 740000,
292+
revision: 20,
293+
});
294+
295+
expect(library.totalTracks).toBe(3);
296+
expect(library.totalDuration).toBe(740000);
297+
});
298+
});
299+
});
300+
301+
describe("_removeFromView", () => {
302+
it("filters section tracks without touching totals", () => {
303+
const store = createTestLibraryStore([
304+
{ id: 1, title: "A", duration: 100 },
305+
{ id: 2, title: "B", duration: 200 },
306+
{ id: 3, title: "C", duration: 300 },
307+
]);
308+
309+
const originalTotal = store.totalTracks;
310+
const originalDuration = store.totalDuration;
311+
312+
store._removeFromView(new Set([2]));
313+
314+
expect(store.filteredTracks.map((t) => t.id)).toEqual([1, 3]);
315+
// Totals are NOT recomputed by _removeFromView
316+
expect(store.totalTracks).toBe(originalTotal);
317+
expect(store.totalDuration).toBe(originalDuration);
318+
});
319+
320+
it("filters paginated tracks", () => {
321+
const store = createTestLibraryStore([]);
322+
store._sectionTracks.length = 0; // Clear section tracks
323+
store._trackPages[0] = [
324+
{ id: 1, title: "A" },
325+
{ id: 2, title: "B" },
326+
];
327+
store._trackPages[1] = [
328+
{ id: 3, title: "C" },
329+
{ id: 4, title: "D" },
330+
];
331+
332+
store._removeFromView(new Set([2, 3]));
333+
334+
expect(store._trackPages[0].map((t) => t.id)).toEqual([1]);
335+
expect(store._trackPages[1].map((t) => t.id)).toEqual([4]);
336+
});
337+
338+
it("clears cache after removal", () => {
339+
const store = createTestLibraryStore([
340+
{ id: 1, title: "A", duration: 100 },
341+
]);
342+
343+
store._removeFromView(new Set([1]));
344+
expect(store.cacheCleared).toBe(true);
345+
});
346+
});

0 commit comments

Comments
 (0)