Files
BiRefNet_WebUI/web/test_app.js
T
2026-10-08 09:46:47 +08:00

588 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 前端回归测试:用最小假 DOM 在 Node 里跑 app.js,模拟完整交互链路。
用法:node web/test_app.js
覆盖:
① 单槽待处理:替换 / 去重 / 一次多张
② 结果历史:刷新后从服务端恢复、跨任务累积
③ 回归重点:添加新图并重新处理时,右侧已有结果绝不能被清空
④ 参数记录:每条记录带参数快照;「重新处理」把参数重新赋值到表单
⑤ 同一张图多次处理 → 参数记录更新为最新
⑥ 删除单条不被轮询复活 / 选中描边 / 打包 ZIP / 清空 */
'use strict';
const fs = require('fs');
const path = require('path');
const appSrc = fs.readFileSync(process.env.APP_SRC || path.join(__dirname, 'app.js'), 'utf8');
const zipSrc = fs.readFileSync(path.join(__dirname, 'zip.js'), 'utf8');
/* ---------------------------- 假 DOM ---------------------------- */
function makeNode(tag = 'div') {
const listeners = {};
const classes = new Set();
let text = '';
const node = {
tag,
parent: null,
children: [],
dataset: {},
style: { setProperty() {}, width: '' },
value: '',
checked: false,
hidden: false,
disabled: false,
title: '',
src: '',
href: '',
download: '',
files: null,
scrollTop: 0,
// textContent 与真实 DOM 一致:赋值会清空所有子节点
get textContent() { return text; },
set textContent(v) {
text = String(v);
for (const c of node.children) c.parent = null;
node.children.length = 0;
},
// className 与 classList 共享同一份类名集合
get className() { return [...classes].join(' '); },
set className(v) {
classes.clear();
for (const c of String(v).split(/\s+/)) if (c) classes.add(c);
},
classList: {
add(c) { classes.add(c); },
remove(c) { classes.delete(c); },
toggle(c, on) {
const want = on === undefined ? !classes.has(c) : !!on;
want ? classes.add(c) : classes.delete(c);
return want;
},
contains(c) { return classes.has(c); },
},
append(...kids) {
for (const k of kids) {
// 真实 DOM 里 append(undefined) 会塞进一个文本节点 —— 静默的视觉 bug,
// 所以这里直接抛错,逼出这类问题(app.js 的 el() 已过滤空子节点)
if (k === undefined || k === null) throw new Error('append() 收到了 undefined/null 子节点');
k.parent = node;
node.children.push(k);
}
},
remove() {
if (!node.parent) return;
const i = node.parent.children.indexOf(node);
if (i >= 0) node.parent.children.splice(i, 1);
node.parent = null;
},
addEventListener(type, fn) { (listeners[type] = listeners[type] || []).push(fn); },
dispatch(type, ev = {}) {
const event = { target: node, preventDefault() {}, stopPropagation() {}, ...ev };
for (const fn of listeners[type] || []) fn(event);
},
closest(sel) {
const parts = sel.split(',').map((s) => s.trim());
let cur = node;
while (cur) {
for (const p of parts) {
if (p.includes('[')) { // 形如 button[data-view]
const m = /^([\w-]*)\[([\w-]+)\]$/.exec(p);
if (m && (!m[1] || cur.tag === m[1]) && cur.dataset[m[2]] !== undefined) return cur;
} else if (p === cur.tag) {
return cur;
} else if (p.startsWith('.') && cur.className.split(/\s+/).includes(p.slice(1))) {
return cur;
}
}
cur = cur.parent;
}
return null;
},
querySelectorAll() { return []; },
setAttribute() {},
click() { node.dispatch('click'); },
scrollIntoView() {},
};
return node;
}
const byId = new Map();
const viewSwitch = makeNode('div');
global.document = {
body: makeNode('body'),
getElementById(id) {
if (!byId.has(id)) byId.set(id, id === 'view-switch' ? viewSwitch : makeNode());
return byId.get(id);
},
createElement(tag) { return makeNode(tag); },
createTextNode(text) { const n = makeNode('#text'); n.textContent = String(text); return n; },
addEventListener() {},
querySelectorAll() { return []; },
};
global.window = { location: { assign() {} } };
new Function('window', zipSrc)(global.window); // 让 window.BiRefNetZip 可用
const lsData = new Map();
global.localStorage = {
getItem: (k) => (lsData.has(k) ? lsData.get(k) : null),
setItem: (k, v) => lsData.set(k, String(v)),
removeItem: (k) => lsData.delete(k),
};
Object.defineProperty(global, 'navigator', { value: { clipboard: {} }, configurable: true });
Object.defineProperty(global, 'File', {
configurable: true,
value: class File {
constructor(parts, name, opts = {}) {
const first = Array.isArray(parts) ? parts[0] : parts;
this.name = name;
this.type = opts.type || '';
this.lastModified = opts.lastModified || 0;
this.size = (first && first.size) || 0;
}
},
});
let lastBlob = null;
Object.defineProperty(global, 'Blob', {
configurable: true,
value: class Blob {
constructor(parts, opts = {}) {
this.parts = parts;
this.type = opts.type || '';
lastBlob = this;
}
},
});
global.URL = { createObjectURL: () => `blob:${Math.random()}`, revokeObjectURL() {} };
global.ClipboardItem = class {};
global.FormData = class {
constructor() { this.entries = []; }
append(k, v, name) { this.entries.push([k, v, name]); }
};
/* ------------------------- 假服务端 ------------------------- */
const STATE = {
ok: true,
node_dir: 'F:\\BiRefNet_WebUI\\vendor\\comfyui_birefnet_ll',
output_dir: 'F:\\BiRefNet_WebUI\\outputs',
environment: {
python: '3.12.10', torch: '2.13.0+cu126', cuda: true,
devices: [{ index: 0, name: 'NVIDIA GeForce RTX 3070 Laptop GPU', free_mem: 7, total_mem: 8 }],
},
models: [{
key: 'Portrait', name: 'Portrait', file: 'Portrait.safetensors', arch: 'v1',
size_mb: 843.9, backbone: 'swin_v1_l', path: 'models/Portrait.safetensors',
}],
defaults: { model: 'Portrait' },
};
/** 与 index.html 里的默认值保持一致,供测试手动铺一遍表单初始值。 */
const FORM_DEFAULTS = {
'opt-model': 'Portrait', 'opt-device': 'auto', 'opt-dtype': 'auto', 'opt-arch': 'auto',
'opt-resolution-mode': 'square', 'opt-width': '1024', 'opt-height': '1024',
'opt-longest-side': '1024', 'opt-upscale': 'bilinear', 'opt-threshold': '0',
'opt-blur1': '90', 'opt-blur2': '6', 'opt-background': 'transparent',
'opt-bgcolor': '#ffffff', 'opt-final-side': '0',
};
const FORM_CHECKS = { 'opt-refine': true, 'opt-mask': false, 'batch-recursive': false };
const P_SQUARE = {
model: 'Portrait', device: 'auto', dtype: 'auto', arch: 'auto',
resolution_mode: 'square', width: 1024, height: 1024, longest_side: 1024,
upscale_method: 'bilinear', mask_threshold: 0, refine_foreground: true,
blur_size: 90, blur_size_two: 6, background: 'transparent', bg_color: '#ffffff',
output_mask: false, final_longest_side: 0,
};
const P_GREEN = {
...P_SQUARE, model: 'birefnet', resolution_mode: 'longest', longest_side: 512,
background: 'color', bg_color: '#00ff00', refine_foreground: false,
};
const stub = { polls: 0, posts: 0, detailFetches: 0, fileHits: 0, originalHits: 0, lastOptions: null, batchPolls: 0, batchPosts: 0, lastBatch: null };
const tasks = new Map();
let clock = 1;
function mkImage(index, name, over = {}) {
const done = (over.state || 'done') === 'done';
return {
index, name, label: name.replace(/\.[^.]+$/, ''),
state: 'done', stage: '完成', progress: 1,
width: 1920, height: 1080, input_size: [1024, 1024], coverage: 0.42, elapsed: 3.1,
error: null, warning: null,
urls: done
? { original: `/api/tasks/${over.task}/file/${index}/original`, cutout: `/api/tasks/${over.task}/file/${index}/cutout`, mask: `/api/tasks/${over.task}/file/${index}/mask` }
: {},
...over,
};
}
function mkTask(id, options, images, state = 'done') {
const task = { id, created: clock++, state, stage: state === 'done' ? '完成' : '排队中', progress: state === 'done' ? 1 : 0, options, images };
tasks.set(id, task);
return task;
}
// 历史:T1(无遮罩输出 + 列表里不带 options,逼前端补拉详情)+ T2(列表里直接带)
const T1 = mkTask('T100', P_SQUARE, [{ index: 0, name: 'old.jpg', label: 'old', state: 'done', stage: '完成', progress: 1, width: 800, height: 600, input_size: [1024, 1024], coverage: 0.3, elapsed: 2.2, error: null, warning: null, urls: {} }]);
T1.images[0].urls = { original: '/api/tasks/T100/file/0/original', cutout: '/api/tasks/T100/file/0/cutout' };
const T2 = mkTask('T200', P_GREEN, [mkImage(0, 'mid.jpg', { task: 'T200' })]);
T2.options = P_GREEN;
const taskList = () => [...tasks.values()]
.sort((a, b) => b.created - a.created)
.map((t) => ({
id: t.id, created: t.created, state: t.state, stage: t.stage, progress: t.progress,
mode: t.mode || 'upload', input_dir: t.input_dir || null, output_dir: t.output_dir || null,
// 模仿服务端列表:T100 不返回 options(触发补拉详情)
...(t.id === 'T100' ? {} : { options: t.options }),
images: t.images,
}));
global.fetch = async (url, options = {}) => {
const json = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) });
const bin = (size) => ({
ok: true, status: 200,
blob: async () => ({ size, type: 'image/png' }),
arrayBuffer: async () => new Uint8Array(size).buffer,
});
const method = options.method || 'GET';
if (url.includes('/api/state')) return json(STATE);
if (url.endsWith('/api/batch') && method === 'POST') {
stub.batchPosts += 1;
const body = JSON.parse(options.body);
stub.lastBatch = body;
const id = `B${400 + stub.batchPosts}`;
const names = ['photo.jpg', 'a_pretty_long_original_filename_here.png', 'third.png'];
const images = names.map((name, i) => ({
index: i, name, label: name, state: 'pending', stage: '排队中', progress: 0,
output_name: null, urls: {},
}));
const task = mkTask(id, body.options || {}, images, 'running');
task.mode = 'batch';
task.input_dir = body.input_dir;
task.output_dir = body.output_dir || STATE.output_dir;
return json({
ok: true, task_id: id, mode: 'batch', count: images.length,
input_dir: task.input_dir, output_dir: task.output_dir,
});
}
if (url.includes('/api/tasks') && method === 'POST') {
stub.posts += 1;
const opts = JSON.parse(options.body.entries.find((e) => e[0] === 'options')[1]);
stub.lastOptions = opts;
const name = options.body.entries.find((e) => e[0] === 'files')[2] || 'upload.png';
const id = `T${300 + stub.posts}`;
const images = [{ index: 0, name, label: name.replace(/\.[^.]+$/, ''), state: 'pending', stage: '排队中', progress: 0, urls: {} }];
mkTask(id, opts, images, 'running');
return json({ ok: true, task_id: id, count: 1 });
}
if (/\/file\/\d+\/(cutout|mask|original)$/.test(url)) {
stub.fileHits += 1;
if (url.endsWith('/original')) stub.originalHits += 1;
return bin(2048);
}
const detail = /\/api\/tasks\/([^/?]+)(?:\?([^#]*))?$/.exec(url);
if (detail && method === 'GET') {
stub.detailFetches += 1;
const task = tasks.get(detail[1]);
if (!task) return { ok: false, status: 404, text: async () => JSON.stringify({ ok: false, error: '任务不存在' }) };
const isBatch = task.mode === 'batch';
if (isBatch) stub.batchPolls += 1;
// 轮询时推进状态:running → done(第二次拿到该任务时)
if (task.state === 'running') {
task.polls = (task.polls || 0) + 1;
if (task.polls >= 2) {
task.state = 'done';
task.stage = '完成';
task.progress = 1;
task.images = task.images.map((img, i) => (isBatch
// 批量:只补上 output_name / 完成态(服务端就是按 RMBG_ 命名写盘的)
? { ...img, state: 'done', stage: '完成', progress: 1, elapsed: 4.2, output_name: `RMBG_${img.label.slice(0, 20)}_1791360000.png` }
: mkImage(i, img.name, { task: task.id })));
} else {
task.images = task.images.map((img) => ({ ...img, state: 'running', stage: '推理', progress: 0.5, urls: {} }));
}
stub.polls += 1;
}
// ?tail=N → 只回传最后 N 张(批量轮询用)
const tail = Number((new URLSearchParams(detail[2] || '').get('tail')) || 0);
const images = tail > 0 ? task.images.slice(Math.max(0, task.images.length - tail)) : task.images;
return json({ ok: true, task: { id: task.id, created: task.created, mode: task.mode || 'upload', input_dir: task.input_dir || null, output_dir: task.output_dir || null, state: task.state, stage: task.stage, progress: task.progress, error: null, counts: { total: task.images.length, done: task.state === 'done' ? task.images.length : 0, failed: 0, pending: 0 }, images_total: task.images.length, images_from: task.images.length - images.length, options: task.options, images } });
}
if (url.endsWith('/api/tasks')) return json({ ok: true, tasks: taskList() });
return json({ ok: false, error: '未知接口 ' + url });
};
/* ---------------------------- 执行 ---------------------------- */
for (const [id, v] of Object.entries(FORM_DEFAULTS)) byId.set(id, Object.assign(makeNode('input'), { id, value: v }));
for (const [id, v] of Object.entries(FORM_CHECKS)) byId.set(id, Object.assign(makeNode('input'), { id, checked: v }));
byId.set('thumbs', makeNode('ul'));
byId.set('results', makeNode('div'));
byId.set('empty', Object.assign(makeNode('div'), { className: 'empty-state' }));
byId.set('btn-run', makeNode('button'));
byId.set('btn-zip', Object.assign(makeNode('button'), { textContent: '打包下载 ZIP' }));
// 批量处理面板(index.html 里存在,但未被 app.js 主动触碰的节点要在这里铺好)
byId.set('batch-rows', makeNode('ul'));
byId.set('batch-report', Object.assign(makeNode('section'), { hidden: true }));
byId.set('batch-progress-bar', makeNode('i'));
// 预置一份「老版本(v1)」表单存档:当年 output_mask 默认是 true,参数名还是 max_output_side。
// 启动时的迁移逻辑必须把遮罩默认值修正掉、把旧尺寸参数搬到新输入框,且不动其它参数。
lsData.set('birefnet.webui.options', JSON.stringify({
background: 'transparent', output_mask: true, max_output_side: 2048,
}));
new Function(appSrc)();
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function waitFor(cond, timeout = 5000) {
const t0 = Date.now();
while (Date.now() - t0 < timeout) { if (cond()) return true; await sleep(15); }
return false;
}
const results = () => byId.get('results').children;
const cards = () => byId.get('results').children;
const cardState = (c) => c.className;
const head = (c) => c.children[0];
const nameBtnOf = (c) => head(c).children[0];
const badgeOf = (c) => head(c).children[1].textContent;
const delBtnOf = (c) => head(c).children[2];
const paramsLineOf = (c) => find(c, 'card-params');
const find = (node, cls) => {
for (const child of node.children || []) {
if (child.className.split(/\s+/).includes(cls)) return child;
const hit = find(child, cls);
if (hit) return hit;
}
return null;
};
const compareImgCount = (c) => ((find(c, 'compare')?.children || []).filter((n) => n.tag === 'img').length);
const statsCount = (c) => ((find(c, 'card-stats')?.children || []).length);
const actionsOf = (c) => find(c, 'card-actions');
const reuseBtnOf = (c) => actionsOf(c).children[0];
function pendingName() {
const ul = byId.get('thumbs');
if (ul.children.length !== 1) return `li×${ul.children.length}`;
const meta = ul.children[0].children.find((x) => x.className.includes('pending-meta'));
return meta ? meta.children[0].textContent : '(无 meta)';
}
function upload(files) {
const input = byId.get('file-input');
input.files = files;
input.dispatch('change');
}
const F = (name, size, mtime) => ({ name, size, lastModified: mtime, type: 'image/jpeg' });
const formValue = (id) => (id === 'opt-refine' || id === 'opt-mask' ? byId.get(id).checked : byId.get(id).value);
const setForm = (id, v) => { if (id === 'opt-refine' || id === 'opt-mask') byId.get(id).checked = v; else byId.get(id).value = v; };
const savedParams = () => JSON.parse(lsData.get('birefnet.webui.imageParams') || '{}');
(async () => {
await sleep(80); // init / loadState / loadHistory
const checks = [];
const check = (name, ok, detail) => checks.push([name, ok, detail]);
/* ============ 场景 0:老版本 localStorage 存档迁移 ============ */
check('⓪ 老存档的 output_mask 不覆盖新默认(默认不输出遮罩)', formValue('opt-mask') === false, `checked=${formValue('opt-mask')}`);
check('⓪ 老参数 max_output_side 搬到新的「最终尺寸 · 最长边」', formValue('opt-final-side') === '2048', formValue('opt-final-side'));
check('⓪ 其余老参数照常恢复', formValue('opt-background') === 'transparent', formValue('opt-background'));
check('⓪ 存档被标记为 v2(迁移只做一次)',
(JSON.parse(lsData.get('birefnet.webui.options') || '{}')._schema) === 2,
lsData.get('birefnet.webui.options'));
setForm('opt-final-side', '0'); // 复原,避免影响后续场景
/* ============ 场景 1:刷新后从服务端恢复历史结果 ============ */
check('① 启动即恢复 2 条历史记录', results().length === 2, `len=${results().length}`);
check('① 列表缺 options 时自动补拉详情', stub.detailFetches >= 1, `fetches=${stub.detailFetches}`);
check('① 历史记录显示参数行', (paramsLineOf(cards()[0])?.textContent || '').includes('Portrait'), paramsLineOf(cards()[0])?.textContent);
check('① 历史记录直接可用(done)', cardState(cards()[0]).includes('done') && compareImgCount(cards()[0]) === 2, cardState(cards()[0]));
check('① 无遮罩记录不产生 null 子节点', actionsOf(cards()[0]).children.every((c) => c.tag && c.tag !== '#text'), actionsOf(cards()[0]).children.map((c) => c.tag));
check('① 计数 = 2', byId.get('result-count').textContent === '2', byId.get('result-count').textContent);
/* ============ 场景 2:单槽待处理 ============ */
upload([F('first.jpg', 1000, 111)]);
await sleep(20);
check('② 上传后待处理 1 张', pendingName() === 'first.jpg', pendingName());
upload([F('second.jpg', 2000, 222)]);
await sleep(20);
check('② 新图替换旧图(仍只有 1 张)', pendingName() === 'second.jpg', pendingName());
upload([F('second.jpg', 2000, 222)]);
await sleep(20);
check('② 完全相同的图被忽略', pendingName() === 'second.jpg' && byId.get('thumbs').children.length === 1, pendingName());
upload([F('x.jpg', 10, 1), F('y.jpg', 20, 2), F('z.jpg', 30, 3)]);
await sleep(20);
check('② 一次多张只留最后一张', pendingName() === 'z.jpg', pendingName());
/* ============ 场景 3(回归重点):新任务不清空已有结果 ============ */
const before = results().slice();
upload([F('new.jpg', 5000, 9)]);
await sleep(20);
setForm('opt-background', 'transparent');
setForm('opt-bgcolor', '#ffffff');
setForm('opt-model', 'Portrait');
setForm('opt-resolution-mode', 'square');
byId.get('btn-run').dispatch('click');
await waitFor(() => stub.polls >= 1);
check('③ 新任务提交后旧结果仍在(核心回归)',
results().length === 3 && results()[0] === before[0] && results()[1] === before[1],
`len=${results().length}`);
check('③ 提交时参数带上了当前表单值', stub.lastOptions && stub.lastOptions.model === 'Portrait' && stub.lastOptions.background === 'transparent', JSON.stringify(stub.lastOptions));
check('③ 新卡片立刻显示参数行', (paramsLineOf(cards()[2])?.textContent || '').includes('透明'), paramsLineOf(cards()[2])?.textContent);
await waitFor(() => stub.polls >= 2);
await sleep(40);
check('③ 新卡片进入 done 且旧结果完好', cardState(cards()[2]).includes('done') && results().length === 3, `${cardState(cards()[2])} len=${results().length}`);
check('③ 旧卡片未被重建(DOM 复用)', results()[0] === before[0] && results()[1] === before[1], '被替换了');
check('③ 参数已写入 localStorage', savedParams()['new.jpg']?.background === 'transparent', JSON.stringify(savedParams()['new.jpg'] || null));
/* ============ 场景 4:重新处理 = 参数重新赋值 ============ */
setForm('opt-background', 'color');
setForm('opt-bgcolor', '#00ff00');
setForm('opt-resolution-mode', 'longest');
setForm('opt-longest-side', '512');
setForm('opt-refine', false);
await sleep(20);
reuseBtnOf(cards()[2]).dispatch('click');
await sleep(80);
check('④ 重新处理把图片放回待处理', pendingName() === 'new.jpg', pendingName());
check('④ 参数被重新赋值(背景透明)', formValue('opt-background') === 'transparent', formValue('opt-background'));
check('④ 参数被重新赋值(颜色/尺寸/精修)',
formValue('opt-bgcolor') === '#ffffff' && formValue('opt-resolution-mode') === 'square' && formValue('opt-refine') === true,
`${formValue('opt-bgcolor')} / ${formValue('opt-resolution-mode')} / ${formValue('opt-refine')}`);
/* ============ 场景 5:同一张图再次处理 → 参数更新为最新 ============ */
setForm('opt-background', 'color');
setForm('opt-bgcolor', '#00ff00');
byId.get('btn-run').dispatch('click');
await waitFor(() => stub.polls >= 3);
check('⑤ 同图二次处理生成新记录(历史保留)', results().length === 4, `len=${results().length}`);
check('⑤ 新记录参数为最新(纯色 #00ff00)', (paramsLineOf(cards()[3])?.textContent || '').includes('纯色 #00ff00'), paramsLineOf(cards()[3])?.textContent);
check('⑤ localStorage 参数被最新值覆盖', savedParams()['new.jpg']?.background === 'color' && savedParams()['new.jpg']?.bg_color === '#00ff00', JSON.stringify(savedParams()['new.jpg'] || null));
// 等这一轮真正跑完再进下一个场景(否则按钮仍处于「处理中」态,点击会被忽略)
await waitFor(() => stub.polls >= 4 && cardState(cards()[3]).includes('done'));
await sleep(30);
check('⑤ 本轮任务已结束(可再次提交)', cardState(cards()[3]).includes('done'), cardState(cards()[3]));
/* ============ 场景 6:删除单条 + 轮询不复活 ============ */
delBtnOf(cards()[1]).dispatch('click');
await sleep(20);
check('⑥ 删除后剩 3 条', results().length === 3, `len=${results().length}`);
check('⑥ 计数同步', byId.get('result-count').textContent === '3', byId.get('result-count').textContent);
const pollsBefore = stub.polls;
byId.get('btn-run').dispatch('click'); // 再起一个任务制造轮询
await waitFor(() => stub.polls > pollsBefore + 1);
await sleep(40);
check('⑥ 被删记录未被轮询复活', results().length === 4 && !results().some((c) => nameBtnOf(c).textContent.includes('mid.jpg')), `len=${results().length}`);
/* ============ 场景 7:选中描边 ============ */
cards()[0].dispatch('click');
await sleep(10);
check('⑦ 点击卡片出现选中态', cardState(cards()[0]).includes('selected'), cardState(cards()[0]));
cards()[0].dispatch('click');
await sleep(10);
check('⑦ 再点一次取消选中', !cardState(cards()[0]).includes('selected'), cardState(cards()[0]));
nameBtnOf(cards()[0]).dispatch('click');
await sleep(60);
check('⑦ 点文件名不触发选中', !cardState(cards()[0]).includes('selected'), cardState(cards()[0]));
/* ============ 场景 8:跨任务打包 ZIP ============ */
const visible = results().length;
byId.get('btn-zip').dispatch('click');
await sleep(120);
const zip = lastBlob ? Buffer.from(concat(lastBlob.parts)) : null;
const eocd = zip ? zip.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06])) : -1;
const entryCount = eocd >= 0 ? zip.readUInt16LE(eocd + 10) : -1;
check('⑧ ZIP 条目数 = 可见结果数', entryCount === visible, `entries=${entryCount} visible=${visible}`);
check('⑧ 打包后按钮恢复可用', byId.get('btn-zip').disabled === false, String(byId.get('btn-zip').disabled));
if (zip) fs.writeFileSync(path.join(__dirname, '..', 'outputs', 'test_zip_out.zip'), zip);
/* ============ 场景 9:清空 ============ */
byId.get('btn-clear').dispatch('click');
await sleep(20);
check('⑨ 清空后列表为空', results().length === 0, `len=${results().length}`);
check('⑨ 空状态重新显示', !byId.get('empty').classList.contains('hidden'), 'hidden 仍存在');
check('⑨ 清空不影响参数记忆', savedParams()['new.jpg']?.background === 'color', JSON.stringify(savedParams()['new.jpg'] || null));
/* ============ 场景 10:目录批量处理 ============ */
check('⑩ 输出目录默认预填为项目 outputs', byId.get('batch-output-dir').value === STATE.output_dir, byId.get('batch-output-dir').value);
setForm('batch-input-dir', 'F:\\photos\\待抠图');
setForm('batch-output-dir', ''); // 留空 → 由服务端兜底默认目录
setForm('opt-background', 'color');
byId.get('batch-recursive').checked = true;
byId.get('btn-batch').dispatch('click');
await waitFor(() => stub.batchPosts >= 1);
check('⑩ 提交了输入/输出目录与递归开关',
stub.lastBatch?.input_dir === 'F:\\photos\\待抠图' && stub.lastBatch?.output_dir === '' && stub.lastBatch?.recursive === true,
JSON.stringify(stub.lastBatch));
check('⑩ 抠图参数沿用左侧表单',
stub.lastBatch?.options?.background === 'color' && stub.lastBatch?.options?.model === 'Portrait',
JSON.stringify(stub.lastBatch?.options));
await waitFor(() => byId.get('batch-rows').children.length === 3);
check('⑩ 批量清单逐文件铺开', byId.get('batch-rows').children.length === 3, byId.get('batch-rows').children.length);
check('⑩ 清单标出输入 → 输出目录',
(byId.get('batch-report-path').textContent || '').includes('待抠图') && (byId.get('batch-report-path').textContent || '').includes('outputs'),
byId.get('batch-report-path').textContent);
check('⑩ 批量任务不铺单图结果卡片', results().length === 0, `cards=${results().length}`);
check('⑩ 清单展开时不显示空状态', byId.get('empty').classList.contains('hidden'), '空状态可见');
// 收尾会再拉一次全量(清单里每行都要是终态),所以要等按钮恢复才断言
await waitFor(() => byId.get('btn-batch').textContent === '开始批量抠图');
await sleep(60);
const rows = byId.get('batch-rows').children;
check('⑩ 收尾每行显示 RMBG_ 输出名', rows.every((r) => (r.cells.to.textContent || '').startsWith('RMBG_')), rows.map((r) => r.cells.to.textContent));
check('⑩ 收尾每行状态为完成', rows.every((r) => r.cells.badge.textContent === '完成'), rows.map((r) => r.cells.badge.textContent));
check('⑩ 汇总行给出张数', (byId.get('batch-report-sum').textContent || '').includes('共 3 张'), byId.get('batch-report-sum').textContent);
check('⑩ 按钮恢复可点', byId.get('btn-batch').disabled === false && byId.get('btn-batch').textContent === '开始批量抠图', byId.get('btn-batch').textContent);
check('⑩ 没有页面错误', byId.get('batch-error').hidden === true, byId.get('batch-error').textContent);
byId.get('btn-batch-hide').dispatch('click');
await sleep(20);
check('⑩ 收起后清单隐藏、空状态回归',
byId.get('batch-report').hidden === true && !byId.get('empty').classList.contains('hidden'), '收起逻辑异常');
/* ============ 场景 11:最终尺寸·最长边 + 遮罩默认关闭 ============ */
check('⑪ 「同时输出遮罩」默认不勾选(默认不产出遮罩文件)', formValue('opt-mask') === false, `checked=${formValue('opt-mask')}`);
check('⑪ 「最终尺寸 · 最长边」默认 0', formValue('opt-final-side') === '0', formValue('opt-final-side'));
setForm('opt-final-side', '1440');
setForm('opt-mask', true);
const posts11 = stub.posts;
const polls11 = stub.polls;
byId.get('btn-run').dispatch('click');
await waitFor(() => stub.posts > posts11);
check('⑪ 提交参数带 final_longest_side / output_mask',
stub.lastOptions?.final_longest_side === 1440 && stub.lastOptions?.output_mask === true,
JSON.stringify(stub.lastOptions));
await waitFor(() => stub.polls > polls11 + 1);
await sleep(50);
check('⑪ 结果卡片参数摘要显示最长边',
(paramsLineOf(cards()[0])?.textContent || '').includes('最长边 1440'),
paramsLineOf(cards()[0])?.textContent);
/* ============ 汇总 ============ */
const errs = byId.get('error-text').textContent;
console.log('='.repeat(64));
let fail = 0;
for (const [name, ok, detail] of checks) {
console.log(` ${ok ? '✔' : '✘'} ${name}${ok ? '' : ` -> 实际: ${JSON.stringify(detail)}`}`);
if (!ok) fail++;
}
if (errs) console.log(` ⚠ 页面上报错误: ${errs}`);
console.log('='.repeat(64));
console.log(fail || errs ? `失败 ${fail} 项${errs ? '(含页面错误)' : ''}` : `前端回归测试全部通过 ✔(${checks.length} 项)`);
console.log(`ZIP 样本:outputs/test_zip_out.zip(${zip ? zip.length : 0} bytes,${entryCount} 个条目)`);
process.exit(fail || errs ? 1 : 0);
})();
function concat(parts) {
const list = parts.map((p) => Buffer.from(p));
return Buffer.concat(list);
}