first commit

This commit is contained in:
richarjiang
2026-09-21 14:45:24 +08:00
commit 12380c715c
34 changed files with 6609 additions and 0 deletions

182
tests/browser.mjs Normal file
View File

@@ -0,0 +1,182 @@
import { chromium } from '@playwright/test';
import assert from 'node:assert/strict';
import { mkdir } from 'node:fs/promises';
await mkdir('artifacts', { recursive: true });
const browser = await chromium.launch({
executablePath:
process.env.CHROME_PATH ||
(process.platform === 'darwin'
? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
: undefined),
headless: true,
args: ['--no-sandbox', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'],
});
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
const errors = [];
page.on('pageerror', (e) => {
errors.push(String(e));
console.log('PAGE ERROR', String(e));
});
page.on('console', (m) => {
if (m.type() === 'error') console.log('CONSOLE ERROR', m.text());
});
await page.goto('http://127.0.0.1:5173/');
try {
await page.getByRole('button', { name: '接受挑战' }).waitFor({ timeout: 10000 });
} catch (e) {
await page.screenshot({ path: 'artifacts/startup-error.png' });
console.log(await page.locator('body').innerText());
await browser.close();
throw e;
}
await page.screenshot({ path: 'artifacts/blind-selection.png' });
await page.getByRole('button', { name: '接受挑战' }).click();
await page.keyboard.press('1');
await page.keyboard.press('2');
assert.equal(await page.locator('.sr-only button[aria-pressed=true]').count(), 2);
await page.screenshot({ path: 'artifacts/playing.png' });
await page.keyboard.press('d');
assert.equal(await page.locator('.counters strong').nth(1).textContent(), '2');
const before = await page.evaluate(() => JSON.parse(localStorage.getItem('velvet-joker-run-v1')));
await page.reload();
await page.getByRole('button', { name: '出牌', exact: false }).waitFor();
const after = await page.evaluate(() => JSON.parse(localStorage.getItem('velvet-joker-run-v1')));
assert.deepEqual(after, before);
await page.mouse.move(474, 665);
await page.mouse.down();
await page.mouse.move(1120, 665, { steps: 15 });
await page.mouse.up();
const moved = await page.evaluate(
() => JSON.parse(localStorage.getItem('velvet-joker-run-v1')).hand,
);
if (JSON.stringify(moved) === JSON.stringify(before.hand)) {
await page.screenshot({ path: 'artifacts/drag-error.png' });
console.log('DRAG MODAL', await page.locator('#modal').innerText());
}
assert.notDeepEqual(moved, before.hand);
await page.keyboard.press('1');
await page.keyboard.press('Enter');
await page.waitForFunction(() => !document.querySelector('#scoring-source'));
assert.equal(await page.locator('.counters strong').first().textContent(), '3');
await page.getByRole('button', { name: '牌型图鉴' }).click();
assert.equal(await page.locator('.hand-table tbody tr').count(), 12);
await page.keyboard.press('Escape');
// Deterministic fixtures exercise full UI flows; they do not assert natural win rate.
await page.evaluate(async () => {
const { createRun, dispatch } = await import('/src/core/run.ts');
let r = createRun('BROWSER');
r = dispatch(r, { type: 'enter' }).state;
r.cards.slice(0, 5).forEach((c, i) => {
c.rank = i === 4 ? 9 : 13;
});
r.hand = r.cards.slice(0, 8).map((c) => c.id);
r.draw = r.cards.slice(8).map((c) => c.id);
r.money = 30;
r.target = 300;
r.jokers = [
{ uid: 200, id: 'smile', value: 0 },
{ uid: 201, id: 'pair', value: 0 },
{ uid: 202, id: 'double', value: 0 },
];
r.consumables = [{ uid: 203, kind: 'planet', ref: 1, price: 3 }];
r.nextId = 300;
localStorage.setItem('velvet-joker-run-v1', JSON.stringify(r));
localStorage.setItem('velvet-speed', '4');
});
await page.reload();
await page.getByRole('button', { name: '出牌', exact: false }).waitFor();
await page.mouse.click(421, 240);
await page.getByRole('button', { name: '右移' }).click();
assert.equal(
await page.evaluate(() => JSON.parse(localStorage.getItem('velvet-joker-run-v1')).jokers[0].id),
'pair',
);
await page.waitForFunction(() => !document.querySelector('.dialog'));
await page.screenshot({ path: 'artifacts/before-consumable.png' });
await page.mouse.click(1232, 228);
try {
await page.getByRole('button', { name: '使用', exact: true }).click({ timeout: 6000 });
} catch (e) {
await page.screenshot({ path: 'artifacts/consumable-error.png' });
console.log('DOM', await page.locator('#modal').innerText());
await browser.close();
throw e;
}
assert.equal(
await page.evaluate(() => JSON.parse(localStorage.getItem('velvet-joker-run-v1')).levels[1]),
2,
);
await page.keyboard.press('1');
await page.keyboard.press('2');
await page.screenshot({ path: 'artifacts/joker-table.png' });
await page.keyboard.press('Enter');
await page.getByRole('button', { name: '前往商店' }).waitFor({ timeout: 10000 });
const paid = await page.evaluate(
() => JSON.parse(localStorage.getItem('velvet-joker-run-v1')).money,
);
await page.reload();
await page.getByRole('button', { name: '前往商店' }).click();
assert.equal(
await page.evaluate(() => JSON.parse(localStorage.getItem('velvet-joker-run-v1')).money),
paid,
);
await page.screenshot({ path: 'artifacts/shop.png' });
await page.locator('[data-action=buy]').first().click();
await page.locator('[data-action=reroll]').click();
await page.locator('[data-action=buy]').last().click();
await page.getByRole('button', { name: '选择此牌' }).first().click();
await page.getByRole('button', { name: '下一盲注' }).click();
await page.getByRole('button', { name: '跳过' }).click();
assert.match(await page.locator('.blind-head').textContent(), /BOSS/);
await page.setViewportSize({ width: 1280, height: 720 });
await page.screenshot({ path: 'artifacts/desktop-1280.png' });
await page.setViewportSize({ width: 932, height: 430 });
await page.screenshot({ path: 'artifacts/mobile-landscape.png' });
await page.getByRole('button', { name: '接受挑战' }).click();
assert.equal(await page.locator('canvas').count(), 1);
assert.deepEqual(errors, []);
const mobile = await browser.newPage({
viewport: { width: 932, height: 430 },
hasTouch: true,
isMobile: true,
});
mobile.on('pageerror', (e) => errors.push(String(e)));
await mobile.goto('http://127.0.0.1:5173/');
await mobile.getByRole('button', { name: '接受挑战' }).click();
const bounds = await mobile.locator('canvas').boundingBox();
const point = (x, y) => ({
x: bounds.x + (x * bounds.width) / 1440,
y: bounds.y + (y * bounds.height) / 900,
});
await mobile.touchscreen.tap(point(474, 665).x, point(474, 665).y);
assert.equal(await mobile.locator('.sr-only button[aria-pressed=true]').count(), 1);
const touch = await mobile.context().newCDPSession(mobile);
await touch.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [point(573, 665)],
});
await mobile.getByRole('dialog').waitFor({ timeout: 4000 });
await touch.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
assert.match(await mobile.locator('.dialog').innerText(), /基础筹码/);
await mobile.getByRole('button', { name: '关闭', exact: true }).click();
const mobileBefore = await mobile.evaluate(
() => JSON.parse(localStorage.getItem('velvet-joker-run-v1')).hand,
);
await touch.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [point(474, 665)],
});
await touch.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [point(1100, 665)],
});
await touch.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
assert.notDeepEqual(
await mobile.evaluate(() => JSON.parse(localStorage.getItem('velvet-joker-run-v1')).hand),
mobileBefore,
);
assert.deepEqual(errors, []);
console.log(
'Browser smoke passed: Canvas selection/drag, play/discard, reload, joker order, consumable, reward, shop, pack, skip, responsive sizes, emulated touch tap/long-press/drag.',
);
await browser.close();