mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-03 21:28:59 +00:00
smoke_dialog_footer_width switched the language by assigning card._config and then measured the first hp-dialog in the tree. Since #627 the main catalog for de/fr and the editor's settings/support/topology dictionaries for ru/de/fr are lazy chunks; while one is in flight the language gate keeps the previous frame (inert, aria-busy) and the dialog is not rendered. The old wait only covered de and only the main catalog (card._t('btn.save') === 'Speichern'), so under load the first dialog after a switch (opening in ru/de) was read from the held frame and four checks went red on a zero row. Both page.evaluate blocks now wait by condition, like smoke_dialog_polish_603 (#712): first for the gate's own markers (no aria-busy, lang equals the requested language), then for hp-dialog[data-kind=<kind>] to have its .dialog-action-footer laid out, with a 5 s deadline and a named error. The measurement reads the dialog of the requested kind instead of the first hp-dialog. Checks, names and thresholds are unchanged (same 36 names under HP_SMOKE_CHECKS=1). Runs on the branch: 10/10 sequential, 12/12 in 6 rounds of two parallel copies (dev: 9/12 red under the same load); green with ru/de chunks delayed 400 ms and 1500 ms and with only the editor dictionaries delayed 150 ms. Sabotage still bites: opening --hp-dialog-wide-width 560px reds opening_*_medium_shell, physical footer buttons min-width 170px red physical_*_three_actions_one_row and _positive_localization_headroom, and an opening dialog that never renders fails with a named error. Issue: #759 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
271 lines
12 KiB
JavaScript
271 lines
12 KiB
JavaScript
// #140: opening and physical-object properties share the existing medium
|
|
// dialog shell. Measure real localized buttons rather than merely checking
|
|
// the `wide` attribute, because the contract is usable spare width plus safe
|
|
// wrapping when the viewport becomes narrow.
|
|
import { launch, checkAll, finish } from './serve.mjs';
|
|
|
|
const DESKTOP_WIDTH = 1000;
|
|
const NARROW_WIDTH = 320;
|
|
const { page, browser } = await launch({ width: DESKTOP_WIDTH, height: 820 }, 1);
|
|
|
|
const desktop = await page.evaluate(async () => {
|
|
const card = window.__card;
|
|
const settle = async () => {
|
|
await card.updateComplete;
|
|
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
};
|
|
// #759: a lazy locale keeps the previous frame, inert and aria-busy, until
|
|
// every dictionary this card paints has arrived — the main catalog and the
|
|
// editor's settings/support/topology namespaces (#627). The first dialog
|
|
// opened after a language switch is not in the tree until then. Wait for the
|
|
// gate's own markers, then for this dialog's footer to be laid out.
|
|
const waitFor = async (what, predicate) => {
|
|
const deadline = performance.now() + 5000;
|
|
for (;;) {
|
|
if (predicate()) return;
|
|
if (performance.now() > deadline) throw new Error(`smoke_dialog_footer_width: ${what}`);
|
|
await new Promise((resolve) => setTimeout(resolve, 16));
|
|
}
|
|
};
|
|
const settleLanguage = async (language, kind) => {
|
|
await waitFor(`язык ${language} не отрисован`,
|
|
() => !card.hasAttribute('aria-busy') && card.getAttribute('lang') === language);
|
|
await waitFor(`диалог ${kind} (${language}) не отрисован`, () => {
|
|
const root = card.shadowRoot || card.renderRoot;
|
|
const box = root.querySelector(`hp-dialog[data-kind="${kind}"]`)
|
|
?.querySelector('.dialog-action-footer')?.getBoundingClientRect();
|
|
return !!box && box.width > 0 && box.height > 0;
|
|
});
|
|
await settle();
|
|
};
|
|
card._serverStorage = true;
|
|
card._serverCfg = {
|
|
spaces: [{
|
|
id: 'dialog-layout', title: 'Dialog layout', cell_cm: 5,
|
|
view_box: [0, 0, 1, 1], rooms: [], settings: {},
|
|
}],
|
|
markers: [], settings: {},
|
|
};
|
|
card._space = 'dialog-layout';
|
|
card._mode = 'plan';
|
|
card._modelCache = null;
|
|
card._saveConfig = () => {};
|
|
await settle();
|
|
|
|
const layout = (kind, language) => {
|
|
const root = card.shadowRoot || card.renderRoot;
|
|
const dialog = root.querySelector(`hp-dialog[data-kind="${kind}"]`);
|
|
const surface = dialog?.shadowRoot?.querySelector('.surface');
|
|
const footer = dialog?.querySelector('.dialog-action-footer');
|
|
const danger = footer?.querySelector('.dialog-action-danger');
|
|
const commit = footer?.querySelector('.dialog-action-commit');
|
|
const surfaceRect = surface?.getBoundingClientRect();
|
|
const footerRect = footer?.getBoundingClientRect();
|
|
const footerStyle = footer ? getComputedStyle(footer) : null;
|
|
const dangerRect = danger?.getBoundingClientRect();
|
|
const commitRect = commit?.getBoundingClientRect();
|
|
const buttons = [...(footer?.querySelectorAll('button') || [])];
|
|
const innerWidth = footerRect && footerStyle
|
|
? footerRect.width - parseFloat(footerStyle.paddingLeft) - parseFloat(footerStyle.paddingRight)
|
|
: 0;
|
|
const contentWidth = (dangerRect?.width || 0) + (commitRect?.width || 0);
|
|
return {
|
|
kind,
|
|
language,
|
|
widePreset: dialog?.wide === true && dialog.hasAttribute('wide'),
|
|
surfaceWidth: Number((surfaceRect?.width || 0).toFixed(2)),
|
|
innerWidth: Number(innerWidth.toFixed(2)),
|
|
contentWidth: Number(contentWidth.toFixed(2)),
|
|
spareWidth: Number((innerWidth - contentWidth).toFixed(2)),
|
|
buttons: buttons.length,
|
|
oneRow: !!dangerRect && !!commitRect && Math.abs(dangerRect.top - commitRect.top) <= 1,
|
|
noHorizontalOverflow: !!surface && !!footer
|
|
&& surface.scrollWidth <= surface.clientWidth + 1
|
|
&& footer.scrollWidth <= footer.clientWidth + 1,
|
|
};
|
|
};
|
|
|
|
const open = async (kind, language) => {
|
|
card._config = { ...(card._config || {}), language };
|
|
card._openingDialog = null;
|
|
card._physicalDialog = null;
|
|
card._spaceDialog = null;
|
|
if (kind === 'opening') {
|
|
card._openingDialog = {
|
|
id: 'opening-1', type: 'door', lengthCm: 90,
|
|
contact: '', lock: '', invert: false, flipH: false, flipV: false,
|
|
x: 200, y: 200, angle: 0,
|
|
};
|
|
} else if (kind === 'physical') {
|
|
card._physicalDialog = {
|
|
kind: 'partition', id: 'partition-1', cm: '12', length: '1 m',
|
|
};
|
|
} else {
|
|
card._openSpaceDialog('edit', 'dialog-layout');
|
|
}
|
|
await settle();
|
|
await settleLanguage(language, kind);
|
|
const result = layout(kind, language);
|
|
card._openingDialog = null;
|
|
card._physicalDialog = null;
|
|
card._spaceDialog = null;
|
|
await settle();
|
|
return result;
|
|
};
|
|
|
|
const metrics = [];
|
|
for (const language of ['en', 'ru', 'de']) {
|
|
metrics.push(await open('opening', language));
|
|
metrics.push(await open('physical', language));
|
|
}
|
|
const space = await open('space', 'ru');
|
|
return { metrics, space };
|
|
});
|
|
|
|
await page.setViewportSize({ width: NARROW_WIDTH, height: 820 });
|
|
|
|
const narrow = await page.evaluate(async () => {
|
|
const card = window.__card;
|
|
const settle = async () => {
|
|
await card.updateComplete;
|
|
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
};
|
|
// #759: a lazy locale keeps the previous frame, inert and aria-busy, until
|
|
// every dictionary this card paints has arrived — the main catalog and the
|
|
// editor's settings/support/topology namespaces (#627). The first dialog
|
|
// opened after a language switch is not in the tree until then. Wait for the
|
|
// gate's own markers, then for this dialog's footer to be laid out.
|
|
const waitFor = async (what, predicate) => {
|
|
const deadline = performance.now() + 5000;
|
|
for (;;) {
|
|
if (predicate()) return;
|
|
if (performance.now() > deadline) throw new Error(`smoke_dialog_footer_width: ${what}`);
|
|
await new Promise((resolve) => setTimeout(resolve, 16));
|
|
}
|
|
};
|
|
const settleLanguage = async (language, kind) => {
|
|
await waitFor(`язык ${language} не отрисован`,
|
|
() => !card.hasAttribute('aria-busy') && card.getAttribute('lang') === language);
|
|
await waitFor(`диалог ${kind} (${language}) не отрисован`, () => {
|
|
const root = card.shadowRoot || card.renderRoot;
|
|
const box = root.querySelector(`hp-dialog[data-kind="${kind}"]`)
|
|
?.querySelector('.dialog-action-footer')?.getBoundingClientRect();
|
|
return !!box && box.width > 0 && box.height > 0;
|
|
});
|
|
await settle();
|
|
};
|
|
const measure = async (kind, language = 'ru') => {
|
|
card._config = { ...(card._config || {}), language };
|
|
card._openingDialog = null;
|
|
card._physicalDialog = null;
|
|
card._spaceDialog = null;
|
|
if (kind === 'opening') {
|
|
card._openingDialog = {
|
|
id: 'opening-1', type: 'door', lengthCm: 90,
|
|
contact: '', lock: '', invert: false, flipH: false, flipV: false,
|
|
x: 200, y: 200, angle: 0,
|
|
};
|
|
} else if (kind === 'physical') {
|
|
card._physicalDialog = {
|
|
kind: 'partition', id: 'partition-1', cm: '12', length: '1 m',
|
|
};
|
|
} else {
|
|
card._openSpaceDialog('edit', 'dialog-layout');
|
|
}
|
|
await settle();
|
|
await settleLanguage(language, kind);
|
|
const root = card.shadowRoot || card.renderRoot;
|
|
const dialog = root.querySelector(`hp-dialog[data-kind="${kind}"]`);
|
|
const surface = dialog?.shadowRoot?.querySelector('.surface');
|
|
const footer = dialog?.querySelector('.dialog-action-footer');
|
|
const danger = footer?.querySelector('.dialog-action-danger');
|
|
const commit = footer?.querySelector('.dialog-action-commit');
|
|
const surfaceRect = surface?.getBoundingClientRect();
|
|
const footerRect = footer?.getBoundingClientRect();
|
|
const footerStyle = footer ? getComputedStyle(footer) : null;
|
|
const dangerRect = danger?.getBoundingClientRect();
|
|
const commitRect = commit?.getBoundingClientRect();
|
|
const innerLeft = footerRect && footerStyle
|
|
? footerRect.left + parseFloat(footerStyle.paddingLeft) : 0;
|
|
const innerRight = footerRect && footerStyle
|
|
? footerRect.right - parseFloat(footerStyle.paddingRight) : 0;
|
|
const buttons = [...(footer?.querySelectorAll('button') || [])];
|
|
const result = {
|
|
kind,
|
|
language,
|
|
surfaceWidth: Number((surfaceRect?.width || 0).toFixed(2)),
|
|
insideViewport: !!surfaceRect && surfaceRect.left >= -1
|
|
&& surfaceRect.right <= innerWidth + 1,
|
|
noHorizontalOverflow: !!surface && !!footer
|
|
&& surface.scrollWidth <= surface.clientWidth + 1
|
|
&& footer.scrollWidth <= footer.clientWidth + 1,
|
|
buttonsContained: !!footerRect && buttons.every((button) => {
|
|
const rect = button.getBoundingClientRect();
|
|
return rect.left >= innerLeft - 1 && rect.right <= innerRight + 1
|
|
&& rect.top >= footerRect.top - 1 && rect.bottom <= footerRect.bottom + 1;
|
|
}),
|
|
wrapped: !!dangerRect && !!commitRect && commitRect.top >= dangerRect.bottom - 1,
|
|
oneRow: buttons.length > 0 && buttons.every((button) =>
|
|
Math.abs(button.getBoundingClientRect().top - buttons[0].getBoundingClientRect().top) <= 1),
|
|
iconOnlyDestructive: kind !== 'space' || [...footer.querySelectorAll('.hpf-mobile-icon')].every((button) =>
|
|
button.getAttribute('aria-label') && getComputedStyle(button.querySelector('.hpf-action-label')).display === 'none'),
|
|
buttons: buttons.length,
|
|
};
|
|
card._openingDialog = null;
|
|
card._physicalDialog = null;
|
|
card._spaceDialog = null;
|
|
await settle();
|
|
return result;
|
|
};
|
|
return {
|
|
opening: await measure('opening'),
|
|
physical: await measure('physical'),
|
|
space: await measure('space'),
|
|
opening_de: await measure('opening', 'de'),
|
|
physical_de: await measure('physical', 'de'),
|
|
space_de: await measure('space', 'de'),
|
|
};
|
|
});
|
|
|
|
const checks = {};
|
|
for (const metric of desktop.metrics) {
|
|
const key = `${metric.kind}_${metric.language}`;
|
|
checks[`${key}_medium_shell`] = metric.widePreset
|
|
&& metric.surfaceWidth >= 499 && metric.surfaceWidth <= 501;
|
|
checks[`${key}_three_actions_one_row`] = metric.buttons === 3 && metric.oneRow;
|
|
checks[`${key}_positive_localization_headroom`] = metric.spareWidth > 0;
|
|
checks[`${key}_no_horizontal_overflow`] = metric.noHorizontalOverflow;
|
|
}
|
|
// #600 Q7: диалоги настроек (`form-shell`) — 560 px по референсу; остальные
|
|
// wide-диалоги остаются на 500 и проверяются циклом выше.
|
|
checks.space_dialog_uses_reference_width = desktop.space.widePreset
|
|
&& desktop.space.surfaceWidth >= 559 && desktop.space.surfaceWidth <= 561;
|
|
checks.space_dialog_footer_not_regressed = desktop.space.buttons === 4
|
|
&& desktop.space.oneRow && desktop.space.noHorizontalOverflow;
|
|
|
|
for (const kind of ['opening', 'physical', 'opening_de', 'physical_de']) {
|
|
const metric = narrow[kind];
|
|
checks[`${kind}_narrow_fits_viewport`] = metric.surfaceWidth <= NARROW_WIDTH * 0.94 + 1
|
|
&& metric.insideViewport && metric.noHorizontalOverflow && metric.buttonsContained;
|
|
checks[`${kind}_narrow_keeps_responsive_wrap`] = metric.buttons === 3 && metric.wrapped;
|
|
}
|
|
checks.space_narrow_not_regressed = narrow.space.buttons === 4
|
|
&& narrow.space.insideViewport && narrow.space.noHorizontalOverflow
|
|
&& narrow.space.buttonsContained && narrow.space.oneRow && narrow.space.iconOnlyDestructive;
|
|
checks.space_de_narrow_not_regressed = narrow.space_de.buttons === 4
|
|
&& narrow.space_de.insideViewport && narrow.space_de.noHorizontalOverflow
|
|
&& narrow.space_de.buttonsContained && narrow.space_de.oneRow && narrow.space_de.iconOnlyDestructive;
|
|
|
|
checkAll(checks);
|
|
await finish(browser, {
|
|
checks,
|
|
desktop: {
|
|
dialogs: desktop.metrics,
|
|
longest: desktop.metrics.reduce((longest, metric) => (
|
|
metric.contentWidth > longest.contentWidth ? metric : longest
|
|
)),
|
|
space: desktop.space,
|
|
},
|
|
narrow,
|
|
});
|