feedback7 UIBACK: the match's top bar lists the spectator seats — a 观战席 capsule (eye + count) beside the latency opens the roster, the host removes a spectator with its ✕ while the match runs (PR #120 by @salt-fishes, the part the owner accepted: the cap stays 2, no per-room spectator option); the server already took room.removeSpectator at any time — test/match/lobby-integration.test.js now covers the host removing one mid-match (room.closed kicked, the other spectator and the host told, no frame after it, the seat refilled), test/ui/spectator-roster.e2e.test.js the capsule; one new UI string in the four packs; the dev mock seats spectators on ?variant=spectators; golden: unchanged (client only)

This commit is contained in:
sganggs committed 2026-10-08 19:19:09 +08:00
1 parent 7fb41f4ff6
commit d7c665dbf6
13 files changed
+273 -9

No files matched your search

+63
View File
@@ -202,6 +202,69 @@ test('co-op spectator over websockets: watches the real match like an eliminated
assert.deepEqual(errors, []);
});
// GitHub #120 (PR #120 by @salt-fishes — the in-match 观战席 list, ui/hud.js SpectatorPill): the host frees a spectator seat WHILE
// the match runs. The server took room.removeSpectator at any time (server/lobby.js removeSpectator; its header says "any
// time") but the lobby tests only removed one in the lobby: this is the running match.
test('co-op spectator removed by the host while the match runs: room.closed {kicked}, the seat freed and refilled, no frame after it, the other spectator watches on', async () => {
FakeBattle.reset();
FakeBattle.script = () => ({ duration: 1 });
const watcher = async (name) => {
const srvNow = await server();
const c = await TestClient.connect(`ws://127.0.0.1:${srvNow.port}/ws`);
clients.push(c);
const w = await c.hello(name);
c.id = w.playerId;
c.token = w.token;
return c;
};
const a = await player('A');
await ok(a, { t: 'room.create', mode: 'coop', difficulty: 'NORMAL' });
const st = await a.waitFor('room.state');
await ok(a, { t: 'room.addBot' });
const s1 = await watcher('Spec1');
const s2 = await watcher('Spec2');
await ok(s1, { t: 'room.spectate', code: st.code });
await ok(s2, { t: 'room.spectate', code: st.code });
await ok(a, { t: 'room.start' });
await s1.waitFor('m.public', (p) => p.phase === 'INFO_CHECK');
await ok(a, { t: 'g.infoReady' });
await pickCoopBands([a]);
await a.waitFor('m.public', (p) => p.phase === 'PREP' && p.round === 1, 10000);
const running = await a.waitFor('room.state', (x) => x.inMatch && x.spectators.length === 2);
assert.deepEqual(running.spectators.map((x) => x.playerId), [s1.id, s2.id], 'both seats are listed while the match runs: what the game screen\'s capsule shows');
// who may remove: the host only, and only a spectator of this room
assert.equal((await s1.request({ t: 'room.removeSpectator', playerId: s2.id })).code, 'NOT_HOST', 'a spectator cannot remove another');
assert.equal((await a.request({ t: 'room.removeSpectator', playerId: 'nobody' })).code, 'BAD_TARGET');
assert.equal((await a.request({ t: 'room.removeSpectator', playerId: a.id })).code, 'BAD_TARGET', 'a player is no spectator');
// the host removes the first one: told room.closed {kicked}; the room.state of the match now lists the other only
await ok(a, { t: 'room.removeSpectator', playerId: s1.id });
assert.equal((await s1.waitFor('room.closed', () => true, 5000)).reason, 'kicked');
const after = await a.waitFor('room.state', (x) => x.inMatch && x.spectators.length === 1);
assert.deepEqual(after.spectators.map((x) => x.playerId), [s2.id]);
assert.equal((await s2.waitFor('room.state', (x) => x.inMatch && x.spectators.length === 1)).spectators[0].playerId, s2.id, 'the other spectator is told too');
assert.equal((await a.request({ t: 'room.removeSpectator', playerId: s1.id })).code, 'BAD_TARGET', 'a seat already freed');
assert.equal((await s1.request({ t: 'g.watch', fieldId: `n:${a.id}` })).code, 'NOT_IN_ROOM', 'out of the room: no more watching');
// the match runs on: the removed seat gets no frame of it any more, the other spectator and the player do
const mark = s1.log.length;
s2.log.length = 0;
await ok(a, { t: 'g.ready', ready: true });
await s2.waitFor('b.start', (x) => x.kind === 'normal', 10000);
await a.waitFor('m.public', (p) => p.phase === 'PREP' && p.round === 2, 15000);
assert.deepEqual(s1.log.slice(mark).filter((x) => x.t !== 'pong').map((x) => x.t), [], 'nothing reaches the removed spectator');
assert.ok(s2.log.some((x) => x.t === 'm.public'), 'the other spectator still gets the match');
// the freed seat can be taken again (no ban list: the key is all it takes)
const s3 = await watcher('Spec3');
await ok(s3, { t: 'room.spectate', code: st.code });
const refilled = await a.waitFor('room.state', (x) => x.inMatch && x.spectators.length === 2);
assert.deepEqual(refilled.spectators.map((x) => x.playerId), [s2.id, s3.id]);
assert.deepEqual(errors, []);
await ok(a, { t: 'g.leave' });
});
test('server-run fallback (SP_COMBAT=server): the match simulates every field and streams m.field + b.snap', async () => {
FakeBattle.reset();
FakeBattle.script = () => ({ duration: 1 });
+113
View File
@@ -0,0 +1,113 @@
// Browser check of the in-match 观战席 capsule (GitHub #120, PR #120 by @salt-fishes; opt-in, ~10 s) on the in-match mock
// harness (public/dev/game-mock.html):
// SP_E2E=1 CHROME_PATH=… node --test test/ui/spectator-roster.e2e.test.js
//
// The room screen lists the spectator seats and lets the host remove one, but the route after 开始 is the game screen: a
// running match showed nothing of them. Now the top bar carries a capsule beside the latency — an eye and how many are
// watching, nothing at all while the room has no spectator; a tap opens the roster (names, an offline mark, the host's ✕ on
// every row: room.removeSpectator). Asserted: no capsule without spectators (and none in the default mock), the count and
// the rows, the ✕ only for the host, the request the ✕ sends, the roster and the count following the room.state that
// comes back, the capsule gone with the last seat, and the roster closing with Esc / 关闭. No console error. The server side
// (the seat freed at any time, room.closed {kicked}, no frame after it) is test/lobby.test.js and test/match/lobby-integration.test.js.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
describe('the 观战席 capsule of the match top bar', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
let srv;
let browser;
let base;
before(async () => {
const { startServer } = await import('../../server/index.js');
const puppeteer = (await import('puppeteer-core')).default;
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
base = `http://127.0.0.1:${srv.port}`;
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--force-device-scale-factor=1'] });
});
after(async () => {
await browser?.close();
await srv?.close();
});
async function open(query) {
const page = await browser.newPage();
await page.setViewport({ width: 1600, height: 900 });
const problems = [];
page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); });
page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
await page.goto(`${base}/dev/game-mock.html?render=fallback&${query}`, { waitUntil: 'networkidle0', timeout: 60000 });
await page.waitForSelector('.gtop', { timeout: 15000 });
await sleep(400);
return { page, problems };
}
const pill = (page) => page.evaluate(() => {
const el = document.querySelector('[data-testid="spectators"]');
return el ? { text: el.textContent.trim(), me: el.classList.contains('is-me') } : null;
});
const roster = (page) => page.evaluate(() => {
const ul = document.querySelector('[data-testid="spectator-roster"]');
if (!ul) return null;
return [...ul.querySelectorAll('li')].map((li) => ({
name: li.querySelector('.spec__name')?.textContent.trim(), offline: li.classList.contains('is-offline'), remove: !!li.querySelector('[data-testid="spectator-remove"]'),
}));
});
const requests = (page) => page.evaluate(() => globalThis.__MOCK__.S().requests.filter((r) => r[0] === 'room.removeSpectator'));
test('no spectator, no capsule: the default match top bar is as it was', async () => {
const { page, problems } = await open('phase=COMBAT');
assert.equal(await pill(page), null);
assert.ok(await page.$('.gtop__net .ping'), 'the latency pill is still there');
assert.deepEqual(problems, []);
await page.close();
});
test('the host: the capsule counts the seats, the roster lists them with a ✕ each, removing one updates both, the last one removes the capsule', async () => {
const { page, problems } = await open('phase=COMBAT&variant=spectators');
assert.deepEqual(await pill(page), { text: '2', me: false }, 'an eye and the count');
assert.ok(await page.$('.gtop__net [data-testid="spectators"]'), 'beside the latency pill');
assert.equal(await roster(page), null, 'closed until asked');
await page.click('[data-testid="spectators"]');
await page.waitForSelector('[data-testid="spectator-roster"]', { timeout: 5000 });
assert.deepEqual(await roster(page), [
{ name: '观战者甲', offline: false, remove: true }, { name: '观战者乙', offline: true, remove: true },
]);
// ✕ on the first row: room.removeSpectator { playerId } goes out, the room.state that follows takes the row away
await page.click('[data-testid="spectator-roster"] li:first-child [data-testid="spectator-remove"]');
await page.waitForFunction(() => document.querySelectorAll('[data-testid="spectator-roster"] li').length === 1, { timeout: 5000 });
assert.deepEqual(await requests(page), [['room.removeSpectator', { playerId: 'sp_1' }]]);
assert.deepEqual((await roster(page)).map((r) => r.name), ['观战者乙']);
assert.equal((await pill(page)).text, '1');
// Esc closes the roster, the capsule stays
await page.keyboard.press('Escape');
await sleep(250);
assert.equal(await roster(page), null);
assert.equal((await pill(page)).text, '1');
// the last seat: the open roster and the capsule go with it
await page.click('[data-testid="spectators"]');
await page.waitForSelector('[data-testid="spectator-roster"]', { timeout: 5000 });
await page.click('[data-testid="spectator-remove"]');
await page.waitForFunction(() => !document.querySelector('[data-testid="spectators"]') && !document.querySelector('.modal'), { timeout: 5000 });
assert.deepEqual(problems, []);
await page.close();
});
test('a player who is not the host sees the roster but no ✕; 关闭 closes it', async () => {
const { page, problems } = await open('phase=COMBAT&variant=spectators,notHost');
assert.equal((await pill(page)).text, '2');
await page.click('[data-testid="spectators"]');
await page.waitForSelector('[data-testid="spectator-roster"]', { timeout: 5000 });
assert.deepEqual((await roster(page)).map((r) => r.remove), [false, false], 'only the host removes');
assert.match(await page.evaluate(() => document.querySelector('.modal')?.textContent ?? ''), /创建者可以把观战者移出/);
await page.evaluate(() => [...document.querySelectorAll('.modal button')].find((b) => /关闭/.test(b.textContent))?.click());
await sleep(250);
assert.equal(await roster(page), null);
assert.deepEqual(await requests(page), []);
assert.deepEqual(problems, []);
await page.close();
});
});