From f6cd99cef49affb9d108969caa81da9749766230 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 00:41:40 +0300 Subject: [PATCH] =?UTF-8?q?feat(tools):=20=D1=81=D1=82=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=86=D0=B0=20/convert=20=D0=B4=D0=BB=D1=8F=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BD=D0=B2=D0=B5=D1=80=D1=82=D0=B5=D1=80=D0=B0=20Sweet?= =?UTF-8?q?=20Home=203D,=20=D1=8D=D1=82=D0=B0=D0=BF=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Артефакт один — scripts/sh3d-convert/dist/index.html, самодостаточный файл, как и лендинг: раскладывается копированием, без сборщиков и CDN. Собирается склейкой тех же модулей, которые проверяют гейты этапа 1; относительных импортов на странице нет, поэтому она работает из любого каталога и без сети. Три обещания страницы проверяются тестами, а не текстом: 1. Ни одного сетевого вызова и ни одной внешней загрузки. Ищутся формы ВЫЗОВА, а не имена API: первая редакция теста краснела на собственном комментарии, где эти API перечислены как запрещённые. Шрифты системные — к шрифтам это внешний запрос при загрузке, а обещание «файл не покидает браузер» должно быть буквальным. Заодно поэтому на странице нет аналитики. 2. Конвертер внутри страницы даёт тот же результат, что модули репозитория: код извлекается из собранного артефакта, прогоняется на фикстуре и сравнивается с golden. Склейка, потерявшая модуль, краснеет. 3. Собранная страница не отстала от исходников: build-page.mjs --check сверяет закоммиченный артефакт с пересборкой. Плюс полнота переводов: у каждой подписи есть ru и en, словари совпадают по ключам, у каждого кода отчёта и каждого кода отказа есть человеческий текст. Иначе пользователь увидит пустую надпись или `not_zip`. Отчёт показывается ДО скачивания и по этажам: сколько комнат, стен и проёмов, какая клетка сетки, и полный список мест, где конвертер принял решение, — спрямлённая дуга, обрезанная толщина, непривязанный проём, пропущенный этаж без комнат. Это требование задачи: искать чужие ошибки в готовом плане из сотен объектов хуже, чем прочитать их заранее. Гайды (ru/en) получили раздел про импорт из Sweet Home 3D с прямой записью, что это необязательный ярлык, а не шаг настройки: SCOPE обещает «no external editors», и обходить это молчанием нельзя. Свидетели этапа 2, все проверены отрицательным прогоном: добавленный в страницу fetch, потерянный русский перевод, правка dist руками, склейка без модуля sh3d.mjs — каждая краснит свой тест. Гейты: npm test 1871 tests, 1870 pass, 0 fail; pytest без HA 378 passed, 3 skipped. Осталось владельцу: раскладка на хост (из песочницы SSH недоступен) и проверка на настоящем файле Sweet Home 3D. Issue: #446 User-Visible: no --- docs/USER-GUIDE.md | 21 + docs/USER-GUIDE.ru.md | 20 + scripts/sh3d-convert/build-page.mjs | 67 ++ scripts/sh3d-convert/dist/index.html | 1258 ++++++++++++++++++++++++++ scripts/sh3d-convert/page/app.mjs | 288 ++++++ scripts/sh3d-convert/page/shell.html | 118 +++ test/sh3d-convert-page.test.mjs | 105 +++ 7 files changed, 1877 insertions(+) create mode 100644 scripts/sh3d-convert/build-page.mjs create mode 100644 scripts/sh3d-convert/dist/index.html create mode 100644 scripts/sh3d-convert/page/app.mjs create mode 100644 scripts/sh3d-convert/page/shell.html create mode 100644 test/sh3d-convert-page.test.mjs diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 1f834823..de120fcf 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -995,6 +995,27 @@ names, exact coordinates and external URLs remain in the JSON. Internal plan files are still referenced rather than embedded and may need to be detached on another Home Assistant instance. +### Importing a plan from Sweet Home 3D + +If the home is already drawn in [Sweet Home 3D](https://www.sweethome3d.com/), +there is no need to draw it again: the converter at +[houseplan.tech/convert](https://houseplan.tech/convert) turns a `.sh3d` file +into import documents — one per level. From there it is the ordinary space +import under **Global settings → Backup and transfer**. + +This is an **optional shortcut**, not a setup step: the normal paths — a +background image or drawing from scratch — stay unchanged. The conversion runs +in your browser; the file is never uploaded. + +Carried over: levels, rooms with their names, walls with thickness, doors and +windows. Not carried over: furniture, materials, textures, lights, cameras — +and the binding of rooms to Home Assistant areas together with device +placement, because the file has neither; both are done in the editor. Curved +walls are straightened, walls thicker than 100 cm are clamped to the limit, and +a level with no drawn rooms cannot be converted at all — House Plan builds +geometry from rooms. The page lists every such case before you download +anything. + ### Storage locations | Data | Location | diff --git a/docs/USER-GUIDE.ru.md b/docs/USER-GUIDE.ru.md index 6bfddfd7..8fb46854 100644 --- a/docs/USER-GUIDE.ru.md +++ b/docs/USER-GUIDE.ru.md @@ -1798,6 +1798,26 @@ JSON хранит названия, идентификаторы HA и точн координаты и внешние URL. Структурные привязки Home Assistant при этом не переносятся. +### Импорт плана из Sweet Home 3D + +Если план дома уже нарисован в [Sweet Home 3D](https://www.sweethome3d.com/), +его не нужно рисовать заново: конвертер на +[houseplan.tech/convert](https://houseplan.tech/convert) превращает файл +`.sh3d` в документ импорта — по одному на этаж. Дальше это обычный импорт +пространства из **Общих настроек → Резервная копия и перенос**. + +Это **необязательный ярлык**, а не шаг настройки: полный путь остаётся прежним +— подложка изображением или рисование с нуля. Конверсия идёт в браузере, файл +никуда не загружается. + +Что переносится: этажи, комнаты с названиями, стены с толщиной, двери и окна. +Что не переносится: мебель, материалы, текстуры, свет, камеры — и привязка +комнат к областям Home Assistant с расстановкой устройств: их в файле нет, это +делается в редакторе. Круглые стены спрямляются, стены толще 100 см +обрезаются до предела, а этаж без нарисованных комнат конвертировать нечем — +House Plan строит геометрию по комнатам. Страница показывает полный список +таких мест до скачивания. + ### Где лежат данные | Данные | Хранение | diff --git a/scripts/sh3d-convert/build-page.mjs b/scripts/sh3d-convert/build-page.mjs new file mode 100644 index 00000000..d63ff8d7 --- /dev/null +++ b/scripts/sh3d-convert/build-page.mjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node +/** + * Сборка страницы /convert в один самодостаточный файл (#446). + * + * Артефакт один: `dist/index.html`. Так же устроен лендинг, и так же его + * раскладывают на хост — копированием, без сборщиков и без CDN. + * + * Модули склеиваются, а не грузятся по относительным путям: страница обязана + * работать из любого каталога и без сети. Склейка нарочно тупая — снимаются + * строки `import ... from '...'` и префикс `export `. Многострочный импорт + * ломает такую склейку молча, поэтому он ОТКАЗ, а не предупреждение. + * + * node scripts/sh3d-convert/build-page.mjs [--check] + * + * `--check` не пишет файл, а сверяет закоммиченный артефакт с пересборкой: + * этим тест ловит правку страницы или модулей без пересборки. + */ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ORDER = ['xml.mjs', 'zip.mjs', 'convert.mjs', 'sh3d.mjs', 'page/app.mjs']; +const MARKER = '/* */'; + +const stripModuleSyntax = (source, name) => { + const lines = source.split('\n'); + const out = []; + for (const [index, line] of lines.entries()) { + if (/^\s*import\s/.test(line)) { + if (!/;\s*$/.test(line)) { + throw new Error( + `${name}:${index + 1}: многострочный import ломает склейку — запишите его в одну строку`); + } + continue; + } + out.push(line.replace(/^export\s+(?=const|function|class|async|let)/, '')); + } + const remaining = out.find((line) => /^\s*export\s/.test(line)); + if (remaining) throw new Error(`${name}: не снят export: ${remaining.trim()}`); + return out.join('\n'); +}; + +export function buildPage() { + const shell = readFileSync(join(HERE, 'page/shell.html'), 'utf8'); + if (!shell.includes(MARKER)) throw new Error('в shell.html нет метки вставки'); + const bundle = ORDER + .map((name) => `// --- ${name} ---\n${stripModuleSyntax(readFileSync(join(HERE, name), 'utf8'), name)}`) + .join('\n'); + return shell.replace(MARKER, `${bundle}\nmount();`); +} + +const page = buildPage(); +const target = join(HERE, 'dist', 'index.html'); +if (process.argv.includes('--check')) { + const committed = readFileSync(target, 'utf8'); + if (committed !== page) { + console.error('FAILED: dist/index.html отстал от исходников —' + + ' пересоберите: node scripts/sh3d-convert/build-page.mjs'); + process.exit(1); + } + console.log('ok: dist/index.html совпадает с пересборкой'); +} else { + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, page); + console.log(`dist/index.html: ${page.length} Б`); +} diff --git a/scripts/sh3d-convert/dist/index.html b/scripts/sh3d-convert/dist/index.html new file mode 100644 index 00000000..68dacdaa --- /dev/null +++ b/scripts/sh3d-convert/dist/index.html @@ -0,0 +1,1258 @@ + + + + + +Sweet Home 3D → House Plan converter + + + + + + + +
+
+ 🏠 House Plan +
+ + +
+
+ +

Sweet Home 3D → House Plan converter

+

+

+

+ +
+

+

+ +

+ +
+ + + + + + + + +
+ + + diff --git a/scripts/sh3d-convert/page/app.mjs b/scripts/sh3d-convert/page/app.mjs new file mode 100644 index 00000000..7f919633 --- /dev/null +++ b/scripts/sh3d-convert/page/app.mjs @@ -0,0 +1,288 @@ +/** + * Страница /convert: интерфейс конвертера (#446). + * + * Вся конверсия идёт здесь, в браузере. Ни одного сетевого вызова на этой + * странице нет и быть не должно: план дома — приватные данные, а сама страница + * живёт на хосте, который по нашему описанию расходный и потенциально + * враждебный. Отсутствие `fetch`/`XMLHttpRequest`/`WebSocket`/`sendBeacon` + * проверяется тестом по собранному артефакту, а не обещанием в тексте. + * + * Отчёт показывается ДО скачивания — это требование задачи, а не украшение: + * человек должен узнать про спрямлённую дугу и непривязанную дверь заранее, а + * не искать их потом в плане из сотен объектов. + */ +import { readSh3d } from '../sh3d.mjs'; +import { ConvertError, MIN_HOUSEPLAN, convertHome } from '../convert.mjs'; + +const TOOL_VERSION = 'sh3d-convert 0.1'; +const MAX_FILE_BYTES = 64 * 1024 * 1024; + +/** Словари вынесены в экспорт: полноту ключей проверяет тест страницы. */ +export const TEXT = { + ru: { + 'page.title': 'Конвертер Sweet Home 3D → House Plan', + 'page.lead': 'Файл плана из Sweet Home 3D превращается в документ импорта House Plan — по одному на этаж.', + 'page.privacy': 'Конверсия идёт в вашем браузере. Файл никуда не загружается: страница не делает ни одного сетевого запроса.', + 'page.requires': `Требуется House Plan ${MIN_HOUSEPLAN} или новее.`, + 'drop.title': 'Перетащите сюда файл .sh3d', + 'drop.or': 'или', + 'drop.button': 'Выбрать файл', + 'drop.hint': 'Ничего не уходит с вашего компьютера', + 'busy': 'Читаю файл…', + 'result.title': 'Что получилось', + 'result.download': 'Скачать этаж', + 'result.rooms': 'комнат', + 'result.walls': 'стен', + 'result.openings': 'проёмов', + 'result.cell': 'клетка сетки', + 'result.skipped': 'этаж пропущен: комнат нет', + 'result.notes': 'На что посмотреть после импорта', + 'howto.title': 'Как импортировать', + 'howto.1': 'Откройте карточку House Plan → Общие настройки → Резервная копия и перенос.', + 'howto.2': 'Выберите импорт и укажите скачанный файл: House Plan покажет предпросмотр и ничего не запишет до подтверждения.', + 'howto.3': 'Этаж добавится рядом с существующими пространствами. Привязку комнат к областям Home Assistant и расстановку устройств сделайте в редакторе — в файле их нет.', + 'error.title': 'Не получилось', + 'error.retry': 'Выбрать другой файл', + 'back': 'На главную', + 'note.vertices_snapped': 'Вершины комнат выровнены по осевым линиям стен: {count}. Sweet Home 3D обводит комнаты по внутренним граням, иначе общая стена превратилась бы в две.', + 'note.curved_wall_straightened': 'Круглых стен спрямлено: {count}. Дуг в House Plan нет.', + 'note.thickness_clamped': 'Стен толще {limit} см обрезано до предела: {count}.', + 'note.edge_without_wall': 'Границ без стены: {count}. Такая граница останется открытой — стены в файле там не было.', + 'note.opening_unhosted': 'Проём не привязался ни к одной стене и не перенесён: {id}.', + 'note.opening_shortened': 'Проём шире стены и обрезан до неё: {id}.', + 'note.opening_without_width': 'Проём без ширины пропущен: {id}.', + 'note.room_without_polygon': 'Комната без контура пропущена: {id}.', + 'note.room_collapsed': 'Контур комнаты рассыпался после выравнивания и пропущен: {id}.', + 'note.level_without_rooms': 'Этаж «{title}» пропущен: в нём нет ни одной комнаты. House Plan строит геометрию по комнатам — нарисуйте их в Sweet Home 3D.', + 'note.furniture_dropped': 'Мебель, материалы, свет и камеры не переносятся: House Plan — живая карта дома, а не редактор интерьера.', + 'code.not_zip': 'Это не файл Sweet Home 3D: архив не читается.', + 'code.entry_missing': 'В архиве нет Home.xml. Пересохраните план в Sweet Home 3D 5.3 или новее.', + 'code.encrypted': 'Архив защищён паролем.', + 'code.zip64': 'Такой архив (zip64) не поддерживается.', + 'code.entry_too_large': 'Файл слишком большой.', + 'code.entity_declaration': 'В файле объявлены XML-сущности — такой файл не читается.', + 'code.entity_reference': 'В файле есть неизвестная XML-сущность.', + 'code.nothing_to_convert': 'В плане нет ни одной комнаты. House Plan строит геометрию по комнатам: нарисуйте их в Sweet Home 3D и сохраните файл заново.', + 'code.room_too_complex': 'Слишком сложный контур комнаты.', + 'code.unit_not_metric': 'План сохранён не в метрических единицах.', + 'code.file_too_large': 'Файл больше 64 МБ.', + 'code.unknown': 'Неизвестная ошибка чтения файла.', + }, + en: { + 'page.title': 'Sweet Home 3D → House Plan converter', + 'page.lead': 'Turns a Sweet Home 3D file into House Plan import documents — one per level.', + 'page.privacy': 'The conversion runs in your browser. The file is never uploaded: this page makes no network requests at all.', + 'page.requires': `Requires House Plan ${MIN_HOUSEPLAN} or newer.`, + 'drop.title': 'Drop a .sh3d file here', + 'drop.or': 'or', + 'drop.button': 'Choose a file', + 'drop.hint': 'Nothing leaves your computer', + 'busy': 'Reading the file…', + 'result.title': 'What came out', + 'result.download': 'Download level', + 'result.rooms': 'rooms', + 'result.walls': 'walls', + 'result.openings': 'openings', + 'result.cell': 'grid cell', + 'result.skipped': 'level skipped: no rooms', + 'result.notes': 'What to check after the import', + 'howto.title': 'How to import', + 'howto.1': 'Open the House Plan card → General settings → Backup & transfer.', + 'howto.2': 'Pick import and choose the downloaded file: House Plan shows a preview and writes nothing until you confirm.', + 'howto.3': 'The level is added next to your existing spaces. Bind rooms to Home Assistant areas and place devices in the editor — the file carries neither.', + 'error.title': 'That did not work', + 'error.retry': 'Choose another file', + 'back': 'Back to the site', + 'note.vertices_snapped': 'Room vertices aligned to wall centrelines: {count}. Sweet Home 3D traces rooms along inner faces, so without this a shared wall would become two.', + 'note.curved_wall_straightened': 'Curved walls straightened: {count}. House Plan has no arcs.', + 'note.thickness_clamped': 'Walls thicker than {limit} cm clamped to the limit: {count}.', + 'note.edge_without_wall': 'Boundaries without a wall: {count}. They stay open — there was no wall in the file.', + 'note.opening_unhosted': 'This opening matched no wall and was skipped: {id}.', + 'note.opening_shortened': 'Opening wider than its wall, shortened to fit: {id}.', + 'note.opening_without_width': 'Opening without a width, skipped: {id}.', + 'note.room_without_polygon': 'Room without an outline, skipped: {id}.', + 'note.room_collapsed': 'Room outline collapsed after alignment and was skipped: {id}.', + 'note.level_without_rooms': 'Level “{title}” skipped: it has no rooms. House Plan builds geometry from rooms — draw them in Sweet Home 3D.', + 'note.furniture_dropped': 'Furniture, materials, lights and cameras are not carried over: House Plan is a live map of the home, not an interior editor.', + 'code.not_zip': 'This is not a Sweet Home 3D file: the archive cannot be read.', + 'code.entry_missing': 'The archive has no Home.xml. Re-save the plan in Sweet Home 3D 5.3 or newer.', + 'code.encrypted': 'The archive is password protected.', + 'code.zip64': 'This archive (zip64) is not supported.', + 'code.entry_too_large': 'The file is too large.', + 'code.entity_declaration': 'The file declares XML entities and cannot be read.', + 'code.entity_reference': 'The file contains an unknown XML entity.', + 'code.nothing_to_convert': 'The plan has no rooms. House Plan builds geometry from rooms: draw them in Sweet Home 3D and save the file again.', + 'code.room_too_complex': 'A room outline is too complex.', + 'code.unit_not_metric': 'The plan is not saved in metric units.', + 'code.file_too_large': 'The file is larger than 64 MB.', + 'code.unknown': 'Unknown error while reading the file.', + }, +}; + +let lang = 'en'; + +const fill = (template, values) => + String(template).replace(/\{(\w+)\}/g, (whole, key) => + (values && values[key] !== undefined ? String(values[key]) : whole)); + +const t = (key, values) => fill(TEXT[lang][key] ?? TEXT.en[key] ?? key, values); + +function applyLanguage(next) { + lang = TEXT[next] ? next : 'en'; + document.documentElement.lang = lang; + try { + localStorage.setItem('hp-convert-lang', lang); + } catch { + // Приватный режим: язык просто не запомнится, это не ошибка. + } + for (const node of document.querySelectorAll('[data-i18n]')) { + node.textContent = t(node.dataset.i18n); + } + for (const node of document.querySelectorAll('[data-lang]')) { + node.classList.toggle('on', node.dataset.lang === lang); + } + if (lastResult) renderResult(lastResult); +} + +let lastResult = null; + +const element = (tag, className, text) => { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; +}; + +const show = (id, visible) => { + document.getElementById(id).hidden = !visible; +}; + +function noteText(note) { + const key = `note.${note.code}`; + const known = TEXT[lang][key] || TEXT.en[key]; + return known ? fill(known, note) : `${note.code}: ${JSON.stringify(note)}`; +} + +function download(name, document_) { + const blob = new Blob([`${JSON.stringify(document_, null, 2)}\n`], { + type: 'application/json', + }); + const url = URL.createObjectURL(blob); + const link = element('a'); + link.href = url; + link.download = name; + link.click(); + // Ссылка живёт ровно до клика: держать её незачем, а память жалко. + setTimeout(() => URL.revokeObjectURL(url), 0); +} + +function renderResult(result) { + lastResult = result; + const { documents, report, stem } = result; + const host = document.getElementById('levels'); + host.textContent = ''; + let downloadIndex = 0; + for (const level of report.levels) { + const card = element('div', 'level'); + card.append(element('h3', null, level.title || level.id || '—')); + if (level.skipped) { + card.append(element('p', 'muted', t('result.skipped'))); + } else { + const stats = element('p', 'stats'); + stats.textContent = [ + `${level.rooms} ${t('result.rooms')}`, + `${level.walls} ${t('result.walls')}`, + `${level.openings} ${t('result.openings')}`, + `${t('result.cell')}: ${level.cellCm} cm`, + ].join(' · '); + card.append(stats); + const index = downloadIndex++; + const button = element('button', 'btn primary', t('result.download')); + button.addEventListener('click', () => + download(`${stem}.${level.spaceId}.json`, documents[index])); + card.append(button); + } + const notes = [...(level.notes || [])]; + if (notes.length) { + card.append(element('h4', 'notes-title', t('result.notes'))); + const list = element('ul', 'notes'); + for (const note of notes) list.append(element('li', null, noteText(note))); + card.append(list); + } + host.append(card); + } + const shared = element('ul', 'notes'); + for (const item of report.items) shared.append(element('li', null, noteText(item))); + document.getElementById('shared-notes').replaceChildren(shared); + show('result', true); + show('error', false); + show('busy', false); +} + +function renderError(code, detail) { + const key = `code.${code}`; + const known = TEXT[lang][key] || TEXT.en[key]; + document.getElementById('error-text').textContent = known || t('code.unknown'); + const details = document.getElementById('error-detail'); + details.textContent = detail && !known ? detail : ''; + details.hidden = !details.textContent; + show('error', true); + show('result', false); + show('busy', false); +} + +async function handleFile(file) { + if (!file) return; + lastResult = null; + show('busy', true); + show('error', false); + show('result', false); + try { + if (file.size > MAX_FILE_BYTES) { + renderError('file_too_large'); + return; + } + const bytes = new Uint8Array(await file.arrayBuffer()); + const home = await readSh3d(bytes); + const { documents, report } = convertHome(home, { toolVersion: TOOL_VERSION }); + renderResult({ + documents, + report, + stem: file.name.replace(/\.sh3d$/i, '') || 'plan', + }); + } catch (error) { + const code = error && error.code ? error.code : 'unknown'; + renderError(code, error instanceof ConvertError ? error.message : String(error?.message || '')); + } +} + +export function mount() { + const input = document.getElementById('file'); + const zone = document.getElementById('drop'); + input.addEventListener('change', () => handleFile(input.files[0])); + document.getElementById('pick').addEventListener('click', () => input.click()); + document.getElementById('retry').addEventListener('click', () => input.click()); + for (const event of ['dragenter', 'dragover']) { + zone.addEventListener(event, (raw) => { + raw.preventDefault(); + zone.classList.add('over'); + }); + } + for (const event of ['dragleave', 'drop']) { + zone.addEventListener(event, () => zone.classList.remove('over')); + } + zone.addEventListener('drop', (raw) => { + raw.preventDefault(); + handleFile(raw.dataTransfer?.files?.[0]); + }); + for (const node of document.querySelectorAll('[data-lang]')) { + node.addEventListener('click', () => applyLanguage(node.dataset.lang)); + } + let saved = null; + try { + saved = localStorage.getItem('hp-convert-lang'); + } catch { + saved = null; + } + applyLanguage(saved || (String(navigator.language || '').toLowerCase().startsWith('ru') ? 'ru' : 'en')); +} diff --git a/scripts/sh3d-convert/page/shell.html b/scripts/sh3d-convert/page/shell.html new file mode 100644 index 00000000..e72fbd4b --- /dev/null +++ b/scripts/sh3d-convert/page/shell.html @@ -0,0 +1,118 @@ + + + + + +Sweet Home 3D → House Plan converter + + + + + + + +
+
+ 🏠 House Plan +
+ + +
+
+ +

Sweet Home 3D → House Plan converter

+

+

+

+ +
+

+

+ +

+ +
+ + + + + + + + +
+ + + diff --git a/test/sh3d-convert-page.test.mjs b/test/sh3d-convert-page.test.mjs new file mode 100644 index 00000000..979644eb --- /dev/null +++ b/test/sh3d-convert-page.test.mjs @@ -0,0 +1,105 @@ +/** + * #446: страница /convert как артефакт. + * + * Проверяется не вид, а три обещания, каждое из которых можно нарушить молча: + * собранный файл действительно содержит рабочий конвертер, он не делает ни + * одного сетевого вызова, и он не отстал от исходников. + */ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { TEXT } from '../scripts/sh3d-convert/page/app.mjs'; + +const root = (relative) => fileURLToPath(new URL(`../${relative}`, import.meta.url)); +const page = () => readFileSync(root('scripts/sh3d-convert/dist/index.html'), 'utf8'); +const shell = () => readFileSync(root('scripts/sh3d-convert/page/shell.html'), 'utf8'); + +test('#446 собранная страница не отстала от исходников', () => { + // Артефакт коммитится, потому что его раскладывают копированием. Значит он + // умеет отстать — и тогда сайт конвертирует не тем кодом, который проверен + // гейтами. Сверка с пересборкой это ловит. + const run = spawnSync(process.execPath, + [root('scripts/sh3d-convert/build-page.mjs'), '--check'], { encoding: 'utf8' }); + assert.equal(run.status, 0, `${run.stdout || ''}${run.stderr || ''}`); +}); + +test('#446 в странице нет ни одного сетевого вызова и ни одной внешней загрузки', () => { + // Обещание «файл не покидает браузер» обязано быть проверяемым, а не + // написанным в тексте страницы. Заодно поэтому же на странице системные + // шрифты: к шрифтам — это внешний запрос при загрузке. + const text = page(); + // Ищутся формы ВЫЗОВА, а не имена: первая редакция теста краснела на + // собственном комментарии в app.mjs, где эти API перечислены как запрещённые. + // Документация, называющая запрет, — не нарушение запрета. + for (const forbidden of [ + /\bfetch\s*\(/, /new\s+XMLHttpRequest/, /new\s+WebSocket/, /new\s+EventSource/, + /\.sendBeacon\s*\(/, /\bimportScripts\s*\(/, /navigator\.(?:connection|geolocation)/, + /fonts\.googleapis/, /fonts\.gstatic/, /\bnavigator\.sendBeacon/, + ]) { + assert.ok(!forbidden.test(text), `в странице появилось ${forbidden}`); + } + assert.ok(!/]+src=/i.test(text), 'внешний скрипт'); + assert.ok(!/]+href="https?:/i.test(text), 'внешний стиль или шрифт'); + assert.ok(!/url\(\s*['"]?https?:/i.test(text), 'внешний ресурс в CSS'); + // Ссылки, по которым человек ЩЁЛКАЕТ сам, — не запросы: они законны. + assert.ok(text.includes('href="https://github.com/Matysh/houseplan-card"')); +}); + +test('#446 конвертер внутри страницы даёт тот же результат, что и модули', async () => { + // Самое важное: страница собирается склейкой, и склейка может тихо потерять + // функцию. Поэтому проверяется не текст, а поведение — прогон фикстуры через + // код, извлечённый из собранного артефакта. + const text = page(); + const script = /