forked from 27hrs/dsh-long-skin
Use host native Switch for reasoning binding
This commit is contained in:
@@ -15,6 +15,7 @@ await build({
|
||||
external: [
|
||||
"react",
|
||||
"react/jsx-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-primitives",
|
||||
],
|
||||
define: {
|
||||
"process.env.NODE_ENV": '"production"',
|
||||
|
||||
@@ -67,10 +67,10 @@ async function prepare(context, options = {}) {
|
||||
await page.setContent(fixture(options));
|
||||
await page.addScriptTag({ content: compiled.outputFiles[0].text });
|
||||
await page.evaluate(({ packageId, enabled }) => {
|
||||
let snapshot = { enabled, bindEffort: true };
|
||||
let snapshot = { enabled };
|
||||
const listeners = new Set();
|
||||
const calls = [];
|
||||
let releaseChoice, holdNextChoice = false, failNextChoice = false, failNextBinding = false;
|
||||
let releaseChoice, holdNextChoice = false, failNextChoice = false;
|
||||
let dispose = () => {};
|
||||
const publish = patch => {
|
||||
snapshot = { ...snapshot, ...patch };
|
||||
@@ -81,11 +81,6 @@ async function prepare(context, options = {}) {
|
||||
const scope = {
|
||||
getSnapshot: () => snapshot,
|
||||
subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
|
||||
async setBindEffort(value) {
|
||||
calls.push(["binding", value]);
|
||||
if (failNextBinding) { failNextBinding = false; throw new Error("fixture write rejected"); }
|
||||
publish({ bindEffort: value });
|
||||
},
|
||||
};
|
||||
const presenter = {
|
||||
async choose(value) {
|
||||
@@ -120,7 +115,7 @@ async function prepare(context, options = {}) {
|
||||
},
|
||||
dispose() { dispose(); }, setEnabled(value) { publish({ enabled: value }); },
|
||||
holdChoice() { holdNextChoice = true; }, releaseChoice() { releaseChoice?.(); },
|
||||
rejectChoice() { failNextChoice = true; }, rejectBinding() { failNextBinding = true; },
|
||||
rejectChoice() { failNextChoice = true; },
|
||||
};
|
||||
document.querySelector("#portal").addEventListener("click", () => window.fixture.portalClicks++);
|
||||
publish({});
|
||||
@@ -183,45 +178,38 @@ async function assertLayout(page, options, enabled = true) {
|
||||
|
||||
async function appearanceChecks(page, hash) {
|
||||
await page.evaluate(hash => window.fixture.install(hash), hash);
|
||||
const custom = page.locator("#native-row > .liang-appearance-choice"), binding = page.locator(".liang-appearance-binding__input");
|
||||
const custom = page.locator("#native-row > .liang-appearance-choice");
|
||||
await custom.waitFor();
|
||||
assert.equal(await page.locator(".liang-appearance-choice").count(), 1);
|
||||
assert.equal(await page.locator("#unrelated .liang-appearance-choice, #nested-unrelated .liang-appearance-choice").count(), 0);
|
||||
assert.equal(await custom.getAttribute("data-plugin"), packageId);
|
||||
assert.equal(await custom.getAttribute("aria-pressed"), "true");
|
||||
await binding.uncheck();
|
||||
await page.waitForFunction(() => !window.fixture.snapshot().bindEffort);
|
||||
assert.equal(await binding.getAttribute("aria-checked"), "false");
|
||||
await binding.check(); await page.waitForFunction(() => window.fixture.snapshot().bindEffort);
|
||||
assert.equal(await page.locator(".liang-appearance-binding, input[role=switch]").count(), 0,
|
||||
"The imperative appearance extension must not draw its own binding switch");
|
||||
for (const theme of ["light", "dark", "system"]) {
|
||||
await page.locator(`[data-theme="${theme}"]`).click();
|
||||
await page.waitForFunction(() => !window.fixture.snapshot().enabled);
|
||||
assert.equal(await page.locator(".liang-appearance-binding").count(), 0);
|
||||
assert.equal(await custom.getAttribute("aria-pressed"), "false");
|
||||
assert.equal(await page.evaluate(() => window.fixture.nativeTheme), theme);
|
||||
await custom.click(); await page.waitForFunction(() => window.fixture.snapshot().enabled); await binding.waitFor();
|
||||
await custom.click(); await page.waitForFunction(() => window.fixture.snapshot().enabled);
|
||||
}
|
||||
assert.equal(await page.evaluate(() => window.fixture.nativeClicks), 3, "Native theme handlers still run");
|
||||
await page.evaluate(() => {
|
||||
window.oldNative = document.querySelector('[data-theme="light"]');
|
||||
window.oldCustom = document.querySelector(".liang-appearance-choice");
|
||||
window.oldBinding = document.querySelector(".liang-appearance-binding__input"); window.fixture.rebuild();
|
||||
window.fixture.rebuild();
|
||||
});
|
||||
await page.locator("#native-row._newHash42_cubeRow > .liang-appearance-choice").waitFor();
|
||||
assert.equal(await page.locator(".liang-appearance-choice").count(), 1, "DOM reconstruction produces one control");
|
||||
const beforeStaleClicks = await page.evaluate(() => window.fixture.calls.length);
|
||||
await page.evaluate(() => { window.oldNative.click(); window.oldCustom.click(); window.oldBinding.dispatchEvent(new Event("change", { bubbles: true })); });
|
||||
await page.evaluate(() => { window.oldNative.click(); window.oldCustom.click(); });
|
||||
assert.equal(await page.evaluate(() => window.fixture.calls.length), beforeStaleClicks, "Replaced nodes lose plugin listeners");
|
||||
await page.locator('[data-theme="dark"]').click(); await page.waitForFunction(() => !window.fixture.snapshot().enabled);
|
||||
await custom.click(); await page.waitForFunction(() => window.fixture.snapshot().enabled);
|
||||
await page.evaluate(() => window.fixture.removeRow());
|
||||
await page.waitForFunction(() => document.querySelector(".liang-appearance-choice") === null);
|
||||
await page.evaluate(() => window.fixture.restoreRow()); await custom.waitFor();
|
||||
await page.evaluate(() => {
|
||||
window.fixture.rejectBinding(); const input = document.querySelector(".liang-appearance-binding__input");
|
||||
input.checked = false; input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
await page.waitForFunction(() => document.querySelector(".liang-appearance-binding__input").checked);
|
||||
await page.evaluate(() => window.fixture.rejectChoice()); await page.locator('[data-theme="light"]').click();
|
||||
await page.waitForFunction(() => !document.querySelector(".liang-appearance-choice").disabled);
|
||||
assert.equal(await custom.getAttribute("aria-pressed"), "true", "Rejected preference preserves skin state");
|
||||
@@ -266,7 +254,7 @@ try {
|
||||
for (const hash of ["_8HJdBW", "_currentHash89"]) {
|
||||
const { page, errors } = await prepare(context); await appearanceChecks(page, hash);
|
||||
assert.deepEqual(errors, [], `${hash}: browser errors`); await page.close();
|
||||
console.log(`PASS appearance ${hash}: multi-class discovery, choices, binding, DOM rebuild, disposal race`);
|
||||
console.log(`PASS appearance ${hash}: multi-class discovery, choices, DOM rebuild, disposal race`);
|
||||
}
|
||||
const first = await prepare(context), second = await prepare(context, { desktop: true, windows: true, top: 40 });
|
||||
await first.page.evaluate(() => window.fixture.install()); await second.page.evaluate(() => window.fixture.install("_otherWindow"));
|
||||
|
||||
@@ -87,6 +87,8 @@ async function fixture({ fail = () => false, holdRetry = null } = {}) {
|
||||
cleanups: [], decoded: new WeakSet(), decodedImages: [],
|
||||
registered: null, scope: null, presenter: null, module: null,
|
||||
activeLocales: 0, activeSlots: 0, errors: [],
|
||||
registrations: new Map(), dictionaries: new Map(), binding: null,
|
||||
bindingHooks: [], bindingHookIndex: 0, renderingBinding: false,
|
||||
};
|
||||
window.__runtime = runtime;
|
||||
window.addEventListener('unhandledrejection', (event) => runtime.errors.push(String(event.reason)));
|
||||
@@ -100,17 +102,34 @@ async function fixture({ fail = () => false, holdRetry = null } = {}) {
|
||||
return value;
|
||||
});
|
||||
};
|
||||
const unexpectedRender = () => { throw new Error('The runtime fixture must not render React components'); };
|
||||
const unexpectedRender = () => { throw new Error('The runtime fixture must not render a host React component'); };
|
||||
// This identity stands for the host's imported component. Never implement
|
||||
// its DOM or styles here: native Switch visuals are verified in real DSH.
|
||||
runtime.nativeSwitch = unexpectedRender;
|
||||
const jsx = (type, props, key) => ({ type, props, key });
|
||||
const useState = (initial) => {
|
||||
if (!runtime.renderingBinding) return unexpectedRender();
|
||||
const index = runtime.bindingHookIndex++;
|
||||
if (!(index in runtime.bindingHooks)) runtime.bindingHooks[index] = initial;
|
||||
return [runtime.bindingHooks[index], (next) => {
|
||||
runtime.bindingHooks[index] = typeof next === 'function' ? next(runtime.bindingHooks[index]) : next;
|
||||
}];
|
||||
};
|
||||
const useSyncExternalStore = (_subscribe, getSnapshot) => {
|
||||
if (!runtime.renderingBinding) return unexpectedRender();
|
||||
return getSnapshot();
|
||||
};
|
||||
window.__ModuleLoader__ = {
|
||||
load({ id, factory }) {
|
||||
runtime.moduleId = id;
|
||||
runtime.module = factory((name) => {
|
||||
if (name === 'react') return {
|
||||
useEffect: unexpectedRender, useMemo: unexpectedRender,
|
||||
useRef: unexpectedRender, useState: unexpectedRender,
|
||||
useSyncExternalStore: unexpectedRender,
|
||||
useRef: unexpectedRender, useState,
|
||||
useSyncExternalStore,
|
||||
};
|
||||
if (name === 'react/jsx-runtime') return { jsx: unexpectedRender, jsxs: unexpectedRender };
|
||||
if (name === 'react/jsx-runtime') return { jsx, jsxs: jsx };
|
||||
if (name === '@deepseek-ai/dsh-client-ui-primitives') return { Switch: runtime.nativeSwitch };
|
||||
throw new Error(`Unexpected client import: ${name}`);
|
||||
});
|
||||
},
|
||||
@@ -137,9 +156,13 @@ async function fixture({ fail = () => false, holdRetry = null } = {}) {
|
||||
throw new Error(`Unexpected host service: ${name}`);
|
||||
},
|
||||
locale: {
|
||||
register() {
|
||||
register(namespace, dictionaries) {
|
||||
runtime.activeLocales += 1;
|
||||
return () => { runtime.activeLocales -= 1; };
|
||||
runtime.dictionaries.set(namespace, dictionaries);
|
||||
return () => {
|
||||
runtime.activeLocales -= 1;
|
||||
runtime.dictionaries.delete(namespace);
|
||||
};
|
||||
},
|
||||
},
|
||||
slots: {
|
||||
@@ -147,12 +170,24 @@ async function fixture({ fail = () => false, holdRetry = null } = {}) {
|
||||
const cleanup = callback();
|
||||
if (typeof cleanup === 'function') runtime.cleanups.push(cleanup);
|
||||
},
|
||||
register(definition) {
|
||||
register(definition, component) {
|
||||
runtime.activeSlots += 1;
|
||||
runtime.registered = definition.inject('fixture-session');
|
||||
runtime.scope = runtime.registered.scope;
|
||||
runtime.presenter = runtime.registered.presenter;
|
||||
return () => { runtime.activeSlots -= 1; };
|
||||
const props = definition.inject?.('fixture-session') ?? {};
|
||||
const registration = { definition, component, props };
|
||||
runtime.registrations.set(definition.id, registration);
|
||||
if (definition.id === 'liang-intensity-control') {
|
||||
runtime.registered = props;
|
||||
runtime.scope = props.scope;
|
||||
runtime.presenter = props.presenter;
|
||||
} else if (definition.id === 'liang-effort-binding') {
|
||||
runtime.binding = registration;
|
||||
} else {
|
||||
throw new Error(`Unexpected client slot: ${definition.id}`);
|
||||
}
|
||||
return () => {
|
||||
runtime.activeSlots -= 1;
|
||||
runtime.registrations.delete(definition.id);
|
||||
};
|
||||
},
|
||||
},
|
||||
sessions: { subagentAddress: () => undefined },
|
||||
@@ -166,6 +201,17 @@ async function fixture({ fail = () => false, holdRetry = null } = {}) {
|
||||
};
|
||||
if (typeof runtime.module?.apply !== 'function') throw new Error('Built client did not export apply(ctx)');
|
||||
runtime.module.apply(ctx);
|
||||
runtime.renderBinding = (language = 'zh') => {
|
||||
if (runtime.binding === null) throw new Error('The native binding row was not registered');
|
||||
const { definition, component, props } = runtime.binding;
|
||||
runtime.bindingHookIndex = 0;
|
||||
runtime.renderingBinding = true;
|
||||
try {
|
||||
return component({ ...props, t: (key) => runtime.dictionaries.get(definition.locale)?.[language]?.[key] ?? key });
|
||||
} finally {
|
||||
runtime.renderingBinding = false;
|
||||
}
|
||||
};
|
||||
runtime.dispose = () => {
|
||||
for (const cleanup of runtime.cleanups.splice(0).reverse()) cleanup();
|
||||
};
|
||||
@@ -205,7 +251,7 @@ async function waitForPortrait(page, filename, retry = null) {
|
||||
}
|
||||
|
||||
async function assertRemoved(page, fullyDisposed = false) {
|
||||
const result = await page.evaluate(() => ({
|
||||
const result = await page.evaluate((fullyDisposed) => ({
|
||||
backdrop: document.querySelectorAll('.liang-skin-backdrop').length,
|
||||
skin: document.body.hasAttribute('data-liang-skin'),
|
||||
stage: document.body.hasAttribute('data-liang-stage'),
|
||||
@@ -215,12 +261,14 @@ async function assertRemoved(page, fullyDisposed = false) {
|
||||
controls: document.querySelectorAll('.liang-appearance-choice, .liang-appearance-binding').length,
|
||||
locales: window.__runtime.activeLocales,
|
||||
slots: window.__runtime.activeSlots,
|
||||
}));
|
||||
bindingVisible: !fullyDisposed && window.__runtime.renderBinding() !== null,
|
||||
}), fullyDisposed);
|
||||
assert.equal(result.backdrop, 0, 'No portrait backdrop remains');
|
||||
assert.equal(result.skin, false, 'Skin marker is removed');
|
||||
assert.equal(result.stage, false, 'Stage marker is removed');
|
||||
assert.deepEqual(result.variables, [], 'All presenter CSS variables are removed');
|
||||
assert.equal(result.retainedSources, 0, 'Decoded images release their network sources');
|
||||
assert.equal(result.bindingVisible, false, 'The binding slot returns null while the skin is off');
|
||||
if (fullyDisposed) {
|
||||
assert.equal(result.styles, 0, 'Client stylesheet is removed');
|
||||
assert.equal(result.controls, 0, 'Appearance controls are removed');
|
||||
@@ -229,6 +277,79 @@ async function assertRemoved(page, fullyDisposed = false) {
|
||||
}
|
||||
}
|
||||
|
||||
async function assertNativeBinding(page) {
|
||||
const result = await page.evaluate(async () => {
|
||||
const runtime = window.__runtime;
|
||||
const check = (condition, message) => { if (!condition) throw new Error(message); };
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const binding = runtime.binding;
|
||||
check(binding?.definition.name === 'settings.general.item'
|
||||
&& binding.definition.id === 'liang-effort-binding'
|
||||
&& binding.definition.order === 10.5, 'Binding uses the native General Settings slot after appearance');
|
||||
check(binding.props.scope === runtime.scope, 'Binding and portrait share the real preference store');
|
||||
const controls = (language = 'zh') => {
|
||||
const tree = runtime.renderBinding(language);
|
||||
const nodes = [];
|
||||
const visit = (node) => {
|
||||
if (Array.isArray(node)) return node.forEach(visit);
|
||||
if (node === null || typeof node !== 'object') return;
|
||||
nodes.push(node);
|
||||
visit(node.props?.children);
|
||||
};
|
||||
visit(tree);
|
||||
const switches = nodes.filter((node) => node.type === runtime.nativeSwitch);
|
||||
check(switches.length === 1, 'Binding must reference the imported host Switch exactly once');
|
||||
check(tree.type === 'div', 'The binding row is a non-interactive div');
|
||||
check(nodes.every((node) => !['input', 'button', 'label'].includes(node.type)
|
||||
&& node.props?.onClick === undefined && node.props?.htmlFor === undefined),
|
||||
'No hand-drawn switch or whole-row/text click handler');
|
||||
check(switches[0].props.className === undefined && switches[0].props.style === undefined,
|
||||
'Native Switch receives no skin shape or color overrides');
|
||||
const text = nodes.find((node) => node.type === 'span');
|
||||
check(text?.props.children === switches[0].props.label, 'Visible text is the native Switch accessible name');
|
||||
return { text, control: switches[0] };
|
||||
};
|
||||
let { text, control } = controls();
|
||||
check(text.props.children === '滑动变祖绑定思考等级', 'Original Chinese binding label is preserved');
|
||||
check(controls('en').text.props.children === 'Bind slider to reasoning level', 'English binding label comes from locale');
|
||||
check(control.props.checked === true && control.props.disabled === false, 'Binding starts with persisted state');
|
||||
text.props.onClick?.();
|
||||
check(runtime.scope.getSnapshot().bindEffort === true, 'Text has no binding write callback');
|
||||
control.props.onChange(false);
|
||||
check(controls().control.props.disabled === true, 'Native Switch is disabled while saving');
|
||||
await flush();
|
||||
control = controls().control;
|
||||
check(runtime.scope.getSnapshot().bindEffort === false && control.props.checked === false
|
||||
&& control.props.disabled === false, 'Only Switch onChange writes the binding preference');
|
||||
const setBindEffort = runtime.scope.setBindEffort;
|
||||
let rejectWrite, writes = 0;
|
||||
runtime.scope.setBindEffort = () => {
|
||||
writes += 1;
|
||||
return new Promise((_, reject) => { rejectWrite = reject; });
|
||||
};
|
||||
try {
|
||||
control.props.onChange(true);
|
||||
control = controls().control;
|
||||
check(control.props.disabled === true, 'Pending write keeps the native control disabled');
|
||||
control.props.onChange(true);
|
||||
check(writes === 1, 'Pending change cannot enqueue a second write');
|
||||
rejectWrite(new Error('fixture: binding write rejected'));
|
||||
await flush();
|
||||
control = controls().control;
|
||||
check(control.props.checked === false && control.props.disabled === false,
|
||||
'Failed write preserves accepted state and unlocks the native control');
|
||||
} finally {
|
||||
runtime.scope.setBindEffort = setBindEffort;
|
||||
}
|
||||
control.props.onChange(true);
|
||||
await flush();
|
||||
check(runtime.scope.getSnapshot().bindEffort === true, 'Binding remains usable after a failed write');
|
||||
return { passed: true, slots: runtime.activeSlots };
|
||||
});
|
||||
assert.equal(result.passed, true);
|
||||
assert.equal(result.slots, 2, 'Runtime registers both conversation and native binding slots');
|
||||
}
|
||||
|
||||
async function assertNoErrors(test) {
|
||||
assert.deepEqual(test.errors, [], 'No browser runtime exception');
|
||||
assert.deepEqual(await test.page.evaluate(() => window.__runtime.errors), [], 'No unhandled runtime promise rejection');
|
||||
@@ -241,6 +362,7 @@ try {
|
||||
await waitForPortrait(success.page, 'stage-30.webp');
|
||||
assert.equal(success.requests.length, 24, 'Decoded display nodes do not start duplicate requests');
|
||||
assert.equal(new Set(success.requests.map(({ name }) => name)).size, 24, 'Every shipped anchor is requested');
|
||||
await assertNativeBinding(success.page);
|
||||
await success.page.evaluate(() => window.__runtime.presenter.setFrame(48));
|
||||
await waitForPortrait(success.page, 'stage-06.webp');
|
||||
await success.page.evaluate(() => window.__runtime.presenter.choose(false));
|
||||
@@ -301,7 +423,7 @@ try {
|
||||
} finally {
|
||||
await recovery.close();
|
||||
}
|
||||
console.log('PASS: built apply(ctx), 24 native WebP decodes, frame swaps, partial failure, first-frame retry, disable/enable, and disposal with late responses.');
|
||||
console.log('PASS: built apply(ctx), 24 native WebP decodes, frame swaps, partial failure, first-frame retry, native Switch binding contract, disable/enable, and disposal with late responses.');
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user