tts-voice-creator-clone-and.../tests/character-identity.test.mjs
mARTin-B78 1f812d2faf fix(characters): merge profiles only on real names, keep other cast names, reasoning and foreign quotes out of a character
Real-library audit (270 records) traced the data damage to:
- identity matching on ANY shared token ("Zwerg", "der Zwerg", "Mann") with
  "longer text wins" merging (Arthag b02 = Kolon's species/relationships), and
- csRosterCanonicalName renaming a sheet to the first cast name among its
  aliases, which saved 16 whole-sheet copies (Zwerg/Kolon = Arthag b03,
  Fremder = Mann, Alchimist = Promos ...).

Fixes:
- static/js/character-identity.js + core/character_records.py: generic labels
  are never identity evidence; csFindMergeKey, csConsolidateAliasDuplicates,
  csRosterCanonicalName, clSameIdentity use it; own name always wins; clUpsert
  writes to the exact-name record before any alias scan; no seed rename onto a
  name that has its own record.
- _charsheets_parse: in cast-target mode drop other cast members' names from
  aliases / first / last name (Kolon: "Sharraz Garthai"), strip reasoning
  leakage ("Warte:", "Der Text sagt ...") from every text field.
- _character_scene_evidence: only a quote naming the character is used,
  otherwise its own mannerisms (16 panels showed another character's scene).
- _character_age_category: Junge/Mädchen/boy/girl, number words, Sommer/
  Winter ages, evidence clause, age-tagged quotes; "15 Jahre älter" is no age.
- scripts/audit_character_records.py (read-only) and
  scripts/clean_character_records.py (dry run by default, --apply backs up).

Tests: tests/test_character_record_hygiene.py, tests/character-identity.test.mjs.
Docs: architecture/character-extraction.md, Manual, CHANGELOG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 14:09:53 +02:00

124 lines
7.3 KiB
JavaScript

// Character identity / merge rules. Run: npm run test:unit
// Fixtures are shaped like the real book-02/03 records found broken on 2026-10-03
// (see architecture/character-extraction.md).
import test from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
const ci = createRequire(import.meta.url)('../static/js/character-identity.js');
const BOOK = 'Das Jahr des Greifen 03';
function loadBrowserFiles(files, extra = {}) {
const el = new Proxy(function () {}, { get: (t, k) => (k === Symbol.toPrimitive ? () => '' : el), apply: () => el });
const sandbox = {
console, setTimeout, clearTimeout, URL, Promise, Map, Set, JSON,
document: { querySelectorAll: () => [], querySelector: () => null, getElementById: () => null,
addEventListener() {}, createElement: () => el, body: el },
$: () => null, toast() {}, indexedDB: { open: () => ({}) },
...extra,
};
sandbox.window = sandbox;
sandbox.globalThis = sandbox;
vm.createContext(sandbox);
for (const f of files) vm.runInContext(readFileSync(new URL(`../static/js/${f}`, import.meta.url), 'utf8'), sandbox, { filename: f });
return sandbox;
}
test('generic labels are not names', () => {
for (const t of ['Zwerg', 'der Zwerg', 'Mann', 'Fremder', 'Fremde', 'Alter Soldat', 'Ork-General', 'Bürgerwehroffizier',
'der Tunneltreiber', 'Kriegshäuptling', 'Erster Offizier', 'junges Mädchen', 'the Executioner']) {
assert.equal(ci.isGenericIdentityToken(t), true, t);
}
for (const t of ['Arthag', 'Kolon', 'Sharraz Garthai', 'Admiral Sanin', 'Schlange Gamba', 'Gutfolg', 'Marcus the Executioner']) {
assert.equal(ci.isGenericIdentityToken(t), false, t);
}
assert.deepEqual(ci.sharedIdentityEvidence(['arthag', 'der zwerg', 'zwerg'], ['kolon', 'zwerg', 'der zwerg']), []);
assert.deepEqual(ci.sharedIdentityEvidence(['marcian', 'inquisitor'], ['inquisitor', 'marcian']), ['marcian']);
});
// Book 03: Arthag's saved aliases contained "der Zwerg, Zwerg, ..., Kolon" and the
// cast roster listed Zwerg and Kolon before Arthag. The roster canonicaliser
// picked the first roster name among ALL tokens, renaming Arthag's sheet, and
// the save created Zwerg/Kolon as full copies of Arthag.
test('a sheet keeps its own cast name instead of a roster name found in its aliases', () => {
const sb = loadBrowserFiles(['character-identity.js', 'character-sheets.js']);
const { csRosterCanonicalName } = sb.TTVC.characterSheets._identity;
const arthag = { name: 'Arthag', aliases: 'der Zwerg, Zwerg, Schreiber, Begleiter, Kolon, der Tunneltreiber' };
assert.equal(csRosterCanonicalName(arthag, ['Zwerg', 'Kolon', 'Arthag']), 'Arthag');
// A legacy record whose name is not cast maps only through a real name, never "Zwerg".
const legacy = { name: 'Amboßzwerg', aliases: 'Zwerg, der Zwerg' };
assert.equal(csRosterCanonicalName(legacy, ['Zwerg', 'Kolon']), '');
assert.equal(csRosterCanonicalName({ name: 'Murlok', aliases: 'Zerwas, Vampir' }, ['Vampir', 'Zerwas']), 'Zerwas');
});
test('discovery merge does not fold two characters together over a generic alias', () => {
const sb = loadBrowserFiles(['character-identity.js', 'character-sheets.js']);
const { csMerge } = sb.TTVC.characterSheets._identity;
const map = new Map();
csMerge(map, [{ name: 'Arthag', aliases: 'der Zwerg, Zwerg', race_species: 'Zwerg', relationships: 'Nyrilla (Gefährtin)', sources: [] }]);
csMerge(map, [{ name: 'Kolon', aliases: 'Zwerg, Ork-General', race_species: 'Mensch (oder Ork-Verbündeter, Kontext unklar ...)',
relationships: 'Führt die Orks; dient Sharraz Garthai; arbeitet mit Drasdech', sources: [] }]);
assert.deepEqual([...map.keys()].sort(), ['arthag', 'kolon']);
assert.equal(map.get('arthag').race_species, 'Zwerg');
assert.equal(map.get('arthag').relationships, 'Nyrilla (Gefährtin)');
// A real shared name still merges (the alias proves the identity).
csMerge(map, [{ name: 'Murlok von Mengbilla', aliases: 'Arthag', notes: 'x', sources: [] }]);
assert.deepEqual([...map.keys()].sort(), ['arthag', 'kolon']);
});
test('consolidation ignores a generic alias that happens to be another entry name', () => {
const sb = loadBrowserFiles(['character-identity.js', 'character-sheets.js']);
const { csConsolidateAliasDuplicates } = sb.TTVC.characterSheets._identity;
const map = new Map([
['mann', { name: 'Mann', aliases: 'Fremde, Offizier, Fremder, Gutfolg', physical: 'rothaarig', sources: [], inventory: [] }],
['fremder', { name: 'Fremder', aliases: '', physical: 'Kapuze', sources: [], inventory: [] }],
]);
csConsolidateAliasDuplicates(map, new Set(), new Map(), []);
assert.deepEqual([...map.keys()].sort(), ['fremder', 'mann']);
assert.equal(map.get('fremder').physical, 'Kapuze');
});
function libraryWithStore(records) {
const store = new Map(records.map(r => [r.id, structuredClone(r)]));
const writes = [];
const fetch = async (url, opts = {}) => {
const u = String(url);
const json = (body, status = 200) => ({ ok: status < 400, status, json: async () => body });
if (u === '/api/characters' || u.startsWith('/api/characters?')) return json({ characters: [...store.values()] });
const id = decodeURIComponent(u.replace('/api/characters/', ''));
if (!opts.method || opts.method === 'GET') return store.has(id) ? json(store.get(id)) : json({}, 404);
if (opts.method === 'PUT') { const rec = JSON.parse(opts.body); store.set(id, rec); writes.push(id); return json(rec); }
return json({ ok: true });
};
const sb = loadBrowserFiles(['character-identity.js', 'characters-library.js'], { fetch });
return { sb, store, writes };
}
// Book 02: Kolon's extracted aliases named "Sharraz" (a separate cast record).
// The upsert scanned all records for ANY shared token before looking at the
// record that already carries the name, so Kolon's sheet could land in Sharraz.
test('library upsert writes to the record with the exact name, not an alias-linked one', async () => {
const { sb, store, writes } = libraryWithStore([
{ id: `${BOOK}::sharraz`.toLowerCase(), book: BOOK, name: 'Sharraz', sheet: { name: 'Sharraz', race_species: 'Ork' } },
{ id: `${BOOK}::kolon`.toLowerCase(), book: BOOK, name: 'Kolon', sheet: { name: 'Kolon', race_species: 'Zwerg' } },
]);
await sb.clUpsert(BOOK, { name: 'Kolon', aliases: 'Sharraz, Ork-General', physical: 'weißer Bart' });
assert.deepEqual(writes, [`${BOOK}::kolon`.toLowerCase()]);
assert.equal(store.get(`${BOOK}::sharraz`.toLowerCase()).sheet.physical, undefined);
});
test('library upsert does not redirect a new character into a record sharing only "Zwerg"', async () => {
const { sb, store, writes } = libraryWithStore([
{ id: `${BOOK}::arthag`.toLowerCase(), book: BOOK, name: 'Arthag',
sheet: { name: 'Arthag', aliases: 'der Zwerg, Zwerg', race_species: 'Zwerg' } },
]);
await sb.clUpsert(BOOK, { name: 'Kolon', aliases: 'Zwerg, der Zwerg', race_species: 'Zwerg', relationships: 'Sharraz Garthai' });
assert.deepEqual(writes, [`${BOOK}::kolon`.toLowerCase()]);
assert.equal(store.get(`${BOOK}::arthag`.toLowerCase()).sheet.relationships, undefined);
// A real shared name still finds the existing record (dedupe of a renamed sheet).
await sb.clUpsert(BOOK, { name: 'Amboßzwerg Arthag', aliases: 'Arthag', notes: 'Schreiber' });
assert.equal(writes.at(-1), `${BOOK}::arthag`.toLowerCase());
});