Sync bookmarks, highlights and display preferences to the account
Build and Push Docker Image / build-and-push (push) Failing after 19s
Build and Push Docker Image / build-and-push (push) Failing after 19s
Bookmarks and text highlights are saved per user and merged across devices (union on first contact, newer server copy when this device is clean). Theme, default translation, verse layout, commentary source and reading position sync too, each with its own timestamp so the newest change to each setting wins. The Reader never moves under you while it is open. Signing out clears this device's marks and forgets sync timestamps. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
297f2c7e84
commit
760c1e568b
20 files changed
+976
-18
No files matched your search
@@ -0,0 +1,87 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useState } from 'react';
|
||||
import { vi, test, expect, beforeEach } from 'vitest';
|
||||
|
||||
const remote = { prefs: {}, puts: [] };
|
||||
vi.mock('../syncService.js', () => ({
|
||||
getRemotePrefs: vi.fn(async () => ({ ok: true, data: { prefs: remote.prefs } })),
|
||||
saveRemotePrefs: vi.fn(async (prefs) => { remote.puts.push(prefs); return { ok: true, data: { prefs } }; }),
|
||||
}));
|
||||
|
||||
import { usePrefsSync } from './usePrefsSync.js';
|
||||
|
||||
beforeEach(() => { localStorage.clear(); remote.prefs = {}; remote.puts = []; });
|
||||
|
||||
// A stand-in for the Reader's state, wired the same way useBibleReader wires it.
|
||||
function setup({ canApplyPosition = () => true, initial = {} } = {}) {
|
||||
return renderHook(() => {
|
||||
const [theme, setTheme] = useState(initial.theme ?? 'paper');
|
||||
const [layout, setLayout] = useState(initial.layout ?? 'flow');
|
||||
const [position, setPosition] = useState(initial.position ?? { book: 'GEN', chapter: 1 });
|
||||
const sync = usePrefsSync({
|
||||
values: { theme, translation: 'BSB', layout, commentarySource: 'matthew-henry', position },
|
||||
apply: (key, v) => { if (key === 'theme') setTheme(v); else if (key === 'layout') setLayout(v); else if (key === 'position') setPosition(v); },
|
||||
canApplyPosition,
|
||||
});
|
||||
return { theme, layout, position, setTheme, setLayout, setPosition, ...sync };
|
||||
});
|
||||
}
|
||||
|
||||
test('the server\'s newer value wins, an older one does not', async () => {
|
||||
localStorage.setItem('prefs-meta', JSON.stringify({ theme: 500, layout: 900 }));
|
||||
remote.prefs = { theme: { v: 'dark', at: 800 }, layout: { v: 'verses', at: 100 } };
|
||||
const h = setup();
|
||||
await act(async () => { await h.result.current.pullServerPrefs(); });
|
||||
expect(h.result.current.theme).toBe('dark'); // server is newer
|
||||
expect(h.result.current.layout).toBe('flow'); // this device changed it later
|
||||
expect(remote.puts).toHaveLength(1);
|
||||
expect(Object.keys(remote.puts[0])).toContain('layout'); // ...so the server hears about it
|
||||
expect(Object.keys(remote.puts[0])).not.toContain('theme');
|
||||
});
|
||||
|
||||
test('a device with no history takes the account\'s settings; a first device uploads its own', async () => {
|
||||
remote.prefs = { theme: { v: 'sepia', at: 700 } };
|
||||
const h = setup();
|
||||
await act(async () => { await h.result.current.pullServerPrefs(); });
|
||||
expect(h.result.current.theme).toBe('sepia');
|
||||
// keys the account doesn't have yet are filled in from this device
|
||||
expect(Object.keys(remote.puts[0]).sort()).toEqual(['commentarySource', 'layout', 'position', 'translation']);
|
||||
});
|
||||
|
||||
test('reading position is not moved under you while the Reader is open', async () => {
|
||||
remote.prefs = { position: { v: { book: 'JHN', chapter: 3 }, at: 900 }, theme: { v: 'dark', at: 900 } };
|
||||
const h = setup({ canApplyPosition: () => false });
|
||||
await act(async () => { await h.result.current.pullServerPrefs(); });
|
||||
expect(h.result.current.position).toEqual({ book: 'GEN', chapter: 1 }); // stays put
|
||||
expect(h.result.current.theme).toBe('dark'); // everything else still syncs
|
||||
});
|
||||
|
||||
test('and it is applied when the Reader is not open', async () => {
|
||||
remote.prefs = { position: { v: { book: 'JHN', chapter: 3 }, at: 900 } };
|
||||
const h = setup({ canApplyPosition: () => true });
|
||||
await act(async () => { await h.result.current.pullServerPrefs(); });
|
||||
expect(h.result.current.position).toEqual({ book: 'JHN', chapter: 3 });
|
||||
expect(remote.puts.every((p) => !('position' in p))).toBe(true); // applying it isn't mistaken for a local edit
|
||||
});
|
||||
|
||||
test('a local change is stamped and pushed shortly after', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const h = setup();
|
||||
await act(async () => { await h.result.current.pullServerPrefs(); });
|
||||
remote.puts = [];
|
||||
act(() => h.result.current.setTheme('dark'));
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(1200); });
|
||||
expect(remote.puts).toHaveLength(1);
|
||||
expect(remote.puts[0].theme.v).toBe('dark');
|
||||
expect(JSON.parse(localStorage.getItem('prefs-meta')).theme).toBe(remote.puts[0].theme.at);
|
||||
} finally { vi.useRealTimers(); }
|
||||
});
|
||||
|
||||
test('stopping sync forgets the timestamps so the next account starts from its own settings', async () => {
|
||||
const h = setup();
|
||||
await act(async () => { await h.result.current.pullServerPrefs(); });
|
||||
expect(localStorage.getItem('prefs-meta')).not.toBeNull();
|
||||
act(() => h.result.current.stopPrefsSync());
|
||||
expect(localStorage.getItem('prefs-meta')).toBeNull();
|
||||
});
|
||||
Reference in new issue
Block a user