Files
readest/apps/readest-app/src/__tests__/services/sync/replicaDictionaryApply.test.ts
T
Huang Xin cbdc3b8f52 feat(sync): wire dictionary store through replica sync (follow-up to #4075) (#4076)
* feat(sync): cross-device dictionary sync

Custom MDict / StarDict / DICT / SLOB dictionaries now sync across
signed-in devices via the replica layer.

- Store mutations publish replica rows with field-level LWW + tombstones.
- Re-importing the same content (renamed or after delete) preserves the
  user's label and reincarnates the server row instead of duplicating.
- Manifest commits after binary upload so other devices never see a row
  whose binaries aren't on cloud storage yet.
- Pull-side orchestrator creates a placeholder dict, queues the binaries
  via TransferManager, and clears the unavailable flag on completion.
- Toast copy branches by transfer kind so dict uploads don't read
  "Book uploaded".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sync): boot pull and binary download path

- Defer the boot pull until TransferManager is initialized so download
  enqueues aren't dropped.
- Auto-persist the local dict store after applyRemoteDictionary; otherwise
  the next loadCustomDictionaries wipes the in-memory rows.
- Boot pull passes since=null so a device whose cursor advanced past
  unpersisted rows can still recover.
- Skip pulling when not authenticated instead of logging
  "SyncError: Not authenticated" on every boot of a signed-out device.
- downloadReplicaFile resolves the destination against the kind's base
  dir; binaries previously landed at the literal lfp and openFile then
  failed with "File not found".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(sync): per-page useReplicaPull hook

Lifts the boot-time pull out of EnvContext into a hook each page mounts
for the kinds it needs: useReplicaPull({ kinds: ['dictionary'] }).
Library page and the shared Reader component opt in. The hook fires 10s
after page load (so feature mounts hydrate first), dedups per-kind
across navigation, and releases the slot on failure so a later mount
can retry. Future kinds plug into the hook's per-kind switch.

Also closes two refresh-loop bugs:

- Hydrate the dict store from settings BEFORE the apply loop, so the
  auto-persist doesn't clobber persisted rows that the in-memory store
  hadn't yet read. Library-page refresh was the visible victim.
- Skip the download queue when every manifest file is already on disk
  under the resolved bundle dir. Refreshing is a no-op; partial-
  download recovery still queues because some files would be missing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 21:39:38 +02:00

193 lines
6.2 KiB
TypeScript

import { describe, expect, test } from 'vitest';
import {
buildLocalDictFromRow,
filesFromManifest,
unwrapDictionaryFields,
} from '@/services/sync/replicaDictionaryApply';
import { hlcPack } from '@/libs/crdt';
import type { Hlc, Manifest, ReplicaRow } from '@/types/replica';
const NOW = 1_700_000_000_000;
const DEV = 'dev-a';
const baseRow = (overrides: Partial<ReplicaRow> = {}): ReplicaRow => ({
user_id: 'u1',
kind: 'dictionary',
replica_id: 'content-hash-abc',
fields_jsonb: {
name: { v: 'Webster', t: hlcPack(NOW, 0, DEV) as Hlc, s: DEV },
kind: { v: 'mdict', t: hlcPack(NOW, 1, DEV) as Hlc, s: DEV },
lang: { v: 'en', t: hlcPack(NOW, 2, DEV) as Hlc, s: DEV },
addedAt: { v: 1700000000000, t: hlcPack(NOW, 3, DEV) as Hlc, s: DEV },
},
manifest_jsonb: null,
deleted_at_ts: null,
reincarnation: null,
updated_at_ts: hlcPack(NOW, 3, DEV) as Hlc,
schema_version: 1,
...overrides,
});
const manifest = (
files: { filename: string; byteSize?: number; partialMd5?: string }[],
): Manifest => ({
files: files.map((f) => ({
filename: f.filename,
byteSize: f.byteSize ?? 0,
partialMd5: f.partialMd5 ?? '',
})),
schemaVersion: 1,
});
describe('unwrapDictionaryFields', () => {
test('extracts plain values from envelope fields', () => {
const row = baseRow();
expect(unwrapDictionaryFields(row.fields_jsonb)).toEqual({
name: 'Webster',
kind: 'mdict',
lang: 'en',
addedAt: 1700000000000,
});
});
test('returns undefined for missing fields', () => {
const row = baseRow();
delete (row.fields_jsonb as Record<string, unknown>)['lang'];
expect(unwrapDictionaryFields(row.fields_jsonb).lang).toBeUndefined();
});
test('handles unsupportedReason if present', () => {
const row = baseRow();
row.fields_jsonb['unsupportedReason'] = {
v: 'encrypted',
t: hlcPack(NOW, 4, DEV) as Hlc,
s: DEV,
};
row.fields_jsonb['unsupported'] = { v: true, t: hlcPack(NOW, 5, DEV) as Hlc, s: DEV };
const fields = unwrapDictionaryFields(row.fields_jsonb);
expect(fields.unsupported).toBe(true);
expect(fields.unsupportedReason).toBe('encrypted');
});
});
describe('filesFromManifest', () => {
test('mdict manifest: classifies mdx + mdd + css', () => {
const m = manifest([
{ filename: 'webster.mdx' },
{ filename: 'webster.mdd' },
{ filename: 'webster.1.mdd' },
{ filename: 'webster.css' },
]);
expect(filesFromManifest(m, 'mdict')).toEqual({
mdx: 'webster.mdx',
mdd: ['webster.mdd', 'webster.1.mdd'],
css: ['webster.css'],
});
});
test('stardict manifest: classifies ifo + idx + dict + syn (skips offsets)', () => {
const m = manifest([
{ filename: 'd.ifo' },
{ filename: 'd.idx' },
{ filename: 'd.dict.dz' },
{ filename: 'd.syn' },
]);
expect(filesFromManifest(m, 'stardict')).toEqual({
ifo: 'd.ifo',
idx: 'd.idx',
dict: 'd.dict.dz',
syn: 'd.syn',
});
});
test('dict manifest: classifies dict + index', () => {
const m = manifest([{ filename: 'w.dict.dz' }, { filename: 'w.index' }]);
expect(filesFromManifest(m, 'dict')).toEqual({
dict: 'w.dict.dz',
index: 'w.index',
});
});
test('slob manifest: classifies single .slob', () => {
const m = manifest([{ filename: 'w.slob' }]);
expect(filesFromManifest(m, 'slob')).toEqual({ slob: 'w.slob' });
});
test('null manifest returns empty files object', () => {
expect(filesFromManifest(null, 'mdict')).toEqual({});
});
test('empty manifest returns empty files object', () => {
expect(filesFromManifest(manifest([]), 'mdict')).toEqual({});
});
});
describe('buildLocalDictFromRow', () => {
test('builds a complete ImportedDictionary from a row + bundleDir', () => {
const row = baseRow({
manifest_jsonb: manifest([
{ filename: 'webster.mdx', byteSize: 1000 },
{ filename: 'webster.mdd', byteSize: 5000 },
]),
});
const dict = buildLocalDictFromRow(row, 'local-bundle-1');
expect(dict).not.toBe(null);
expect(dict!.id).toBe('local-bundle-1');
expect(dict!.contentId).toBe('content-hash-abc');
expect(dict!.kind).toBe('mdict');
expect(dict!.name).toBe('Webster');
expect(dict!.lang).toBe('en');
expect(dict!.addedAt).toBe(1700000000000);
expect(dict!.bundleDir).toBe('local-bundle-1');
expect(dict!.files).toEqual({ mdx: 'webster.mdx', mdd: ['webster.mdd'] });
expect(dict!.unavailable).toBe(true);
});
test('null manifest produces an empty files object (still unavailable)', () => {
const row = baseRow({ manifest_jsonb: null });
const dict = buildLocalDictFromRow(row, 'b');
expect(dict!.files).toEqual({});
expect(dict!.unavailable).toBe(true);
});
test('returns null when fields are malformed (missing kind)', () => {
const row = baseRow();
delete (row.fields_jsonb as Record<string, unknown>)['kind'];
expect(buildLocalDictFromRow(row, 'b')).toBe(null);
});
test('returns null when fields are malformed (missing name)', () => {
const row = baseRow();
delete (row.fields_jsonb as Record<string, unknown>)['name'];
expect(buildLocalDictFromRow(row, 'b')).toBe(null);
});
test('propagates reincarnation token from the row to the dict', () => {
const row = baseRow({ reincarnation: 'epoch-1' });
expect(buildLocalDictFromRow(row, 'b')!.reincarnation).toBe('epoch-1');
});
test('propagates unsupported flags', () => {
const row = baseRow();
row.fields_jsonb['unsupported'] = { v: true, t: hlcPack(NOW, 4, DEV) as Hlc, s: DEV };
row.fields_jsonb['unsupportedReason'] = {
v: 'encrypted MDX',
t: hlcPack(NOW, 5, DEV) as Hlc,
s: DEV,
};
const dict = buildLocalDictFromRow(row, 'b')!;
expect(dict.unsupported).toBe(true);
expect(dict.unsupportedReason).toBe('encrypted MDX');
});
test('falls back to current time when addedAt missing', () => {
const row = baseRow();
delete (row.fields_jsonb as Record<string, unknown>)['addedAt'];
const before = Date.now();
const dict = buildLocalDictFromRow(row, 'b')!;
const after = Date.now();
expect(dict.addedAt).toBeGreaterThanOrEqual(before);
expect(dict.addedAt).toBeLessThanOrEqual(after);
});
});