Spaces:
Sleeping
Sleeping
| """ | |
| 为 title_styler 回归测试结果生成一个零依赖前端 (viewer.html)。 | |
| 工作流: | |
| PYTHONPATH=. python tests/title_styler_regression/run.py --run baseline | |
| PYTHONPATH=. python tests/title_styler_regression/run.py --run after_fix | |
| PYTHONPATH=. python tests/title_styler_regression/viewer.py | |
| # 然后浏览器打开 tests/title_styler_regression/viewer.html | |
| 特性: | |
| - 扫描 results/<run>/summary.json 自动发现所有 run | |
| - 顶部两个 dropdown 选 左/右 run, 一键切换 | |
| - 每个 case 一行: 左 PNG | 右 PNG | meta (尺寸、字号、dh) | |
| - 支持按 |dh| 排序、按模板名 / test_id 过滤 | |
| - 仅生成一个 HTML 文件 (相对路径引用 PNG), file:// 协议可直接打开 | |
| """ | |
| import base64 | |
| import json | |
| import os | |
| import sys | |
| ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) | |
| HERE = os.path.abspath(os.path.dirname(__file__)) | |
| RESULTS_ROOT = os.path.join(HERE, "results") | |
| OUT_HTML = os.path.join(HERE, "viewer.html") | |
| def discover_runs(): | |
| runs = {} | |
| if not os.path.isdir(RESULTS_ROOT): | |
| return runs | |
| for entry in sorted(os.listdir(RESULTS_ROOT)): | |
| run_dir = os.path.join(RESULTS_ROOT, entry) | |
| summary_path = os.path.join(run_dir, "summary.json") | |
| if not os.path.isfile(summary_path): | |
| continue | |
| with open(summary_path, encoding="utf-8") as f: | |
| summary = json.load(f) | |
| cases = {} | |
| for c in summary.get("cases", []): | |
| cid = c.get("case_id") | |
| if not cid: | |
| continue | |
| png_rel = os.path.relpath( | |
| os.path.join(run_dir, cid, "title.png"), start=HERE | |
| ) | |
| svg_rel = os.path.relpath( | |
| os.path.join(run_dir, cid, "title.svg"), start=HERE | |
| ) | |
| png_exists = os.path.isfile(os.path.join(HERE, png_rel)) | |
| cases[cid] = { | |
| "ok": c.get("ok", False), | |
| "reason": c.get("reason"), | |
| "png": png_rel if png_exists else None, | |
| "svg": svg_rel, | |
| "w": c.get("svg_width"), | |
| "h": c.get("svg_height"), | |
| "fonts": c.get("font_size_hist", {}), | |
| "test_id": c.get("test_id"), | |
| "template": c.get("template_name"), | |
| "title": c.get("title_segments"), | |
| "description": c.get("description"), | |
| } | |
| runs[entry] = { | |
| "n_ok": summary.get("n_ok", 0), | |
| "n_fail": summary.get("n_fail", 0), | |
| "cases": cases, | |
| } | |
| return runs | |
| HTML_TEMPLATE = """<!DOCTYPE html> | |
| <html lang="zh"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <title>title_styler regression viewer</title> | |
| <style> | |
| * { box-sizing: border-box; } | |
| body { margin: 0; font: 13px/1.4 -apple-system, BlinkMacSystemFont, sans-serif; background: #f6f7f9; color: #222; } | |
| header { position: sticky; top: 0; background: #1f2937; color: #fff; padding: 10px 16px; z-index: 10; | |
| box-shadow: 0 1px 4px rgba(0,0,0,.15); } | |
| header h1 { font-size: 14px; margin: 0 0 6px; font-weight: 600; letter-spacing: .3px; } | |
| .controls { display: flex; gap: 14px; flex-wrap: wrap; align-items: center; font-size: 12px; } | |
| .controls label { color: #cbd5e1; } | |
| select, input[type=text] { background: #374151; color: #fff; border: 1px solid #4b5563; | |
| border-radius: 4px; padding: 4px 8px; font: inherit; } | |
| select:focus, input:focus { outline: 2px solid #60a5fa; } | |
| .badge { background: #374151; padding: 2px 8px; border-radius: 10px; color: #e5e7eb; } | |
| .badge.up { background: #14532d; color: #bbf7d0; } | |
| .badge.down { background: #7f1d1d; color: #fecaca; } | |
| .badge.zero { background: #374151; color: #9ca3af; } | |
| main { padding: 14px; } | |
| .summary { background: #fff; padding: 10px 14px; border-radius: 6px; margin-bottom: 12px; | |
| box-shadow: 0 1px 2px rgba(0,0,0,.06); font-size: 12px; color: #4b5563; | |
| display: flex; gap: 18px; flex-wrap: wrap; } | |
| .row { background: #fff; border-radius: 6px; margin-bottom: 12px; padding: 10px; | |
| box-shadow: 0 1px 2px rgba(0,0,0,.06); } | |
| .row.changed { box-shadow: 0 0 0 2px #fbbf24, 0 1px 2px rgba(0,0,0,.06); } | |
| .row-head { display: flex; gap: 10px; align-items: center; font-weight: 600; margin-bottom: 8px; | |
| padding-bottom: 6px; border-bottom: 1px solid #e5e7eb; flex-wrap: wrap; } | |
| .row-head .case-id { color: #1f2937; font-size: 13px; } | |
| .row-head .pill { font-size: 11px; padding: 1px 6px; border-radius: 8px; | |
| background: #eef2ff; color: #3730a3; font-weight: 500; } | |
| .row-head .pill.tpl { background: #ecfeff; color: #155e75; } | |
| .row-meta { font-size: 11px; color: #6b7280; font-weight: 400; } | |
| .pair { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } | |
| .panel { background: #fafbfc; border: 1px solid #e5e7eb; border-radius: 4px; padding: 8px; } | |
| .panel .head { font-size: 11px; color: #6b7280; margin-bottom: 6px; display: flex; justify-content: space-between; } | |
| .panel img { display: block; max-width: 100%; height: auto; | |
| background-image: repeating-conic-gradient(#f3f4f6 0 25%, #fff 0 50%); | |
| background-size: 16px 16px; } | |
| .panel.missing { background: #fef2f2; border-color: #fecaca; color: #991b1b; padding: 18px; text-align: center; } | |
| .fonts { font-family: ui-monospace, SF Mono, Menlo, monospace; font-size: 11px; color: #1f2937; } | |
| details { margin-top: 6px; } | |
| details > summary { cursor: pointer; color: #4b5563; font-size: 11px; } | |
| details pre { background: #f3f4f6; padding: 6px 8px; border-radius: 4px; overflow: auto; | |
| font: 11px ui-monospace, monospace; margin: 6px 0 0; } | |
| .empty { text-align: center; color: #6b7280; padding: 60px; } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <h1>title_styler regression viewer</h1> | |
| <div class="controls"> | |
| <label>left run <select id="runA"></select></label> | |
| <label>right run <select id="runB"></select></label> | |
| <label>filter <input type="text" id="filter" placeholder="template / test_id"></label> | |
| <label>sort <select id="sort"> | |
| <option value="abs_dh_desc">|dh| desc (changed first)</option> | |
| <option value="dh_desc">dh desc</option> | |
| <option value="dh_asc">dh asc</option> | |
| <option value="case_id">case_id</option> | |
| </select></label> | |
| <label><input type="checkbox" id="onlyChanged"> only changed (|dh|≥1)</label> | |
| <span class="badge" id="counts"></span> | |
| </div> | |
| </header> | |
| <main> | |
| <div class="summary" id="summary"></div> | |
| <div id="grid"></div> | |
| </main> | |
| <script> | |
| const RUNS = __RUNS_JSON__; | |
| function caseIds(run) { | |
| return run ? Object.keys(run.cases) : []; | |
| } | |
| function pickDefaults() { | |
| const names = Object.keys(RUNS); | |
| if (names.length === 0) return [null, null]; | |
| if (names.length === 1) return [names[0], names[0]]; | |
| const afterAIdx = names.indexOf('after_fix'); | |
| const preferredB = [ | |
| 'after_fix_linegap_tune', | |
| 'after_fix_bg_width', | |
| 'after_fix_desc_gap', | |
| 'after_fix_AB_anchor', | |
| 'after_fix_AB', | |
| ].find(n => names.includes(n)); | |
| const preferredBIdx = preferredB ? names.indexOf(preferredB) : -1; | |
| const a = afterAIdx >= 0 ? names[afterAIdx] : names[0]; | |
| const b = preferredBIdx >= 0 ? names[preferredBIdx] : names[1]; | |
| return [a, b]; | |
| } | |
| function fontsHistToString(h) { | |
| if (!h) return ''; | |
| return Object.keys(h).sort((a,b)=>parseFloat(a)-parseFloat(b)).map(k => `${k}×${h[k]}`).join(' '); | |
| } | |
| function classifyDh(dh) { | |
| if (dh > 0) return 'up'; | |
| if (dh < 0) return 'down'; | |
| return 'zero'; | |
| } | |
| function render() { | |
| const aName = document.getElementById('runA').value; | |
| const bName = document.getElementById('runB').value; | |
| const runA = RUNS[aName] || { cases: {} }; | |
| const runB = RUNS[bName] || { cases: {} }; | |
| const filterStr = document.getElementById('filter').value.trim().toLowerCase(); | |
| const sortBy = document.getElementById('sort').value; | |
| const onlyChanged = document.getElementById('onlyChanged').checked; | |
| const allIds = Array.from(new Set([...caseIds(runA), ...caseIds(runB)])); | |
| let rows = allIds.map(cid => { | |
| const a = runA.cases[cid]; | |
| const b = runB.cases[cid]; | |
| const dh = (a && b && a.ok && b.ok) ? (b.h - a.h) : 0; | |
| return { cid, a, b, dh }; | |
| }); | |
| if (filterStr) { | |
| rows = rows.filter(r => r.cid.toLowerCase().includes(filterStr)); | |
| } | |
| if (onlyChanged) { | |
| rows = rows.filter(r => Math.abs(r.dh) >= 1); | |
| } | |
| switch (sortBy) { | |
| case 'abs_dh_desc': rows.sort((x,y) => Math.abs(y.dh) - Math.abs(x.dh) || x.cid.localeCompare(y.cid)); break; | |
| case 'dh_desc': rows.sort((x,y) => y.dh - x.dh); break; | |
| case 'dh_asc': rows.sort((x,y) => x.dh - y.dh); break; | |
| case 'case_id': rows.sort((x,y) => x.cid.localeCompare(y.cid)); break; | |
| } | |
| const summaryEl = document.getElementById('summary'); | |
| const ups = rows.filter(r => r.dh > 0).length; | |
| const downs = rows.filter(r => r.dh < 0).length; | |
| const zeros = rows.filter(r => r.dh === 0).length; | |
| const max = Math.max(0, ...rows.map(r => Math.abs(r.dh))); | |
| summaryEl.innerHTML = | |
| `<span><b>${rows.length}</b> cases shown</span>` + | |
| `<span class="badge up">↑ taller: ${ups}</span>` + | |
| `<span class="badge down">↓ shorter: ${downs}</span>` + | |
| `<span class="badge zero">– unchanged: ${zeros}</span>` + | |
| `<span>max |Δh| = <b>${max}</b>px</span>` + | |
| `<span>left = <b>${aName}</b> · right = <b>${bName}</b></span>`; | |
| const grid = document.getElementById('grid'); | |
| if (rows.length === 0) { | |
| grid.innerHTML = '<div class="empty">no cases match</div>'; | |
| return; | |
| } | |
| grid.innerHTML = rows.map(r => renderRow(r, aName, bName)).join(''); | |
| } | |
| function renderRow({ cid, a, b, dh }, aName, bName) { | |
| const klass = Math.abs(dh) >= 4 ? 'row changed' : 'row'; | |
| const dhKlass = classifyDh(dh); | |
| const dhText = dh === 0 ? '±0' : (dh > 0 ? `+${dh}` : `${dh}`); | |
| const aFonts = a && a.fonts ? fontsHistToString(a.fonts) : '—'; | |
| const bFonts = b && b.fonts ? fontsHistToString(b.fonts) : '—'; | |
| const tpl = (a && a.template) || (b && b.template) || ''; | |
| const tid = (a && a.test_id) || (b && b.test_id) || ''; | |
| return ` | |
| <div class="${klass}"> | |
| <div class="row-head"> | |
| <span class="case-id">${cid}</span> | |
| <span class="pill tpl">${tpl}</span> | |
| <span class="pill">${tid}</span> | |
| <span class="badge ${dhKlass}">Δh ${dhText}px</span> | |
| <span class="row-meta">${a ? `left ${a.w}×${a.h}` : 'left missing'} · ${b ? `right ${b.w}×${b.h}` : 'right missing'}</span> | |
| </div> | |
| <div class="pair"> | |
| ${renderPanel(a, aName, aFonts)} | |
| ${renderPanel(b, bName, bFonts)} | |
| </div> | |
| ${a && a.title ? `<details><summary>show title text / description</summary> | |
| <pre>title: ${escapeHtml(a.title)}\\ndescription: ${escapeHtml(a.description||'')}</pre></details>` : ''} | |
| </div>`; | |
| } | |
| function renderPanel(rec, runName, fontsStr) { | |
| if (!rec) return `<div class="panel missing">missing in ${runName}</div>`; | |
| if (!rec.ok) return `<div class="panel missing">failed: ${escapeHtml(rec.reason||'')}</div>`; | |
| if (!rec.png) return `<div class="panel missing">no png for ${runName}</div>`; | |
| return `<div class="panel"> | |
| <div class="head"><span>${runName}</span><span class="fonts">${fontsStr}</span></div> | |
| <img src="${rec.png}" alt="${rec.png}"> | |
| </div>`; | |
| } | |
| function escapeHtml(s) { | |
| return String(s).replace(/[&<>"']/g, ch => ({ | |
| '&':'&','<':'<','>':'>','"':'"',"'":''' | |
| })[ch]); | |
| } | |
| function populateSelects() { | |
| const names = Object.keys(RUNS); | |
| const [defA, defB] = pickDefaults(); | |
| for (const id of ['runA', 'runB']) { | |
| const sel = document.getElementById(id); | |
| sel.innerHTML = names.map(n => `<option value="${n}">${n}</option>`).join(''); | |
| sel.value = id === 'runA' ? defA : defB; | |
| sel.addEventListener('change', render); | |
| } | |
| document.getElementById('filter').addEventListener('input', render); | |
| document.getElementById('sort').addEventListener('change', render); | |
| document.getElementById('onlyChanged').addEventListener('change', render); | |
| } | |
| if (Object.keys(RUNS).length === 0) { | |
| document.getElementById('grid').innerHTML = | |
| '<div class="empty">no runs found. run <code>tests/title_styler_regression/run.py --run baseline</code> first.</div>'; | |
| } else { | |
| populateSelects(); | |
| render(); | |
| } | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| def main(): | |
| runs = discover_runs() | |
| if not runs: | |
| print("no run found under tests/title_styler_regression/results/", file=sys.stderr) | |
| print("first run:", file=sys.stderr) | |
| print(" PYTHONPATH=. python tests/title_styler_regression/run.py --run baseline", | |
| file=sys.stderr) | |
| payload = json.dumps(runs, ensure_ascii=False) | |
| html = HTML_TEMPLATE.replace("__RUNS_JSON__", payload) | |
| with open(OUT_HTML, "w", encoding="utf-8") as f: | |
| f.write(html) | |
| print(f"wrote {OUT_HTML}") | |
| if runs: | |
| print("available runs:") | |
| for name, r in runs.items(): | |
| print(f" - {name} ok={r['n_ok']} fail={r['n_fail']}") | |
| print(f"\nopen in browser: file://{OUT_HTML}") | |
| if __name__ == "__main__": | |
| main() | |