// 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(/]*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(/
]*)>/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", /]*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)", /]*class="hm-sw-header__document-button"/.test(untitled) && /]*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