mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-11-13 17:08:20 +00:00
feat: add source aware resolver, update reset, update generator
This commit is contained in:
@@ -1,23 +1,29 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
import {
|
||||
expectNoErrorAlert,
|
||||
expectNoErrors,
|
||||
openTool,
|
||||
resultImage,
|
||||
trackErrors,
|
||||
} from "./helpers/page";
|
||||
|
||||
test.describe("generators — known UI gap (#checklist, п.1)", () => {
|
||||
// FIXME: Генераторы открываются, но в UI нет ни полей схемы, ни кнопки
|
||||
// «Generate» — только RU/EN/Reset. Задача Q5: `docs/backlog.md`,
|
||||
// `docs/checklist-manual-testing.md`. После фикса убрать fixme.
|
||||
test.fixme("single-color-png renders Generate controls and produces a result", async ({
|
||||
test.describe("generators", () => {
|
||||
test("single-color-png auto-runs and reacts to parameters", async ({
|
||||
page,
|
||||
}) => {
|
||||
const sink = trackErrors(page);
|
||||
await openTool(page, "single-color-png");
|
||||
const generate = page.getByRole("button", { name: "Generate" });
|
||||
await expect(generate).toBeVisible();
|
||||
await generate.click();
|
||||
await expect(resultImage(page)).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Generate" })).toHaveCount(0);
|
||||
|
||||
const firstSource = await resultImage(page).getAttribute("src");
|
||||
await page.getByRole("spinbutton", { name: "Width" }).fill("32");
|
||||
await expect(resultImage(page)).not.toHaveAttribute(
|
||||
"src",
|
||||
firstSource ?? "",
|
||||
);
|
||||
await expectNoErrorAlert(page);
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
|
||||
test("create-empty-png page opens without errors", async ({ page }) => {
|
||||
|
||||
+15
-5
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
import { opaquePng } from "./helpers/fixtures";
|
||||
import { openTool, toolLink, uploadImage, useEnglish } from "./helpers/page";
|
||||
import { pixelRow } from "./helpers/fixtures";
|
||||
import { openTool, toolLink, useEnglish } from "./helpers/page";
|
||||
|
||||
test("поиск каталога находит по названию из другой локали", async ({
|
||||
page,
|
||||
@@ -31,11 +31,21 @@ test("RU/EN: переключение переводит заголовок ин
|
||||
await expect(heading).toHaveText("Resize PNG");
|
||||
});
|
||||
|
||||
test("ошибка run'а локализуется, не сырой i18n-ключ", async ({ page }) => {
|
||||
await openTool(page, "resize-png");
|
||||
await uploadImage(page, opaquePng);
|
||||
test("ошибка run'а локализуется и переводится при смене языка", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openTool(page, "bytes-to-png");
|
||||
await page
|
||||
.getByRole("textbox", { name: "Text data" })
|
||||
.fill(pixelRow(33, [255, 0, 0, 255]));
|
||||
await page.getByRole("button", { name: "Render text" }).click();
|
||||
const alert = page.getByRole("alert");
|
||||
await expect(alert).toBeVisible();
|
||||
await expect(alert).toHaveText(/\S/);
|
||||
await expect(alert).not.toHaveText(/^errors\./);
|
||||
const english = await alert.textContent();
|
||||
|
||||
await page.getByRole("button", { name: "RU", exact: true }).click();
|
||||
await expect(alert).not.toHaveText(english ?? "");
|
||||
await expect(alert).not.toHaveText(/^errors\./);
|
||||
});
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { expect, test } from "playwright/test";
|
||||
import { opaquePng } from "./helpers/fixtures";
|
||||
import { openTool, resultImage, uploadImage } from "./helpers/page";
|
||||
|
||||
// FIXME: В файле зарегистрированы баги production UI (см.
|
||||
// `docs/backlog.md` и `docs/checklist-manual-testing.md`, раздел G). Тела
|
||||
// assert'ят ОЖИДАЕМОЕ поведение. Статус fixme означает «мы знаем, что сейчас
|
||||
// падает»; когда баг починят — убрать fixme и тест станет зелёным «сам по
|
||||
// себе».
|
||||
|
||||
test.describe("known bugs — documented as fixme", () => {
|
||||
test.fixme("resize-png: upload produces a resized result (no alert)", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openTool(page, "resize-png");
|
||||
await uploadImage(page, opaquePng);
|
||||
await expect(page.getByRole("alert")).toHaveCount(0);
|
||||
await expect(resultImage(page)).toBeVisible();
|
||||
});
|
||||
|
||||
test.fixme("crop-png: upload produces a cropped result (no alert)", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openTool(page, "crop-png");
|
||||
await uploadImage(page, opaquePng);
|
||||
await expect(page.getByRole("alert")).toHaveCount(0);
|
||||
await expect(resultImage(page)).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
} from "./helpers/fixtures";
|
||||
import {
|
||||
downloadResult,
|
||||
emptyState,
|
||||
errorAlert,
|
||||
expectNoErrorAlert,
|
||||
expectNoErrors,
|
||||
@@ -21,6 +20,36 @@ import {
|
||||
useEnglish,
|
||||
} from "./helpers/page";
|
||||
|
||||
test("resize-png uses the source dimensions by default", async ({ page }) => {
|
||||
const sink = trackErrors(page);
|
||||
await openTool(page, "resize-png");
|
||||
await uploadImage(page, opaquePng);
|
||||
await expect(page.getByRole("spinbutton", { name: "Width" })).toHaveValue(
|
||||
"64",
|
||||
);
|
||||
await expect(page.getByRole("spinbutton", { name: "Height" })).toHaveValue(
|
||||
"48",
|
||||
);
|
||||
await expect(resultImage(page)).toBeVisible();
|
||||
await expectNoErrorAlert(page);
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
|
||||
test("crop-png starts with the full source area", async ({ page }) => {
|
||||
const sink = trackErrors(page);
|
||||
await openTool(page, "crop-png");
|
||||
await uploadImage(page, opaquePng);
|
||||
await expect(page.getByRole("spinbutton", { name: "Width" })).toHaveValue(
|
||||
"64",
|
||||
);
|
||||
await expect(page.getByRole("spinbutton", { name: "Height" })).toHaveValue(
|
||||
"48",
|
||||
);
|
||||
await expect(resultImage(page)).toBeVisible();
|
||||
await expectNoErrorAlert(page);
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
|
||||
test("flip-png: full flow - upload, result, download", async ({ page }) => {
|
||||
const sink = trackErrors(page);
|
||||
await openTool(page, "flip-png");
|
||||
@@ -41,14 +70,23 @@ test("convert-png-to-jpg: produces a downloadable jpg", async ({ page }) => {
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
|
||||
test.fixme("reset clears result but keeps source", async ({ page }) => {
|
||||
await openTool(page, "flip-png");
|
||||
test("reset restores defaults and re-runs with the source", async ({
|
||||
page,
|
||||
}) => {
|
||||
const sink = trackErrors(page);
|
||||
await openTool(page, "blur-png");
|
||||
await uploadImage(page, opaquePng);
|
||||
await expect(resultImage(page)).toBeVisible();
|
||||
const radius = rangeInput(page, /radius/i);
|
||||
await radius.fill("20");
|
||||
await expect(radius).toHaveValue("20");
|
||||
|
||||
await page.getByRole("button", { name: "Reset" }).click();
|
||||
await expect(resultImage(page)).toHaveCount(0);
|
||||
await expect(emptyState(page)).toBeVisible();
|
||||
await expect(radius).toHaveValue("4");
|
||||
await expect(sourceImage(page)).toBeVisible();
|
||||
await expect(resultImage(page)).toBeVisible();
|
||||
await expectNoErrorAlert(page);
|
||||
expectNoErrors(sink);
|
||||
});
|
||||
|
||||
test("blur: changing slider updates result image", async ({ page }) => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import SchemaFields from "$lib/components/SchemaFields.svelte";
|
||||
import SchemaPreview from "$lib/components/SchemaPreview.svelte";
|
||||
import { debounce } from "$lib/core/debounce";
|
||||
import { ToolError } from "$lib/core/errors";
|
||||
import { ToolError, type ErrorVars } from "$lib/core/errors";
|
||||
import { decodeFile, encode } from "$lib/core/io";
|
||||
import type { PixelImage } from "$lib/core/types";
|
||||
import { execute } from "$lib/executor";
|
||||
@@ -14,6 +14,7 @@
|
||||
import { t } from "$lib/i18n/t";
|
||||
import type { FileResult, ToolEntry } from "$lib/registry";
|
||||
import {
|
||||
applySourceDefaults,
|
||||
defaultSchemaParams,
|
||||
sanitizeSchemaParams,
|
||||
type ToolSchema,
|
||||
@@ -23,6 +24,9 @@
|
||||
interface Props {
|
||||
tool: ToolEntry;
|
||||
}
|
||||
type DisplayError =
|
||||
| { kind: "i18n"; key: string; vars?: ErrorVars }
|
||||
| { kind: "plain"; text: string };
|
||||
let { tool }: Props = $props();
|
||||
|
||||
const schema = $derived(
|
||||
@@ -42,9 +46,17 @@
|
||||
undefined,
|
||||
);
|
||||
let running = $state(false);
|
||||
let error = $state("");
|
||||
let displayError = $state<DisplayError | null>(null);
|
||||
let started = $state(false);
|
||||
|
||||
const errorText = $derived.by(() => {
|
||||
const current = displayError;
|
||||
if (!current) return "";
|
||||
return current.kind === "i18n"
|
||||
? t(current.key, current.vars)
|
||||
: current.text;
|
||||
});
|
||||
|
||||
const debouncedRun = debounce(() => run(), 200);
|
||||
|
||||
function init() {
|
||||
@@ -58,30 +70,38 @@
|
||||
}
|
||||
|
||||
function reset() {
|
||||
values = schema ? defaultSchemaParams(schema) : {};
|
||||
values = schema
|
||||
? defaultSchemaParams(schema, { source: source ?? undefined })
|
||||
: {};
|
||||
result = null;
|
||||
fileResult = null;
|
||||
textResult = null;
|
||||
verdictVars = undefined;
|
||||
error = "";
|
||||
displayError = null;
|
||||
}
|
||||
|
||||
async function handleFile(file: File) {
|
||||
error = "";
|
||||
displayError = null;
|
||||
try {
|
||||
source = await decodeFile(file);
|
||||
const decoded = await decodeFile(file);
|
||||
source = decoded;
|
||||
if (schema) {
|
||||
values = applySourceDefaults(schema, values, { source: decoded });
|
||||
}
|
||||
} catch (e) {
|
||||
error = errorText(e);
|
||||
displayError = toDisplayError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (!schema) return;
|
||||
error = "";
|
||||
displayError = null;
|
||||
running = true;
|
||||
try {
|
||||
const out = await execute(tool, {
|
||||
params: sanitizeSchemaParams(schema, values),
|
||||
params: sanitizeSchemaParams(schema, values, {
|
||||
source: source ?? undefined,
|
||||
}),
|
||||
source: source ?? undefined,
|
||||
text: textSource || undefined,
|
||||
});
|
||||
@@ -108,7 +128,7 @@
|
||||
fileResult = null;
|
||||
}
|
||||
} catch (e) {
|
||||
error = errorText(e);
|
||||
displayError = toDisplayError(e);
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
@@ -144,7 +164,7 @@
|
||||
try {
|
||||
await navigator.clipboard.writeText(copyValue);
|
||||
} catch (e) {
|
||||
error = errorText(e);
|
||||
displayError = toDisplayError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,18 +184,21 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
// TODO: can any edge case start infinite loop?
|
||||
if (!started) return;
|
||||
if (inputMode === "image" && !source) return;
|
||||
if (inputMode === "text" && !textSource.trim()) return;
|
||||
void values;
|
||||
void source;
|
||||
debouncedRun();
|
||||
return () => debouncedRun.cancel();
|
||||
});
|
||||
|
||||
function errorText(e: unknown): string {
|
||||
if (e instanceof ToolError) return t(e.key, e.vars);
|
||||
if (e instanceof Error) return e.message;
|
||||
return String(e);
|
||||
function toDisplayError(e: unknown): DisplayError {
|
||||
if (e instanceof ToolError) {
|
||||
return { kind: "i18n", key: e.key, vars: e.vars };
|
||||
}
|
||||
if (e instanceof Error) return { kind: "plain", text: e.message };
|
||||
return { kind: "plain", text: String(e) };
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -215,7 +238,7 @@
|
||||
{inputMode}
|
||||
{resultKind}
|
||||
{running}
|
||||
{error}
|
||||
error={errorText}
|
||||
onupload={handleFile}
|
||||
ontextsource={(textValue) => {
|
||||
textSource = textValue;
|
||||
|
||||
@@ -20,7 +20,9 @@ export async function execute(
|
||||
tool: ToolEntry,
|
||||
ctx: ExecuteContext,
|
||||
): Promise<ToolResult> {
|
||||
const params = sanitizeSchemaParams(tool.schema, ctx.params);
|
||||
const params = sanitizeSchemaParams(tool.schema, ctx.params, {
|
||||
source: ctx.source,
|
||||
});
|
||||
if (tool.input === "image" && !ctx.source) {
|
||||
throw new ToolError("errors.sourceRequired");
|
||||
}
|
||||
@@ -129,13 +131,17 @@ function ensureWorker(): Worker | null {
|
||||
} else if (payload.errorKey) {
|
||||
entry.reject(new ToolError(payload.errorKey, payload.errorVars));
|
||||
} else {
|
||||
entry.reject(new Error(payload.error ?? "errors.workerFailed"));
|
||||
entry.reject(
|
||||
payload.error
|
||||
? new Error(payload.error)
|
||||
: new ToolError("errors.workerFailed"),
|
||||
);
|
||||
}
|
||||
};
|
||||
candidate.onerror = () => {
|
||||
worker = null;
|
||||
for (const entry of pending.values()) {
|
||||
entry.reject(new Error("errors.workerUnavailable"));
|
||||
entry.reject(new ToolError("errors.workerUnavailable"));
|
||||
}
|
||||
pending.clear();
|
||||
};
|
||||
|
||||
@@ -46,7 +46,7 @@ async function handle(request: WorkerRequest): Promise<void> {
|
||||
try {
|
||||
const tool = getTool(request.toolId);
|
||||
if (!tool?.run) {
|
||||
throw new Error("errors.noImageRun");
|
||||
throw new ToolError("errors.noImageRun");
|
||||
}
|
||||
const source: PixelImage | undefined = request.source
|
||||
? {
|
||||
@@ -56,7 +56,7 @@ async function handle(request: WorkerRequest): Promise<void> {
|
||||
}
|
||||
: undefined;
|
||||
const output = await tool.run({
|
||||
params: sanitizeSchemaParams(tool.schema, request.params),
|
||||
params: sanitizeSchemaParams(tool.schema, request.params, { source }),
|
||||
source,
|
||||
text: request.text,
|
||||
});
|
||||
|
||||
@@ -36,6 +36,17 @@ describe("t", () => {
|
||||
expect(t("home.onlyEnKey")).toBe("Only English string");
|
||||
});
|
||||
|
||||
it("переводит ошибки с vars в активной локали", () => {
|
||||
setLocale("ru");
|
||||
expect(t("errors.pixelCountMismatch", { count: 33, width: 32 })).toContain(
|
||||
"33",
|
||||
);
|
||||
setLocale("en");
|
||||
expect(t("errors.pixelCountMismatch", { count: 33, width: 32 })).toContain(
|
||||
"33",
|
||||
);
|
||||
});
|
||||
|
||||
it("неизвестный путь возвращает сам путь", () => {
|
||||
expect(t("no.such.key")).toBe("no.such.key");
|
||||
});
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applySourceDefaults,
|
||||
defaultSchemaParams,
|
||||
field,
|
||||
sanitizeSchemaParams,
|
||||
toolSchema,
|
||||
type Dimension,
|
||||
} from "./registry-schema";
|
||||
|
||||
interface FrameParams {
|
||||
@@ -32,6 +34,24 @@ const frameSchema = toolSchema<FrameParams>(
|
||||
},
|
||||
);
|
||||
|
||||
interface SourceParams {
|
||||
size: Dimension;
|
||||
fixed: Dimension;
|
||||
}
|
||||
|
||||
const sourceSchema = toolSchema<SourceParams>({
|
||||
size: field.dimension({
|
||||
min: 1,
|
||||
max: 100,
|
||||
width: 1,
|
||||
height: 1,
|
||||
defaultFromSource: true,
|
||||
}),
|
||||
fixed: field.dimension({ min: 1, max: 100, width: 10, height: 20 }),
|
||||
});
|
||||
|
||||
const source = { width: 64, height: 48 };
|
||||
|
||||
describe("registry-schema: дефолты из схемы", () => {
|
||||
it("собирает дефолты всех полей", () => {
|
||||
expect(defaultSchemaParams(frameSchema)).toEqual({
|
||||
@@ -43,6 +63,51 @@ describe("registry-schema: дефолты из схемы", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("registry-schema: source-aware dimension defaults", () => {
|
||||
it("uses static fallback before a source exists", () => {
|
||||
expect(defaultSchemaParams(sourceSchema)).toEqual({
|
||||
size: { width: 1, height: 1 },
|
||||
fixed: { width: 10, height: 20 },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses source dimensions for opted-in fields only", () => {
|
||||
expect(defaultSchemaParams(sourceSchema, { source })).toEqual({
|
||||
size: source,
|
||||
fixed: { width: 10, height: 20 },
|
||||
});
|
||||
expect(
|
||||
applySourceDefaults(
|
||||
sourceSchema,
|
||||
{ size: { width: 2, height: 3 }, fixed: { width: 30, height: 40 } },
|
||||
{ source },
|
||||
),
|
||||
).toEqual({
|
||||
size: source,
|
||||
fixed: { width: 30, height: 40 },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses source dimensions when sanitizing missing values", () => {
|
||||
expect(sanitizeSchemaParams(sourceSchema, {}, { source })).toEqual({
|
||||
size: source,
|
||||
fixed: { width: 10, height: 20 },
|
||||
});
|
||||
const manual = sanitizeSchemaParams(
|
||||
sourceSchema,
|
||||
{ size: { width: 12, height: 13 } },
|
||||
{ source },
|
||||
);
|
||||
expect(manual.size).toEqual({ width: 12, height: 13 });
|
||||
const sentinel = sanitizeSchemaParams(
|
||||
sourceSchema,
|
||||
{ size: { width: 0, height: 0 } },
|
||||
{ source },
|
||||
);
|
||||
expect(sentinel.size).toEqual(source);
|
||||
});
|
||||
});
|
||||
|
||||
describe("registry-schema: раскладка (schema.layout)", () => {
|
||||
it("toolSchema сохраняет группы полей", () => {
|
||||
expect(frameSchema.layout).toEqual({
|
||||
|
||||
@@ -69,6 +69,11 @@ export interface DimensionSpec extends FieldSpecBase {
|
||||
max: number;
|
||||
width: number;
|
||||
height: number;
|
||||
defaultFromSource?: boolean;
|
||||
}
|
||||
|
||||
export interface SchemaContext {
|
||||
source?: Dimension;
|
||||
}
|
||||
|
||||
/** Пара цветов «от → к» (градиенты, сведение к двум цветам и т.п.). */
|
||||
@@ -270,6 +275,7 @@ export const field = {
|
||||
max: number;
|
||||
width: number;
|
||||
height: number;
|
||||
defaultFromSource?: boolean;
|
||||
}): Field<Dimension> => ({
|
||||
spec: { kind: "dimension", ...s },
|
||||
}),
|
||||
@@ -341,15 +347,27 @@ function clamp(value: number, min?: number, max?: number): number {
|
||||
return value;
|
||||
}
|
||||
|
||||
function resolveDimensionDefaults(
|
||||
spec: DimensionSpec,
|
||||
context?: SchemaContext,
|
||||
): Dimension {
|
||||
const source = spec.defaultFromSource ? context?.source : undefined;
|
||||
return {
|
||||
width: clamp(source?.width ?? spec.width, spec.min, spec.max),
|
||||
height: clamp(source?.height ?? spec.height, spec.min, spec.max),
|
||||
};
|
||||
}
|
||||
|
||||
/** Дефолты из схемы — единый источник значений по умолчанию для нового UI. */
|
||||
export function defaultSchemaParams<P>(
|
||||
schema: ToolSchema<P>,
|
||||
context?: SchemaContext,
|
||||
): Record<keyof P, unknown> {
|
||||
const out = {} as Record<keyof P, unknown>;
|
||||
for (const key of Object.keys(schema.fields) as (keyof P)[]) {
|
||||
const spec = schema.fields[key].spec;
|
||||
if (spec.kind === "dimension") {
|
||||
out[key] = { width: spec.width, height: spec.height };
|
||||
out[key] = resolveDimensionDefaults(spec, context);
|
||||
} else if (spec.kind === "color-pair") {
|
||||
out[key] = { from: spec.from, to: spec.to };
|
||||
} else if (spec.kind === "colors") {
|
||||
@@ -382,10 +400,27 @@ export function defaultSchemaParams<P>(
|
||||
return out;
|
||||
}
|
||||
|
||||
export function applySourceDefaults<P>(
|
||||
schema: ToolSchema<P>,
|
||||
values: Record<string, unknown>,
|
||||
context: SchemaContext,
|
||||
): Record<keyof P, unknown> {
|
||||
const defaults = defaultSchemaParams(schema, context);
|
||||
for (const key of Object.keys(schema.fields) as (keyof P)[]) {
|
||||
const spec = schema.fields[key].spec;
|
||||
if (spec.kind === "dimension" && spec.defaultFromSource) continue;
|
||||
if (Object.prototype.hasOwnProperty.call(values, key)) {
|
||||
defaults[key] = values[key as string];
|
||||
}
|
||||
}
|
||||
return defaults;
|
||||
}
|
||||
|
||||
/** Валидация/нормализация значений по схеме. Аналог старого `sanitizeParams`. */
|
||||
export function sanitizeSchemaParams<P>(
|
||||
schema: ToolSchema<P>,
|
||||
values: Record<string, unknown>,
|
||||
context?: SchemaContext,
|
||||
): Record<keyof P, unknown> {
|
||||
const out = {} as Record<keyof P, unknown>;
|
||||
for (const key of Object.keys(schema.fields) as (keyof P)[]) {
|
||||
@@ -425,17 +460,22 @@ export function sanitizeSchemaParams<P>(
|
||||
"height" in raw
|
||||
? (raw as Record<string, unknown>)
|
||||
: undefined;
|
||||
const defaults = resolveDimensionDefaults(spec, context);
|
||||
const w =
|
||||
typeof r?.width === "number" && Number.isFinite(r.width)
|
||||
? r.width
|
||||
: spec.width;
|
||||
: defaults.width;
|
||||
const h =
|
||||
typeof r?.height === "number" && Number.isFinite(r.height)
|
||||
? r.height
|
||||
: spec.height;
|
||||
: defaults.height;
|
||||
const sourceSize =
|
||||
spec.defaultFromSource && context?.source && w === 0 && h === 0
|
||||
? defaults
|
||||
: { width: w, height: h };
|
||||
out[key] = {
|
||||
width: clamp(w, spec.min, spec.max),
|
||||
height: clamp(h, spec.min, spec.max),
|
||||
width: clamp(sourceSize.width, spec.min, spec.max),
|
||||
height: clamp(sourceSize.height, spec.min, spec.max),
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -194,8 +194,9 @@ export const resizeSchema = toolSchema<ResizeParams>(
|
||||
label: "fields.canvasSize",
|
||||
min: 0,
|
||||
max: 20000,
|
||||
width: 0,
|
||||
height: 0,
|
||||
width: 1,
|
||||
height: 1,
|
||||
defaultFromSource: true,
|
||||
}),
|
||||
keepAspect: field.checkbox({ label: "fields.keepAspect", default: true }),
|
||||
},
|
||||
@@ -263,10 +264,11 @@ export const cropSchema = toolSchema<CropParams>(
|
||||
}),
|
||||
size: field.dimension({
|
||||
label: "fields.cropAreaSize",
|
||||
min: 0,
|
||||
min: 1,
|
||||
max: 2000,
|
||||
width: 0,
|
||||
height: 0,
|
||||
width: 1,
|
||||
height: 1,
|
||||
defaultFromSource: true,
|
||||
}),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -65,6 +65,45 @@ describe("registry-new (переведённые инструменты)", () =>
|
||||
}
|
||||
});
|
||||
|
||||
it("source-aware dimension defaults применяются только к image tools", () => {
|
||||
for (const tool of TOOLS) {
|
||||
for (const field of Object.values(tool.schema.fields)) {
|
||||
if (field.spec.kind === "dimension" && field.spec.defaultFromSource) {
|
||||
expect(tool.input, tool.id).toBe("image");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("resize и crop получают размеры текущего source", async () => {
|
||||
const source = solid(64, 48);
|
||||
const resize = TOOLS.find((t) => t.id === "resize-png")!;
|
||||
const resizeDefaults = defaultSchemaParams(resize.schema, { source });
|
||||
expect(resizeDefaults.size).toEqual({ width: 64, height: 48 });
|
||||
const resized = asImage(
|
||||
await resize.run({
|
||||
source,
|
||||
params: sanitizeSchemaParams(resize.schema, resizeDefaults, { source }),
|
||||
}),
|
||||
);
|
||||
expect([resized.width, resized.height]).toEqual([64, 48]);
|
||||
|
||||
const crop = TOOLS.find((t) => t.id === "crop-png")!;
|
||||
const cropDefaults = defaultSchemaParams(crop.schema, { source });
|
||||
expect(cropDefaults).toMatchObject({
|
||||
x: 0,
|
||||
y: 0,
|
||||
size: { width: 64, height: 48 },
|
||||
});
|
||||
const cropped = asImage(
|
||||
await crop.run({
|
||||
source,
|
||||
params: sanitizeSchemaParams(crop.schema, cropDefaults, { source }),
|
||||
}),
|
||||
);
|
||||
expect([cropped.width, cropped.height]).toEqual([64, 48]);
|
||||
});
|
||||
|
||||
it("create-empty: dimension-дефолты корректны", () => {
|
||||
const tool = TOOLS.find((t) => t.id === "create-empty-png")!;
|
||||
const d = defaultSchemaParams(tool.schema);
|
||||
|
||||
Reference in New Issue
Block a user