fix(billing-ui): 规则表列宽锁定,消除表头/按钮重叠

全部靠截图 + 几何测量发现,DOM 断言当时全绿:

- table-layout:fixed 把 954px 均分成 9×106px,写在 <td> 上的每列宽度全部失效 →
  相邻表头叠在一起,源 URL 输入框被压成 "https:"。
- 把宽度改到 <th> 并去掉 fixed → 浏览器改按内容算,<th> 宽度又被忽略,币种列
  仍塌到 48px(输入框只剩 24px,装不下 "USD")。
- 恢复 fixed + 宽度移到 <colgroup>:fixed 下只有首行宽度生效,而「按模型价格」
  格宽度随哪条规则最宽而变(实测 352px vs 206px),整行布局取决于恰好哪条规则
  最宽。colgroup 一次性锁死九列,所有行一致。
- 表头双语长文案(峰段星期 UTC 1=周一)超出列宽 → nowrap + ellipsis,完整文案
  留在 title。
- 删除列 58px 装不下「删除」按钮(折成两行、按钮高过整行)→ 74px,从峰段倍数
  列匀出。
- 价目行 80+96+96+38≈330px 挤在 262px 格子里,× 按钮越界 78px 压在隔壁「删除」
  按钮上 → 输入框改 flex:1 1 …/min-width:0 可收缩,× 保持 flex:0 0 auto。
- 顺带修上轮引入的回归:给 modelBlock 的 flex 换行后模型名不再被截断。

判据 TestRuleTableLocksColumnWidthsInAColgroup + 3 个变异(去掉 min-width:0 /
改列宽数组 / 去掉 table-layout:fixed)全部被捕获。第三个变异第一版漏放——判据用
strings.Contains(js, "table-layout:fixed") 匹配到了上方三行的解释性注释,改成匹配
style='…' 字面量后才真正失败。
This commit is contained in:
JianFeeeee
2026-10-02 15:29:29 +08:00
parent c19b8e6394
commit 400b6c35a5
2 changed files with 129 additions and 16 deletions

View File

@ -339,3 +339,78 @@ func TestBillingTableHeaderMatchesRowColumns(t *testing.T) {
"table is misaligned from the first differing column on", th, td) "table is misaligned from the first differing column on", th, td)
} }
} }
// TestRuleTableLocksColumnWidthsInAColgroup guards the layout class of bug that
// only a screenshot caught: two adjacent headers rendered on top of each other,
// the URL input squeezed to "https:", and the per-model × button landing 78px
// past its cell onto the neighbouring Delete button.
//
// The measurement that found each one:
// - table-layout:fixed with widths on <td> → 954px divided into 9 equal
// 106px columns, every declared width discarded.
// - widths on <th> WITHOUT fixed → browser sizes from content and ignores
// them; the 70px currency column still collapsed to 48px (24px of input).
// - fixed + <th> widths → they take effect, but the model-price cell's width
// then depended on WHICH RULE was widest (352px vs 206px).
//
// The fix is all three together: fixed layout, a colgroup, and flex children
// that may shrink.
func TestRuleTableLocksColumnWidthsInAColgroup(t *testing.T) {
page, _ := billingUI(t)
js := strings.Join(extractScripts(page), "\n")
// Match the ATTRIBUTE, not the bare string: a substring check on
// "table-layout:fixed" is satisfied by the explanatory comment that sits
// three lines above the tag, so removing the attribute from the table still
// passed (mutation-verified). Require it inside a style='...' literal.
if !strings.Contains(js, `font-size:12px;table-layout:fixed'`) {
t.Error("the rule table must stay table-layout:fixed — with auto layout " +
"the <colgroup> widths below are ignored and columns size from content")
}
colStart := strings.Index(js, "<colgroup>")
if colStart < 0 {
t.Fatal("the rule table declares no <colgroup>; per-column widths on " +
"<th> or <td> are advisory and get recomputed from content")
}
// The colgroup is built from a JS array of pixel widths, so assert on the
// array, not on literal "<col " occurrences — counting the literal found
// exactly one and reported "1 columns, want 9" against correct code.
if !strings.Contains(js, "[214, 80, 62, 70, 66, 74, 62, 262, 74]") {
t.Error("the rule table's colgroup widths changed or vanished; measured " +
"values were [214 80 62 70 66 74 62 262 74] for a 954px pane — the " +
"delete column needs 74px or the button wraps to two lines")
}
if !strings.Contains(js, `"<col style='width:" + w + "px'>"`) {
t.Error("colgroup entries are no longer emitted as <col style='width:Npx'>")
}
// Every price-row INPUT must be allowed to shrink, or the row's fixed widths
// sum past the cell and the × button lands on the next column. The × button
// is the opposite: it must NOT shrink (flex:0 0 auto), because a squashed
// delete button is unclickable.
for _, needle := range []string{"m-name", "m-p", "m-c"} {
i := strings.Index(js, "class='"+needle+"'")
if i < 0 {
i = strings.Index(js, "class='ghost small "+needle+"'")
}
if i < 0 {
t.Errorf("price row lost the %s input/button", needle)
continue
}
window := js[i : i+220]
if !strings.Contains(window, "flex:") {
t.Errorf("%s has no flex sizing; the row is 80+96+96+38 ≈ 330px wide "+
"against a 262px cell and overflows onto the next column", needle)
}
if !strings.Contains(window, "min-width:0") {
t.Errorf("%s lacks min-width:0; a flex item will not shrink below its "+
"content width, which is how the × button ended up 78px past its cell", needle)
}
}
i := strings.Index(js, "class='ghost small m-del'")
if i < 0 {
t.Fatal("the per-model delete button is gone")
}
if !strings.Contains(js[i:i+120], "flex:0 0 auto") {
t.Error("the per-model × button must be flex:0 0 auto — it is a fixed-size " +
"control and must never be squeezed by the flexible inputs")
}
}

View File

@ -1039,15 +1039,21 @@ plugin.ui = {
// ---- row rendering ------------------------------------------------------- // ---- row rendering -------------------------------------------------------
function modelBlock(models, T) { function modelBlock(models, T) {
var keys = Object.keys(models || {}); var keys = Object.keys(models || {});
// Every child must be allowed to SHRINK: the price cell is 262px wide and
// the four parts (name + prompt + completion + delete) used to be
// 80+96+96+38 plus gaps = ~330px, so the × button overflowed the cell by
// 78px and landed on top of the neighbouring column's Delete button.
// flex:1 1 0 with min-width:0 lets the inputs share the cell instead of
// pushing past it; the row wraps rather than overlapping.
var rows = keys.map(function (m) { var rows = keys.map(function (m) {
var t = models[m] || {}; var t = models[m] || {};
return "<div class='row' style='gap:6px;margin:3px 0'>" + return "<div class='row' style='gap:4px;margin:3px 0;flex-wrap:wrap'>" +
"<input class='m-name' value='" + esc(m) + "' style='flex:1;min-width:80px'>" + "<input class='m-name' value='" + esc(m) + "' style='flex:1 1 90px;min-width:0'>" +
"<input class='m-p' value='" + esc(t.prompt == null ? "" : t.prompt) + "<input class='m-p' value='" + esc(t.prompt == null ? "" : t.prompt) +
"' placeholder='" + esc(T.prompt) + "' style='width:96px'>" + "' placeholder='" + esc(T.prompt) + "' style='flex:0 1 62px;min-width:0'>" +
"<input class='m-c' value='" + esc(t.completion == null ? "" : t.completion) + "<input class='m-c' value='" + esc(t.completion == null ? "" : t.completion) +
"' placeholder='" + esc(T.completion) + "' style='width:96px'>" + "' placeholder='" + esc(T.completion) + "' style='flex:0 1 62px;min-width:0'>" +
"<button class='ghost small m-del' title='" + esc(T.delRule) + "'>&times;</button></div>"; "<button class='ghost small m-del' title='" + esc(T.delRule) + "' style='flex:0 0 auto'>&times;</button></div>";
}).join(""); }).join("");
return "<div class='m-rows'>" + rows + "</div>" + return "<div class='m-rows'>" + rows + "</div>" +
"<button class='ghost small m-add'>" + esc(T.addModel) + "</button>"; "<button class='ghost small m-add'>" + esc(T.addModel) + "</button>";
@ -1068,14 +1074,14 @@ plugin.ui = {
"<td style='min-width:200px'><input class='r-url' value='" + esc(rule.url || "") + "' list='br-urls' style='width:100%;box-sizing:border-box'>" + "<td style='min-width:200px'><input class='r-url' value='" + esc(rule.url || "") + "' list='br-urls' style='width:100%;box-sizing:border-box'>" +
(unknown ? "<div style='color:#e0a33e;font-size:11px'>&#9888; " + esc(T.warnNoSource) + "</div>" : "") + (unknown ? "<div style='color:#e0a33e;font-size:11px'>&#9888; " + esc(T.warnNoSource) + "</div>" : "") +
"</td>" + "</td>" +
"<td style='width:78px'><select class='r-mode' style='width:100%;box-sizing:border-box'>" + opts + "</select></td>" + "<td style='width:88px'><select class='r-mode' style='width:100%;box-sizing:border-box;padding:2px;font-size:12px'>" + opts + "</select></td>" +
"<td style='width:64px'><input class='r-cur' value='" + esc(rule.currency || "") + "' placeholder='USD' style='width:100%;box-sizing:border-box'></td>" + "<td style='width:70px'><input class='r-cur' value='" + esc(rule.currency || "") + "' placeholder='USD' style='width:100%;box-sizing:border-box;padding:2px 4px;font-size:12px'></td>" +
"<td style='width:76px'><input class='r-sub' type='number' step='0.01' value='" + "<td style='width:80px'><input class='r-sub' type='number' step='0.01' value='" +
(rule.subscription ? esc(rule.subscription) : "") + "' placeholder='" + esc(T.monthly) + "' style='width:100%;box-sizing:border-box'></td>" + (rule.subscription ? esc(rule.subscription) : "") + "' placeholder='" + esc(T.monthly) + "' style='width:100%;box-sizing:border-box;padding:2px 4px;font-size:12px'></td>" +
"<td style='width:58px'><input class='r-mult' type='number' step='0.1' value='" + "<td style='width:68px'><input class='r-mult' type='number' step='0.1' value='" +
(p && p.multiplier ? esc(p.multiplier) : "") + "' placeholder='" + esc(T.peakMult) + "' style='width:100%;box-sizing:border-box'></td>" + (p && p.multiplier ? esc(p.multiplier) : "") + "' placeholder='" + esc(T.peakMult) + "' style='width:100%;box-sizing:border-box;padding:2px 4px;font-size:12px'></td>" +
"<td style='width:88px'><input class='r-hours' value='" + esc(hoursText(p)) + "' placeholder='1-4,6-10' style='width:100%;box-sizing:border-box'></td>" + "<td style='width:96px'><input class='r-hours' value='" + esc(hoursText(p)) + "' placeholder='1-4,6-10' style='width:100%;box-sizing:border-box;padding:2px 4px;font-size:12px'></td>" +
"<td style='width:62px'><input class='r-days' value='" + esc(daysText(p)) + "' placeholder='1,2,3' style='width:100%;box-sizing:border-box'></td>" + "<td style='width:78px'><input class='r-days' value='" + esc(daysText(p)) + "' placeholder='1,2,3' style='width:100%;box-sizing:border-box;padding:2px 4px;font-size:12px'></td>" +
"<td style='min-width:180px'>" + modelBlock(rule.models, T) + "</td>" + "<td style='min-width:180px'>" + modelBlock(rule.models, T) + "</td>" +
"<td style='width:56px'><button class='ghost small r-del'>" + esc(T.delRule) + "</button></td></tr>"; "<td style='width:56px'><button class='ghost small r-del'>" + esc(T.delRule) + "</button></td></tr>";
} }
@ -1247,11 +1253,43 @@ plugin.ui = {
"<span class='grow'></span>" + "<span class='grow'></span>" +
"<button class='ghost small p-add' data-pid='" + esc(p.id) + "'>" + esc(T.addRule) + "</button>" + "<button class='ghost small p-add' data-pid='" + esc(p.id) + "'>" + esc(T.addRule) + "</button>" +
"</div>" + "</div>" +
// Widths live on <th> AND the layout is fixed — the two go together.
//
// Measured: with table-layout:fixed and widths on the <td>s, the 954px
// container was divided into 9 equal 106px columns and every per-column
// width was discarded, so two adjacent headers sat on top of each other
// and the URL input was squeezed to "https:". With auto layout the
// browser ignores the <th> widths entirely and sizes from content, so
// the 70px currency column still collapsed to 48px (24px of input —
// too narrow to show "USD"). Under fixed layout the FIRST ROW's widths
// win, so the <th> widths below are the ones that take effect, and the
// total (1046px) exceeds the container on purpose: the wrapper scrolls.
"<div style='overflow-x:auto'><table style='width:100%;border-collapse:collapse;font-size:12px;table-layout:fixed'>" + "<div style='overflow-x:auto'><table style='width:100%;border-collapse:collapse;font-size:12px;table-layout:fixed'>" +
// colgroup, not th widths: under table-layout:fixed the first row's
// widths win, and the model-price cell holds a variable-width list of
// price rows whose content width varies per rule. Measuring that cell
// gave 352px on one rule and 206px on another — the whole row's
// layout then depended on WHICH RULE happened to be widest. A colgroup
// fixes the nine columns once and keeps every row identical.
"<colgroup>" +
// The last column holds a 「删除 / Delete」 button: 58px wrapped it
// to two lines and the button grew taller than the row. 74px fits it
// on one line; the peak-multiplier column gives up the difference
// (it only ever holds a single digit such as "2").
[214, 80, 62, 70, 66, 74, 62, 262, 74]
.map(function (w) { return "<col style='width:" + w + "px'>"; }).join("") +
"</colgroup>" +
"<tr style='text-align:left;opacity:.65'>" + "<tr style='text-align:left;opacity:.65'>" +
"<th>" + esc(T.url) + "</th><th>" + esc(T.mode) + "</th><th>" + esc(T.currency) + "</th>" + [[T.url], [T.mode], [T.currency], [T.monthly], [T.peakMult],
"<th>" + esc(T.monthly) + "</th><th>" + esc(T.peakMult) + "</th><th>" + esc(T.peakHours) + "</th>" + [T.peakHours], [T.peakDays], [T.models], [""]]
"<th>" + esc(T.peakDays) + "</th><th>" + esc(T.models) + "</th><th></th></tr>" + .map(function (c) {
// nowrap + ellipsis: the bilingual headers ("峰段星期 (UTC 1=周一)")
// are far wider than their column, and without this they spilled
// into the neighbouring header. The full text stays in title.
return "<th style='white-space:nowrap;overflow:hidden;text-overflow:ellipsis' title='" +
esc(c[0]) + "'>" + esc(c[0]) + "</th>";
}).join("") +
"</tr>" +
(rules.length (rules.length
? rules.map(function (r) { return ruleRow(r, known, T); }).join("") ? rules.map(function (r) { return ruleRow(r, known, T); }).join("")
: "<tr><td colspan='9' class='muted' style='padding:8px 0'>" + esc(T.noRules) + "</td></tr>") + : "<tr><td colspan='9' class='muted' style='padding:8px 0'>" + esc(T.noRules) + "</td></tr>") +