A bar-right toggle reveals a squarified treemap of the shown step's context: category regions sized by tokens, with same-kind items folded into one weighted block (every bash result together, skill loads by skill, injections by form) and an 'others' tail past the fold cap. Clicking a singleton block lands on its exact row (the DNA band pick's path), a folded block filters the category's rows to the group's tag, and a region header toggles the category like its row does. Block and region hovers float a figure bubble and, outside DNA mode, report the category to the shared composition hover; the open category keeps its region framed.
189 lines
7.3 KiB
TypeScript
189 lines
7.3 KiB
TypeScript
// Client test kit: real dictionaries, real React rendering, and a faithful harness-context implementation
|
|
// (the documented cordis/locale/slots contracts, not mocks of plugin code), shared by every client spec.
|
|
|
|
import { act, type ReactElement, type ReactNode } from 'react'
|
|
import { createRoot, type Root } from 'react-dom/client'
|
|
import { DICT_EN, DICT_ZH } from '../../../src/client/i18n'
|
|
import type { Translate } from '../../../src/client/i18n'
|
|
import { makeViewKit, type ViewKit } from '../../../src/client/viewkit'
|
|
import type { SurfaceNode } from '../../../src/shared/types'
|
|
|
|
/** A user-category surface node fixture with a non-trivial token figure — the card specs' shared shape. */
|
|
export function surfaceNode(over: Partial<SurfaceNode> & { seq: number }): SurfaceNode {
|
|
return { cat: 'user', tokens: 5, ...over }
|
|
}
|
|
|
|
/** A minimal user-category surface node fixture, seq first — the assemble/brief specs' shared shape. */
|
|
export function surfaceNodeAt(seq: number, over: Partial<SurfaceNode> = {}): SurfaceNode {
|
|
return { seq, cat: 'user', tokens: 1, ...over }
|
|
}
|
|
|
|
/** The harness locale chain: active-locale dictionary → en → the key itself,
|
|
* with `{name}` interpolation. Built over the plugin's REAL dictionaries, so
|
|
* assertions exercise the shipped strings rather than a test copy. */
|
|
export function makeTranslate(active: 'en' | 'zh' = 'en', dicts?: Record<string, Record<string, string>>): Translate {
|
|
const table: Record<string, Record<string, string>> = dicts ?? { zh: DICT_ZH, en: DICT_EN }
|
|
return (key, params) => {
|
|
let s = table[active]?.[key] ?? table.en[key] ?? key
|
|
if (params) for (const k in params) s = s.replace('{' + k + '}', String(params[k]))
|
|
return s
|
|
}
|
|
}
|
|
|
|
export function makeKit(active: 'en' | 'zh' = 'en'): ViewKit {
|
|
return makeViewKit(makeTranslate(active))
|
|
}
|
|
|
|
export interface Mounted {
|
|
container: HTMLElement
|
|
root: Root
|
|
update(el: ReactElement): Promise<void>
|
|
unmount(): Promise<void>
|
|
}
|
|
|
|
/** Mount a real React tree in jsdom, effects flushed through act. */
|
|
export async function mount(el: ReactElement): Promise<Mounted> {
|
|
const container = document.createElement('div')
|
|
document.body.appendChild(container)
|
|
const root = createRoot(container)
|
|
await act(async () => {
|
|
root.render(el)
|
|
})
|
|
return {
|
|
container,
|
|
root,
|
|
async update(next) {
|
|
await act(async () => {
|
|
root.render(next)
|
|
})
|
|
},
|
|
async unmount() {
|
|
await act(async () => {
|
|
root.unmount()
|
|
})
|
|
container.remove()
|
|
},
|
|
}
|
|
}
|
|
|
|
/** Flush pending promises/effects (image loads, deferred setState). */
|
|
export async function flush(): Promise<void> {
|
|
await act(async () => {})
|
|
}
|
|
|
|
/** Poll until the predicate holds, one short act() scope per tick: timers and
|
|
* promise chains only get to run while a scope's sleep is awaiting, so the
|
|
* state updates they land are act-attributed (no React act() warnings) and
|
|
* committed when the scope exits — the check between ticks sees them. Real
|
|
* timers ride the store's own debounce; `message` labels the give-up failure. */
|
|
export async function until(fn: () => boolean, message: string): Promise<void> {
|
|
for (let i = 0; i < 400; i++) {
|
|
await act(async () => {
|
|
if (fn()) return
|
|
await new Promise(resolve => setTimeout(resolve, 5))
|
|
})
|
|
if (fn()) return
|
|
}
|
|
throw new Error(message)
|
|
}
|
|
|
|
/** Silence a deliberately-thrown render error: React 18 dev replays a failed
|
|
* render through a fake DOM event, which jsdom reports as an uncaught window
|
|
* error and vitest forwards to an uncaughtException — a user error listener
|
|
* that prevents the default keeps the throw inside the test. Returns the cleanup that restores normal error reporting. */
|
|
export function silenceWindowErrors(): () => void {
|
|
const onError = (e: Event) => { e.preventDefault() }
|
|
window.addEventListener('error', onError)
|
|
return () => { window.removeEventListener('error', onError) }
|
|
}
|
|
|
|
export function text(container: HTMLElement): string {
|
|
return container.textContent ?? ''
|
|
}
|
|
|
|
/** A composition bar's tooltip rows — head, figure, optional note — so specs assert the
|
|
* bubble's structure instead of a run-together textContent. */
|
|
export function tipRows(tip: HTMLElement): (string | null)[] {
|
|
return ['.lc-bar-tip-head', '.lc-bar-tip-fig', '.lc-bar-tip-note']
|
|
.map(sel => tip.querySelector<HTMLElement>(sel)?.textContent ?? null)
|
|
}
|
|
|
|
export function query<T extends Element = HTMLElement>(container: ParentNode, selector: string): T {
|
|
const el = container.querySelector<T>(selector)
|
|
if (el === null) throw new Error(`element not found: ${selector}`)
|
|
return el
|
|
}
|
|
|
|
export function queryAll<T extends Element = HTMLElement>(container: ParentNode, selector: string): T[] {
|
|
return [...container.querySelectorAll<T>(selector)]
|
|
}
|
|
|
|
/** Pin a jsdom element's boxes the way a real layout would: components that measure
|
|
* getBoundingClientRect get deterministic geometry (jsdom lays nothing out). */
|
|
export function pinRect(el: Element, width: number, height = 0): void {
|
|
Object.defineProperty(el, 'getBoundingClientRect', {
|
|
configurable: true,
|
|
value: () => ({ left: 0, top: 0, width, height, right: width, bottom: height }),
|
|
})
|
|
}
|
|
|
|
/** Click through act so React processes the dispatch synchronously. */
|
|
export async function click(el: HTMLElement): Promise<void> {
|
|
await act(async () => {
|
|
el.click()
|
|
})
|
|
}
|
|
|
|
/** Type into an input/textarea through act: the native value setter plus the
|
|
* input event React's onChange listens for (a plain assignment is swallowed
|
|
* by React's value tracker). */
|
|
export async function typeText(el: HTMLTextAreaElement | HTMLInputElement, text: string): Promise<void> {
|
|
await act(async () => {
|
|
const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype
|
|
Object.getOwnPropertyDescriptor(proto, 'value')?.set?.call(el, text)
|
|
el.dispatchEvent(new Event('input', { bubbles: true }))
|
|
})
|
|
}
|
|
|
|
/** Pointer hover. React 18 synthesizes onMouseEnter/onMouseLeave from the
|
|
* BUBBLING mouseover/mouseout pair — dispatching raw mouseenter/leave (they do not bubble) never reaches React's handlers. */
|
|
export async function hover(el: Element): Promise<void> {
|
|
await act(async () => {
|
|
el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }))
|
|
})
|
|
}
|
|
|
|
export async function unhover(el: Element): Promise<void> {
|
|
await act(async () => {
|
|
el.dispatchEvent(new MouseEvent('mouseout', { bubbles: true }))
|
|
})
|
|
}
|
|
|
|
/** Keyboard focus. React synthesizes onFocus/onBlur from the BUBBLING focusin/focusout pair. */
|
|
export async function focus(el: Element): Promise<void> {
|
|
await act(async () => {
|
|
el.dispatchEvent(new FocusEvent('focusin', { bubbles: true }))
|
|
})
|
|
}
|
|
|
|
export async function blur(el: Element): Promise<void> {
|
|
await act(async () => {
|
|
el.dispatchEvent(new FocusEvent('focusout', { bubbles: true }))
|
|
})
|
|
}
|
|
|
|
export async function keydown(key: string, target: HTMLElement | Window = window): Promise<void> {
|
|
await act(async () => {
|
|
target.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }))
|
|
})
|
|
}
|
|
|
|
/** Dispatch a real wheel gesture on an element and report whether a listener canceled it. */
|
|
export function wheel(el: Element, deltaX: number, deltaY: number): boolean {
|
|
const event = new WheelEvent('wheel', { bubbles: true, cancelable: true, deltaX, deltaY })
|
|
el.dispatchEvent(event)
|
|
return event.defaultPrevented
|
|
}
|
|
|
|
export type { ReactElement, ReactNode }
|