mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
На окне не шире 480 px шапка карточки и страницы бокового меню занимает одну строку ≤ 56 px: вкладки пространств (без шестерёнок и «+», прокрутка вбок, текущая вкладка докручивается в видимую область), масштаб, одна шестерёнка «Действия и настройки» и у админа слот крестика #647. Заголовок скрыт. Меню шестерёнки (src/header-menu.ts): редакторы, настройки и добавление пространства, общие настройки, PDF, помощь, сводная панель и объёмный вид (alpha) — в фиксированном порядке и с теми же условиями и действиями, что у прежних кнопок. Пункт закрывает меню; Escape возвращает фокус на шестерёнку; касание вне меню поглощается прозрачной подложкой. На > 480 px шапка прежняя, в киоске меню нет. - три кнопки шапки вынесены в renderHeaderActions — шапка карточки не растёт; - сводная панель отдаёт свои пункты методом menuItems(); - i18n: title.header_menu (en/ru/de/fr); - потолок initial View 291 000 → 292 600 (замер 291 635), база bundleBytes; - смок smoke_mobile_view_header, юнит header-menu, 10 мутантов; смоки support_feedback, gear_tabs, modes, toolbar_stable_width переведены на меню на ≤ 480 px; подпись вкладки в span.tabtitle без pointer-events; - USER-GUIDE.ru, UX-MODES, TOUCH-SUPPORT, STYLING-HOOKS, data-hp-contract, оба changelog. Issue: #616 User-Visible: yes
149 lines
6.9 KiB
JavaScript
149 lines
6.9 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import test from 'node:test';
|
|
import {
|
|
codePointLength,
|
|
newSupportDialogState,
|
|
supportCanSubmit,
|
|
supportApiCompatible,
|
|
supportDraftError,
|
|
supportErrorCode,
|
|
supportRuntimeFacts,
|
|
supportSubmissionFingerprint,
|
|
supportSubmissionIdentity,
|
|
} from '../test-build/support-feedback.js';
|
|
|
|
test('support capability is exact and independent from release versions', () => {
|
|
assert.equal(supportApiCompatible(1), true);
|
|
for (const value of [undefined, null, 0, 2, '1', 1.0.toString(), true, {}, NaN]) {
|
|
assert.equal(supportApiCompatible(value), false, `unexpected compatibility for ${String(value)}`);
|
|
}
|
|
});
|
|
|
|
test('a fresh dialog never opts into exact plan geometry', () => {
|
|
const state = newSupportDialogState();
|
|
assert.equal(state.attach, false);
|
|
assert.equal(state.preview, null);
|
|
assert.equal(state.contact, '');
|
|
assert.equal(state.message, '');
|
|
assert.match(state.draftId, /^draft-/);
|
|
assert.match(state.idempotencyKey, /^report-/);
|
|
});
|
|
|
|
test('message/contact limits count Unicode code points rather than UTF-16 units', () => {
|
|
assert.equal(codePointLength('🙂'), 1);
|
|
const state = { ...newSupportDialogState(), message: 'ok', contact: '🙂'.repeat(321) };
|
|
assert.equal(supportDraftError(state), 'contact_too_long');
|
|
assert.equal(supportCanSubmit(state), false);
|
|
});
|
|
|
|
test('attachment submit requires the exact non-expired preview', () => {
|
|
const now = 10_000;
|
|
const base = { ...newSupportDialogState(), message: 'repro', attach: true };
|
|
assert.equal(supportDraftError(base, now), 'preview_missing');
|
|
const preview = {
|
|
token: 'a'.repeat(48), expiresAt: now + 1, size: 2, sha256: 'b'.repeat(64),
|
|
spaces: 1, format: 'houseplan-support-package', version: 1, text: '{}\n', preparedAt: now,
|
|
};
|
|
assert.equal(supportCanSubmit({ ...base, preview, status: 'ready' }, now), true);
|
|
assert.equal(supportDraftError({ ...base, preview }, now + 1), 'preview_expired');
|
|
});
|
|
|
|
test('submission identity follows the effective trimmed payload', () => {
|
|
const initial = {
|
|
...newSupportDialogState(),
|
|
message: ' first message ',
|
|
contact: ' user@example.test ',
|
|
};
|
|
const first = supportSubmissionIdentity(initial);
|
|
assert.equal(first.idempotencyKey, initial.idempotencyKey);
|
|
assert.equal(
|
|
first.fingerprint,
|
|
supportSubmissionFingerprint({ ...initial, message: 'first message', contact: 'user@example.test' }),
|
|
);
|
|
|
|
const attempted = { ...initial, submissionFingerprint: first.fingerprint };
|
|
assert.equal(
|
|
supportSubmissionIdentity({ ...attempted, message: 'first message ' }).idempotencyKey,
|
|
initial.idempotencyKey,
|
|
);
|
|
assert.notEqual(
|
|
supportSubmissionIdentity({ ...attempted, message: 'different message' }).idempotencyKey,
|
|
initial.idempotencyKey,
|
|
);
|
|
assert.notEqual(
|
|
supportSubmissionIdentity({ ...attempted, contact: 'other@example.test' }).idempotencyKey,
|
|
initial.idempotencyKey,
|
|
);
|
|
|
|
const preview = {
|
|
token: 'a'.repeat(48), expiresAt: Date.now() + 1_000, size: 2, sha256: 'b'.repeat(64),
|
|
spaces: 1, format: 'houseplan-support-package', version: 1, text: '{}\n', preparedAt: Date.now(),
|
|
};
|
|
assert.notEqual(
|
|
supportSubmissionIdentity({ ...attempted, attach: true, preview }).idempotencyKey,
|
|
initial.idempotencyKey,
|
|
);
|
|
assert.equal(
|
|
supportSubmissionFingerprint({ ...attempted, attach: false, preview }),
|
|
first.fingerprint,
|
|
);
|
|
});
|
|
|
|
test('runtime facts are bounded enums without a raw user agent', () => {
|
|
const facts = supportRuntimeFacts({
|
|
userAgent: 'Mozilla/5.0 Chrome/140.0.0.0 Safari/537.36 private-suffix',
|
|
language: 'ru', coarsePointer: false, hoverCapable: true,
|
|
registryAccess: 'full', registryLastSuccess: 9_500, now: 10_000,
|
|
});
|
|
assert.deepEqual(facts, {
|
|
browser_family: 'chromium', browser_major: 140, language: 'ru',
|
|
coarse_pointer: false, hover_capable: true, registry_access: 'full',
|
|
registry_age_bucket: 'fresh',
|
|
});
|
|
assert.equal(JSON.stringify(facts).includes('private-suffix'), false);
|
|
});
|
|
|
|
test('remote and unknown failures collapse to stable local error codes', () => {
|
|
assert.equal(supportErrorCode({ code: 'support_rate_limited', message: 'private' }), 'support_rate_limited');
|
|
assert.equal(supportErrorCode({ code: 'provider leaked details' }), 'support_unavailable');
|
|
assert.equal(supportErrorCode(new Error('network secret')), 'support_unavailable');
|
|
});
|
|
|
|
test('Help is lazy, ordered after settings, and owns the single About/Guide surface', () => {
|
|
const card = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
|
|
const runtime = readFileSync(new URL('../src/houseplan-editor-runtime.ts', import.meta.url), 'utf8');
|
|
const styles = readFileSync(new URL('../src/styles/plan.styles.ts', import.meta.url), 'utf8');
|
|
const dialogStyles = readFileSync(new URL('../src/styles/dialogs.styles.ts', import.meta.url), 'utf8');
|
|
const header = card.slice(card.indexOf('<div class="zoomctl">'), card.indexOf('</div>\n ${this._canEdit'));
|
|
assert.ok(header.indexOf('_openSettingsDialog') < header.indexOf('_openSupportDialog'));
|
|
assert.ok(header.indexOf('_openSettingsDialog') < header.indexOf('_openPdfDialog'));
|
|
assert.ok(header.indexOf('_openPdfDialog') < header.indexOf('_openSupportDialog'));
|
|
assert.match(card, /if \(!this\._editorRuntime\)[\s\S]*?_ensureEditorRuntime\(\)[\s\S]*?_openSupportDialog/);
|
|
assert.equal((runtime.match(/_t\('gs\.about_version'/g) || []).length, 1);
|
|
assert.match(runtime, /docs\/USER-GUIDE\.ru\.md/);
|
|
assert.match(runtime, /docs\/USER-GUIDE\.md/);
|
|
// #616: the three header buttons are rendered by src/header-menu.ts (the card
|
|
// passes the openers in the same order); on a phone they are its menu items.
|
|
const headerMenu = readFileSync(new URL('../src/header-menu.ts', import.meta.url), 'utf8');
|
|
const actions = headerMenu.slice(headerMenu.indexOf('export function renderHeaderActions'));
|
|
assert.equal((actions.match(/header-action/g) || []).length, 3);
|
|
assert.ok(actions.indexOf('open.settings') < actions.indexOf('open.pdf'));
|
|
assert.ok(actions.indexOf('open.pdf') < actions.indexOf('open.support'));
|
|
assert.match(styles, /\.header-action\s*\{[\s\S]*?min-width:\s*44px;[\s\S]*?min-height:\s*44px;/);
|
|
assert.match(dialogStyles, /\.supportmessage\s*\{[\s\S]*?background:\s*var\(--hp-bg\);/);
|
|
});
|
|
|
|
test('the consent copy names exact geometry, project relay, retention and network address', () => {
|
|
const en = JSON.parse(readFileSync(new URL('../src/i18n/support/en.json', import.meta.url), 'utf8'));
|
|
const ru = JSON.parse(readFileSync(new URL('../src/i18n/support/ru.json', import.meta.url), 'utf8'));
|
|
assert.match(en['support.privacy'], /exact geometry/);
|
|
assert.match(en['support.privacy'], /project relay/);
|
|
assert.match(en['support.privacy'], /30 days/);
|
|
assert.match(en['support.privacy'], /server address/);
|
|
assert.equal(
|
|
ru['support.contact'],
|
|
'Контакт для связи (email/tg/WhatsApp), необязательно.',
|
|
);
|
|
});
|