diff --git a/docs/README.md b/docs/README.md
index 7c27c30..adf9b07 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -51,13 +51,13 @@
только если темы нет в существующем документе; иначе обновляется источник
истины.
-## Текущий baseline Q6f.1
+## Текущий baseline Q6f.2
-На момент фиксации Q6f.1:
+На момент фиксации Q6f.2:
- production registry содержит 122 инструмента;
- `pnpm --dir web check` проходит без ошибок и предупреждений;
-- `pnpm --dir web test` — 42 test files / 460 tests;
+- `pnpm --dir web test` — 43 test files / 465 tests;
- `pnpm --dir web test:rules` — 4 test files / 74 tests;
- `pnpm --dir web lint:all` — 0 errors и 0 i18n warnings; остаются 4 unused
design tokens от token audit;
diff --git a/docs/backlog.md b/docs/backlog.md
index 14d17cb..4c6b0f0 100644
--- a/docs/backlog.md
+++ b/docs/backlog.md
@@ -107,8 +107,8 @@
## Качество production UI
-Q5–Q6f.1 закрывают известные preview-проблемы, special fixtures, smoke и
-layout-инварианты; активных `test.fixme` в e2e больше нет.
+Q5–Q6f.2 закрывают известные preview-проблемы, special fixtures, smoke и
+layout/view-model инварианты; активных `test.fixme` в e2e больше нет.
- [x] Генераторы auto-run без кнопки Generate — `web/e2e/generators.spec.ts`.
- [x] `resize-png` и `crop-png` используют размеры текущего source через общий
@@ -124,6 +124,8 @@ layout-инварианты; активных `test.fixme` в e2e больше
`text-and-verdicts.spec.ts`.
- [x] Layout-группировка вынесена в `resolveLayoutGroups` и покрыта
unit-тестами; `SchemaFields.svelte` использует общий seam.
+- [x] `SchemaPreview` view-model вынесена в `buildSchemaPreviewModel` и покрыта
+ unit-тестами для всех input/result-типов.
- [ ] Component-тесты `SchemaToolView`/`SchemaPreview` требуют отдельного
решения: node Vitest не имеет DOM/canvas, а e2e уже покрывает поведение.
- [ ] Ошибка декодирования изображения из `createImageBitmap` пока показывается
diff --git a/docs/checklist-manual-testing.md b/docs/checklist-manual-testing.md
index 87c14ec..f5cb703 100644
--- a/docs/checklist-manual-testing.md
+++ b/docs/checklist-manual-testing.md
@@ -96,10 +96,10 @@
корректный (не «кракозябры»).
- [ ] remove-background на сложной полупрозрачности — края не «звенят».
-## G. Закрытые Q5/Q6b/Q6c/Q6d/Q6e/Q6f.1-проблемы
+## G. Закрытые Q5/Q6b/Q6c/Q6d/Q6e/Q6f.1/Q6f.2-проблемы
-> Q5/Q6b/Q6c/Q6d/Q6e/Q6f.1-проблемы закрыты автоматическими тестами; для ручной
-> проверки остаются субъективные сценарии разделов A–F.
+> Q5/Q6b/Q6c/Q6d/Q6e/Q6f.1/Q6f.2-проблемы закрыты автоматическими тестами; для
+> ручной проверки остаются субъективные сценарии разделов A–F.
- [x] **Генераторы:** auto-run по дефолтам, результат и изменение параметра без
кнопки Generate. `web/e2e/generators.spec.ts`.
@@ -122,6 +122,8 @@
`generators.spec.ts`, `text-and-verdicts.spec.ts`.
- [x] **Layout resolver:** `resolveLayoutGroups` покрыт unit-тестами; UI
группировка полей использует тот же seam.
+- [x] **Preview model:** `buildSchemaPreviewModel` покрывает source/result
+ values, формат и `hasResult` для image/text/verdict/files.
## Как долго
diff --git a/docs/plan-codebase-quality.md b/docs/plan-codebase-quality.md
index 07a329b..6550820 100644
--- a/docs/plan-codebase-quality.md
+++ b/docs/plan-codebase-quality.md
@@ -1,9 +1,9 @@
# План: линтеры, тесты и качество кодовой базы
-> Статус: **draft — Q0–Q5 и Q6a–Q6f.1 готовы к ревью; Q6f.2 — следующий этап**.
+> Статус: **draft — Q0–Q5 и Q6a–Q6f.2 готовы к ревью; Q6f.3 — следующий этап**.
>
> Источник: `docs/backlog.md`, аудит конфигурации `web/`, e2e-тестов и
-> инфраструктурных скриптов. Q0–Q5 и Q6a–Q6f.1 выполнены; Q6f.2–Q8 выполняются
+> инфраструктурных скриптов. Q0–Q5 и Q6a–Q6f.2 выполнены; Q6f.3–Q8 выполняются
> отдельными атомарными шагами.
>
> Цель: сделать качество кодовой базы воспроизводимым, убрать ложную «зелёность»
@@ -287,11 +287,21 @@ image→image, image→text, text→image, generator input→image и verdict. F
отдельным решением Q6f.2.
- Новые зависимости, vitest-конфиг и browser matrix не добавлялись.
-#### Q6f.2–Q6f.4. Оставшиеся направления
+#### Q6f.2. SchemaPreview model seam
-- выбрать чистый seam для `SchemaPreview`/view-model или DOM-окружение для
- `SchemaToolView`/`SchemaPreview`;
-- browser matrix, mobile viewport, visual snapshots и coverage thresholds.
+**Статус:** выполнен. Вычисления `sourceValue`, `resultValue`, `formatValue` и
+`hasResult` вынесены в `buildSchemaPreviewModel`; Svelte-компонент использует
+модель, а unit-тесты проверяют все input/result-ветки без DOM.
+
+- Translator передаётся в модель явно, поэтому unit-тесты не зависят от locale
+ state и проверяют только view-model.
+- image/text/verdict/files и пустые результаты покрыты в `pnpm verify`.
+- Новые зависимости, vitest-конфиг и browser matrix не добавлялись.
+
+#### Q6f.3–Q6f.4. Оставшиеся направления
+
+- browser matrix, mobile viewport, visual snapshots и coverage thresholds;
+- при необходимости отдельное решение по DOM-тестам `SchemaToolView`/controls.
Не применять один одинаковый smoke-сценарий к image-, text-, generator- и
file-result-инструментам.
diff --git a/docs/testing-strategy.md b/docs/testing-strategy.md
index b6f8f6f..a0909b3 100644
--- a/docs/testing-strategy.md
+++ b/docs/testing-strategy.md
@@ -59,9 +59,10 @@ browser-контракт: critical CRC и truncated отклоняются, anci
только для ограниченного ожидания hydration или асинхронного результата; он не
должен превращать проверку продукта в бесконечный retry.
-Чистая логика схемы и layout (defaults, sanitization, `resolveLayoutGroups`)
-проверяется в `src/**/*.test.ts`; DOM, hydration и пользовательские потоки — в
-Playwright. Component-тесты не дублируют e2e-контракт.
+Чистая логика схемы, layout и preview-модели (defaults, sanitization,
+`resolveLayoutGroups`, `buildSchemaPreviewModel`) проверяется в
+`src/**/*.test.ts`; DOM, hydration и пользовательские потоки — в Playwright.
+Component-тесты не дублируют e2e-контракт.
## 5. Helpers и `test.fixme`
diff --git a/web/src/lib/components/SchemaPreview.svelte b/web/src/lib/components/SchemaPreview.svelte
index 0addba8..9e57199 100644
--- a/web/src/lib/components/SchemaPreview.svelte
+++ b/web/src/lib/components/SchemaPreview.svelte
@@ -1,5 +1,6 @@
@@ -83,7 +59,7 @@
{t("resultCard.previewPanel")}
diff --git a/web/src/lib/components/schema-preview-model.test.ts b/web/src/lib/components/schema-preview-model.test.ts
new file mode 100644
index 0000000..e5f2962
--- /dev/null
+++ b/web/src/lib/components/schema-preview-model.test.ts
@@ -0,0 +1,110 @@
+import { describe, expect, it } from "vitest";
+import { makeImage } from "../core/test-helpers";
+import {
+ buildSchemaPreviewModel,
+ type PreviewTranslate,
+} from "./schema-preview-model";
+
+const image = makeImage(2, 3, [
+ [1, 2, 3, 255],
+ [4, 5, 6, 255],
+ [7, 8, 9, 255],
+ [10, 11, 12, 255],
+ [13, 14, 15, 255],
+ [16, 17, 18, 255],
+]);
+
+const translate: PreviewTranslate = (key) => {
+ if (key === "resultCard.parts") return "parts";
+ if (key === "textInput.heading") return "Text";
+ return key;
+};
+
+function input(
+ overrides: Partial[0]> = {},
+) {
+ return {
+ inputMode: "image" as const,
+ resultKind: "image" as const,
+ source: null,
+ result: null,
+ fileResult: null,
+ textResult: null,
+ ...overrides,
+ };
+}
+
+describe("buildSchemaPreviewModel", () => {
+ it("formats source values for every input mode", () => {
+ expect(
+ buildSchemaPreviewModel(input({ inputMode: "none" }), translate),
+ ).toMatchObject({ sourceValue: "—" });
+ expect(
+ buildSchemaPreviewModel(input({ inputMode: "text" }), translate),
+ ).toMatchObject({ sourceValue: "Text" });
+ expect(
+ buildSchemaPreviewModel(
+ input({ inputMode: "image", source: image }),
+ translate,
+ ),
+ ).toMatchObject({ sourceValue: "2 × 3 px" });
+ });
+
+ it("formats image results and enables download only with a result", () => {
+ expect(
+ buildSchemaPreviewModel(input({ result: image }), translate),
+ ).toEqual({
+ sourceValue: "—",
+ resultValue: "2 × 3 px",
+ formatValue: "PNG",
+ hasResult: true,
+ });
+ expect(buildSchemaPreviewModel(input(), translate)).toMatchObject({
+ resultValue: "—",
+ hasResult: false,
+ });
+ });
+
+ it("formats file results as ZIP and rejects empty file results", () => {
+ const fileResult = { files: [{ name: "part.png", image }] };
+ expect(
+ buildSchemaPreviewModel(
+ input({ resultKind: "files", fileResult }),
+ translate,
+ ),
+ ).toEqual({
+ sourceValue: "—",
+ resultValue: "1 parts",
+ formatValue: "ZIP (PNG)",
+ hasResult: true,
+ });
+ expect(
+ buildSchemaPreviewModel(
+ input({ resultKind: "files", fileResult: { files: [] } }),
+ translate,
+ ),
+ ).toMatchObject({ resultValue: "—", hasResult: false });
+ });
+
+ it.each(["text", "verdict"] as const)(
+ "uses text result presence for %s output",
+ (resultKind) => {
+ expect(
+ buildSchemaPreviewModel(
+ input({ resultKind, textResult: "done" }),
+ translate,
+ ),
+ ).toMatchObject({
+ resultValue: "Text",
+ formatValue: "PNG",
+ hasResult: true,
+ });
+ expect(
+ buildSchemaPreviewModel(input({ resultKind }), translate),
+ ).toMatchObject({
+ resultValue: "—",
+ hasResult: false,
+ });
+ },
+ );
+});
diff --git a/web/src/lib/components/schema-preview-model.ts b/web/src/lib/components/schema-preview-model.ts
new file mode 100644
index 0000000..65eb4b0
--- /dev/null
+++ b/web/src/lib/components/schema-preview-model.ts
@@ -0,0 +1,60 @@
+import type { PixelImage } from "../core/types";
+import type { FileResult, InputMode, ResultKind } from "../registry";
+
+export type PreviewTranslate = (key: string) => string;
+
+export type SchemaPreviewModelInput = {
+ inputMode: InputMode;
+ resultKind: ResultKind;
+ source: PixelImage | null;
+ result: PixelImage | null;
+ fileResult: FileResult | null;
+ textResult: string | null;
+};
+
+export type SchemaPreviewModel = {
+ sourceValue: string;
+ resultValue: string;
+ formatValue: string;
+ hasResult: boolean;
+};
+
+export function buildSchemaPreviewModel(
+ input: SchemaPreviewModelInput,
+ translate: PreviewTranslate,
+): SchemaPreviewModel {
+ const { inputMode, resultKind, source, result, fileResult, textResult } =
+ input;
+ const sourceValue =
+ inputMode === "none"
+ ? "—"
+ : inputMode === "text"
+ ? translate("textInput.heading")
+ : source
+ ? `${source.width} × ${source.height} px`
+ : "—";
+ const hasFiles = Boolean(fileResult && fileResult.files.length > 0);
+ const resultValue =
+ resultKind === "image"
+ ? result
+ ? `${result.width} × ${result.height} px`
+ : "—"
+ : resultKind === "files"
+ ? hasFiles
+ ? `${fileResult?.files.length} ${translate("resultCard.parts")}`
+ : "—"
+ : textResult
+ ? translate("textInput.heading")
+ : "—";
+ return {
+ sourceValue,
+ resultValue,
+ formatValue: resultKind === "files" ? "ZIP (PNG)" : "PNG",
+ hasResult:
+ resultKind === "image"
+ ? Boolean(result)
+ : resultKind === "files"
+ ? hasFiles
+ : Boolean(textResult),
+ };
+}