Files
office_translator/wordly.art---traduction-de-documents/tests/utils/scriptDetector.test.ts
sepehr f403b2851d
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 3m5s
feat(quality): add L0 quality layer (Track A1 + A2 of dev plan)
L0 quality detection layer to catch translation failures BEFORE they
reach users. Pure Python/TypeScript, zero new dependencies, no API calls.

Backend (Python — services/quality/):
  - Script detection: 145 langs mapped to 23 scripts (Latin, Cyrillic,
    Greek, Arabic, Hebrew, CJK, Hangul, Kana, Devanagari, Bengali, etc.)
  - Language confusion detection (e.g. Arabic text for French target)
  - Arabic-script variant discrimination (Persian/Urdu/Pashto/Kurdish
    confusion — e.g. Persian text returned when Arabic was requested)
  - Length sanity check (with numeric/short-source exemptions)
  - Prompt leak detection (Translation: / Voici la traduction: / 翻译:)
  - Repetition hallucination detection (token + character level)
  - File text extraction for .docx/.xlsx/.pptx/.pdf (no translator
    changes needed)
  - Defensive pipeline that never raises (L0 must NEVER break a job)

Frontend (TypeScript — wordly.art---traduction-de-documents/src/utils/):
  - Exact 1:1 mirror of the Python module
  - Zero dependencies, works in browser AND Node.js
  - Native Unicode regex (\\p{L}/u) and codePoint iteration
  - 63 tests using Node's built-in test runner

Integration:
  - Feature-flagged: QUALITY_L0_ENABLED=false (default)
  - Observation only: logs structured events, never modifies files
  - try/except wrapped: impossible to break a translation job
  - Lazy imports: only loaded when flag is on
  - Zero impact on existing tests / behavior

Tests:
  - 111 Python tests covering all paths (config, script, length, leak,
    pipeline, file_extractor) — 100% pass
  - 63 TypeScript tests (Node --test) — 100% pass
  - 174/174 total tests for the L0 layer

Bug fixes in script mapping:
  - yi (Yiddish) -> hebrew (was incorrectly mapped to arabic)
  - dv (Maldivian) -> thaana (was incorrectly mapped to arabic)
  - ja (Japanese) -> hiragana_katakana (distinguishes from Chinese CJK)

Phase 1 (backend) + Phase 2 (frontend) of Track A complete.
Next: Track B1 (Word/Excel format preservation quick wins).

Closes Track A phase 1+2 of the dev plan.
2026-07-14 16:17:43 +02:00

422 lines
12 KiB
TypeScript

/**
* Tests for src/utils/scriptDetector.ts
*
* Run with:
* npx tsx --test tests/utils/scriptDetector.test.ts
*
* Or in package.json scripts:
* "test:quality": "tsx --test tests/utils/scriptDetector.test.ts"
*/
import { describe, it } from 'node:test';
import { strict as assert } from 'node:assert';
import {
getScript,
isArabicScriptLang,
evaluateChunk,
evaluateDocument,
detectArabicVariant,
lengthCheck,
patternCheck,
} from '../../src/utils/scriptDetector.ts';
describe('getScript', () => {
it('maps cyrillic languages', () => {
assert.equal(getScript('ru'), 'cyrillic');
assert.equal(getScript('uk'), 'cyrillic');
assert.equal(getScript('be'), 'cyrillic');
});
it('maps latin languages', () => {
assert.equal(getScript('en'), 'latin');
assert.equal(getScript('fr'), 'latin');
assert.equal(getScript('de'), 'latin');
assert.equal(getScript('vi'), 'latin');
});
it('maps CJK languages', () => {
assert.equal(getScript('zh'), 'cjk');
assert.equal(getScript('zh-cn'), 'cjk');
});
it('maps Japanese to hiragana_katakana', () => {
assert.equal(getScript('ja'), 'hiragana_katakana');
});
it('maps Korean to hangul', () => {
assert.equal(getScript('ko'), 'hangul');
});
it('maps Yiddish to hebrew (not arabic)', () => {
assert.equal(getScript('yi'), 'hebrew');
});
it('maps Maldivian to thaana (not arabic)', () => {
assert.equal(getScript('dv'), 'thaana');
});
it('falls back to latin for unknown', () => {
assert.equal(getScript('xx'), 'latin');
assert.equal(getScript(''), 'latin');
assert.equal(getScript(null), 'latin');
});
it('is case-insensitive', () => {
assert.equal(getScript('FR'), 'latin');
assert.equal(getScript('ZH-CN'), 'cjk');
});
});
describe('isArabicScriptLang', () => {
it('returns true for Arabic-script langs', () => {
assert.equal(isArabicScriptLang('ar'), true);
assert.equal(isArabicScriptLang('fa'), true);
assert.equal(isArabicScriptLang('ur'), true);
assert.equal(isArabicScriptLang('ckb'), true);
});
it('returns false for non-Arabic langs', () => {
assert.equal(isArabicScriptLang('fr'), false);
assert.equal(isArabicScriptLang('he'), false);
assert.equal(isArabicScriptLang('en'), false);
});
});
// ---------- Happy path ----------
describe('evaluateChunk — correct script', () => {
it('russian', () => {
const r = evaluateChunk('Hello world', 'Привет мир', 'ru');
assert.equal(r.passed, true);
assert.ok(!r.issues.includes('wrong_script'));
});
it('chinese', () => {
const r = evaluateChunk('Hello', '你好世界', 'zh');
assert.equal(r.passed, true);
});
it('arabic', () => {
const r = evaluateChunk('Hello', 'مرحبا بالعالم', 'ar');
assert.equal(r.passed, true);
});
it('persian (with unique chars)', () => {
const r = evaluateChunk('Hello', 'سلام چطوری؟ من پژوهشگر هستم', 'fa');
assert.equal(r.passed, true);
});
it('french', () => {
const r = evaluateChunk('Hello', 'Bonjour le monde', 'fr');
assert.equal(r.passed, true);
});
it('hebrew', () => {
const r = evaluateChunk('Hello', 'שלום עולם', 'he');
assert.equal(r.passed, true);
});
it('korean', () => {
const r = evaluateChunk('Hello', '안녕하세요 세계', 'ko');
assert.equal(r.passed, true);
});
it('japanese', () => {
const r = evaluateChunk('Hello', 'こんにちは世界', 'ja');
assert.equal(r.passed, true);
});
it('hindi', () => {
const r = evaluateChunk('Hello', 'नमस्ते दुनिया', 'hi');
assert.equal(r.passed, true);
});
it('thai', () => {
const r = evaluateChunk('Hello', 'สวัสดีชาวโลก', 'th');
assert.equal(r.passed, true);
});
it('greek', () => {
const r = evaluateChunk('Hello', 'Γεια σας κόσμε', 'el');
assert.equal(r.passed, true);
});
});
// ---------- Wrong script detection ----------
describe('evaluateChunk — wrong script', () => {
it('french text for japanese target', () => {
const r = evaluateChunk('Hello', 'Bonjour le monde', 'ja');
assert.equal(r.passed, false);
assert.ok(r.issues.includes('wrong_script'));
});
it('arabic text for french target (language confusion)', () => {
const r = evaluateChunk('Hello', 'مرحبا بالعالم', 'fr');
assert.equal(r.passed, false);
assert.ok(r.issues.includes('wrong_script'));
});
it('russian text for english target', () => {
const r = evaluateChunk('Hello', 'Привет мир', 'en');
assert.equal(r.passed, false);
assert.ok(r.issues.includes('wrong_script'));
});
it('chinese text for korean target', () => {
const r = evaluateChunk('Hello', '你好世界', 'ko');
assert.equal(r.passed, false);
assert.ok(r.issues.includes('wrong_script'));
});
});
// ---------- Arabic variant discrimination ----------
describe('evaluateChunk — Arabic variant discrimination', () => {
it('persian text for arabic target fails', () => {
const r = evaluateChunk('Hello', 'سلام چطوری؟', 'ar');
assert.equal(r.passed, false);
assert.ok(r.issues.includes('wrong_arabic_variant'));
});
it('urdu text for persian target fails', () => {
const r = evaluateChunk('Hello', 'السلام ٹڈ', 'fa');
assert.equal(r.passed, false);
assert.ok(r.issues.includes('wrong_arabic_variant'));
});
it('pashto text for arabic target fails', () => {
const r = evaluateChunk('Hello', 'السلام ټډړ', 'ar');
assert.equal(r.passed, false);
assert.ok(r.issues.includes('wrong_arabic_variant'));
});
});
// ---------- Edge cases ----------
describe('evaluateChunk — edge cases', () => {
it('empty translation passes (skipped)', () => {
const r = evaluateChunk('Hello', '', 'fr');
assert.equal(r.passed, true);
assert.ok(r.issues.includes('empty_translation'));
});
it('whitespace-only translation passes', () => {
const r = evaluateChunk('Hello', ' \n ', 'fr');
assert.equal(r.passed, true);
});
it('null translation passes', () => {
const r = evaluateChunk('Hello', null, 'fr');
assert.equal(r.passed, true);
});
it('undefined translation passes', () => {
const r = evaluateChunk('Hello', undefined, 'fr');
assert.equal(r.passed, true);
});
it('numbers only pass for fr target', () => {
const r = evaluateChunk('Price: 100', '100', 'fr');
assert.equal(r.passed, true);
});
it('unknown target lang falls back to latin', () => {
const r = evaluateChunk('Hello', 'Bonjour', 'xx');
assert.equal(r.passed, true);
});
});
// ---------- Length ----------
describe('evaluateChunk — length', () => {
it('huge translation flagged via repetition (source too short)', () => {
const r = evaluateChunk('Short', 'x'.repeat(1000), 'fr');
assert.ok(r.issues.includes('repetition_hallucination'));
});
it('huge translation with long source flagged as length', () => {
const src = 'A'.repeat(200);
const r = evaluateChunk(src, 'x'.repeat(1000), 'fr');
assert.ok(r.issues.includes('length_outlier'));
});
it('tiny translation flagged', () => {
const r = evaluateChunk('A'.repeat(100), 'ok', 'fr');
assert.ok(r.issues.includes('truncation_suspect'));
});
it('numeric source skips length check', () => {
const r = evaluateChunk('Price: 100', '100', 'fr');
assert.ok(!r.issues.includes('truncation_suspect'));
assert.ok(!r.issues.includes('length_outlier'));
});
});
// ---------- Pattern leak / repetition ----------
describe('evaluateChunk — pattern leak', () => {
it('english prompt leak', () => {
const r = evaluateChunk('Hello', 'Translation: Bonjour le monde', 'fr');
assert.ok(r.issues.includes('prompt_leak'));
});
it('french prompt leak', () => {
const r = evaluateChunk('Hello', 'Voici la traduction : Bonjour', 'fr');
assert.ok(r.issues.includes('prompt_leak'));
});
it('chinese prompt leak', () => {
const r = evaluateChunk('Hello', '翻译:你好', 'zh');
assert.ok(r.issues.includes('prompt_leak'));
});
it('token repetition detected', () => {
const r = evaluateChunk('Hello', 'the the the the the the the', 'fr');
assert.ok(r.issues.includes('repetition_hallucination'));
});
it('char repetition detected', () => {
const r = evaluateChunk('Hello', 'x'.repeat(30), 'fr');
assert.ok(r.issues.includes('repetition_hallucination'));
});
it('normal text has no leak', () => {
const r = evaluateChunk('Hello', 'Bonjour le monde', 'fr');
assert.ok(!r.issues.includes('prompt_leak'));
assert.ok(!r.issues.includes('repetition_hallucination'));
});
});
// ---------- Document-level ----------
describe('evaluateDocument', () => {
it('all good', () => {
const result = evaluateDocument(
['Hello', 'World', 'Good morning'],
['Bonjour', 'Monde', 'Bonjour'],
'fr',
);
assert.equal(result.passed, true);
assert.equal(result.chunkCount, 3);
assert.equal(result.failedChunkCount, 0);
});
it('some failures', () => {
const result = evaluateDocument(
['Hello', 'World', 'Good morning'],
['Bonjour', 'مرحبا', 'Bonjour'],
'fr',
);
assert.equal(result.passed, false);
assert.equal(result.failedChunkCount, 1);
assert.ok('wrong_script' in result.issues);
assert.equal(result.samples.length, 1);
});
it('empty lists', () => {
const result = evaluateDocument([], [], 'fr');
assert.equal(result.passed, true);
assert.equal(result.chunkCount, 0);
});
it('sample size caps samples', () => {
const result = evaluateDocument(
Array(10).fill('hi'),
Array(10).fill('مرحبا'),
'fr',
3,
);
assert.equal(result.failedChunkCount, 10);
assert.equal(result.samples.length, 3);
});
});
// ---------- detectArabicVariant direct ----------
describe('detectArabicVariant', () => {
it('persian for persian passes', () => {
const r = detectArabicVariant('سلام چطوری', 'fa');
assert.equal(r.verdict, 'pass');
});
it('persian for arabic fails', () => {
const r = detectArabicVariant('سلام چطوری', 'ar');
assert.equal(r.verdict, 'fail');
assert.ok(r.detectedVariants.includes('fa'));
});
it('urdu for persian fails', () => {
const r = detectArabicVariant('السلام ٹڈ', 'fa');
assert.equal(r.verdict, 'fail');
assert.ok(r.detectedVariants.includes('ur'));
});
it('non-arabic text skipped', () => {
const r = detectArabicVariant('Bonjour le monde', 'fa');
assert.equal(r.verdict, 'skip');
});
});
// ---------- lengthCheck direct ----------
describe('lengthCheck', () => {
it('normal length returns ratio', () => {
const r = lengthCheck(
'Hello world this is a longer source string',
'Bonjour le monde ceci est une traduction francaise',
);
assert.equal(r.issue, null);
assert.ok(r.ratio !== null);
});
it('huge translation', () => {
const r = lengthCheck('A'.repeat(200), 'x'.repeat(1000));
assert.equal(r.issue, 'length_outlier');
});
it('tiny translation', () => {
const r = lengthCheck('A'.repeat(100), 'ok');
assert.equal(r.issue, 'truncation_suspect');
});
it('empty translation flagged', () => {
const r = lengthCheck('Hello world this is a test', '');
assert.equal(r.issue, 'truncation_suspect');
});
});
// ---------- patternCheck direct ----------
describe('patternCheck', () => {
it('no leak on normal text', () => {
const r = patternCheck('Bonjour le monde');
assert.equal(r.issue, null);
});
it('english leak', () => {
const r = patternCheck('Translation: Bonjour');
assert.equal(r.issue, 'prompt_leak');
});
it('french leak', () => {
const r = patternCheck('Voici la traduction : Bonjour');
assert.equal(r.issue, 'prompt_leak');
});
it('chinese leak', () => {
const r = patternCheck('翻译:你好');
assert.equal(r.issue, 'prompt_leak');
});
it('token repetition', () => {
const r = patternCheck('the the the the the the the');
assert.equal(r.issue, 'repetition_hallucination');
});
it('char repetition', () => {
const r = patternCheck('x'.repeat(30));
assert.equal(r.issue, 'repetition_hallucination');
});
});