mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
195 lines
9.9 KiB
JavaScript
195 lines
9.9 KiB
JavaScript
// Стоимость растеризации дневного цикла — свидетель #532.
|
|
//
|
|
// Владелец: «в Firefox план тормозит; со статичным цветом фона тормоза
|
|
// пропадают». Профиль подтвердил: наш JS спал 89 % времени, а в GPU-процессе
|
|
// на кадр уезжало 23 МБ текстур. Виновником оказалась не сцена плана, а
|
|
// тройной drop-shadow внешнего контура. После #582 alpha-силуэт живёт в
|
|
// отдельном stage-sized SVG: это по-прежнему собственный промотированный слой,
|
|
// но уже не координатно огромная внутренняя .hp-paperg.
|
|
//
|
|
// Смок проверяет две вещи, и вторая — не время, а ОТНОШЕНИЕ: абсолютные
|
|
// миллисекунды зависят от машины и раннера. Три пары замеров идут в одном
|
|
// процессе, на одной странице и чередуют static/daynight; медиана парных
|
|
// отношений отбрасывает единичный шум соседних mutation-шардов, не ослабляя
|
|
// бюджет.
|
|
//
|
|
// Замер аналитики на панораме демо-стенда: 7.9 без подсказки, 0.26 с ней.
|
|
// Порог 2.0 стоит примерно посередине по логарифму и даёт запас в обе стороны.
|
|
//
|
|
// Движок здесь Chromium — он же движок CI и golden. Приговор по Gecko даёт
|
|
// профиль Firefox с машины владельца: подсказка это совет движку, и Gecko
|
|
// вправе отклонить её по бюджету памяти (layout.css.will-change.budget).
|
|
// Смок нужен не вместо того профиля, а чтобы правку нельзя было молча снять.
|
|
import { launch, check, finish } from './serve.mjs';
|
|
|
|
const PAN_STEPS = 60;
|
|
const SAMPLE_ROUNDS = 3;
|
|
const RATIO_CEILING = 2.0;
|
|
|
|
const { page, browser } = await launch({ width: 1400, height: 900 }, 1);
|
|
const cdp = await page.context().newCDPSession(page);
|
|
|
|
const setBackground = async (mode) => {
|
|
await page.evaluate(async (mode) => {
|
|
const card = window.__card;
|
|
card._serverCfg.settings = { ...(card._serverCfg.settings || {}), bg_mode: mode };
|
|
// Пространство со своим bg_mode переопределяет глобальный — снимаем, иначе
|
|
// смок мерил бы не то, что переключил.
|
|
for (const space of card._serverCfg.spaces) {
|
|
space.settings = { ...(space.settings || {}) };
|
|
delete space.settings.bg_mode;
|
|
}
|
|
card.hass = { ...card.hass, states: { ...card.hass.states, 'sun.sun': {
|
|
entity_id: 'sun.sun', state: 'above_horizon',
|
|
attributes: { azimuth: 180, elevation: 40, rising: false },
|
|
} } };
|
|
card._cfgRev = (card._cfgRev || 0) + 1;
|
|
card.requestUpdate();
|
|
await card.updateComplete;
|
|
}, mode);
|
|
// Переход фильтра длится 1100 мс: замер до его конца мерил бы анимацию.
|
|
await page.waitForTimeout(1400);
|
|
};
|
|
|
|
const outlineStyle = () => page.evaluate(() => {
|
|
const root = window.__card.shadowRoot || window.__card.renderRoot;
|
|
const stage = root.querySelector('.stage');
|
|
const outline = root.querySelector('.hp-paper-outline-svg');
|
|
const paper = root.querySelector('.hp-paperg');
|
|
const plan = root.querySelector('.plan-svg');
|
|
if (!paper) return null;
|
|
const style = outline ? getComputedStyle(outline) : null;
|
|
return {
|
|
daycycle: stage.classList.contains('daycycle'),
|
|
safe: stage.classList.contains('hp-safe-daycycle-outline'),
|
|
outlinePresent: !!outline,
|
|
outlineVisibility: style?.visibility || 'hidden',
|
|
filter: style?.filter || 'none',
|
|
willChange: style?.willChange || 'auto',
|
|
paperFilter: getComputedStyle(paper).filter,
|
|
paperWillChange: getComputedStyle(paper).willChange,
|
|
planWillChange: plan ? getComputedStyle(plan).willChange : 'auto',
|
|
};
|
|
});
|
|
|
|
const stageBox = () => page.evaluate(() => {
|
|
const root = window.__card.shadowRoot || window.__card.renderRoot;
|
|
const rect = root.querySelector('.stage').getBoundingClientRect();
|
|
return { x: rect.x, y: rect.y, w: rect.width, h: rect.height };
|
|
});
|
|
|
|
/** Одна панорама с зажатой кнопкой; возвращает суммарный RasterTask в мс. */
|
|
const measurePan = async () => {
|
|
const box = await stageBox();
|
|
const events = [];
|
|
const collect = ({ value }) => events.push(...value);
|
|
cdp.on('Tracing.dataCollected', collect);
|
|
await cdp.send('Tracing.start', {
|
|
traceConfig: { includedCategories: ['disabled-by-default-devtools.timeline'] },
|
|
transferMode: 'ReportEvents',
|
|
});
|
|
const cx = box.x + box.w / 2, cy = box.y + box.h / 2;
|
|
await page.mouse.move(cx, cy);
|
|
await page.mouse.down();
|
|
// Замкнутая восьмёрка возвращает камеру туда, где начала: static и daynight
|
|
// всегда растеризуют один и тот же участок плана, а не постепенно уезжают
|
|
// к краю синтетической сцены.
|
|
for (let step = 1; step <= PAN_STEPS; step += 1) {
|
|
const progress = step / PAN_STEPS;
|
|
await page.mouse.move(
|
|
cx + Math.sin(progress * Math.PI * 2) * 140,
|
|
cy + Math.sin(progress * Math.PI * 4) * 90,
|
|
);
|
|
// Два кадра на шаг: первый ставит кадр в очередь, второй ждёт, пока он
|
|
// действительно отрисуется. Иначе замер считает не работу, а очередь.
|
|
await page.evaluate(() => new Promise((resolve) => {
|
|
requestAnimationFrame(() => requestAnimationFrame(resolve));
|
|
}));
|
|
}
|
|
await page.mouse.up();
|
|
await page.waitForTimeout(200);
|
|
const complete = new Promise((resolve) => cdp.once('Tracing.tracingComplete', resolve));
|
|
await cdp.send('Tracing.end');
|
|
await complete;
|
|
cdp.off('Tracing.dataCollected', collect);
|
|
let micros = 0, tasks = 0;
|
|
for (const event of events) {
|
|
if (event.name !== 'RasterTask' || event.ph !== 'X' || typeof event.dur !== 'number') continue;
|
|
micros += event.dur;
|
|
tasks += 1;
|
|
}
|
|
return { ms: micros / 1000, tasks };
|
|
};
|
|
|
|
const median = (values) => {
|
|
const sorted = [...values].sort((a, b) => a - b);
|
|
return sorted[Math.floor(sorted.length / 2)];
|
|
};
|
|
|
|
const out = {};
|
|
|
|
// 1. Подсказка стоит там и только там, где стоит фильтр.
|
|
await setBackground('daynight');
|
|
const dayStyle = await outlineStyle();
|
|
out.dayCycleClassOn = dayStyle?.daycycle === true;
|
|
out.dayCycleOutlinePresent = dayStyle?.outlinePresent === true;
|
|
out.idleUsesHistoricalPaperFilter = dayStyle?.safe === false
|
|
&& dayStyle?.outlineVisibility === 'hidden'
|
|
&& /drop-shadow/.test(dayStyle?.paperFilter || '')
|
|
&& /filter/.test(dayStyle?.paperWillChange || '');
|
|
|
|
await setBackground('static');
|
|
const staticStyle = await outlineStyle();
|
|
out.staticClassOff = staticStyle?.daycycle === false;
|
|
out.staticOutlineAbsent = staticStyle?.outlinePresent === false;
|
|
out.staticOutlineUnfiltered = (staticStyle?.filter || 'none') === 'none';
|
|
// Статичный фон не платит за чужую подсказку ни слоем, ни памятью.
|
|
out.staticOutlineNotPromoted = !/filter/.test(staticStyle?.willChange || '');
|
|
|
|
// 2. Отношение растеризации на настоящей панораме. Один CDP trace на общем
|
|
// GitHub runner иногда ловит короткий всплеск соседнего shard-а. Берём три
|
|
// чередующиеся пары и берём медиану их отношений: систематическая регрессия #532
|
|
// остаётся во всех трёх daynight-замерах, одиночный выброс — нет.
|
|
const staticSamples = [];
|
|
const dayCycleSamples = [];
|
|
let safeDayStyle = null;
|
|
for (let round = 0; round < SAMPLE_ROUNDS; round += 1) {
|
|
staticSamples.push(await measurePan());
|
|
await setBackground('daynight');
|
|
safeDayStyle = await outlineStyle();
|
|
dayCycleSamples.push(await measurePan());
|
|
if (round < SAMPLE_ROUNDS - 1) await setBackground('static');
|
|
}
|
|
out.cameraUsesSafeOutline = safeDayStyle?.safe === true
|
|
&& safeDayStyle?.outlineVisibility === 'visible';
|
|
out.dayCycleOutlineFiltered = /drop-shadow/.test(safeDayStyle?.filter || '');
|
|
out.dayCycleOutlinePromoted = /filter/.test(safeDayStyle?.willChange || '');
|
|
out.dayCyclePaperStaysUnfiltered = (safeDayStyle?.paperFilter || 'none') === 'none'
|
|
&& !/filter/.test(safeDayStyle?.paperWillChange || '');
|
|
out.safePlanLayerIsExplicit = /transform/.test(safeDayStyle?.planWillChange || '');
|
|
|
|
out.rasterTasksObserved = [...staticSamples, ...dayCycleSamples].every((sample) => sample.tasks > 0);
|
|
const staticMedianMs = median(staticSamples.map((sample) => sample.ms));
|
|
const dayCycleMedianMs = median(dayCycleSamples.map((sample) => sample.ms));
|
|
const pairedRatios = dayCycleSamples.map((sample, index) => (
|
|
staticSamples[index].ms > 0 ? sample.ms / staticSamples[index].ms : Infinity
|
|
));
|
|
const ratio = median(pairedRatios);
|
|
out.rasterRatioWithinBudget = ratio <= RATIO_CEILING;
|
|
|
|
console.log(JSON.stringify({
|
|
staticSamples: staticSamples.map((sample) => ({
|
|
ms: Number(sample.ms.toFixed(1)), tasks: sample.tasks,
|
|
})),
|
|
dayCycleSamples: dayCycleSamples.map((sample) => ({
|
|
ms: Number(sample.ms.toFixed(1)), tasks: sample.tasks,
|
|
})),
|
|
staticMedianMs: Number(staticMedianMs.toFixed(1)),
|
|
dayCycleMedianMs: Number(dayCycleMedianMs.toFixed(1)),
|
|
pairedRatios: pairedRatios.map((value) => Number(value.toFixed(2))),
|
|
ratio: Number(ratio.toFixed(2)), ceiling: RATIO_CEILING,
|
|
}, null, 1));
|
|
|
|
for (const [name, value] of Object.entries(out)) check(name, value);
|
|
await finish(browser, out);
|