- 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
93 lines
7.2 KiB
JavaScript
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
|