Files
noitu/web/tests/i18n.test.js
T
tiennm99 d5f89665d8 test(web): cover the difficulty-hint table and the last-difficulty setting
difficultyHints is walked against the Difficulty schema the same way
difficultyLabels already is, and the settings store gets coverage for
lastDifficulty: starts unset, persists across a reload, ignores a corrupt
stored value, and stays usable in memory when storage is hostile.
2026-09-21 16:59:39 +07:00

138 lines
4.5 KiB
JavaScript

// The Vietnamese copy is the only place prose lives, so these tests guard the
// two ways it can silently fall out of step with the wire contract: a new enum
// value with no message, and a placeholder no caller fills.
import { describe, expect, it } from 'vitest';
import {
Difficulty,
GameEndReason,
PointKind,
RejectReason,
RejectReasonSchema,
GameEndReasonSchema,
DifficultySchema,
PointKindSchema
} from '../src/lib/proto/noitu/v1/game_pb.js';
import {
difficultyHints,
difficultyLabels,
endReasonMessages,
errorFallback,
errorMessage,
fill,
pointKindLabels,
rejectMessage,
rejectMessages
} from '../src/lib/i18n/vi.js';
/**
* Enumerates a generated enum's numeric values from its schema rather than
* from the message table under test. Reading the table to check the table
* would pass no matter what is missing.
* @param {{ values: { number: number }[] }} schema
*/
function valuesOf(schema) {
return schema.values.map((v) => v.number);
}
describe('rejection messages', () => {
it('covers every RejectReason in the schema', () => {
for (const value of valuesOf(RejectReasonSchema)) {
expect(rejectMessages[value], `RejectReason ${value} has no Vietnamese message`).toBeTypeOf(
'string'
);
expect(rejectMessages[value].length).toBeGreaterThan(0);
}
});
it('fills the syllable placeholder for a wrong link', () => {
expect(rejectMessage(RejectReason.WRONG_LINK, 'sinh')).toBe(
'Từ phải bắt đầu bằng tiếng “sinh”.'
);
});
it('leaves no unfilled placeholder in any rendered message', () => {
for (const value of valuesOf(RejectReasonSchema)) {
expect(rejectMessage(value, 'sinh')).not.toMatch(/\{\w+\}/);
}
});
it('falls back to the unspecified message for a value this build predates', () => {
expect(rejectMessage(999, 'sinh')).toBe(rejectMessages[RejectReason.UNSPECIFIED]);
});
});
describe('end reason messages', () => {
it('covers every GameEndReason in the schema', () => {
for (const value of valuesOf(GameEndReasonSchema)) {
expect(endReasonMessages[value], `GameEndReason ${value} has no entry`).toBeTypeOf('string');
}
});
it('gives every real reason a non-empty message', () => {
// Only UNSPECIFIED is deliberately blank. Asserting the type alone would
// let a reason be silently emptied and still report green while the
// game-over screen explained nothing.
const real = valuesOf(GameEndReasonSchema).filter((v) => v !== GameEndReason.UNSPECIFIED);
for (const value of real) {
expect(endReasonMessages[value].length, `GameEndReason ${value} is blank`).toBeGreaterThan(0);
}
});
it('leaves the unspecified reason empty, so the screen shows only the result', () => {
expect(endReasonMessages[GameEndReason.UNSPECIFIED]).toBe('');
});
});
describe('point kind labels', () => {
it('names every scoring term except the unspecified one', () => {
const real = valuesOf(PointKindSchema).filter((v) => v !== PointKind.UNSPECIFIED);
expect(real.length).toBeGreaterThan(0);
for (const value of real) {
expect(pointKindLabels[value], `PointKind ${value} has no label`).toBeTypeOf('string');
expect(pointKindLabels[value].length).toBeGreaterThan(0);
}
});
it('carries no label for the unspecified kind, which never reaches a player', () => {
expect(pointKindLabels[PointKind.UNSPECIFIED]).toBeUndefined();
});
});
describe('difficulty labels', () => {
it('names every playable difficulty', () => {
const playable = valuesOf(DifficultySchema).filter((v) => v !== Difficulty.UNSPECIFIED);
for (const value of playable) {
expect(difficultyLabels[value], `Difficulty ${value} has no label`).toBeTypeOf('string');
}
});
it('gives every playable difficulty a hint for the record line to fall back on', () => {
const playable = valuesOf(DifficultySchema).filter((v) => v !== Difficulty.UNSPECIFIED);
for (const value of playable) {
expect(difficultyHints[value], `Difficulty ${value} has no hint`).toBeTypeOf('string');
expect(difficultyHints[value].length).toBeGreaterThan(0);
}
});
});
describe('server error codes', () => {
it('translates a known code', () => {
expect(errorMessage('room_not_found')).toBe('Không tìm thấy phòng với mã này.');
});
it('never leaks an unknown key into the interface', () => {
expect(errorMessage('a_code_added_after_this_build')).toBe(errorFallback);
});
});
describe('fill', () => {
it('leaves a placeholder alone when no value was supplied', () => {
expect(fill('xin chào {name}')).toBe('xin chào {name}');
});
it('replaces every occurrence', () => {
expect(fill('{a} và {a}', { a: 'x' })).toBe('x và x');
});
});