morphit/apps/web/scripts/display-name-cap-smoke.ts

112 lines
4 KiB
TypeScript

#!/usr/bin/env tsx
/**
* display-name-cap-smoke (cp404).
*
* Ken set the display-name cap to 24 code points. This locks:
* - DISPLAY_NAME_MAX_LENGTH === 24,
* - validateDisplayName() rejects a 25-codepoint name and accepts 24,
* - capDisplayName() truncates legacy over-long names by CODE POINT
* (emoji count as one), leaving short names untouched,
* - profileProps runs stored display_name through the cap (so every
* order card / identity label renders inside the limit),
* - the too_long i18n message no longer says "40" in any locale.
*/
import { readFileSync, readdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import {
DISPLAY_NAME_MAX_LENGTH,
capDisplayName,
validateDisplayName
} from '../src/lib/crypto/profile';
import { extractLabelPropsFromProfile } from '../src/lib/indexer/profileProps';
let pass = 0;
let fail = 0;
const ok = (m: string) => {
pass++;
console.log(` \u2713 ${m}`);
};
const bad = (m: string, d = '') => {
fail++;
console.log(` \u2717 ${m}`);
if (d) console.log(` ${d}`);
};
// 1. Cap constant.
if (DISPLAY_NAME_MAX_LENGTH === 24) ok('1 DISPLAY_NAME_MAX_LENGTH is 24');
else bad('1 cap constant', String(DISPLAY_NAME_MAX_LENGTH));
// 2. validateDisplayName rejects 25, accepts 24.
{
const n25 = 'a'.repeat(25);
const n24 = 'a'.repeat(24);
const r25 = validateDisplayName(n25);
const r24 = validateDisplayName(n24);
if (!r25.ok && r25.reasonKey.includes('too_long')) ok('2 rejects a 25-codepoint name (too_long)');
else bad('2 should reject 25', JSON.stringify(r25));
if (r24.ok) ok('2b accepts a 24-codepoint name');
else bad('2b should accept 24', JSON.stringify(r24));
}
// 3. capDisplayName truncates by code point.
{
const long = 'x'.repeat(30);
const capped = capDisplayName(long);
if ([...capped].length === 24) ok('3 caps a 30-char name to 24 code points');
else bad('3 cap length', `${[...capped].length}`);
}
// 4. Short names untouched; null/empty → ''.
{
if (capDisplayName('Alice') === 'Alice') ok('4 leaves a short name untouched');
else bad('4 short untouched');
if (capDisplayName(null) === '' && capDisplayName('') === '') ok("4b null/empty → ''");
else bad('4b empty guard');
}
// 5. Emoji count as one code point (astral pair not double-counted).
{
// 24 emoji = 24 code points (48 UTF-16 units) — must NOT be truncated.
const emoji = '\u{1F600}'.repeat(24);
const capped = capDisplayName(emoji);
if ([...capped].length === 24) ok('5 24 emoji (48 UTF-16 units) kept whole (code-point aware)');
else bad('5 emoji cap', `${[...capped].length}`);
// 30 emoji → truncated to 24.
if ([...capDisplayName('\u{1F600}'.repeat(30))].length === 24) ok('5b 30 emoji → 24');
else bad('5b emoji truncate');
}
// 6. profileProps caps a stored over-long display_name.
{
const props = extractLabelPropsFromProfile({
display_name: 'z'.repeat(50),
json_metadata: null
} as unknown as Parameters<typeof extractLabelPropsFromProfile>[0]);
if (props.displayName && [...props.displayName].length === 24)
ok('6 profileProps caps stored display_name to 24');
else bad('6 profileProps cap', String(props.displayName));
}
// 7. No locale's too_long message still says "40".
{
const dir = fileURLToPath(new URL('../src/lib/i18n/locales', import.meta.url));
const files = readdirSync(dir).filter((f) => f.endsWith('.json'));
const offenders: string[] = [];
for (const f of files) {
const j = JSON.parse(readFileSync(`${dir}/${f}`, 'utf-8'));
const msg: string | undefined = j?.profile?.display_name?.errors?.too_long;
// "40" (ASCII) or "۴۰" (Persian) must be gone.
if (msg && (msg.includes('40') || msg.includes('\u06F4\u06F0'))) offenders.push(f);
}
if (offenders.length === 0) ok(`7 no locale's too_long message says 40 (${files.length} locales)`);
else bad('7 stale "40" in too_long', offenders.join(', '));
}
console.log('');
console.log(`${pass} passed, ${fail} failed`);
if (fail > 0) {
console.log('\u2717 display-name-cap smoke FAILED');
process.exit(1);
}
console.log(`\u2713 all ${pass} display-name-cap scenarios passed`);