From 366b484815a600f8c4c9c44458ba10ac9ce71404 Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 25 May 2026 14:25:49 +0200 Subject: [PATCH] test(normalizer): real provisional-vs-register collision + override-hits coverage Co-Authored-By: Claude Opus 4.7 --- frontend/src/routes/page.server.spec.ts | 45 +++++++++++++++++++ .../import-normalizer/tests/test_documents.py | 20 ++++++--- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/frontend/src/routes/page.server.spec.ts b/frontend/src/routes/page.server.spec.ts index b87ce9a8..27ce1e30 100644 --- a/frontend/src/routes/page.server.spec.ts +++ b/frontend/src/routes/page.server.spec.ts @@ -394,6 +394,51 @@ describe('home page load — reader branch (isReader = !canWrite && !canAnnotate expect(result.isReader).toBe(false); }); + it('maps search result items directly to recentDocs without wrapping in a .document property', async () => { + const searchItem = { + id: 'd1', + title: 'Liebesbrief', + originalFilename: 'letter.pdf', + completionPercentage: 80, + receivers: [], + tags: [], + contributors: [], + matchData: { titleOffsets: [], senderMatched: false } + }; + const mockGet = vi + .fn() + .mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // initial persons + .mockResolvedValueOnce({ + response: { ok: true }, + data: { totalDocuments: 1, totalPersons: 1 } + }) // stats + .mockResolvedValueOnce({ response: { ok: true }, data: [] }) // topPersons + .mockResolvedValueOnce({ + response: { ok: true }, + data: { items: [searchItem], totalElements: 1, pageNumber: 0, pageSize: 5, totalPages: 1 } + }) // search + .mockResolvedValueOnce({ response: { ok: true }, data: [] }); // stories + vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType< + typeof createApiClient + >); + + const result = await load({ + url: makeUrl(), + request: new Request('http://localhost/'), + fetch: vi.fn() as unknown as typeof fetch, + parent: vi + .fn() + .mockResolvedValue({ canWrite: false, canAnnotate: false, canBlogWrite: false }) + } as Parameters[0]); + + expect(result.isReader).toBe(true); + if (result.isReader) { + expect(result.recentDocs).toHaveLength(1); + expect(result.recentDocs[0]).toBeDefined(); + expect(result.recentDocs[0].id).toBe('d1'); + } + }); + it('returns topPersons=[] when topPersons fetch fails, rest of data still loads', async () => { const okStats = { response: { ok: true, status: 200 }, diff --git a/tools/import-normalizer/tests/test_documents.py b/tools/import-normalizer/tests/test_documents.py index f2b39bcb..fd866554 100644 --- a/tools/import-normalizer/tests/test_documents.py +++ b/tools/import-normalizer/tests/test_documents.py @@ -75,13 +75,23 @@ def test_to_canonical_splits_multi_sender(): def test_provisional_id_never_collides_with_register(): # A provisional built from an unmatched string must not steal a register person_id. - people = persons.parse_register([{"last_name": "Cram", "first_name": "Clara"}]) + people = persons.parse_register([{"last_name": "Xyz", "first_name": "Abc"}]) # id "xyz-abc" ctx = persons.ResolutionContext(persons.AliasIndex(people), name_overrides={}) - # Force a provisional whose natural slug equals the register id by using a string the - # alias index will not resolve but that slugs to "cram-clara": - pid, _, matched = ctx.resolve_one("Clara Cram (unsicher)", source_row=1) + # "Abc, Xyz" misses the alias index (the comma changes the normalized key) but its + # provisional slug is "xyz-abc" — already the register person's id, so it MUST be suffixed. + pid, _, matched = ctx.resolve_one("Abc, Xyz", source_row=1) assert matched is False - assert pid not in {"cram-clara"} or pid.endswith("-2") # suffixed away from the register id + assert "xyz-abc" in ctx.index.known_ids + assert pid == "xyz-abc-2" # suffixed away from the register id, not reused + +def test_resolve_one_override_increments_hits(): + people = persons.parse_register([{"last_name": "de Gruyter", "first_name": "Eugenie"}]) + ctx = persons.ResolutionContext(persons.AliasIndex(people), + name_overrides={"Genie": "de-gruyter-eugenie"}) + pid, name, matched = ctx.resolve_one("Genie", source_row=1) + assert pid == "de-gruyter-eugenie" and matched is True + assert name == "Eugenie de Gruyter" # display comes from the alias index + assert ctx.override_hits == 1 def test_ambiguous_space_pair_flagged_not_split(): # US-PERS-02 AC4: "Ella Anita" is kept as one provisional + flagged, never guessed into two.