396 lines
17 KiB
TypeScript
396 lines
17 KiB
TypeScript
import assert from 'node:assert/strict'
|
|
import { getFontLevel, getFontLevelSpec, initFontSize } from '../src/renderer/src/fontSize'
|
|
import {
|
|
buildFontSizeOverrideCSS,
|
|
buildWbFontSizeTokenCSS,
|
|
createFontSizeDomAdapter,
|
|
getFontSizeShortcutAction
|
|
} from '../src/renderer/src/workbuddy/typography/font-size'
|
|
|
|
let assertions = 0
|
|
|
|
function equal<T>(actual: T, expected: T, message: string): void {
|
|
assert.equal(actual, expected, message)
|
|
assertions += 1
|
|
}
|
|
|
|
function includes(value: string, expected: string, message: string): void {
|
|
assert.ok(value.includes(expected), message)
|
|
assertions += 1
|
|
}
|
|
|
|
function excludes(value: string, forbidden: string, message: string): void {
|
|
assert.ok(!value.includes(forbidden), message)
|
|
assertions += 1
|
|
}
|
|
|
|
class FakeStyleDeclaration {
|
|
private readonly entries: Array<[string, string]>
|
|
|
|
constructor(values: Record<string, string>) {
|
|
this.entries = Object.entries(values)
|
|
}
|
|
|
|
get length(): number {
|
|
return this.entries.length
|
|
}
|
|
|
|
item(index: number): string {
|
|
return this.entries[index]?.[0] ?? ''
|
|
}
|
|
|
|
getPropertyValue(name: string): string {
|
|
return this.entries.find(([key]) => key === name)?.[1] ?? ''
|
|
}
|
|
}
|
|
|
|
function styleRule(selectorText: string, values: Record<string, string>): CSSRule {
|
|
return {
|
|
type: 1,
|
|
selectorText,
|
|
style: new FakeStyleDeclaration(values)
|
|
} as unknown as CSSRule
|
|
}
|
|
|
|
function mediaRule(conditionText: string, cssRules: CSSRule[]): CSSRule {
|
|
return {
|
|
type: 4,
|
|
conditionText,
|
|
cssRules: cssRules as unknown as CSSRuleList
|
|
} as unknown as CSSRule
|
|
}
|
|
|
|
function stylesheet(cssRules: CSSRule[], ownerId?: string): CSSStyleSheet {
|
|
return {
|
|
ownerNode: ownerId ? ({ id: ownerId } as unknown as Element) : null,
|
|
cssRules: cssRules as unknown as CSSRuleList
|
|
} as unknown as CSSStyleSheet
|
|
}
|
|
|
|
function testRuleConstruction(): void {
|
|
const rules = [
|
|
styleRule('.card, .dialog', { 'font-size': '14px' }),
|
|
styleRule('.fallback', { 'font-size': 'var(--unresolved-size, 13.5px)' }),
|
|
styleRule(':root', { '--stable-size': '18px' }),
|
|
styleRule('.stable', { 'font-size': 'var(--stable-size, 14px)' }),
|
|
styleRule(':root', { '--dynamic-size': 'calc(15px + var(--yc-font-offset) * 1px)' }),
|
|
styleRule('.dynamic', { 'font-size': 'var(--dynamic-size, 15px)' }),
|
|
styleRule('.already-dynamic', { 'font-size': 'calc(16px + var(--cb-font-size-offset))' }),
|
|
styleRule('.token', { 'font-size': 'var(--yc-text-body-size)' }),
|
|
styleRule('.relative', { 'font-size': '1rem' }),
|
|
styleRule('.repeated', { 'font-size': '11px' }),
|
|
mediaRule('(min-width: 720px)', [styleRule('.media', { 'font-size': '12px' })]),
|
|
mediaRule('(min-width: 720px)', [styleRule('.media', { 'font-size': '12px' })]),
|
|
mediaRule('(max-width: 500px)', [styleRule('.repeated', { 'font-size': '11px' })])
|
|
]
|
|
const css = buildFontSizeOverrideCSS([stylesheet(rules), stylesheet([styleRule('.repeated', { 'font-size': '11px' })])])
|
|
|
|
includes(css, '.card:not(.cb-font-size-fixed):not(.cb-font-size-fixed *)', 'adds the fixed-zone exclusion to the first selector')
|
|
includes(css, '.dialog:not(.cb-font-size-fixed):not(.cb-font-size-fixed *)', 'splits grouped selectors before adding the fixed-zone exclusion')
|
|
includes(css, 'font-size: max(8px, calc(14px + var(--cb-font-size-offset) * 1px)) !important', 'scales a literal px size with an 8px floor')
|
|
includes(css, 'calc(13.5px + var(--cb-font-size-offset) * 1px)', 'uses the numeric px fallback from var() declarations')
|
|
includes(css, 'calc(14px + var(--cb-font-size-offset) * 1px)', 'uses the declared static fallback without resolving the custom property')
|
|
includes(css, '.dynamic:not(.cb-font-size-fixed)', 'matches the reference fallback behavior for var() declarations')
|
|
includes(css, '@media (min-width: 720px)', 'keeps media rules scoped')
|
|
includes(css, '@media (max-width: 500px)', 'keeps separate media conditions distinct')
|
|
excludes(css, '.already-dynamic:not(', 'does not rescale an existing calc() declaration')
|
|
excludes(css, '.token:not(', 'does not replace a token-only font-size declaration')
|
|
excludes(css, '.relative:not(', 'does not convert relative units to px')
|
|
equal((css.match(/\.repeated:not\(/g) ?? []).length, 2, 'dedupes within a CSS context but retains a different media context')
|
|
equal((css.match(/\.media:not\(/g) ?? []).length, 2, 'uses a fresh dedupe set for each media rule')
|
|
|
|
const inaccessible = {
|
|
get cssRules(): CSSRuleList {
|
|
throw new Error('cross-origin stylesheet')
|
|
}
|
|
} as unknown as CSSStyleSheet
|
|
equal(buildFontSizeOverrideCSS([inaccessible]), '', 'skips stylesheets whose cssRules cannot be read')
|
|
}
|
|
|
|
function testTokenCSS(): void {
|
|
const css = buildWbFontSizeTokenCSS()
|
|
includes(css, '--wb-font-display-size: calc(32px + var(--wb-font-size-offset, 0) * 1px)', 'provides the display token override')
|
|
includes(css, '--wb-button-icon-size-medium: calc(16px + var(--wb-font-size-offset, 0) * 1px)', 'scales button icon tokens with the same offset')
|
|
includes(css, '--wb-button-height-large: calc(var(--wb-button-line-height-large) + 13px * 2)', 'derives the large button height from line height and padding')
|
|
includes(css, '.cb-font-size-fixed,\n.cb-font-size-fixed *', 'creates the fixed-zone token reset')
|
|
includes(css, '--wb-font-body-size: 14px', 'restores token base sizes inside fixed zones')
|
|
includes(css, '--wb-button-line-height-small: 20px', 'restores button line-height tokens inside fixed zones')
|
|
}
|
|
|
|
class FakeStyleElement {
|
|
id = ''
|
|
textContent: string | null = ''
|
|
readonly tagName = 'STYLE'
|
|
parent: FakeHead | null = null
|
|
|
|
remove(): void {
|
|
this.parent?.removeChild(this)
|
|
}
|
|
}
|
|
|
|
class FakeHead {
|
|
readonly children: FakeStyleElement[] = []
|
|
|
|
appendChild(element: FakeStyleElement): FakeStyleElement {
|
|
element.parent = this
|
|
this.children.push(element)
|
|
return element
|
|
}
|
|
|
|
removeChild(element: FakeStyleElement): void {
|
|
const index = this.children.indexOf(element)
|
|
if (index >= 0) this.children.splice(index, 1)
|
|
element.parent = null
|
|
}
|
|
}
|
|
|
|
class FakeMutationObserver {
|
|
disconnected = false
|
|
private readonly callback: MutationCallback
|
|
|
|
constructor(callback: MutationCallback) {
|
|
this.callback = callback
|
|
}
|
|
|
|
observe(_target: Node, _options?: MutationObserverInit): void {}
|
|
|
|
disconnect(): void {
|
|
this.disconnected = true
|
|
}
|
|
|
|
triggerAddedNode(node: Node): void {
|
|
const record = { addedNodes: [node] as unknown as NodeList }
|
|
this.callback([record as MutationRecord], this as unknown as MutationObserver)
|
|
}
|
|
}
|
|
|
|
function testDOMAdapter(): void {
|
|
const head = new FakeHead()
|
|
const properties = new Map<string, string>()
|
|
const styleSheets: CSSStyleSheet[] = [stylesheet([styleRule('.live', { 'font-size': '14px' })])]
|
|
const rootStyle = {
|
|
setProperty: (name: string, value: string): void => properties.set(name, value),
|
|
removeProperty: (name: string): void => {
|
|
properties.delete(name)
|
|
},
|
|
getPropertyValue: (name: string): string => properties.get(name) ?? '',
|
|
get length(): number {
|
|
return properties.size
|
|
},
|
|
item: (index: number): string => [...properties.keys()][index] ?? ''
|
|
} as unknown as CSSStyleDeclaration
|
|
|
|
const documentRef = {
|
|
documentElement: { style: rootStyle, dataset: {} },
|
|
styleSheets,
|
|
head,
|
|
body: null,
|
|
getElementById: (id: string): FakeStyleElement | null => head.children.find((child) => child.id === id) ?? null,
|
|
createElement: (name: string): FakeStyleElement => {
|
|
assert.equal(name, 'style')
|
|
return new FakeStyleElement()
|
|
}
|
|
} as unknown as Document
|
|
|
|
const observerInstances: FakeMutationObserver[] = []
|
|
const timers = new Map<number, () => void>()
|
|
let timerId = 0
|
|
const windowRef = {
|
|
MutationObserver: class extends FakeMutationObserver {
|
|
constructor(callback: MutationCallback) {
|
|
super(callback)
|
|
observerInstances.push(this)
|
|
}
|
|
},
|
|
setTimeout: (callback: () => void): number => {
|
|
timerId += 1
|
|
timers.set(timerId, callback)
|
|
return timerId
|
|
},
|
|
clearTimeout: (timer: number): void => {
|
|
timers.delete(timer)
|
|
}
|
|
} as unknown as Window & typeof globalThis
|
|
|
|
const adapter = createFontSizeDomAdapter(documentRef, windowRef)
|
|
adapter.apply(3, '18.286px')
|
|
equal(properties.get('--yc-font-offset-global'), '3', 'writes the unbounded YCode offset')
|
|
equal(properties.get('--cb-font-size-offset-global'), '3', 'writes the unbounded compatibility offset')
|
|
equal(properties.get('--wb-font-size-offset'), 'clamp(-1, var(--cb-font-size-offset-global, 0), 2)', 'clamps token consumers independently')
|
|
equal(properties.get('--yc-md-root'), '18.286px', 'keeps the YCode markdown root')
|
|
equal(properties.get('--cb-md-root'), '18.286px', 'keeps the compatibility markdown root')
|
|
includes(head.children.find((child) => child.id === 'yc-font-size-override')?.textContent ?? '', '.live:not(', 'injects scanned CSS rules at nonzero levels')
|
|
includes(head.children.find((child) => child.id === 'yc-wb-token-font-size-override')?.textContent ?? '', '--wb-font-body-size', 'injects WB token rules at nonzero levels')
|
|
equal(observerInstances.length, 1, 'starts one observer for active global sizing')
|
|
const initialOverride = documentRef.getElementById('yc-font-size-override')
|
|
adapter.apply(4, '20.571px')
|
|
equal(documentRef.getElementById('yc-font-size-override'), initialOverride, 'reuses scanned CSS between nonzero levels')
|
|
equal(head.children.filter((child) => child.id === 'yc-wb-token-font-size-override').length, 1, 'does not duplicate token overrides')
|
|
|
|
styleSheets.push(stylesheet([styleRule('.late-style', { 'font-size': '15px' })]))
|
|
observerInstances[0].triggerAddedNode({ nodeType: 1, id: 'late-style-node', tagName: 'STYLE' } as unknown as Node)
|
|
equal(timers.size, 1, 'debounces a newly inserted stylesheet')
|
|
for (const callback of timers.values()) callback()
|
|
timers.clear()
|
|
includes(head.children.find((child) => child.id === 'yc-font-size-override')?.textContent ?? '', '.late-style:not(', 'rebuilds scanned overrides after a style insertion')
|
|
|
|
adapter.apply(0, '17.143px')
|
|
equal(documentRef.getElementById('yc-font-size-override'), null, 'removes scanned CSS overrides at zero offset')
|
|
equal(documentRef.getElementById('yc-wb-token-font-size-override'), null, 'removes WB token overrides at zero offset')
|
|
adapter.dispose()
|
|
equal(observerInstances[0].disconnected, true, 'disconnects the observer during cleanup')
|
|
}
|
|
|
|
function testPublicMappingAndShortcuts(): void {
|
|
equal(getFontLevelSpec(1).offset, -1, 'level one keeps the raw -1 offset')
|
|
equal(getFontLevelSpec(2).offset, 0, 'level two is the zero-offset default')
|
|
equal(getFontLevelSpec(5).offset, 3, 'level five retains raw +3 before consumer clamping')
|
|
equal(getFontLevelSpec(6).offset, 4, 'level six retains raw +4 before consumer clamping')
|
|
equal(getFontSizeShortcutAction({ key: '-', code: 'Minus', shiftKey: false }), 'decrease', 'maps the minus key')
|
|
equal(getFontSizeShortcutAction({ key: 'Unidentified', code: 'Equal', shiftKey: true }), 'increase', 'maps the Equal code fallback')
|
|
equal(getFontSizeShortcutAction({ key: 'Unidentified', code: 'Digit0', shiftKey: false }), 'reset', 'maps the Digit0 code fallback')
|
|
equal(getFontSizeShortcutAction({ key: '-', code: 'Minus', shiftKey: true }), null, 'does not treat shifted minus as decrease')
|
|
equal(getFontSizeShortcutAction({ key: '0', code: 'Digit0', shiftKey: true }), null, 'does not treat shifted zero as reset')
|
|
}
|
|
|
|
function testKeyboardIntegration(): void {
|
|
const descriptors = new Map<string, PropertyDescriptor | undefined>()
|
|
const originalGlobal = globalThis as unknown as Record<string, unknown>
|
|
const styleValues = new Map<string, string>()
|
|
const head = new FakeHead()
|
|
const listeners = new Map<string, { listener: EventListener; capture: boolean }>()
|
|
const eventNames: string[] = []
|
|
const timeoutCallbacks = new Map<number, () => void>()
|
|
let nextTimerId = 0
|
|
|
|
const documentRef = {
|
|
documentElement: {
|
|
style: {
|
|
setProperty: (name: string, value: string): void => styleValues.set(name, value),
|
|
removeProperty: (name: string): void => {
|
|
styleValues.delete(name)
|
|
},
|
|
getPropertyValue: (name: string): string => styleValues.get(name) ?? '',
|
|
get length(): number {
|
|
return styleValues.size
|
|
},
|
|
item: (index: number): string => [...styleValues.keys()][index] ?? ''
|
|
},
|
|
dataset: {}
|
|
},
|
|
styleSheets: [],
|
|
head,
|
|
body: null,
|
|
getElementById: (id: string): FakeStyleElement | null => head.children.find((child) => child.id === id) ?? null,
|
|
createElement: (): FakeStyleElement => new FakeStyleElement(),
|
|
addEventListener: (type: string, listener: EventListener, options?: boolean | AddEventListenerOptions): void => {
|
|
listeners.set(type, { listener, capture: options === true || (typeof options === 'object' && options.capture === true) })
|
|
},
|
|
removeEventListener: (type: string): void => {
|
|
listeners.delete(type)
|
|
}
|
|
} as unknown as Document
|
|
|
|
class TestCustomEvent {
|
|
readonly type: string
|
|
readonly detail: unknown
|
|
|
|
constructor(type: string, init?: { detail?: unknown }) {
|
|
this.type = type
|
|
this.detail = init?.detail
|
|
}
|
|
}
|
|
|
|
const windowRef = {
|
|
MutationObserver: FakeMutationObserver,
|
|
setTimeout: (callback: () => void): number => {
|
|
nextTimerId += 1
|
|
timeoutCallbacks.set(nextTimerId, callback)
|
|
return nextTimerId
|
|
},
|
|
clearTimeout: (id: number): void => {
|
|
timeoutCallbacks.delete(id)
|
|
},
|
|
dispatchEvent: (event: Event): boolean => {
|
|
eventNames.push((event as unknown as TestCustomEvent).type)
|
|
return true
|
|
}
|
|
} as unknown as Window & typeof globalThis
|
|
|
|
const storage = {
|
|
value: '2',
|
|
getItem(_key: string): string | null {
|
|
return this.value
|
|
},
|
|
setItem(_key: string, value: string): void {
|
|
this.value = value
|
|
}
|
|
}
|
|
|
|
const globals: Record<string, unknown> = {
|
|
document: documentRef,
|
|
window: windowRef,
|
|
localStorage: storage,
|
|
CustomEvent: TestCustomEvent
|
|
}
|
|
|
|
for (const [key, value] of Object.entries(globals)) {
|
|
descriptors.set(key, Object.getOwnPropertyDescriptor(globalThis, key))
|
|
Object.defineProperty(originalGlobal, key, { value, configurable: true, writable: true })
|
|
}
|
|
|
|
try {
|
|
const cleanup = initFontSize()
|
|
equal(listeners.get('keydown')?.capture, true, 'registers shortcuts in capture phase')
|
|
const listener = listeners.get('keydown')?.listener
|
|
assert.ok(listener, 'registers a keydown listener')
|
|
assertions += 1
|
|
|
|
const sendKey = (key: string, code: string, options: { ctrl?: boolean; meta?: boolean; alt?: boolean; shift?: boolean; target?: EventTarget | null } = {}): { prevented: boolean } => {
|
|
let prevented = false
|
|
listener!({
|
|
key,
|
|
code,
|
|
ctrlKey: options.ctrl ?? true,
|
|
metaKey: options.meta ?? false,
|
|
altKey: options.alt ?? false,
|
|
shiftKey: options.shift ?? false,
|
|
target: options.target ?? null,
|
|
preventDefault: () => {
|
|
prevented = true
|
|
}
|
|
} as unknown as KeyboardEvent)
|
|
return { prevented }
|
|
}
|
|
|
|
const focusedComposer = { tagName: 'TEXTAREA' } as unknown as EventTarget
|
|
equal(sendKey('+', 'Equal', { target: focusedComposer }).prevented, true, 'keeps shortcuts available while a composer input is focused')
|
|
equal(getFontLevel(), 3, 'increases the current level from the key handler')
|
|
equal(storage.value, '3', 'persists the selected YCode level')
|
|
equal(sendKey('-', 'Minus', { alt: true }).prevented, false, 'ignores Alt-modified shortcut combinations')
|
|
equal(getFontLevel(), 3, 'Alt-modified input leaves the current level unchanged')
|
|
equal(sendKey('Unidentified', 'Minus').prevented, true, 'uses the physical Minus code when key is unavailable')
|
|
equal(getFontLevel(), 2, 'decreases the current level from the code fallback')
|
|
equal(eventNames.filter((name) => name === 'ycode:font-level-change').length, 2, 'preserves the YCode public event')
|
|
|
|
for (const callback of timeoutCallbacks.values()) callback()
|
|
timeoutCallbacks.clear()
|
|
equal(eventNames.filter((name) => name === 'cb-font-size-change').length, 1, 'dispatches the compatible offset event after coalescing changes')
|
|
equal(eventNames.filter((name) => name === 'cb-font-size-level-change').length, 1, 'dispatches the compatible level event after coalescing changes')
|
|
cleanup()
|
|
equal(listeners.has('keydown'), false, 'removes the keydown listener during cleanup')
|
|
} finally {
|
|
for (const [key, descriptor] of descriptors) {
|
|
if (descriptor) Object.defineProperty(originalGlobal, key, descriptor)
|
|
else delete originalGlobal[key]
|
|
}
|
|
}
|
|
}
|
|
|
|
testRuleConstruction()
|
|
testTokenCSS()
|
|
testDOMAdapter()
|
|
testPublicMappingAndShortcuts()
|
|
testKeyboardIntegration()
|
|
console.log(`Font-size regression passed (${assertions} assertions).`)
|