Files
HMDesign/packages/sw/tests/process-header-states.check.mjs
shkim4 c7926aeffd fix(sw): square the multi step header process ends
- SWProcessHeader appearance="multi": the process base and both end
  covers meet the file name and the window area with right angles
  (border-radius 0, multi block only). default keeps the Strana top
  corners 6 / covers 5; step tabs, arrows, colors and sizes unchanged
- DESIGN_RULES §14 and Step Header spec §9.2 record the multi-only rule
- process-header-states.check.mjs: default 6 / 5 kept, multi 0 (17/17);
  build:sw and check:ai-docs pass, Storybook zoom shows square multi ends
2026-11-05 17:31:38 +09:00

93 lines
7.2 KiB
JavaScript

// SWProcessHeader state regression check (2026-11-05): step enablement, step selection by activeStepId, the "no step"
// cases and the file name before a file exists. Server render of the built package (no browser, no Storybook).
// Usage: npm run build -w @hm-design/sw && node packages/sw/tests/process-header-states.check.mjs
import { readFile } from "node:fs/promises"
import { createElement as h } from "react"
import { renderToStaticMarkup } from "react-dom/server"
import { SWProcessHeader } from "../dist/index.js"
const results = []
const check = (name, ok, detail) => { results.push(ok); console.log(`${ok ? "PASS" : "FAIL"} ${name}${ok || detail === undefined ? "" : ` ${JSON.stringify(detail)}`}`) }
const render = props => renderToStaticMarkup(h(SWProcessHeader, { appearance: "multi", documentTitle: "", documentEmptyLabel: "프로젝트 생성 전", ...props }))
const tabs = html => [...html.matchAll(/<button[^>]*role="radio"[^>]*>/g)].map(([tag]) => ({
id: tag.match(/data-step-id="([^"]*)"/)?.[1], disabled: /\sdisabled=""/.test(tag), checked: /aria-checked="true"/.test(tag), stop: /tabindex="0"/i.test(tag),
}))
const groupsOf = html => [...html.matchAll(/<div class="hm-sw-process-bar__group"([^>]*)>/g)].map(([, attrs]) => ({
active: /data-active="true"/.test(attrs), dim: /data-dim="true"/.test(attrs), done: /data-progress="done"/.test(attrs),
}))
const count = (list, key) => list.filter(item => item[key]).length
const GROUPS = [
{ id: "g1", number: "01", label: "데이터 구축", steps: [{ id: "input", label: "데이터입력" }, { id: "review", label: "데이터확인", disabled: false }] },
{ id: "g2", number: "02", label: "선형 입력", steps: [{ id: "line", label: "선형입력" }] },
{ id: "g3", number: "03", label: "해석", steps: [{ id: "model", label: "해석모델" }, { id: "result", label: "성과품", disabled: true }] },
]
// 1. Enablement: only an explicit disabled: true (step, group, process, header) turns steps off.
{
const t = tabs(render({ process: { groups: GROUPS, activeStepId: "input" } }))
check("steps without disabled (omitted or false) are enabled; only disabled: true is off", t.length === 5 && count(t, "disabled") === 1 && t[4].disabled)
const group = tabs(render({ process: { groups: GROUPS.map(g => (g.id === "g2" ? { ...g, disabled: true } : g)), activeStepId: "input" } }))
check("group.disabled turns only that group's steps off", count(group, "disabled") === 2 && group[2].disabled)
check("process.disabled turns every step off", count(tabs(render({ process: { groups: GROUPS, disabled: true } })), "disabled") === 5)
check("header disabled turns every step off", count(tabs(render({ process: { groups: GROUPS }, disabled: true })), "disabled") === 5)
}
// 2. Project / file state never decides enablement.
{
const variants = [
render({ process: { groups: GROUPS, activeStepId: "input" } }),
render({ documentEmptyLabel: undefined, process: { groups: GROUPS, activeStepId: "input" } }),
render({ documentTitle: "Bridge.sa", documentEmptyLabel: undefined, process: { groups: GROUPS, activeStepId: "input" } }),
].map(html => tabs(html).map(t => t.disabled).join())
check("empty title, no empty label and a real file name give the same enabled steps", new Set(variants).size === 1, variants)
}
// 3. Selection follows activeStepId (what step clicks, keyboard and host previous / next buttons change).
{
const order = ["input", "review", "line", "model"]
const states = order.map(step => { const html = render({ process: { groups: GROUPS, activeStepId: step } }); return { t: tabs(html), g: groupsOf(html) } })
check("each activeStepId checks exactly that step, with the single tab stop on it",
states.every(({ t }, i) => count(t, "checked") === 1 && t.findIndex(x => x.checked) === [0, 1, 2, 3][i] && count(t, "stop") === 1 && t[[0, 1, 2, 3][i]].stop))
const lit = states.map(({ g }) => g.findIndex(x => x.active))
check("crossing groups moves the lit group (input/review -> 01, line -> 02, model -> 03)", lit.join() === "0,0,1,2", lit)
check("multi: groups before the lit one are done, the others not", states[3].g.map(x => x.done).join() === "true,true,false" && states[0].g.every(x => !x.done))
}
// 4. No step set: nothing is created or selected, the steps stay selectable.
{
const html = render({ process: { groups: GROUPS } })
const t = tabs(html), g = groupsOf(html)
check("no activeStepId: no checked step, no lit or done group, every group dim", count(t, "checked") === 0 && g.every(x => !x.active && !x.done && x.dim))
check("no activeStepId: steps stay enabled and the first enabled step is the tab stop", count(t, "disabled") === 1 && count(t, "stop") === 1 && t[0].stop)
const empty = render({ process: { groups: [] } })
check("groups = []: no step tab and no group are rendered (nothing invented)", tabs(empty).length === 0 && groupsOf(empty).length === 0 && /hm-sw-process-bar/.test(empty))
}
// 5. File name before a file exists: marked untitled, still the same button (click kept), CSS removes the clickable look.
{
const untitled = render({ process: { groups: GROUPS } })
const named = render({ documentTitle: "Bridge.sa", documentEmptyLabel: undefined, process: { groups: GROUPS } })
check("documentTitle \"\" marks the header data-document-untitled; a real file name does not",
/<header[^>]*data-document-untitled="true"/.test(untitled) && !/data-document-untitled/.test(named))
check("the file name stays a button in both states (onDocumentClick is still wired)",
/<button[^>]*class="hm-sw-header__document-button"/.test(untitled) && /<button[^>]*class="hm-sw-header__document-button"/.test(named))
const css = (await readFile(new URL("../dist/styles.css", import.meta.url), "utf8")).replace(/\s+/g, " ")
check("styles.css: untitled file name has the default cursor", /\[data-document-untitled\] \.hm-sw-header__document-button ?\{ ?cursor: ?default/.test(css))
check("styles.css: the empty label keeps its color on hover (no :not(:hover) scope)",
/\[data-document-empty\] \.hm-sw-header__document-button \.hm-sw-header__document-name ?\{ ?color: ?var\(--hm-sw-process-document-empty-color\)/.test(css)
&& !/\[data-document-empty\] \.hm-sw-header__document-button:not\(:hover\)/.test(css))
// 6. Process-area ends (2026-11-05): `multi` square (base and end covers radius 0), `default` keeps the Strana 6 / 5.
const block = selector => css.match(new RegExp(`(?:^|\\}|\\*/) ?${selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} ?\\{([^}]*)\\}`))?.[1] ?? null
const base = block(".hm-sw-process-bar::before") ?? ""
check("styles.css: default keeps the process base top corners 6 and the end covers 5",
/border-radius: ?6px 6px 0 0/.test(base) && /border-top-left-radius: ?5px/.test(block('.hm-sw-process-bar__rest[data-side="start"]') ?? "")
&& /border-top-right-radius: ?5px/.test(block('.hm-sw-process-bar__rest[data-side="end"]') ?? ""), { base })
check("styles.css: multi sets radius 0 on the process base and both end covers",
/\.hm-sw-process-bar\[data-appearance="?multi"?\]::before ?, ?\.hm-sw-process-bar\[data-appearance="?multi"?\] ?> ?\.hm-sw-process-bar__rest ?\{ ?border-radius: ?0 ?;? ?\}/.test(css))
}
const passed = results.filter(Boolean).length
console.log(`${passed}/${results.length} PASS`)
process.exitCode = passed === results.length ? 0 : 1