|
| 1 | +// #P769b: a Past room row's time read English in a zh window. The row itself says `14:05` today and |
| 2 | +// `Oct 19` on an earlier day; hovering it says `Ended Oct 19 14:05`. Both the word (`Ended` / `Started`) |
| 3 | +// and the month/day order were English text built at the draw site, so no dictionary entry could ever |
| 4 | +// reach them — `text: …, full: `${…} ${day} ${hhmm}`` is the kind of template literal the copy scans |
| 5 | +// cannot see. This mounts the real client.js with the tiny hook renderer the other client tests use |
| 6 | +// (no jsdom), binds `__flybot__.t` to the zh dictionary, and reads the `face-time` node's own text and |
| 7 | +// `title` out of a Past card. |
| 8 | +import assert from 'node:assert/strict' |
| 9 | +import { readFileSync } from 'node:fs' |
| 10 | +import { test } from 'node:test' |
| 11 | +import { DICTIONARY } from '../../../core/src/client/i18n.js' |
| 12 | + |
| 13 | +const zh = DICTIONARY.zh |
| 14 | +const live = { inst: null, idx: 0 } |
| 15 | +const reactStub = { |
| 16 | + createElement: (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }), |
| 17 | + Fragment: 'F', |
| 18 | + Component: class {}, |
| 19 | + useState(init) { |
| 20 | + const { inst } = live |
| 21 | + const i = live.idx++ |
| 22 | + if (!(i in inst.hooks)) inst.hooks[i] = { value: typeof init === 'function' ? init() : init } |
| 23 | + const slot = inst.hooks[i] |
| 24 | + return [slot.value, (next) => { slot.value = typeof next === 'function' ? next(slot.value) : next; inst.dirty = true }] |
| 25 | + }, |
| 26 | + useEffect(fn, deps) { |
| 27 | + const { inst } = live |
| 28 | + const i = live.idx++ |
| 29 | + if (!(i in inst.hooks)) inst.hooks[i] = { deps: undefined } |
| 30 | + inst.effects.push([i, fn, deps]) |
| 31 | + }, |
| 32 | + useRef(value) { |
| 33 | + const { inst } = live |
| 34 | + const i = live.idx++ |
| 35 | + if (!(i in inst.hooks)) inst.hooks[i] = { current: value } |
| 36 | + return inst.hooks[i] |
| 37 | + }, |
| 38 | +} |
| 39 | +for (const name of ['useMemo', 'useCallback', 'useLayoutEffect', 'useContext', 'useReducer', 'useId', 'useSyncExternalStore', 'useTransition', 'useDeferredValue', 'memo', 'forwardRef', 'createContext', 'createPortal', 'cloneElement', 'isValidElement', 'startTransition']) reactStub[name] = (value) => value |
| 40 | + |
| 41 | +globalThis.document ??= { createElement: () => ({ setAttribute() {} }), head: { appendChild() {} }, getElementById: () => null, addEventListener() {}, removeEventListener() {} } |
| 42 | + |
| 43 | +function loadApp() { |
| 44 | + const modules = new Map() |
| 45 | + const window = { __ModuleLoader__: { load: ({ id, factory }) => modules.set(id, factory(name => (name === 'react' ? reactStub : {}))) } } |
| 46 | + new Function('window', readFileSync(new URL('../client.js', import.meta.url), 'utf8'))(window) |
| 47 | + return modules.get('@flybot/social') |
| 48 | +} |
| 49 | + |
| 50 | +const fresh = () => ({ hooks: {}, effects: [] }) |
| 51 | +function render(Component, inst) { |
| 52 | + live.inst = inst |
| 53 | + live.idx = 0 |
| 54 | + inst.effects = [] |
| 55 | + return Component(inst.props ?? {}) |
| 56 | +} |
| 57 | +// A component element is a function in `type`: it has to be called to see the card it draws. Its own |
| 58 | +// hook slots are separate from the caller's, the way React keeps them apart. |
| 59 | +function mount(element) { |
| 60 | + const outer = { inst: live.inst, idx: live.idx } |
| 61 | + live.inst = fresh() |
| 62 | + live.idx = 0 |
| 63 | + const out = element.type(element.props ?? {}) |
| 64 | + live.inst = outer.inst |
| 65 | + live.idx = outer.idx |
| 66 | + return out |
| 67 | +} |
| 68 | +function walk(node, visit) { |
| 69 | + if (node == null || node === false || node === true) return |
| 70 | + if (Array.isArray(node)) { for (const child of node) walk(child, visit); return } |
| 71 | + if (typeof node === 'string' || typeof node === 'number') return |
| 72 | + visit(node) |
| 73 | + if (typeof node.type === 'function') return walk(mount(node), visit) |
| 74 | + return walk(node.children ?? [], visit) |
| 75 | +} |
| 76 | +const nodeOf = (tree, name) => { |
| 77 | + let hit = null |
| 78 | + walk(tree, node => { if (!hit && node?.props?.['data-flybot'] === name) hit = node }) |
| 79 | + return hit |
| 80 | +} |
| 81 | +const nodeByClass = (tree, name) => { |
| 82 | + let hit = null |
| 83 | + walk(tree, node => { if (!hit && node?.props?.className === name) hit = node }) |
| 84 | + return hit |
| 85 | +} |
| 86 | + |
| 87 | +// The host's `t`: the dictionary entry when it has one, the English otherwise, which is what a |
| 88 | +// Chinese window sees. |
| 89 | +const hostT = (text, params) => { |
| 90 | + const hit = Object.hasOwn(zh, text) ? zh[text] : text |
| 91 | + return String(hit).replace(/\{(\w+)\}/g, (_, key) => String(params?.[key] ?? '')) |
| 92 | +} |
| 93 | +const englishT = (text, params) => String(text).replace(/\{(\w+)\}/g, (_, key) => String(params?.[key] ?? '')) |
| 94 | + |
| 95 | +// One local timestamp, so the day the row names does not depend on the runner's zone. |
| 96 | +const local = (day, hh, mm) => new Date(2026, 9, day, hh, mm).toISOString() |
| 97 | + |
| 98 | +// A Past room's time: the row's own text, and the hover a reader gets on `face-time`. |
| 99 | +const faceRowTime = (t, room) => { |
| 100 | + globalThis.__flybot__ = { t, call: () => Promise.resolve({}) } |
| 101 | + const app = loadApp() |
| 102 | + const inst = { hooks: {}, effects: [], props: { room: { roomId: 'r1', faceRoomId: 'frm1', name: 'Hall', members: [], ...room }, past: true } } |
| 103 | + const node = nodeOf(render(app.FaceRoomCard, inst), 'face-time') |
| 104 | + assert.ok(node, 'the Past row draws its time') |
| 105 | + return { text: (node.children ?? []).filter(child => typeof child === 'string').join(''), title: node.props?.title } |
| 106 | +} |
| 107 | + |
| 108 | +test('#P769b: a Past row’s hover names the end and the day in the window’s language', () => { |
| 109 | + try { |
| 110 | + const seen = faceRowTime(hostT, { endedAt: local(19, 14, 5) }) |
| 111 | + assert.equal(seen.text, '10月19日', `the row’s own day: saw ${JSON.stringify(seen.text)}`) |
| 112 | + assert.equal(seen.title, `${zh.Ended} 10月19日 14:05`, `the hover: saw ${JSON.stringify(seen.title)}`) |
| 113 | + } finally { delete globalThis.__flybot__ } |
| 114 | +}) |
| 115 | + |
| 116 | +test('#P769b: a room that never ended says when it started, in the window’s language', () => { |
| 117 | + try { |
| 118 | + const seen = faceRowTime(hostT, { startedAt: local(19, 14, 5) }) |
| 119 | + assert.equal(seen.text, '10月19日', `the row’s own day: saw ${JSON.stringify(seen.text)}`) |
| 120 | + assert.equal(seen.title, `${zh.Started} 10月19日 14:05`, `the hover: saw ${JSON.stringify(seen.title)}`) |
| 121 | + } finally { delete globalThis.__flybot__ } |
| 122 | +}) |
| 123 | + |
| 124 | +test('#P769b: today’s row keeps its bare clock and still hovers in the window’s language', () => { |
| 125 | + try { |
| 126 | + const now = new Date() |
| 127 | + const seen = faceRowTime(hostT, { endedAt: now.toISOString() }) |
| 128 | + assert.match(seen.text, /^\d\d:\d\d$/, `today’s row text: saw ${JSON.stringify(seen.text)}`) |
| 129 | + assert.ok(seen.title.startsWith(`${zh.Ended} `), `the hover’s word: saw ${JSON.stringify(seen.title)}`) |
| 130 | + assert.ok(/\d+月\d+日 {1,2}\d\d:\d\d$/.test(seen.title), `the hover’s day: saw ${JSON.stringify(seen.title)}`) |
| 131 | + } finally { delete globalThis.__flybot__ } |
| 132 | +}) |
| 133 | + |
| 134 | +test('#P769b: an English window keeps the row and hover bytes it drew before', () => { |
| 135 | + try { |
| 136 | + const ended = faceRowTime(englishT, { endedAt: local(19, 14, 5) }) |
| 137 | + assert.equal(ended.text, 'Oct 19', `the English row day: saw ${JSON.stringify(ended.text)}`) |
| 138 | + assert.equal(ended.title, 'Ended Oct 19 14:05', `the English hover: saw ${JSON.stringify(ended.title)}`) |
| 139 | + const started = faceRowTime(englishT, { startedAt: local(19, 14, 5) }) |
| 140 | + assert.equal(started.title, 'Started Oct 19 14:05', `the English hover: saw ${JSON.stringify(started.title)}`) |
| 141 | + } finally { delete globalThis.__flybot__ } |
| 142 | +}) |
| 143 | + |
| 144 | +// The row's own title is built the same way when the stored name carries no people: the word and the |
| 145 | +// day are display text, while the `Face-to-face · …` parsing above them stays on the stored English. |
| 146 | +const faceTitle = (t, room) => { |
| 147 | + globalThis.__flybot__ = { t, call: () => Promise.resolve({}) } |
| 148 | + const app = loadApp() |
| 149 | + const inst = { hooks: {}, effects: [], props: { room: { roomId: 'r1', faceRoomId: 'frm1', name: 'Face-to-face room', members: [{ handle: '@alice', ownerName: 'Alice' }], ...room }, past: true } } |
| 150 | + const node = nodeByClass(render(app.FaceRoomCard, inst), 'fs-name') |
| 151 | + assert.ok(node, 'the Past row draws its title') |
| 152 | + return { title: node.props?.title, text: (node.children ?? []).filter(child => typeof child === 'string').join('') } |
| 153 | +} |
| 154 | + |
| 155 | +test('#P769b: a Past row’s rebuilt title reads the window’s language too', () => { |
| 156 | + try { |
| 157 | + const built = faceTitle(hostT, { createdAt: local(19, 14, 5) }) |
| 158 | + assert.equal(built.title, `面对面 · Alice · 10月19日 14:05`, `a rebuilt title: saw ${JSON.stringify(built.title)}`) |
| 159 | + assert.equal(built.text, built.title, 'the row and its hover name the same room') |
| 160 | + // A stored auto title carries its own English stamp; it is still display text here. |
| 161 | + const stored = faceTitle(hostT, { name: 'Face-to-face · Oct 19 14:05' }) |
| 162 | + assert.equal(stored.title, `面对面 · Alice · 10月19日 14:05`, `a stored stamp: saw ${JSON.stringify(stored.title)}`) |
| 163 | + } finally { delete globalThis.__flybot__ } |
| 164 | +}) |
| 165 | + |
| 166 | +test('#P769b: an English window keeps the rebuilt title bytes it drew before', () => { |
| 167 | + try { |
| 168 | + const built = faceTitle(englishT, { createdAt: local(19, 14, 5) }) |
| 169 | + assert.equal(built.title, 'Face-to-face · Alice · Oct 19 14:05', `the English rebuilt title: saw ${JSON.stringify(built.title)}`) |
| 170 | + const stored = faceTitle(englishT, { name: 'Face-to-face · Oct 19 14:05' }) |
| 171 | + assert.equal(stored.title, 'Face-to-face · Alice · Oct 19 14:05', `the English stored title: saw ${JSON.stringify(stored.title)}`) |
| 172 | + } finally { delete globalThis.__flybot__ } |
| 173 | +}) |
0 commit comments