morphit/apps/web/scripts/split-on-placeholder-smoke.ts

225 lines
8.8 KiB
TypeScript

/**
* splitOnPlaceholder smoke.
*
* Validates the i18n placeholder-split helper used by the
* daily-ceiling voucher-path UI in
* apps/web/src/routes/[lang]/onboarding/register-name/+page.svelte
* (and any future caller that needs the same pattern).
*
* Coverage:
* - Happy path: tokens present and ordered → 3-tuple
* [before, linkText, after].
* - Token preservation through real translation strings
* (positional reordering by translators).
* - Graceful degradation: missing open token, missing
* close token, reversed order, both missing — all
* return the input as `[whole, '', '']` so the UI
* renders plain text instead of breaking.
* - Edge cases: empty input, empty link text between
* adjacent tokens, tokens at start / end of string,
* non-ASCII content (Persian RTL, Chinese), tokens
* used as substrings of larger text.
*
* Design rationale: the helper is the trust boundary
* between translator-supplied text and DOM construction.
* If a translator drops a placeholder by mistake, we want
* a degraded-but-readable result, NOT a runtime crash or
* a broken layout.
*
* Usage:
* tsx apps/web/scripts/split-on-placeholder-smoke.ts
*/
import { splitOnPlaceholder } from '../src/lib/utils/splitOnPlaceholder.ts';
let failures = 0;
let scenarios = 0;
function scenario(name: string, fn: () => void): void {
scenarios++;
try {
fn();
console.log(`${name}`);
} catch (err) {
failures++;
console.error(`${name}`);
console.error(` ${err instanceof Error ? err.message : String(err)}`);
}
}
function expect(actual: unknown, expected: unknown, label = ''): void {
const a = JSON.stringify(actual);
const e = JSON.stringify(expected);
if (a !== e) {
throw new Error(`${label ? label + ': ' : ''}expected ${e}, got ${a}`);
}
}
console.log('splitOnPlaceholder smoke:\n');
// ─── Happy path ──────────────────────────────────────────────
scenario('tokens present and ordered → 3-tuple split', () => {
const r = splitOnPlaceholder(
'Join us in {open}#agorise:matrix.org{close} for help.',
'{open}',
'{close}'
);
expect(r, ['Join us in ', '#agorise:matrix.org', ' for help.']);
});
scenario('English translation preserves link position mid-sentence', () => {
const text =
'Join us in the Agorise Matrix room {matrix_open}#agorise:matrix.org{matrix_close}. A community member will give you a voucher code.';
const r = splitOnPlaceholder(text, '{matrix_open}', '{matrix_close}');
expect(r[0], 'Join us in the Agorise Matrix room ');
expect(r[1], '#agorise:matrix.org');
expect(r[2], '. A community member will give you a voucher code.');
});
scenario('translator reorders sentence — link at start', () => {
const text =
'{plugin_open}blurtplugin.online/account{plugin_close} — open this URL, paste your voucher.';
const r = splitOnPlaceholder(text, '{plugin_open}', '{plugin_close}');
expect(r[0], '');
expect(r[1], 'blurtplugin.online/account');
expect(r[2], ' — open this URL, paste your voucher.');
});
scenario('translator reorders sentence — link at end', () => {
const text = 'Click here: {plugin_open}blurtplugin.online/account{plugin_close}';
const r = splitOnPlaceholder(text, '{plugin_open}', '{plugin_close}');
expect(r[0], 'Click here: ');
expect(r[1], 'blurtplugin.online/account');
expect(r[2], '');
});
// ─── Real translations ───────────────────────────────────────
scenario('Persian (RTL) translation preserves split order', () => {
const text =
'به اتاق Matrix Agorise بپیوندید: {matrix_open}#agorise:matrix.org{matrix_close}. یکی از اعضای جامعه به شما یک کد ووچر می‌دهد.';
const r = splitOnPlaceholder(text, '{matrix_open}', '{matrix_close}');
expect(r[1], '#agorise:matrix.org');
// Before/after both contain Persian text.
if (!r[0].includes('Matrix')) {
throw new Error(`expected 'Matrix' in before, got: ${r[0]}`);
}
if (!r[2].includes('کد')) {
throw new Error(`expected Persian after-text, got: ${r[2]}`);
}
});
scenario('Simplified Chinese translation preserves split order', () => {
const text =
'加入我们的 Agorise Matrix 房间 {matrix_open}#agorise:matrix.org{matrix_close}。社区成员会给你一个 voucher 码。';
const r = splitOnPlaceholder(text, '{matrix_open}', '{matrix_close}');
expect(r[1], '#agorise:matrix.org');
if (!r[0].includes('房间')) {
throw new Error(`expected Chinese before-text, got: ${r[0]}`);
}
});
// ─── Graceful degradation ────────────────────────────────────
scenario('missing open token → [whole, "", ""]', () => {
const text = 'Translator dropped the open marker {close}#agorise:matrix.org';
const r = splitOnPlaceholder(text, '{open}', '{close}');
expect(r, [text, '', '']);
});
scenario('missing close token → [whole, "", ""]', () => {
const text = 'Translator dropped the close marker {open}#agorise:matrix.org';
const r = splitOnPlaceholder(text, '{open}', '{close}');
expect(r, [text, '', '']);
});
scenario('both tokens missing → [whole, "", ""]', () => {
const text = 'No placeholders at all in this string.';
const r = splitOnPlaceholder(text, '{open}', '{close}');
expect(r, [text, '', '']);
});
scenario('reversed order (close before open) → [whole, "", ""]', () => {
// Translator put the placeholders backwards.
const text = 'Backwards: {close}link{open} text.';
const r = splitOnPlaceholder(text, '{open}', '{close}');
expect(r, [text, '', '']);
});
scenario('open and close are the same position (impossible — close is open) → degraded', () => {
// Edge case where the open and close strings happen to be
// identical. indexOf finds the same index for both, which
// fails the `c <= o` guard (c === o).
const text = 'A{X}link{X}B';
const r = splitOnPlaceholder(text, '{X}', '{X}');
expect(r, [text, '', '']);
});
// ─── Edge cases ──────────────────────────────────────────────
scenario('empty input → ["", "", ""]', () => {
const r = splitOnPlaceholder('', '{open}', '{close}');
expect(r, ['', '', '']);
});
scenario('empty link text (adjacent tokens) → ["", "", "rest"]', () => {
const r = splitOnPlaceholder('{open}{close}rest', '{open}', '{close}');
expect(r, ['', '', 'rest']);
});
scenario('whole string is the link', () => {
const r = splitOnPlaceholder('{open}link{close}', '{open}', '{close}');
expect(r, ['', 'link', '']);
});
scenario('multi-character tokens with special regex chars', () => {
// indexOf is plain-string, not regex, so {} and others
// don't need escaping. This is intentional — we don't
// want translators thinking about regex.
const r = splitOnPlaceholder('a[[b]]c', '[[', ']]');
expect(r, ['a', 'b', 'c']);
});
scenario('token literal appears multiple times — uses first occurrence', () => {
// indexOf returns the first match. If a translator
// accidentally wrote the open token twice, we use the
// first; everything between the first open and the first
// close becomes the link text, even if that includes a
// stray duplicate token. This is the "least surprising"
// behavior: degraded but predictable.
const r = splitOnPlaceholder('A{open}first{open}second{close}B', '{open}', '{close}');
expect(r, ['A', 'first{open}second', 'B']);
});
scenario('close token appears before any open — returns degraded', () => {
// Real-world case: translator wrote
// "before {close}…{open}link{close} after"
// — the first {close} comes before any {open}.
const text = 'Pre-{close}garbage{open}link{close}-post';
const r = splitOnPlaceholder(text, '{open}', '{close}');
// First {close} is at index 4, first {open} is at index
// 18; c < o means our guard fires and we degrade. Good:
// trying to recover from this is ambiguous.
expect(r, [text, '', '']);
});
scenario('non-ASCII tokens work', () => {
// Hypothetical: someone uses Unicode placeholders.
const r = splitOnPlaceholder('hello «link» world', '«', '»');
expect(r, ['hello ', 'link', ' world']);
});
// ─── Idempotency / round-trip ────────────────────────────────
scenario('reassembling [before+token+linkText+token+after] === original', () => {
const original = 'one {open}two{close} three';
const [before, link, after] = splitOnPlaceholder(original, '{open}', '{close}');
const reassembled = `${before}{open}${link}{close}${after}`;
expect(reassembled, original);
});
console.log(
`\n${failures === 0 ? '✓ all' : '✗'} ${scenarios - failures}${failures === 0 ? '' : '/' + scenarios} scenarios passed`
);
process.exit(failures === 0 ? 0 : 1);