test(ui): 加 PS5 视口档与逐 demo 回归钉

背景:用户在 PS5 上直接打开这个插件用,屏幕形状与桌面不同
(浏览器可视区约 1920×970,横向充裕、纵向紧缺),且本轮起
游戏页要显示 PKG 封面、存档页对标 Garlic SaveMgr 的三栏骨架。

- ui_demos_check.mjs
  · 新增 PS5 视口档(1920×970):断言一级导航单行不折行、落在
    首屏顶部、无横向溢出,并逐个切视图再各量一遍
  · 单页长滚动的 demo(B/C/D)没有一级导航 ⇒ 导航项 SKIP 而不是
    FAIL —— 假失败和假通过一样会让人忽略整组断言
  · 新增 EXTRA 逐 demo 回归钉:demo1(导航置顶后触控目标仍 ≥44px、
    不再是左侧竖栏)、demo5(封面网格 ≥6、含抽不到 icon0.png 的
    回退态与加密锁定态、筛选真会筛、存档页有快照列与操作日志终端、
    空状态可来回切换)
  · EXTRA 的判定必须以字符串形式传入:playwright 的 evaluate 在
    序列化阶段就拒绝函数数组
- proposal_check.mjs
  · 新增 8 条界面硬规则关键词断言(kstuff 启动 / 1920×970 /
    顶部单行吸顶 / sce_sys/icon0.png / .covers/ / 抽不到必须回退 /
    底部操作日志终端 / 以及刻意不抄的那四个动作)
This commit is contained in:
Songlx516 committed 2026-09-27 10:02:49 +08:00
1 parent 9a03fb7c05
commit cfa02562b2
2 files changed
+100

No files matched your search

+15
View File
@@ -97,6 +97,21 @@ for (const key of [
]) {
check(r.body.includes(key), `save-writeback content present: ${key}`);
}
// UI hard rules (2026-09-27 round 9). PS5's viewport SHAPE (wide, short) is what forces
// the top nav — not taste. And the library cover must come from INSIDE the pkg, not from
// a scraper. Lose these and the decisions the user already made get re-litigated.
for (const key of [
"kstuff 启动", // the status wording the user picked (was "在位")
"1920×970", // the PS5 viewport shape that forces the top nav
"顶部单行吸顶", // nav placement rule
"sce_sys/icon0.png", // where covers actually come from
".covers/", // the runtime cover cache
"抽不到必须回退", // no blank holes in the cover grid
"底部操作日志终端", // the Garlic-derived save-page skeleton
"Decrypt / Encrypt / Resign / Import", // and the part we deliberately do NOT copy
]) {
check(r.body.includes(key), `UI rule content present: ${key}`);
}
// the install-layer AuthID was mis-stated in section 6 until this round; it may only
// survive as a quoted correction, never as a live claim
quotedOnly("0x3800000000000010", /0 命中|作废|误记|误写|上一轮/, "no live '0x3800000000000010' AuthID survives");
+85
View File
@@ -45,6 +45,47 @@ const CONTRAST_TARGETS = {
let fails = 0;
const check = (ok, name) => { console.log((ok ? " PASS " : " FAIL ") + name); if (!ok) fails++; };
/* 逐 demo 的回归钉:只验「这个 demo 该有的东西」,不硬套到别的 demo 上。
⚠️ 必须是**字符串形式**的箭头函数:playwright 的 evaluate 只会序列化普通值,
把函数数组直接当参数传,会在序列化阶段就抛
"Attempting to serialize unexpected value"(本次真踩过)。 */
const EXTRA = {
demo1: [
["导航改到顶部后触控目标仍 ≥44px(PS5 用触摸板光标)",
`() => [...document.querySelectorAll(".nav button")].every(b => b.getBoundingClientRect().height >= 44)`],
["导航不再产生左侧竖栏(横向空间全让给内容)",
`() => { const r = document.querySelector("nav").getBoundingClientRect();
return r.width > 900 && r.height <= 80; }`],
],
demo5: [
["游戏页有封面网格(≥6 张封面)",
`() => document.querySelectorAll("#view-library .gcard .cover").length >= 6`],
["封面有「抽不到 icon0.png」的回退态(一排卡片里不留空洞)",
`() => !!document.querySelector("#view-library .cover.fb")`],
["封面有加密锁定态(需要口令的包也得有封面)",
`() => !!document.querySelector("#view-library .cover.locked")`],
["筛选控件真会筛,不是只换按下态",
`() => { const s = document.getElementById("view-library");
const segs = s.querySelectorAll(".seg"); if (segs.length < 2) return false;
const vis = () => [...s.querySelectorAll(".gcard")].filter(c => !c.hidden).length;
const before = vis();
segs[1].querySelectorAll("button")[2].click(); // 需口令
const after = vis();
segs[1].querySelectorAll("button")[0].click(); // 切回「在盘上」
return before === 6 && after === 1 && vis() === 6; }`],
["存档页有快照列(这是要卖的差异化,必须看得见)",
`() => /快照/.test((document.getElementById("view-saves") || {}).textContent || "")`],
["存档页有操作日志终端(借自 Garlic 的 TERMINAL 面板)",
`() => !!document.querySelector("#view-saves .console .lines li")`],
["存档页空状态可来回切换(空态不是留白,是真会出现的状态)",
`() => { const d = document.getElementById("svDetail"), e = document.getElementById("svEmpty"),
c = document.getElementById("svClose"), it = document.querySelector("#view-saves .svi");
if (!d || !e || !c || !it) return false;
c.click(); const a = d.hidden === true && e.hidden === false;
it.click(); const b = d.hidden === false && e.hidden === true;
return a && b; }`],
],
};
const browser = await playwright.chromium.launch();
@@ -136,6 +177,15 @@ for (const d of DEMOS) {
check(base.svgIcons >= 6, `图标为内联 SVG 而非 emoji (${base.svgIcons} 个)`);
check(base.emoji === 0, `正文无 emoji (${base.emoji})`);
/* ---------- 本次改动的回归钉 ---------- */
const extras = EXTRA[d.key] || [];
if (extras.length) {
const got = await p.evaluate(srcs => srcs.map(s => {
try { return !!(new Function("return (" + s + ")")())(); } catch (e) { return false; }
}), extras.map(e => e[1]));
extras.forEach((e, i) => check(got[i], e[0]));
}
/* ---------- 视图化页面:逐视图量溢出 ---------- */
const vw = await widestOverflow(p);
if (vw) {
@@ -273,6 +323,41 @@ for (const d of DEMOS) {
}
await q.close();
}
/* ---------- PS5 档:1920×970 ----------
PS5 的浏览器自己不把 1080 全留给页面,可视区形状是「横向充裕、纵向紧缺」。
1080×1920 那一轮抓不到这个形状特有的问题,所以单独补一档:
① 一级导航必须停在顶部并且**单行** —— 折行等于白吃纵向空间;
② 导航必须在首屏内(吸顶或至少在顶部),滚一次就找不到了等于没有导航。 */
const ps5p = await browser.newPage({ viewport: { width: 1920, height: 970 } });
await ps5p.goto(url, { waitUntil: "load" });
await ps5p.waitForTimeout(320);
const ps5 = await ps5p.evaluate(() => {
const de = document.documentElement;
const bs = [...document.querySelectorAll("button[data-view]")];
const rects = bs.map(b => b.getBoundingClientRect());
return {
ov: de.scrollWidth - de.clientWidth,
n: bs.length,
rows: new Set(rects.map(r => Math.round(r.top))).size,
top: rects.length ? Math.min(...rects.map(r => r.top)) : -1,
bottom: rects.length ? Math.max(...rects.map(r => r.bottom)) : -1,
};
});
check(ps5.ov <= 1, `PS5 1920×970 无横向溢出 (${ps5.ov}px)`);
/* 只有「视图化」页面(带 data-view 导航)才验「导航置顶且单行」。
风格 B/C/D 是单页长滚动、压根没有一级导航 —— 硬套只会造出假失败,
而假失败和假通过一样有毒:它会让人开始忽略这一组断言。 */
if (ps5.n >= 2) {
check(ps5.n >= 4 && ps5.rows === 1, `PS5 档一级导航单行不折行(${ps5.n} 项 / ${ps5.rows} 行)`);
check(ps5.top >= 0 && ps5.bottom > 0 && ps5.bottom <= 970,
`PS5 档导航在首屏顶部(top=${Math.round(ps5.top)}px bottom=${Math.round(ps5.bottom)}px)`);
} else {
console.log(" SKIP PS5 档导航检查(此 demo 无一级导航,是单页长滚动)");
}
const pvq = await widestOverflow(ps5p);
if (pvq) check(pvq.worst <= 1, `PS5 档 ${pvq.n} 个视图逐个切换后均无横向溢出 (最大 ${pvq.worst}px @ ${pvq.at})`);
await ps5p.close();
await p.close();
}