Files
HMDesign/packages/sw/tests/process-header-states.check.mjs
T
shkim4 0086d66a94 fix(sw): refine process header empty title states and UI guidance
- AI_GUIDE stage 5 "Header 전용 스타일의 적용 범위": header-only styles stay
  in the header (SW SWHeader / SWProcessHeader / SWViewerHeader, Web GNB /
  LNBMobileHeader / DashboardHeader); no header CSS or tokens on body UI,
  no global overrides, layout-only joins, package-shared tokens unchanged,
  whole-screen use only on explicit request. Synced to the 4 package
  copies; referenced from stage 6, SW and Web AI_USAGE §3
- SWProcessHeader: with documentTitle "" the file name keeps #AEDBCC on
  hover and a default cursor (root data-document-untitled). The button,
  focus and onDocumentClick are kept; a real file name keeps the yellow
  hover and pointer. Visual vs. click difference recorded as a UX item
- Step enablement documented as implemented: only an explicit disabled
  (step, group, process, header) turns steps off; project / file state
  never does; selection is activeStepId + onStepSelect (also for host
  previous / next); no activeStepId and empty groups described
- Regression check tests/process-header-states.check.mjs (15/15);
  typecheck:sw, build:sw, check:ai-docs pass; public-api keeps its 3
  existing failures, none new
2026-11-05 16:45:54 +09:00

85 lines
6.3 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))
}
const passed = results.filter(Boolean).length
console.log(`${passed}/${results.length} PASS`)
process.exitCode = passed === results.length ? 0 : 1