mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 03:13:45 +00:00
The chain shows each word's score breakdown as small chips beside the total. A "Bí từ" button next to the input claims a dead end on the player's own turn, armed the same way resign is; a false claim is answered inline near the input rather than in the general error banner. A rejection for a word not in the dictionary offers the one real word a diacritic typo differs from, filling and focusing the field on click, and a button to report the word as real, acknowledged with a confirmation once the server has heard it.
175 lines
7.2 KiB
JavaScript
175 lines
7.2 KiB
JavaScript
// Cross-language wire check.
|
|
//
|
|
// These fixtures are binary messages produced by the Go server's own test
|
|
// suite (server/internal/wsapi/wire_test.go, regenerated with `-update`).
|
|
// Decoding them here proves the two generated clients agree on the actual
|
|
// bytes — a JS-only round trip would only prove this runtime is
|
|
// self-consistent, which is exactly the failure mode that ships a broken
|
|
// client.
|
|
|
|
import { readFileSync, readdirSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { describe, expect, it } from 'vitest';
|
|
import { PROTOCOL_VERSION } from '../src/lib/ws/messages.js';
|
|
import { fromBinary } from '@bufbuild/protobuf';
|
|
import {
|
|
ClientMessageSchema,
|
|
GameEndReason,
|
|
RejectReason,
|
|
ServerMessageSchema
|
|
} from '../src/lib/proto/noitu/v1/game_pb.js';
|
|
|
|
const fixtureDir = fileURLToPath(new URL('../../proto/testdata', import.meta.url));
|
|
|
|
const fixtures = readdirSync(fixtureDir).filter((f) => f.endsWith('.bin'));
|
|
|
|
/** Decode a fixture by name, choosing the schema from its client_/server_ prefix. */
|
|
function decode(name) {
|
|
const bytes = readFileSync(join(fixtureDir, `${name}.bin`));
|
|
const schema = name.startsWith('client_') ? ClientMessageSchema : ServerMessageSchema;
|
|
return fromBinary(schema, bytes);
|
|
}
|
|
|
|
describe('generated wire types', () => {
|
|
it('finds the fixtures the Go suite emits', () => {
|
|
expect(fixtures.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
// Every arm must decode to a *named* case. An arm whose payload is empty on
|
|
// the wire (create_room, resign) still has to be distinguishable, so an
|
|
// undefined case here means the oneof tag was lost, not that the message
|
|
// was empty.
|
|
it.each(fixtures)('decodes %s to a known payload case', (file) => {
|
|
const msg = decode(file.replace(/\.bin$/, ''));
|
|
expect(msg.payload.case).toBeTypeOf('string');
|
|
});
|
|
|
|
it('preserves Vietnamese diacritics through UTF-8', () => {
|
|
const msg = decode('server_welcome');
|
|
expect(msg.payload.case).toBe('welcome');
|
|
expect(msg.payload.value.acceptedNickname).toBe('Người chơi ẩn danh');
|
|
expect(msg.payload.value.protocolVersion).toBe(PROTOCOL_VERSION);
|
|
});
|
|
|
|
// int64 is a bigint in this runtime. Reading it as a Number would silently
|
|
// lose precision on timestamps far smaller than the ones a real deadline
|
|
// carries, so the type itself is the assertion.
|
|
it('reads int64 timestamps as bigint', () => {
|
|
const msg = decode('server_pong');
|
|
expect(msg.payload.case).toBe('pong');
|
|
expect(msg.payload.value.clientTimeMs).toBe(1756998000123n);
|
|
expect(msg.payload.value.serverTimeMs).toBe(1756998000456n);
|
|
});
|
|
|
|
it('agrees with Go on enum numbering', () => {
|
|
const rejected = decode('server_move_rejected');
|
|
expect(rejected.payload.case).toBe('moveRejected');
|
|
expect(rejected.payload.value.reason).toBe(RejectReason.WRONG_LINK);
|
|
expect(rejected.payload.value.word).toBe('cà phê');
|
|
|
|
const over = decode('server_game_over');
|
|
expect(over.payload.case).toBe('gameOver');
|
|
expect(over.payload.value.reason).toBe(GameEndReason.NO_LEGAL_MOVE);
|
|
expect(over.payload.value.iWon).toBe(false);
|
|
// The final table, in the order the server ranked it.
|
|
expect(over.payload.value.standings.map((/** @type {any} */ p) => p.rank)).toEqual([1, 2, 3]);
|
|
|
|
const out = decode('server_player_eliminated');
|
|
// The only repeated string in the contract, and the one the losing
|
|
// player's screen is built from.
|
|
expect(out.payload.value.suggestions).toEqual(['sinh viên', 'sinh sôi']);
|
|
|
|
const chat = decode('server_chat_message');
|
|
expect(chat.payload.case).toBe('chatMessage');
|
|
expect(chat.payload.value.text).toBe('Tiếng “sinh” khó nối lắm.');
|
|
// int64, which this runtime hands over as a bigint. Anything that
|
|
// forgets to convert it fails here rather than as NaN on a screen.
|
|
expect(typeof chat.payload.value.sentUnixMs).toBe('bigint');
|
|
|
|
const history = decode('server_chat_history');
|
|
expect(history.payload.case).toBe('chatHistory');
|
|
const messages = history.payload.value.messages;
|
|
expect(messages).toHaveLength(3);
|
|
expect(messages[0].fromMe).toBe(true);
|
|
expect(messages[1].fromMe).toBe(false);
|
|
// An author who has left the room carries no name, which is how the
|
|
// client knows to label the line rather than attribute it.
|
|
expect(messages[2].author).toBe('');
|
|
});
|
|
|
|
// The canonical/typed pair is what lets the UI show that the server
|
|
// corrected a spelling instead of appearing to rewrite the player's text.
|
|
it('carries both the canonical word and the raw input', () => {
|
|
const msg = decode('server_turn_update');
|
|
expect(msg.payload.case).toBe('turnUpdate');
|
|
const turn = msg.payload.value;
|
|
expect(turn.played.word).toBe('bình yên');
|
|
expect(turn.played.typed).toBe('binh yên');
|
|
expect(turn.played.byMe).toBe(false);
|
|
expect(turn.currentSyllable).toBe('yên');
|
|
expect(turn.deadlineUnixMs).toBe(1756998040000n);
|
|
expect(turn.turnSeq).toBe(2);
|
|
});
|
|
|
|
// The label and the definition travel as two fields, so the client decides
|
|
// how a label is shown and never has to parse it back out of the text.
|
|
it('carries a word\'s senses on the opening and on each move', () => {
|
|
const start = decode('server_game_started');
|
|
expect(start.payload.value.openingMeanings).toEqual([
|
|
expect.objectContaining({ pos: 'danh từ', gloss: 'Tình trạng không có chiến tranh.' })
|
|
]);
|
|
|
|
const turn = decode('server_turn_update');
|
|
const senses = turn.payload.value.played.meanings;
|
|
expect(senses).toHaveLength(2);
|
|
expect(senses[0].pos).toBe('tính từ');
|
|
expect(senses[1].pos).toBe('');
|
|
expect(senses[1].gloss).toBe('Xem bình an.');
|
|
});
|
|
|
|
it('decodes an empty client arm without losing its tag', () => {
|
|
const msg = decode('client_create_room');
|
|
expect(msg.payload.case).toBe('createRoom');
|
|
});
|
|
|
|
// A word's score breakdown must sum to the same total the player sees next
|
|
// to it — the invariant the client relies on to draw the chips at all.
|
|
it('carries a score breakdown that sums to the total', () => {
|
|
const turn = decode('server_turn_update');
|
|
const parts = turn.payload.value.played.parts;
|
|
expect(parts.length).toBeGreaterThan(0);
|
|
const sum = parts.reduce((total, /** @type {any} */ p) => total + p.value, 0);
|
|
expect(sum).toBe(turn.payload.value.played.points);
|
|
});
|
|
|
|
it('carries a near-miss suggestion for a diacritic typo', () => {
|
|
const rejected = decode('server_move_rejected_near_miss');
|
|
expect(rejected.payload.case).toBe('moveRejected');
|
|
expect(rejected.payload.value.reason).toBe(RejectReason.NOT_IN_DICTIONARY);
|
|
expect(rejected.payload.value.suggestion).toBe('bình yên');
|
|
});
|
|
|
|
it('carries no suggestion when a rejection has none', () => {
|
|
const rejected = decode('server_move_rejected');
|
|
expect(rejected.payload.value.suggestion).toBe('');
|
|
});
|
|
|
|
it('echoes a reported word', () => {
|
|
const reported = decode('server_word_reported');
|
|
expect(reported.payload.case).toBe('wordReported');
|
|
expect(reported.payload.value.word).toBe('bình tâm');
|
|
});
|
|
|
|
it('decodes an empty client claim', () => {
|
|
const claim = decode('client_claim_dead_end');
|
|
expect(claim.payload.case).toBe('claimDeadEnd');
|
|
});
|
|
|
|
it('carries the word a client reports', () => {
|
|
const report = decode('client_report_word');
|
|
expect(report.payload.case).toBe('reportWord');
|
|
expect(report.payload.value.word).toBe('bình tâm');
|
|
});
|
|
});
|