You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
188 lines
7.8 KiB
188 lines
7.8 KiB
import { describe, expect, it } from 'vitest';
|
|
import { ERROR_CODES, type InspectView } from '../dkc/index.ts';
|
|
import {
|
|
cliJSON,
|
|
displayText,
|
|
errorGloss,
|
|
escapeInvisible,
|
|
formatByteCount,
|
|
formatDateTime,
|
|
formatInteger,
|
|
formatRelative,
|
|
INSPECT_STEPS,
|
|
OPEN_STEPS_TIME_AND_KEY,
|
|
OPEN_STEPS_TIME_ONLY,
|
|
openStepGloss,
|
|
instantToEpochMs,
|
|
policyGloss,
|
|
printableText,
|
|
safeFileName,
|
|
stepGloss,
|
|
stepName,
|
|
viewerTimeZone,
|
|
} from './format.ts';
|
|
|
|
const te = new TextEncoder();
|
|
|
|
describe('steps and codes', () => {
|
|
it('names the eight steps as the CLI and explains each one', () => {
|
|
expect(INSPECT_STEPS.map(stepName)).toEqual([
|
|
'parse DKC1',
|
|
'prelude',
|
|
'public header',
|
|
'header validation',
|
|
'sealed control structure',
|
|
'payload structure',
|
|
'condition',
|
|
'tlock stanza',
|
|
]);
|
|
for (const s of INSPECT_STEPS) expect(stepGloss(s)).toMatch(/^\p{Lu}.*\.$/u);
|
|
expect(stepName(9)).toBe('step 9');
|
|
expect(stepGloss(9)).toBe('');
|
|
});
|
|
|
|
it('explains every check of the opening, step 17 included', () => {
|
|
const names = new Set([...OPEN_STEPS_TIME_ONLY, ...OPEN_STEPS_TIME_AND_KEY].map(([, name]) => name));
|
|
expect(names.size).toBe(11);
|
|
expect(names.has('open payload')).toBe(true);
|
|
const glosses = [...names].map(openStepGloss);
|
|
for (const g of glosses) expect(g).toMatch(/^\p{Lu}.*\.$/u);
|
|
expect(new Set(glosses).size).toBe(11);
|
|
expect(openStepGloss('tlock stanza')).toBe('');
|
|
// The same order as the reference, time_and_key with one more check.
|
|
expect(OPEN_STEPS_TIME_AND_KEY.filter(([, n]) => n !== 'access credential' && n !== 'open access layer')).toEqual(OPEN_STEPS_TIME_ONLY);
|
|
});
|
|
|
|
it('explains every normative code in Spanish', () => {
|
|
const glosses = ERROR_CODES.map(errorGloss);
|
|
for (const g of glosses) expect(g).toMatch(/^\p{Lu}.*\.$/u);
|
|
expect(new Set(glosses).size).toBe(ERROR_CODES.length);
|
|
});
|
|
|
|
it('explains the access policies', () => {
|
|
expect(policyGloss('time_only')).toMatch(/^Solo tiempo/);
|
|
expect(policyGloss('time_and_key')).toMatch(/\.dkk/);
|
|
expect(policyGloss('policy(7)')).toBe('Política no definida en V1.');
|
|
});
|
|
});
|
|
|
|
describe('text', () => {
|
|
it('replaces what is not printable in a file name', () => {
|
|
expect(safeFileName('informe 2026 — ñ.pdf')).toBe('informe 2026 — ñ.pdf');
|
|
expect(safeFileName('a\u202eb\tc\nd\u{1f600}')).toBe('a_b_c_d\u{1f600}');
|
|
});
|
|
|
|
it('shows printable UTF-8 as text', () => {
|
|
expect(printableText(te.encode('public label'))).toBe('public label');
|
|
expect(printableText(te.encode('línea 1\n\tlínea 2 — ok ✓'))).toBe('línea 1\n\tlínea 2 — ok ✓');
|
|
});
|
|
|
|
it('refuses invalid UTF-8 and every invisible or control character', () => {
|
|
expect(printableText(new Uint8Array([0xa2, 0x00]))).toBeUndefined();
|
|
expect(printableText(new Uint8Array([0xc0, 0x80]))).toBeUndefined();
|
|
for (const s of ['a\rb', 'a\u0000b', 'a\u007fb', 'a\u0085b', 'a\u00a0b', 'abc\u202edcba', 'a\u200db', '\ufeffabc', 'a\u2028b', 'a\ue000b']) {
|
|
expect(printableText(te.encode(s)), JSON.stringify(s)).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it('escapes identifiers that hold non-printable characters', () => {
|
|
expect(displayText('org.example.label')).toEqual({ text: 'org.example.label', escaped: false });
|
|
expect(displayText('ñandú.example')).toEqual({ text: 'ñandú.example', escaped: false });
|
|
expect(displayText('org.example\u202e.label')).toEqual({ text: '"org.example\\u202e.label"', escaped: true });
|
|
expect(displayText('a\u200bb')).toEqual({ text: '"a\\u200bb"', escaped: true });
|
|
expect(displayText('tab\there')).toEqual({ text: '"tab\\there"', escaped: true });
|
|
});
|
|
});
|
|
|
|
describe('escapeInvisible', () => {
|
|
const c = (...codes: number[]): string => String.fromCodePoint(...codes);
|
|
const bs = c(0x5c);
|
|
|
|
it('leaves printable text, line feeds and tabs alone', () => {
|
|
const s = 'capsule: profile "x" is not pinned\n\tnote: ñ ✓ 😀';
|
|
expect(escapeInvisible(s)).toBe(s);
|
|
});
|
|
|
|
it('writes bidi overrides, format and control characters as JSON escapes', () => {
|
|
expect(escapeInvisible(`extension org.example${c(0x202e)}.must v1`)).toBe(`extension org.example${bs}u202e.must v1`);
|
|
expect(escapeInvisible(`a${c(0x200d)}b${c(0xfeff)}${c(0x0d)}${c(0x85)}${c(0xa0)}`)).toBe(
|
|
`a${bs}u200db${bs}ufeff${bs}u000d${bs}u0085${bs}u00a0`,
|
|
);
|
|
expect(escapeInvisible(c(0xe0001))).toBe(`${bs}udb40${bs}udc01`);
|
|
});
|
|
|
|
it('keeps the CLI JSON valid and equivalent', () => {
|
|
const view = { file: `x${c(0x202e)}.dkc`, valid: true, checks: [] } as unknown as InspectView;
|
|
const shown = escapeInvisible(cliJSON(view));
|
|
expect(shown).not.toBe(cliJSON(view));
|
|
expect(JSON.parse(shown)).toEqual(view);
|
|
});
|
|
});
|
|
|
|
describe('numbers and dates', () => {
|
|
it('formats integers and byte counts in Spanish', () => {
|
|
expect(formatInteger(891)).toBe('891');
|
|
expect(formatInteger(78799)).toBe('78.799');
|
|
expect(formatInteger(1048576)).toBe('1.048.576');
|
|
expect(formatByteCount(1)).toBe('1 byte');
|
|
expect(formatByteCount(0)).toBe('0 bytes');
|
|
expect(formatByteCount(1234)).toBe('1.234 bytes');
|
|
});
|
|
|
|
it('converts instants to milliseconds within the range of Date', () => {
|
|
expect(instantToEpochMs({ seconds: 1692809364, nanos: 0 })).toBe(1692809364000);
|
|
expect(instantToEpochMs({ seconds: 1, nanos: 999_999_999 })).toBe(1999);
|
|
expect(instantToEpochMs({ seconds: 253402300799, nanos: 0 })).toBe(253402300799000);
|
|
expect(instantToEpochMs({ seconds: 8.64e12 + 1, nanos: 0 })).toBeUndefined();
|
|
});
|
|
|
|
it('writes long dates in UTC and in a given zone', () => {
|
|
const t = Date.UTC(2023, 7, 23, 16, 49, 24);
|
|
// The joiner of date and time depends on the ICU version: ", " or " a las ".
|
|
expect(formatDateTime(t, 'UTC')).toMatch(/^23 de agosto de 2023(, | a las )16:49:24 UTC$/);
|
|
expect(formatDateTime(t, 'Europe/Madrid')).toMatch(/^23 de agosto de 2023(, | a las )18:49:24 CEST$/);
|
|
expect(formatDateTime(Date.UTC(2023, 0, 5, 1, 2, 3), 'Europe/Madrid')).toMatch(/^5 de enero de 2023(, | a las )02:02:03 CET$/);
|
|
expect(formatDateTime(t)).toMatch(/^23 de agosto de 2023(, | a las )\d\d:\d\d:24 /);
|
|
expect(typeof viewerTimeZone()).toBe('string');
|
|
});
|
|
|
|
it('says how far a date is from now', () => {
|
|
const now = Date.UTC(2026, 8, 25, 12, 0, 0);
|
|
expect(formatRelative(Date.UTC(2023, 7, 23), now)).toBe('hace 3 años');
|
|
expect(formatRelative(Date.UTC(2027, 0, 1), now)).toBe('dentro de 3 meses');
|
|
expect(formatRelative(now + 86400_000, now)).toBe('mañana');
|
|
expect(formatRelative(now - 2 * 3600_000, now)).toBe('hace 2 horas');
|
|
expect(formatRelative(now + 30_000, now)).toBe('dentro de 30 segundos');
|
|
expect(formatRelative(now + 400, now)).toBe('ahora');
|
|
});
|
|
});
|
|
|
|
describe('cliJSON', () => {
|
|
const view = {
|
|
file: 'x.dkc',
|
|
capsule_id: '00',
|
|
valid: false,
|
|
error: 'ERR_EXTENSION_CRITICAL_UNKNOWN',
|
|
checks: [
|
|
{ step: 1, name: 'parse DKC1', ok: true, detail: 'magic DKC1' },
|
|
{ step: 4, name: 'header validation', ok: false, detail: 'extension <a>&b\u2028\u2029 "q" \\ \n\t\b\f\u0001: X', error: 'X' },
|
|
],
|
|
} as InspectView;
|
|
|
|
it('writes the JSON of the Go encoder with two-space indentation and a final newline', () => {
|
|
const out = cliJSON(view);
|
|
expect(out.endsWith('}\n')).toBe(true);
|
|
expect(out.split('\n').slice(0, 4)).toEqual(['{', ' "file": "x.dkc",', ' "capsule_id": "00",', ' "valid": false,']);
|
|
expect(out).toContain('\n "checks": [\n {\n "step": 1,\n');
|
|
expect(JSON.parse(out)).toEqual(view);
|
|
});
|
|
|
|
it('escapes HTML characters and line separators as Go does', () => {
|
|
const out = cliJSON(view);
|
|
expect(out).toContain(
|
|
'"detail": "extension \\u003ca\\u003e\\u0026b\\u2028\\u2029 \\"q\\" \\\\ \\n\\t\\b\\f\\u0001: X"',
|
|
);
|
|
expect(out).not.toMatch(/[<>&\u2028\u2029]/);
|
|
});
|
|
});
|