mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 03:13:45 +00:00
feat(web): show each chain word's meaning, newest open, click to toggle
open set is client-only state; every word is a button; `Chưa có nghĩa` for a word without one.
This commit is contained in:
1 parent
9e74f6f959
commit
e652f7764d
7 files changed
+292
-10
No files matched your search
@@ -1,9 +1,10 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { ONE_SYLLABLE_WORD, UNKNOWN_WORD } from './fixture-dictionary.js';
|
||||
import { ONE_SYLLABLE_WORD, UNKNOWN_WORD, renderedSense } from './fixture-dictionary.js';
|
||||
import {
|
||||
board,
|
||||
chainWords,
|
||||
chooseDifficulty,
|
||||
openMeanings,
|
||||
playLegalMove,
|
||||
setNickname,
|
||||
submitWord,
|
||||
@@ -59,6 +60,36 @@ test.describe('playing the bot', () => {
|
||||
expect(words[words.length - 1]).toBe(opening);
|
||||
});
|
||||
|
||||
test('the newest word shows its meaning, and a click toggles any word', async ({ page }) => {
|
||||
await page.goto('/play?difficulty=1');
|
||||
await waitForMyTurn(page);
|
||||
|
||||
// The opening word is the newest word there is, so it is open, and the
|
||||
// panel shows the fixture's sense for it as `(pos) gloss`. Which
|
||||
// opening is drawn varies, so the expectation is read from the list.
|
||||
const [opening] = await chainWords(page);
|
||||
expect(await openMeanings(page)).toEqual([opening]);
|
||||
const sense = renderedSense(opening);
|
||||
expect(sense, `${opening} has no fixture meaning`).toBeDefined();
|
||||
await expect(page.locator('.meanings li').first()).toHaveText(sense ?? '');
|
||||
await expect(page.locator('.meanings li').first()).toHaveText(/^\([^)]+\) /);
|
||||
|
||||
await playLegalMove(page, new Set([opening]));
|
||||
await waitForMyTurn(page);
|
||||
|
||||
// The bot's reply is the newest word now; mine and the opening closed
|
||||
// as each was overtaken.
|
||||
const [newest] = await chainWords(page);
|
||||
expect(await openMeanings(page)).toEqual([newest]);
|
||||
|
||||
// Clicking reopens the opening word without closing the newest, and
|
||||
// clicking the newest closes it.
|
||||
await page.getByRole('button', { name: `Xem nghĩa của ${opening}` }).click();
|
||||
expect((await openMeanings(page)).sort()).toEqual([newest, opening].sort());
|
||||
await page.getByRole('button', { name: `Ẩn nghĩa của ${newest}` }).click();
|
||||
expect(await openMeanings(page)).toEqual([opening]);
|
||||
});
|
||||
|
||||
test('the score rises and the board shows both sides', async ({ page }) => {
|
||||
await page.goto('/play?difficulty=1');
|
||||
await waitForMyTurn(page);
|
||||
|
||||
@@ -183,3 +183,13 @@ export async function setNickname(page, name) {
|
||||
export async function chainWords(page) {
|
||||
return page.locator('ol li .word').allTextContents();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the words whose meaning panel is open.
|
||||
*
|
||||
* @param {import('@playwright/test').Page} page
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
export async function openMeanings(page) {
|
||||
return page.locator('ol li:has(> .meanings) > .word').allTextContents();
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
board,
|
||||
chainWords,
|
||||
chat,
|
||||
openMeanings,
|
||||
playLegalMove,
|
||||
readyAndStart,
|
||||
say,
|
||||
@@ -556,3 +557,23 @@ test.describe('playing a stranger', () => {
|
||||
await close();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('word meanings', () => {
|
||||
test('both players see the newest word open', async ({ browser }) => {
|
||||
const { lead, second, close } = await playingPair(browser);
|
||||
|
||||
const [opening] = await chainWords(lead);
|
||||
expect(await openMeanings(lead)).toEqual([opening]);
|
||||
expect(await openMeanings(second)).toEqual([opening]);
|
||||
|
||||
const played = await playLegalMove(lead, new Set([opening]));
|
||||
await expect(second.locator('ol li .word').first()).toHaveText(played);
|
||||
|
||||
// The same word is open on both sides: the rule lives in the client
|
||||
// store and both clients received the same move.
|
||||
expect(await openMeanings(lead)).toEqual([played]);
|
||||
expect(await openMeanings(second)).toEqual([played]);
|
||||
|
||||
await close();
|
||||
});
|
||||
});
|
||||
@@ -23,10 +23,24 @@
|
||||
{#if game.state.chain.length === 0}
|
||||
<p class="empty">{t.chainEmpty}</p>
|
||||
{:else}
|
||||
<ol bind:this={list}>
|
||||
<ol class="rows" bind:this={list}>
|
||||
{#each entries as entry, index}
|
||||
<!-- The panel id comes from the row's place in the chain, not the
|
||||
word, so two rows can never share one. -->
|
||||
{@const open = game.isExpanded(entry.word)}
|
||||
{@const panelId = `meaning-${entries.length - 1 - index}`}
|
||||
<li class:mine={entry.byMe} class:opening={entry.opening} class:latest={index === 0}>
|
||||
<span class="word">{entry.word}</span>
|
||||
<!-- Every word is a button, with or without a definition, so the
|
||||
chain behaves the same for all of them. Never focused from
|
||||
here: the word input keeps focus while a player types. -->
|
||||
<button
|
||||
class="word"
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? panelId : undefined}
|
||||
aria-label={fill(open ? t.meaningHide : t.meaningShow, { word: entry.word })}
|
||||
onclick={() => game.toggleMeaning(entry.word)}>{entry.word}</button
|
||||
>
|
||||
<!-- Who played it, not merely whether it was mine: a chain
|
||||
four people built is unreadable without the names. -->
|
||||
{#if !entry.opening && !entry.byMe && game.nameOf(entry.playerId)}
|
||||
@@ -47,6 +61,19 @@
|
||||
{fill(t.correctedFrom, { typed: entry.typed, word: entry.word })}
|
||||
</span>
|
||||
{/if}
|
||||
{#if open}
|
||||
<!-- Plain text from the server, rendered as text: the builder
|
||||
stripped the wiki markup and nothing here re-interprets it. -->
|
||||
{#if entry.meanings.length}
|
||||
<ol class="meanings" id={panelId}>
|
||||
{#each entry.meanings as sense}
|
||||
<li>{sense.pos ? `(${sense.pos}) ` : ''}{sense.gloss}</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{:else}
|
||||
<p class="meanings none" id={panelId}>{t.meaningNone}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
@@ -74,7 +101,9 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
ol {
|
||||
/* Row rules are scoped to the outer list: the meanings list nested in a
|
||||
row is an <ol> of <li> too and must not inherit the card styling. */
|
||||
.rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
@@ -84,7 +113,7 @@
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
li {
|
||||
.rows > li {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
@@ -95,23 +124,58 @@
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
li.mine {
|
||||
.rows > li.mine {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
li.opening {
|
||||
.rows > li.opening {
|
||||
border-style: dashed;
|
||||
background: var(--surface-alt);
|
||||
}
|
||||
|
||||
li.latest {
|
||||
.rows > li.latest {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.word {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.word:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.word:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.meanings {
|
||||
flex-basis: 100%;
|
||||
margin: 2px 0 0;
|
||||
padding-left: 1.4em;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
ol.meanings {
|
||||
list-style: decimal;
|
||||
}
|
||||
|
||||
.meanings.none {
|
||||
padding-left: 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.meta {
|
||||
|
||||
@@ -31,6 +31,9 @@ export const t = {
|
||||
submit: 'Gửi',
|
||||
chainTitle: 'Chuỗi từ',
|
||||
chainEmpty: 'Chưa có từ nào.',
|
||||
meaningShow: 'Xem nghĩa của {word}',
|
||||
meaningHide: 'Ẩn nghĩa của {word}',
|
||||
meaningNone: 'Chưa có nghĩa',
|
||||
you: 'Bạn',
|
||||
opponent: 'Đối thủ',
|
||||
someone: 'Người chơi',
|
||||
|
||||
@@ -20,6 +20,12 @@ export const CHAT_WINDOW = 20;
|
||||
* @property {number} points
|
||||
* @property {number} syllables
|
||||
* @property {boolean} opening - the seed word, played by neither side
|
||||
* @property {Sense[]} meanings - what the word means, at most five; empty when
|
||||
* the dictionary has none
|
||||
*
|
||||
* @typedef {object} Sense
|
||||
* @property {string} pos - Vietnamese part-of-speech label, empty when unknown
|
||||
* @property {string} gloss - the definition, plain text
|
||||
*
|
||||
* @typedef {object} PlayerSlot
|
||||
* @property {string} playerId
|
||||
@@ -56,6 +62,16 @@ function initialState() {
|
||||
phase: 'idle',
|
||||
/** @type {ChainEntry[]} */
|
||||
chain: [],
|
||||
/**
|
||||
* Words in the chain whose meaning is open. Client-only state, like the
|
||||
* theme: the newest word opens on arrival and closes the one before it,
|
||||
* and a click toggles any word, so any number may be open at once. A
|
||||
* list with set semantics rather than a Set, because $state proxies
|
||||
* arrays and not Sets.
|
||||
*
|
||||
* @type {string[]}
|
||||
*/
|
||||
expanded: [],
|
||||
currentSyllable: '',
|
||||
myTurn: false,
|
||||
deadlineMs: 0,
|
||||
@@ -153,6 +169,16 @@ function initialState() {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a word's senses off the wire.
|
||||
*
|
||||
* @param {any[] | undefined} senses
|
||||
* @returns {Sense[]}
|
||||
*/
|
||||
function toSenses(senses) {
|
||||
return (senses ?? []).map((/** @type {any} */ s) => ({ pos: s.pos, gloss: s.gloss }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads one PlayerScore off the wire.
|
||||
*
|
||||
@@ -267,9 +293,12 @@ export function createGameStore() {
|
||||
playerId: '',
|
||||
points: 0,
|
||||
syllables: 0,
|
||||
opening: true
|
||||
opening: true,
|
||||
meanings: toSenses(value.openingMeanings)
|
||||
}
|
||||
];
|
||||
// The opening word is the newest word there is.
|
||||
state.expanded = [value.openingWord];
|
||||
state.currentSyllable = value.currentSyllable;
|
||||
state.myTurn = value.myTurn;
|
||||
state.deadlineMs = Number(value.deadlineUnixMs);
|
||||
@@ -286,6 +315,9 @@ export function createGameStore() {
|
||||
// on: the syllable and the chain survive the player who could
|
||||
// not answer them, so there is nothing to append.
|
||||
if (played) {
|
||||
// The newest word takes over the open panel from the one
|
||||
// before it. Words the player opened by hand stay open.
|
||||
const previous = state.chain[state.chain.length - 1]?.word;
|
||||
state.chain.push({
|
||||
word: played.word,
|
||||
typed: played.typed,
|
||||
@@ -293,8 +325,11 @@ export function createGameStore() {
|
||||
playerId: played.playerId,
|
||||
points: played.points,
|
||||
syllables: played.syllables,
|
||||
opening: false
|
||||
opening: false,
|
||||
meanings: toSenses(played.meanings)
|
||||
});
|
||||
state.expanded = state.expanded.filter((/** @type {string} */ w) => w !== previous);
|
||||
if (!state.expanded.includes(played.word)) state.expanded.push(played.word);
|
||||
}
|
||||
state.currentSyllable = value.currentSyllable;
|
||||
state.myTurn = value.myTurn;
|
||||
@@ -495,6 +530,28 @@ export function createGameStore() {
|
||||
clearError() {
|
||||
state.error = null;
|
||||
},
|
||||
/**
|
||||
* Whether a word's meaning panel is open.
|
||||
*
|
||||
* @param {string} word
|
||||
*/
|
||||
isExpanded(word) {
|
||||
return state.expanded.includes(word);
|
||||
},
|
||||
/**
|
||||
* Opens a closed word's meaning or closes an open one. Every word in the
|
||||
* chain toggles, with or without a definition, so the chain behaves
|
||||
* the same for all of them.
|
||||
*
|
||||
* @param {string} word
|
||||
*/
|
||||
toggleMeaning(word) {
|
||||
if (state.expanded.includes(word)) {
|
||||
state.expanded = state.expanded.filter((/** @type {string} */ w) => w !== word);
|
||||
} else {
|
||||
state.expanded.push(word);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Forgets the conversation without forgetting the room. The screen
|
||||
* calls this when it is entered and left: chat survives reset() so a
|
||||
|
||||
@@ -172,6 +172,102 @@ describe('turnUpdate', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('meanings', () => {
|
||||
/** A move by p1 with optional senses, seq counting from 2. */
|
||||
function move(word, seq, meanings = []) {
|
||||
return msg('turnUpdate', {
|
||||
played: { word, byMe: true, points: 2, syllables: 2, typed: word, playerId: 'p1', meanings },
|
||||
currentSyllable: word.split(' ').at(-1),
|
||||
myTurn: false,
|
||||
deadlineUnixMs: 1_700_000_040_000n,
|
||||
turnSeq: seq,
|
||||
chainLength: seq,
|
||||
players: table(),
|
||||
turnPlayerId: 'p2'
|
||||
});
|
||||
}
|
||||
|
||||
it('carries the senses off the wire, label and gloss apart', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(
|
||||
started({ openingMeanings: [{ pos: 'danh từ', gloss: 'Người học ở trường phổ thông.' }] })
|
||||
);
|
||||
store.apply(move('sinh viên', 2, [{ pos: '', gloss: 'Người học đại học.' }]));
|
||||
|
||||
expect(store.state.chain[0].meanings).toEqual([
|
||||
{ pos: 'danh từ', gloss: 'Người học ở trường phổ thông.' }
|
||||
]);
|
||||
expect(store.state.chain[1].meanings).toEqual([{ pos: '', gloss: 'Người học đại học.' }]);
|
||||
});
|
||||
|
||||
it('opens the opening word when the game starts', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(started());
|
||||
expect(store.state.expanded).toEqual(['học sinh']);
|
||||
expect(store.isExpanded('học sinh')).toBe(true);
|
||||
});
|
||||
|
||||
it('opens the new word and closes the previous newest', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(started());
|
||||
store.apply(move('sinh viên', 2));
|
||||
expect(store.state.expanded).toEqual(['sinh viên']);
|
||||
store.apply(move('viên chức', 3));
|
||||
expect(store.state.expanded).toEqual(['viên chức']);
|
||||
});
|
||||
|
||||
it('opens a word without a definition the same way', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(started());
|
||||
store.apply(move('sinh viên', 2, []));
|
||||
expect(store.state.chain[1].meanings).toEqual([]);
|
||||
expect(store.isExpanded('sinh viên')).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves the open set alone when an elimination moves the turn', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(started());
|
||||
store.apply(
|
||||
msg('turnUpdate', {
|
||||
currentSyllable: 'sinh',
|
||||
myTurn: true,
|
||||
deadlineUnixMs: 1_700_000_040_000n,
|
||||
turnSeq: 2,
|
||||
chainLength: 1,
|
||||
players: table(),
|
||||
turnPlayerId: 'p1'
|
||||
})
|
||||
);
|
||||
expect(store.state.expanded).toEqual(['học sinh']);
|
||||
});
|
||||
|
||||
it('toggles any word, and two can be open together', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(started());
|
||||
store.apply(move('sinh viên', 2));
|
||||
|
||||
store.toggleMeaning('học sinh');
|
||||
expect(store.state.expanded.sort()).toEqual(['học sinh', 'sinh viên']);
|
||||
store.toggleMeaning('sinh viên');
|
||||
expect(store.state.expanded).toEqual(['học sinh']);
|
||||
store.toggleMeaning('học sinh');
|
||||
expect(store.state.expanded).toEqual([]);
|
||||
});
|
||||
|
||||
it('is cleared by reset, and a resumed session ends with one word open', () => {
|
||||
const store = createGameStore();
|
||||
store.apply(started());
|
||||
store.toggleMeaning('sinh viên');
|
||||
store.reset();
|
||||
expect(store.state.expanded).toEqual([]);
|
||||
|
||||
// A resume replays the opening and then the last move.
|
||||
store.apply(started());
|
||||
store.apply(move('sinh viên', 2));
|
||||
expect(store.state.expanded).toEqual(['sinh viên']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('moveRejected', () => {
|
||||
it('renders the specific Vietnamese reason', () => {
|
||||
const store = createGameStore();
|
||||
|
||||
Reference in new issue
Block a user