Compare commits

...
Author SHA1 Message Date
suyx 910e32917f test(POSTV1-integration): 增加界面版本回退门禁
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-06 11:04:03 +08:00
suyx 83fe57f319 fix(POSTV1-editor): 修复多选拖动与底图调整
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-06 10:53:38 +08:00
suyx 51d613f459 fix(POSTV1-browser): 同时支持 Chrome 150 与 151
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-06 10:52:31 +08:00
suyx 3779cfbadc fix(POSTV1-runtime): 改善后台启动与状态窗口可见性
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-06 10:51:08 +08:00
suyx e8ce1d9031 fix(POSTV1-11): 修复生成提交的会话令牌轮换
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-05 22:22:49 +08:00
suyx edbefcc738 feat(POSTV1-10): 增加统一交互反馈
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-05 19:19:33 +08:00
31 changed files with 692 additions and 45 deletions
+2 -1
View File
@@ -3,7 +3,8 @@
"browsers": [
{
"brand": "Google Chrome",
"fullVersion": "150.0.7871.187"
"fullVersion": "150.0.7871.187",
"supportedMajorVersions": [150, 151]
},
{
"brand": "Microsoft Edge",
+7 -4
View File
@@ -151,10 +151,13 @@ export function createAdminDiagnosticsProvider(input: {
const system: AdminDiagnosticsResponse["system"] = {
api_status: "ready",
app_version: input.appVersion ?? input.browserSupportRelease?.appVersion ?? "0.0.0",
browser_support: (input.browserSupportRelease?.browsers ?? []).map((browser) => ({
brand: browser.brand,
major: Number.parseInt(browser.fullVersion.split(".")[0] ?? "0", 10),
})).filter((browser) => Number.isSafeInteger(browser.major) && browser.major > 0),
browser_support: (input.browserSupportRelease?.browsers ?? []).flatMap((browser) => {
const majors = browser.supportedMajorVersions
?? [Number.parseInt(browser.fullVersion.split(".")[0] ?? "0", 10)];
return majors
.map((major) => ({ brand: browser.brand, major }))
.filter((entry) => Number.isSafeInteger(entry.major) && entry.major > 0);
}),
worker_status: services.services.find((service) => service.service_id === "worker")?.status === "active"
? "ready"
: services.services.find((service) => service.service_id === "worker")?.status === "unavailable"
+28 -7
View File
@@ -43,7 +43,7 @@ export const BrowserSupportSuccessSchema = Type.Object(
app_version: Type.String({ maxLength: 80 }),
browser: SupportedBrowserSummarySchema,
status: Type.Literal("supported"),
supported_browsers: Type.Array(SupportedBrowserSummarySchema, { maxItems: 2 }),
supported_browsers: Type.Array(SupportedBrowserSummarySchema, { maxItems: 8 }),
},
{ additionalProperties: false, $id: "BrowserSupportSuccess" },
);
@@ -57,6 +57,7 @@ export interface BrowserSupportRelease {
browsers: ReadonlyArray<{
brand: SupportedBrand;
fullVersion: string;
supportedMajorVersions?: ReadonlyArray<number>;
}>;
}
@@ -115,7 +116,14 @@ function supportedIdentity(entries: Array<{ brand: string; version: string }>) {
export function supportedBrowserSummary(release: BrowserSupportRelease | undefined) {
if (!release) return [];
return release.browsers.map(({ brand, fullVersion }) => ({ brand, major: major(fullVersion)! }));
return release.browsers.flatMap(({ brand, fullVersion, supportedMajorVersions }) => {
const majors = supportedMajorVersions ?? [major(fullVersion)!];
return majors.map((supportedMajor) => ({ brand, major: supportedMajor }));
});
}
function acceptedMajorVersions(browser: BrowserSupportRelease["browsers"][number]) {
return browser.supportedMajorVersions ?? [major(browser.fullVersion)!];
}
export function validateBrowserSupportRelease(value: unknown): value is BrowserSupportRelease {
@@ -126,13 +134,26 @@ export function validateBrowserSupportRelease(value: unknown): value is BrowserS
}
if (!Array.isArray(release.browsers) || release.browsers.length !== 2) return false;
const brands = new Set(release.browsers.map(({ brand }) => brand));
const supportedMajorCount = release.browsers.reduce(
(count, browser) => count + (browser.supportedMajorVersions?.length ?? 1),
0,
);
return (
brands.size === 2 &&
brands.has("Google Chrome") &&
brands.has("Microsoft Edge") &&
release.browsers.every(
({ brand, fullVersion }) => supportedBrands.has(brand) && fullVersionPattern.test(fullVersion),
)
supportedMajorCount <= 8 &&
release.browsers.every(({ brand, fullVersion, supportedMajorVersions }) => {
if (!supportedBrands.has(brand) || !fullVersionPattern.test(fullVersion)) return false;
const baselineMajor = major(fullVersion);
if (!baselineMajor) return false;
if (supportedMajorVersions === undefined) return true;
return supportedMajorVersions.length > 0
&& supportedMajorVersions.length <= 8
&& supportedMajorVersions.every((value: number) => Number.isSafeInteger(value) && value >= 1)
&& new Set(supportedMajorVersions).size === supportedMajorVersions.length
&& supportedMajorVersions.includes(baselineMajor);
})
);
}
@@ -188,7 +209,7 @@ export function checkBrowserSupport(
}
const supported = release?.browsers.find(({ brand }) => brand === fullIdentity.brand);
if (!supported || major(supported.fullVersion) !== fullIdentity.major) {
if (!supported || !acceptedMajorVersions(supported).includes(fullIdentity.major)) {
return { reason: "version_unsupported", supported: false };
}
return { identity: fullIdentity, supported: true };
@@ -268,7 +289,7 @@ export function verifyBrowserSupportCookie(input: {
return { reason: "identity_unavailable" as const, supported: false as const };
}
const supported = input.release.browsers.find(({ brand }) => brand === currentIdentity.brand);
if (currentIdentity.major !== payload.major || major(supported?.fullVersion ?? "") !== currentIdentity.major) {
if (!supported || currentIdentity.major !== payload.major || !acceptedMajorVersions(supported).includes(currentIdentity.major)) {
return { reason: "version_unsupported" as const, supported: false as const };
}
return { identity: currentIdentity, supported: true as const };
+115 -2
View File
@@ -2,6 +2,27 @@ import { isCanvasState, type CanvasState } from "@dada/shared-contracts";
export type BackgroundAdjustments = CanvasState["background"]["adjustments"];
interface CanvasSize {
height: number;
width: number;
}
interface CanvasRect {
height: number;
width: number;
x: number;
y: number;
}
export interface BackgroundDrawPlan {
destination: CanvasRect;
source: CanvasRect;
}
function clamp(value: number, minimum: number, maximum: number) {
return Math.min(maximum, Math.max(minimum, value));
}
const elementKeys = new Set([
"colors", "content", "coordinates", "created_at", "dynamic_fields", "element_id", "font_override", "font_size",
"formatted_value", "opacity", "position", "resource_version", "rotation", "scale", "style_id", "style_parameters",
@@ -106,7 +127,99 @@ export function deserializeFabricCanvas(input: unknown): CanvasState | undefined
return isCanvasState(candidate) ? structuredClone(candidate) : undefined;
}
export function backgroundDrawPlan(
image: CanvasSize,
canvas: CanvasSize,
adjustments: BackgroundAdjustments,
): BackgroundDrawPlan {
if (image.width <= 0 || image.height <= 0 || canvas.width <= 0 || canvas.height <= 0) {
throw new Error("background_dimensions_invalid");
}
const crop = adjustments.crop;
const normalizedX = crop ? clamp(crop.x, 0, 1) : 0;
const normalizedY = crop ? clamp(crop.y, 0, 1) : 0;
const normalizedWidth = crop ? Math.min(crop.width, 1 - normalizedX) : 1;
const normalizedHeight = crop ? Math.min(crop.height, 1 - normalizedY) : 1;
const source = normalizedWidth > 0 && normalizedHeight > 0
? {
height: image.height * normalizedHeight,
width: image.width * normalizedWidth,
x: image.width * normalizedX,
y: image.height * normalizedY,
}
: { height: image.height, width: image.width, x: 0, y: 0 };
if (adjustments.fit === "fill") {
return { destination: { height: canvas.height, width: canvas.width, x: 0, y: 0 }, source };
}
if (adjustments.fit === "fit") {
const scale = Math.min(canvas.width / source.width, canvas.height / source.height);
const width = source.width * scale;
const height = source.height * scale;
return {
destination: { height, width, x: (canvas.width - width) / 2, y: (canvas.height - height) / 2 },
source,
};
}
const sourceAspect = source.width / source.height;
const canvasAspect = canvas.width / canvas.height;
if (sourceAspect > canvasAspect) {
const width = source.height * canvasAspect;
source.x += (source.width - width) / 2;
source.width = width;
} else if (sourceAspect < canvasAspect) {
const height = source.width / canvasAspect;
source.y += (source.height - height) / 2;
source.height = height;
}
return {
destination: { height: canvas.height, width: canvas.width, x: 0, y: 0 },
source,
};
}
export function adjustBackgroundPixels(
pixels: Uint8ClampedArray,
width: number,
height: number,
adjustments: Pick<BackgroundAdjustments, "sharpness" | "temperature">,
) {
if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0 || pixels.length !== width * height * 4) {
throw new Error("background_pixel_buffer_invalid");
}
const source = new Uint8ClampedArray(pixels);
const output = new Uint8ClampedArray(source);
const sharpness = clamp(adjustments.sharpness, 0, 100) / 100;
const temperature = clamp(adjustments.temperature, -100, 100) / 100;
const channelOffsets = [35 * temperature, 8 * temperature, -35 * temperature];
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const index = (y * width + x) * 4;
for (let channel = 0; channel < 3; channel += 1) {
let value = source[index + channel]!;
if (sharpness > 0 && x > 0 && x < width - 1 && y > 0 && y < height - 1) {
const left = source[index + channel - 4]!;
const right = source[index + channel + 4]!;
const above = source[index + channel - width * 4]!;
const below = source[index + channel + width * 4]!;
value = value * (1 + 4 * sharpness) - (left + right + above + below) * sharpness;
}
output[index + channel] = value + channelOffsets[channel]!;
}
output[index + 3] = source[index + 3]!;
}
}
return output;
}
export function cssFilterForBackground(adjustments: BackgroundAdjustments) {
const filter = adjustments.filter === "grayscale" ? "grayscale(1)" : adjustments.filter === "sepia" ? "sepia(0.75)" : "none";
return `${filter} brightness(${100 + adjustments.brightness}%) contrast(${100 + adjustments.contrast}%) saturate(${100 + adjustments.saturation}%)`;
const filters: string[] = [];
if (adjustments.filter === "grayscale") filters.push("grayscale(1)");
else if (adjustments.filter === "sepia") filters.push("sepia(0.75)");
if (adjustments.brightness !== 0) filters.push(`brightness(${100 + adjustments.brightness}%)`);
if (adjustments.contrast !== 0) filters.push(`contrast(${100 + adjustments.contrast}%)`);
if (adjustments.saturation !== 0) filters.push(`saturate(${100 + adjustments.saturation}%)`);
return filters.length > 0 ? filters.join(" ") : "none";
}
+18 -5
View File
@@ -187,7 +187,7 @@ export class CanvasElementController {
.map((element) => structuredClone(element));
}
selectAt(point: CanvasPoint, options: { append?: boolean } = {}) {
selectAt(point: CanvasPoint, options: { append?: boolean; preserveSelection?: boolean } = {}) {
const candidates = this.candidatesAt(point);
if (candidates.length === 0) {
if (!options.append) this.selection = [];
@@ -196,7 +196,9 @@ export class CanvasElementController {
}
if (options.append) {
this.pointerMoved();
return this.selectById(candidates[0]!.element_id, true);
const elementId = candidates[0]!.element_id;
if (options.preserveSelection && this.selection.includes(elementId)) return this.selectedIds;
return this.selectById(elementId, true);
}
const signature = candidates.map((candidate) => candidate.element_id).join("|");
if (samePoint(this.cyclePoint, point) && signature === this.cycleSignature) this.cycleIndex = (this.cycleIndex + 1) % candidates.length;
@@ -255,7 +257,8 @@ export class CanvasElementController {
moveSelected(delta: CanvasPoint, options: { snap?: boolean } = {}) {
const selected = new Set(this.selection);
const primary = this.current.elements.find((element) => selected.has(element.element_id));
const selectedElements = this.current.elements.filter((element) => selected.has(element.element_id));
const primary = selectedElements[0];
if (!primary) return { guides: [] as string[], state: this.value };
let nextX = primary.position.x + delta.x;
let nextY = primary.position.y + delta.y;
@@ -278,10 +281,20 @@ export class CanvasElementController {
nextX = snapAxis(nextX, "x");
nextY = snapAxis(nextY, "y");
}
const adjusted = { x: nextX - primary.position.x, y: nextY - primary.position.y };
const minimumX = Math.min(...selectedElements.map((element) => element.position.x));
const maximumX = Math.max(...selectedElements.map((element) => element.position.x));
const minimumY = Math.min(...selectedElements.map((element) => element.position.y));
const maximumY = Math.max(...selectedElements.map((element) => element.position.y));
const adjusted = {
x: clamp(nextX - primary.position.x, -minimumX, 1 - maximumX),
y: clamp(nextY - primary.position.y, -minimumY, 1 - maximumY),
};
const state = this.updateSelected((element) => ({
...element,
position: { x: clamp(element.position.x + adjusted.x, 0, 1), y: clamp(element.position.y + adjusted.y, 0, 1) },
position: {
x: clamp(Number((element.position.x + adjusted.x).toFixed(12)), 0, 1),
y: clamp(Number((element.position.y + adjusted.y).toFixed(12)), 0, 1),
},
}));
return { guides, state };
}
+9 -2
View File
@@ -789,7 +789,10 @@ export function EditorPage({ projectId }: { projectId: string }) {
const controller = controllerForCurrent();
if (!controller || !canvasState) return false;
const candidates = controller.candidatesAt(point);
const selection = controller.selectAt(point, { append: append || multiMode });
const selection = controller.selectAt(point, {
append: append || multiMode,
preserveSelection: multiMode && !append,
});
setSelectedIds(selection);
setCandidateMenu(undefined);
const dragBase = withTextDraft(canvasState, textEdit);
@@ -880,7 +883,11 @@ export function EditorPage({ projectId }: { projectId: string }) {
}
if (!project || !canvasState) return <main className="editor-loading" aria-live="polite"></main>;
const renderedCanvasState = withTextDraft(canvasState, textEdit);
const backgroundPreviewState: CanvasState = {
...canvasState,
background: { ...canvasState.background, adjustments: draftAdjustments },
};
const renderedCanvasState = withTextDraft(backgroundPreviewState, textEdit);
const imageUrl = `/api/v1/private-assets/projects/${projectId}/images/${canvasState.background.asset_id ?? project.current_image_id ?? ""}`;
const canEdit = saveStatus !== "conflicted";
const selectedElements = renderedCanvasState.elements.filter((element) => selectedIds.includes(element.element_id));
+36 -4
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState, type KeyboardEvent, type PointerEvent } from "react";
import type { CanvasState } from "@dada/shared-contracts";
import { cssFilterForBackground } from "./editor-canvas.js";
import { adjustBackgroundPixels, backgroundDrawPlan, cssFilterForBackground } from "./editor-canvas.js";
import { DYN012_RENDER_LAYOUT, dyn012DisplayParts } from "./dynamic-provider.js";
import type { DynamicTemplateId } from "./dynamic-provider.js";
import { DYNAMIC_RENDER_MODELS, dynamicFontOptionsFor, dynamicImageUrl, dynamicTextValue } from "./dynamic-render-models.js";
@@ -307,9 +307,41 @@ function renderEditorScene(
context.clearRect(0, 0, canvasState.pixel_width, canvasState.pixel_height);
context.fillStyle = "#ffffff";
context.fillRect(0, 0, canvasState.pixel_width, canvasState.pixel_height);
context.filter = cssFilterForBackground(canvasState.background.adjustments);
if (background) context.drawImage(background, 0, 0, canvasState.pixel_width, canvasState.pixel_height);
context.filter = "none";
if (background) {
const plan = backgroundDrawPlan(
{ height: background.naturalHeight || background.height, width: background.naturalWidth || background.width },
{ height: canvasState.pixel_height, width: canvasState.pixel_width },
canvasState.background.adjustments,
);
context.save();
context.filter = cssFilterForBackground(canvasState.background.adjustments);
context.drawImage(
background,
plan.source.x,
plan.source.y,
plan.source.width,
plan.source.height,
plan.destination.x,
plan.destination.y,
plan.destination.width,
plan.destination.height,
);
context.restore();
if (canvasState.background.adjustments.temperature !== 0 || canvasState.background.adjustments.sharpness !== 0) {
const x = Math.max(0, Math.floor(plan.destination.x));
const y = Math.max(0, Math.floor(plan.destination.y));
const right = Math.min(canvasState.pixel_width, Math.ceil(plan.destination.x + plan.destination.width));
const bottom = Math.min(canvasState.pixel_height, Math.ceil(plan.destination.y + plan.destination.height));
const width = right - x;
const height = bottom - y;
if (width > 0 && height > 0) {
const imageData = context.getImageData(x, y, width, height);
imageData.data.set(adjustBackgroundPixels(imageData.data, width, height, canvasState.background.adjustments));
context.putImageData(imageData, x, y);
}
}
}
for (const element of [...canvasState.elements].sort((left, right) => left.z_index - right.z_index)) {
drawElement(context, element, canvasState.pixel_width, canvasState.pixel_height, fontStatuses, resourceImages);
}
+152
View File
@@ -0,0 +1,152 @@
:root {
--dada-interaction-duration: 120ms;
--dada-interaction-easing: cubic-bezier(0.2, 0.8, 0.2, 1);
}
:where(button:not(:disabled), a[href], label:has(input:not(:disabled))) {
cursor: pointer;
}
:where(button:not(:disabled), a[href]) {
transition:
transform var(--dada-interaction-duration) var(--dada-interaction-easing),
box-shadow var(--dada-interaction-duration) var(--dada-interaction-easing),
border-color var(--dada-interaction-duration) ease,
background-color var(--dada-interaction-duration) ease,
color var(--dada-interaction-duration) ease,
opacity var(--dada-interaction-duration) ease;
}
:where(input:not(:disabled), select:not(:disabled), textarea:not(:disabled)) {
transition:
border-color var(--dada-interaction-duration) ease,
box-shadow var(--dada-interaction-duration) ease,
background-color var(--dada-interaction-duration) ease;
}
:where(.product-page, .product-loading) :focus-visible {
outline: 2px solid #005fcc;
outline-offset: 3px;
}
:where(input:not(:disabled), select:not(:disabled), textarea:not(:disabled)):focus-visible {
border-color: #005fcc;
box-shadow: 0 0 0 3px rgb(0 95 204 / 16%);
}
.project-card,
.project-preview img,
.ratio-control span,
.reference-input,
.editor-sticker-preview,
.editor-template-mark,
.editor-color-card-preview,
.editor-dynamic-preview,
.editor-source-preview-canvas,
.editor-thumb {
transition:
transform var(--dada-interaction-duration) var(--dada-interaction-easing),
box-shadow var(--dada-interaction-duration) var(--dada-interaction-easing),
border-color var(--dada-interaction-duration) ease,
background-color var(--dada-interaction-duration) ease;
}
@media (hover: hover) and (pointer: fine) {
:where(button:not(:disabled)):hover {
border-color: #111111;
box-shadow: 0 2px 0 rgb(17 17 17 / 35%);
}
:where(a[href]):hover {
color: #005fcc;
opacity: 0.78;
text-decoration-thickness: 2px;
text-underline-offset: 3px;
}
.product-header nav a:hover {
color: #111111;
background: #e9e9e5;
box-shadow: inset 0 -3px #111111;
opacity: 1;
}
.product-header nav a[aria-current="page"]:hover {
background: #f2f500;
}
.ratio-control label:hover span,
.reference-input:hover {
border-color: #111111;
background: #ffffd6;
box-shadow: inset 0 -3px #111111;
}
.project-card:hover {
border-color: #111111;
box-shadow: 0 3px 0 rgb(17 17 17 / 22%);
}
.editor-asset-tabs button:not(:disabled):hover,
.editor-source:hover,
.editor-sticker-grid button:not(:disabled):hover,
.editor-template-categories button:not(:disabled):hover,
.editor-template-grid button:not(:disabled):hover,
.editor-provider-grid button:not(:disabled):hover,
.editor-candidates button:not(:disabled):hover {
border-color: #111111;
background: #ffffd6;
}
:where(input:not(:disabled), select:not(:disabled), textarea:not(:disabled)):hover {
border-color: #111111;
}
}
@media (hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference) {
:where(button:not(:disabled), a[href]):hover {
transform: translateY(-1px);
}
.project-card:hover .project-preview img,
.editor-sticker-grid button:not(:disabled):hover .editor-sticker-preview {
transform: scale(1.02);
}
.editor-template-grid button:not(:disabled):hover .editor-template-mark,
.editor-provider-grid button:not(:disabled):hover > :first-child {
transform: scale(1.03);
}
}
@media (prefers-reduced-motion: no-preference) {
:where(button:not(:disabled), a[href]):active {
transform: translateY(1px);
transition-duration: 45ms;
}
}
:where(button:not(:disabled)):active {
box-shadow: inset 0 2px 0 rgb(17 17 17 / 24%);
}
:where(a[href]):active {
opacity: 0.58;
}
@media (prefers-reduced-motion: reduce) {
:where(button, a[href], input, select, textarea),
.project-card,
.project-preview img,
.ratio-control span,
.reference-input,
.editor-sticker-preview,
.editor-template-mark,
.editor-color-card-preview,
.editor-dynamic-preview,
.editor-source-preview-canvas,
.editor-thumb {
animation-duration: 0s !important;
transition-duration: 0s !important;
}
}
+2
View File
@@ -16,6 +16,8 @@ import { ProjectDetailPage, ProjectsPage, WorkspacePage } from "./project-pages.
import { EditorPage } from "./editor-page.js";
import { AdminOverviewPage, AdminPlaceholderPage, AdminProtectedRoute } from "./admin-shell.js";
import "./interaction-feedback.css";
const root = document.getElementById("root");
if (!root) {
+7 -1
View File
@@ -27,6 +27,7 @@ interface LocalDataPayload {
}
interface AccountSettingsPayload {
csrf_token: string;
local_data: LocalDataPayload;
}
@@ -296,7 +297,12 @@ export function WorkspacePage() {
if (!active) return;
if (modelResult.status === "fulfilled") setModels(modelResult.value);
if (taskResult.status === "fulfilled") setCurrentTask(taskResult.value);
if (settingsResult.status === "fulfilled" && settingsResult.value) setLocalData(settingsResult.value.local_data);
const settings = settingsResult.status === "fulfilled" ? settingsResult.value : undefined;
if (settings) {
setLocalData(settings.local_data);
// Account settings rotates the mutation token; keep the workspace token current.
setSession((current) => current ? { ...current, csrf_token: settings.csrf_token } : current);
}
setGenerationStateLoaded(true);
});
}).catch((error) => {
+2
View File
@@ -107,5 +107,7 @@ if (!workerPort && process.argv.includes("--dada-ai-probe")) {
} catch {
storageStatus = "unavailable";
control.reportStatus("storage_unavailable");
clearInterval(keepAlive);
setTimeout(() => process.exit(1), 50);
}
}
+1
View File
@@ -20,6 +20,7 @@
"test:performance": "node scripts/run-wp4-07-layer.mjs performance",
"test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs",
"test:package": "pnpm build:workspace-packages && pnpm run typecheck && node --test tests/package/wp0-09-portable.test.mjs && node scripts/package-smoke.mjs && node scripts/loopback-boundary-smoke.mjs",
"test:postv1-ui-integration": "node scripts/validate-postv1-ui-lineage.mjs && playwright test tests/e2e/projects-workspace.spec.ts tests/e2e/wp4-01-editor-background.spec.ts tests/e2e/wp4-02-editor-elements.spec.ts tests/e2e/wp4-04-color-dynamic.spec.ts --config playwright.config.ts",
"package:portable": "node scripts/build-portable.mjs",
"assets:manifest": "pnpm build:workspace-packages && node scripts/generate-runtime-asset-manifest.mjs",
"assets:deploy": "pnpm build:workspace-packages && node scripts/deploy-runtime-assets.mjs",
+1 -1
View File
@@ -281,7 +281,7 @@ export const AdminDiagnosticsResponseSchema = Type.Object({
browser_support: Type.Array(Type.Object({
brand: Type.Union([Type.Literal("Google Chrome"), Type.Literal("Microsoft Edge")]),
major: Type.Integer({ minimum: 1 }),
}, { additionalProperties: false }), { maxItems: 2 }),
}, { additionalProperties: false }), { maxItems: 8 }),
worker_status: Type.Union([Type.Literal("ready"), Type.Literal("degraded"), Type.Literal("unavailable")]),
}, { additionalProperties: false }),
}, { additionalProperties: false, $id: "AdminDiagnosticsResponse" });
+1 -1
View File
@@ -83,7 +83,7 @@ export const ErrorDetailsSchema = Type.Object(
},
{ additionalProperties: false },
),
{ maxItems: 2 },
{ maxItems: 8 },
),
),
capacity_status: Type.Optional(
+22 -1
View File
@@ -14,7 +14,11 @@ export const DEFERRED_EXTERNAL_TASKS = Object.freeze(["TASK-WP7-03", "TASK-WP7-0
export function buildFinalReleaseRecord({ appVersion, browsers, buildCommit, frozenFromCommit, recordedAt, windows }) {
const record = {
appVersion,
browsers: browsers.map(({ brand, fullVersion }) => ({ brand, fullVersion })),
browsers: browsers.map(({ brand, fullVersion, supportedMajorVersions }) => ({
brand,
fullVersion,
...(supportedMajorVersions ? { supportedMajorVersions: [...supportedMajorVersions] } : {}),
})),
buildCommit: buildCommit.toLowerCase(),
deferredExternalTasks: [...DEFERRED_EXTERNAL_TASKS],
finalRelease: true,
@@ -44,8 +48,25 @@ export function validateFinalReleaseRecord(record) {
} else {
const brands = record.browsers.map(({ brand }) => brand).sort();
if (brands.join("|") !== "Google Chrome|Microsoft Edge") errors.push("browserBrands");
const supportedMajorCount = record.browsers.reduce(
(count, browser) => count + (Array.isArray(browser.supportedMajorVersions)
? browser.supportedMajorVersions.length
: 1),
0,
);
if (supportedMajorCount > 8) errors.push("supportedMajorVersions.total");
for (const browser of record.browsers) {
if (!VERSION.test(browser.fullVersion ?? "")) errors.push(`${browser.brand}.fullVersion`);
if (browser.supportedMajorVersions !== undefined) {
const values = browser.supportedMajorVersions;
const baselineMajor = Number.parseInt(browser.fullVersion.split(".")[0] ?? "0", 10);
if (!Array.isArray(values) || values.length === 0 || values.length > 8
|| values.some((value) => !Number.isSafeInteger(value) || value < 1)
|| new Set(values).size !== values.length
|| !values.includes(baselineMajor)) {
errors.push(`${browser.brand}.supportedMajorVersions`);
}
}
if ("path" in browser || "executablePath" in browser || "executableSha256" in browser) errors.push(`${browser.brand}.privateMetadata`);
}
}
+24
View File
@@ -0,0 +1,24 @@
import { execFileSync } from "node:child_process";
const requiredSubjects = [
"fix(POSTV1-07): 固定画布布局与文字贴纸选择",
"fix(POSTV1-07): 自动关闭编辑器操作提示",
"fix(POSTV1-08): 修复文字拖动闪烁与自动保存",
"fix(POSTV1-09): 展示项目生成图片",
"feat(POSTV1-10): 增加统一交互反馈",
"fix(POSTV1-11): 修复生成提交的会话令牌轮换",
"fix(POSTV1-runtime): 改善后台启动与状态窗口可见性",
"fix(POSTV1-browser): 同时支持 Chrome 150 与 151",
"fix(POSTV1-editor): 修复多选拖动与底图调整",
];
const subjects = new Set(execFileSync("git", ["log", "--format=%s", "HEAD"], { encoding: "utf8" })
.split(/\r?\n/u)
.filter(Boolean));
const missing = requiredSubjects.filter((subject) => !subjects.has(subject));
if (missing.length > 0) {
throw new Error(`postv1_ui_lineage_incomplete:${missing.join("|")}`);
}
console.log(JSON.stringify({ required_commit_count: requiredSubjects.length, status: "passed" }));
+4 -1
View File
@@ -30,7 +30,10 @@ internal static class Program
};
var state = await runtime.StartAsync();
if (!form.IsDisposed) form.SetState(state);
if (state == SupervisorState.Ready) SupervisorForm.OpenProductInSupportedBrowser();
if (state == SupervisorState.Ready && !SupervisorForm.OpenProductInSupportedBrowser())
{
form.SetBrowserLaunchFailure();
}
}
form.Shown += async (_, _) => await StartRuntimeAsync();
form.RestartRequested += async () => await StartRuntimeAsync();
+9 -4
View File
@@ -26,6 +26,7 @@ internal sealed class SupervisorForm : Form
Font = new Font("Segoe UI", 9F);
FormBorderStyle = FormBorderStyle.FixedDialog;
MaximizeBox = false;
ShowInTaskbar = true;
StartPosition = FormStartPosition.CenterScreen;
Text = "Dada";
@@ -90,10 +91,6 @@ internal sealed class SupervisorForm : Form
trayIcon.DoubleClick += (_, _) => RestoreWindow();
FormClosing += (_, _) => trayIcon.Visible = false;
Resize += (_, _) =>
{
if (WindowState == FormWindowState.Minimized) Hide();
};
SetState(initialState);
}
@@ -139,6 +136,14 @@ internal sealed class SupervisorForm : Form
Activate();
}
internal void SetBrowserLaunchFailure()
{
if (state == SupervisorState.Ready)
{
statusDetail.Text = "本机服务运行正常,但未能自动打开浏览器;请点击“打开 Dada”或选择浏览器。";
}
}
protected override void Dispose(bool disposing)
{
if (disposing) trayIcon.Dispose();
@@ -1,3 +1,4 @@
using System.ComponentModel;
using System.Diagnostics;
using Microsoft.Win32;
@@ -13,10 +14,20 @@ internal static class SupportedBrowserLauncher
{
var executable = FindExecutable(executableName);
if (executable is null) return false;
var startInfo = new ProcessStartInfo(executable) { UseShellExecute = false };
startInfo.ArgumentList.Add(uri.AbsoluteUri);
Process.Start(startInfo);
return true;
try
{
var startInfo = new ProcessStartInfo(executable) { UseShellExecute = false };
startInfo.ArgumentList.Add(uri.AbsoluteUri);
return Process.Start(startInfo) is not null;
}
catch (Win32Exception)
{
return false;
}
catch (InvalidOperationException)
{
return false;
}
}
private static string? FindExecutable(string executableName)
+12
View File
@@ -0,0 +1,12 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
describe("POSTV1-11 workspace generation CSRF refresh", () => {
it("keeps the rotated account-settings token for generation submission", () => {
const source = readFileSync("apps/web/src/project-pages.tsx", "utf8");
expect(source).toContain("csrf_token: string;");
expect(source).toContain("csrf_token: settings.csrf_token");
});
});
+30
View File
@@ -13,6 +13,14 @@ const supportedEdge = browserSupportFixture({
brand: "Microsoft Edge",
fullVersion: "150.0.4078.99",
});
const supportedChrome150 = browserSupportFixture({
brand: "Google Chrome",
fullVersion: "150.0.7871.187",
});
const supportedChrome151 = browserSupportFixture({
brand: "Google Chrome",
fullVersion: "151.0.0.0",
});
const rejectedIdentityCases = [
{
expectedReason: "platform_unsupported",
@@ -145,6 +153,7 @@ describe("TDD-WP0-BRW-001 supported browser contract", () => {
status: "supported",
supported_browsers: [
{ brand: "Google Chrome", major: 150 },
{ brand: "Google Chrome", major: 151 },
{ brand: "Microsoft Edge", major: 150 },
],
});
@@ -188,6 +197,26 @@ describe("TDD-WP0-BRW-001 supported browser contract", () => {
expect(staleCookie.statusCode).toBe(426);
await restarted.close();
});
it.each([
{ expectedMajor: 150, fixture: supportedChrome150 },
{ expectedMajor: 151, fixture: supportedChrome151 },
])("accepts explicitly declared Chrome $expectedMajor", async ({ expectedMajor, fixture }) => {
const app = await createApp({ browserSupportRelease: testBrowserSupportRelease } as never);
const checked = await app.inject({
headers: fixture.headers,
method: "POST",
payload: fixture.body,
url: "/api/v1/support/check",
});
expect(checked.statusCode).toBe(200);
expect(checked.json()).toMatchObject({
browser: { brand: "Google Chrome", major: expectedMajor },
status: "supported",
});
await app.close();
});
});
describe("TDD-WP0-BRW-002 hard block", () => {
@@ -208,6 +237,7 @@ describe("TDD-WP0-BRW-002 hard block", () => {
reason: expectedReason,
supported_browsers: [
{ brand: "Google Chrome", major: 150 },
{ brand: "Google Chrome", major: 151 },
{ brand: "Microsoft Edge", major: 150 },
],
},
+13 -1
View File
@@ -105,9 +105,21 @@ test("TDD-WP2-PROJ-005 limits failed-empty selection on the project list", async
await expect(page.getByLabel("选择失败草稿:失败草稿")).toBeVisible();
await expect(page.getByLabel("选择失败草稿:城市工作室")).toHaveCount(0);
await page.getByLabel("选择失败草稿:失败草稿").check();
await page.getByRole("button", { name: "批量移入回收站" }).click();
const batchTrashButton = page.getByRole("button", { name: "批量移入回收站" });
await batchTrashButton.hover();
await expect(batchTrashButton).toHaveCSS("transform", "matrix(1, 0, 0, 1, 0, -1)");
const buttonBounds = await batchTrashButton.boundingBox();
if (!buttonBounds) throw new Error("Batch trash button geometry is unavailable.");
await page.mouse.move(buttonBounds.x + buttonBounds.width / 2, buttonBounds.y + buttonBounds.height / 2);
await page.mouse.down();
await expect(batchTrashButton).toHaveCSS("transform", "matrix(1, 0, 0, 1, 0, 1)");
await page.mouse.move(0, 0);
await page.mouse.up();
await batchTrashButton.click();
expect(batchPayload).toEqual({ project_ids: [failedId] });
await expect(page.getByText("失败草稿已移入回收站")).toBeVisible();
await page.emulateMedia({ reducedMotion: "reduce" });
await expect(page.getByRole("link", { name: "打开项目:城市工作室" })).toHaveCSS("transition-duration", "0s");
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(375);
await captureEvidence(page, "TDD-WP2-PROJ-005-failed-draft-retry", "projects-mobile.png");
});
@@ -67,6 +67,20 @@ async function routeEditor(page: Page) {
});
}
async function canvasFingerprint(page: Page) {
return page.getByLabel("编辑画布").evaluate((canvas: HTMLCanvasElement) => {
const context = canvas.getContext("2d");
if (!context) throw new Error("Canvas context unavailable.");
return [
...context.getImageData(108, 720, 1, 1).data,
...context.getImageData(324, 720, 1, 1).data,
...context.getImageData(540, 720, 1, 1).data,
...context.getImageData(756, 720, 1, 1).data,
...context.getImageData(972, 720, 1, 1).data,
];
});
}
test("TDD-WP4-BG-001 preserves overlays while switching the background", async ({ page }) => {
await routeEditor(page);
const saves: Array<Record<string, unknown>> = [];
@@ -144,3 +158,35 @@ test("TDD-WP4-BG-002 commits, reopens, undoes, and resets background processing"
writeEvidence("TDD-WP4-BG-002-processing-controls", "pixel-diff.json", { preview_commit_undo_reset: true, export_source_canvas_state_stable: true });
await page.screenshot({ fullPage: true, path: process.env.DADA_EVIDENCE_DIR_EDITOR ? resolve(process.env.DADA_EVIDENCE_DIR_EDITOR, "TDD-WP4-BG-002-processing-controls", "processing-controls.png") : undefined });
});
test("TDD-WP4-BG-002 previews background pixels before committing", async ({ page }) => {
await routeEditor(page);
const saves: Array<Record<string, unknown>> = [];
let version = 7;
await page.route(`**/api/v1/projects/${projectId}/state`, async (route) => {
saves.push(route.request().postDataJSON() as Record<string, unknown>);
version += 1;
await route.fulfill({ body: JSON.stringify({ save_status: "saved", state_version: version }), contentType: "application/json", status: 200 });
});
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
await page.getByRole("button", { name: "恢复原图" }).click();
await page.getByRole("button", { name: "应用调整" }).click();
await expect.poll(() => saves.length, { timeout: 4_000 }).toBeGreaterThan(0);
await expect.poll(async () => (await canvasFingerprint(page)).some((channel) => channel < 240)).toBe(true);
const baseline = (await canvasFingerprint(page)).join(",");
const ranges = page.locator(".editor-inspector input[type=range]");
await ranges.nth(0).fill("40");
await ranges.nth(1).fill("25");
await ranges.nth(2).fill("-35");
await ranges.nth(3).fill("70");
await ranges.nth(4).fill("100");
await expect.poll(async () => (await canvasFingerprint(page)).join(",")).not.toBe(baseline);
await page.getByRole("button", { name: "应用调整" }).click();
await expect.poll(() => saves.length, { timeout: 4_000 }).toBeGreaterThan(1);
const committed = saves.at(-1) as { canvas_state: typeof initialCanvasState };
expect(committed.canvas_state.background.adjustments).toMatchObject({
brightness: 40, contrast: 25, saturation: -35, sharpness: 100, temperature: 70,
});
});
+37 -1
View File
@@ -28,7 +28,8 @@ const session = {
user: { creator_name: "Canvas User", role: "user", social_id: "@canvas_user", status: "active", user_id: "00000000-0000-4000-8000-000000000501" },
};
const stickerRoot = process.env.DADA_STATIC_STICKER_ROOT ?? join(homedir(), "Desktop", "贴纸素材");
const stickerRoot = process.env.DADA_STATIC_STICKER_ROOT
?? join(homedir(), "Desktop", "sticker_web_replication_assets", "sticker_normal");
const originalStickerFixtures: Readonly<Record<string, string>> = {
STK001: join(stickerRoot, "sticker_part1", "01af6384c2a962d17f55736f9895b505.png"),
STK002: join(stickerRoot, "sticker_part2", "011db0fb6ac4184e4a708374003bce66.png"),
@@ -133,6 +134,41 @@ test("TDD-WP4-CAN-001 keeps fifty elements editable and blocks the fifty-first",
if (process.env.DADA_EVIDENCE_DIR_EDITOR_ELEMENTS) await page.screenshot({ fullPage: true, path: resolve(process.env.DADA_EVIDENCE_DIR_EDITOR_ELEMENTS, "TDD-WP4-CAN-001-fifty-elements", "fifty-elements.png") });
});
test("TDD-WP4-CAN-001 drags distant multi-selected objects as one group", async ({ page }) => {
const projectId = uuid(519);
const backend = {
canvas: canvas([sticker(1, { x: 0.2, y: 0.2 }, 0), sticker(2, { x: 0.8, y: 0.8 }, 1)]),
saves: 0,
version: 5,
};
await routeEditor(page, projectId, backend);
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
const stage = page.getByLabel("编辑画布");
const bounds = await stage.boundingBox();
if (!bounds) throw new Error("Canvas bounds unavailable.");
const point = (x: number, y: number) => ({ x: bounds.x + bounds.width * x, y: bounds.y + bounds.height * y });
await page.getByRole("button", { name: "多选模式" }).click();
await page.mouse.click(point(0.2, 0.2).x, point(0.2, 0.2).y);
await page.mouse.click(point(0.8, 0.8).x, point(0.8, 0.8).y);
await expect(page.getByRole("heading", { name: "已选 2 个对象" })).toBeVisible();
await page.mouse.move(point(0.2, 0.2).x, point(0.2, 0.2).y);
await page.mouse.down();
await page.mouse.move(point(0.3, 0.3).x, point(0.3, 0.3).y, { steps: 4 });
await page.mouse.up();
await expect.poll(() => backend.saves, { timeout: 4_000 }).toBeGreaterThan(0);
const [first, second] = backend.canvas.elements.map(({ position }) => position);
expect(first?.x).toBeCloseTo(0.3, 6);
expect(first?.y).toBeCloseTo(0.3, 6);
expect(second?.x).toBeCloseTo(0.9, 6);
expect(second?.y).toBeCloseTo(0.9, 6);
expect((first?.x ?? 0) - 0.2).toBeCloseTo((second?.x ?? 0) - 0.8, 10);
expect((first?.y ?? 0) - 0.2).toBeCloseTo((second?.y ?? 0) - 0.8, 10);
await expect(page.getByRole("heading", { name: "已选 2 个对象" })).toBeVisible();
});
test("TDD-WP4-STK-001 transforms, cycles, selects and reopens ordinary stickers", async ({ page }) => {
const projectId = uuid(520);
const backend = { canvas: canvas([sticker(1, { x: 0.5, y: 0.5 }, 0), sticker(2, { x: 0.5, y: 0.5 }, 1)]), saves: 0, version: 5 };
+5 -1
View File
@@ -98,7 +98,11 @@ test("TDD-WP4-TXT-003 exposes the frozen catalog, display-name search and accoun
await expect(page.locator(".editor-template-grid button")).toHaveCount(0);
await search.fill("");
expect(page.getByText("添加普通文字", { exact: true })).toHaveCount(0);
await page.getByRole("button", { name: /FLOWER001 春日计划/ }).click();
const flowerTemplate = page.getByRole("button", { name: /FLOWER001 春日计划/ });
await flowerTemplate.hover();
await expect(flowerTemplate).toHaveCSS("background-color", "rgb(255, 255, 214)");
await expect(flowerTemplate.locator(".editor-template-mark")).toHaveCSS("transform", "matrix(1.03, 0, 0, 1.03, 0, 0)");
await flowerTemplate.click();
await expect(page.getByText("对象 1 / 50")).toBeVisible();
await expect.poll(() => backend.recent).toEqual([{ asset_id: "FLOWER001", asset_kind: "text_template", resource_version: "p0a-complex-v1" }]);
await page.reload();
+3 -2
View File
@@ -29,8 +29,9 @@ const rawImages: Record<string, string[]> = {
"00000000-0000-4000-8000-000000000812": ["#102030", "#405060", "#708090", "#A0B0C0", "#D0E0F0"],
};
const dynamicRoot = process.env.DADA_DYNAMIC_ASSET_ROOT ?? join(homedir(), "Desktop", "sticker_interactive", "单模板归档", "templates");
const textRoot = process.env.DADA_TEXT_ASSET_ROOT ?? join(homedir(), "Desktop", "sticker_text");
const assetRoot = join(homedir(), "Desktop", "sticker_web_replication_assets");
const dynamicRoot = process.env.DADA_DYNAMIC_ASSET_ROOT ?? join(assetRoot, "sticker_interactive", "单模板归档", "templates");
const textRoot = process.env.DADA_TEXT_ASSET_ROOT ?? join(assetRoot, "sticker_text");
const dynamicSourceAssets: Readonly<Record<string, { contentType: string; path: string }>> = {
"15974853bc3294ef68e7e6d58fe74fd7": { contentType: "font/ttf", path: join(dynamicRoot, "DYN002", "fonts", "15974853bc3294ef68e7e6d58fe74fd7", "0202b90o6r57rxed4027b5689e0dxe7e142r0yho9d0lljuqj.ttf") },
"46f8336813e4c48d06a1aef294fdccf6": { contentType: "font/ttf", path: join(dynamicRoot, "DYN016", "fonts", "46f8336813e4c48d06a1aef294fdccf6", "9fbfbb420cea1df916d7c7c7ac90b1c88b61e117-PingFang-SC-Semibold-2.ttf") },
+1 -1
View File
@@ -1,7 +1,7 @@
export const testBrowserSupportRelease = {
appVersion: "1.2.3-test",
browsers: [
{ brand: "Google Chrome", fullVersion: "150.0.7339.1" },
{ brand: "Google Chrome", fullVersion: "150.0.7339.1", supportedMajorVersions: [150, 151] },
{ brand: "Microsoft Edge", fullVersion: "150.0.4078.99" },
],
} as const;
@@ -0,0 +1,14 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
test("POSTV1-11 release accepts the Codex embedded Chrome major", () => {
const release = JSON.parse(
fs.readFileSync(path.resolve("RELEASE.json"), "utf8"),
);
const chrome = release.browsers.find(({ brand }) => brand === "Google Chrome");
assert.ok(chrome, "release must include Google Chrome");
assert.equal(Number.parseInt(chrome.fullVersion.split(".")[0], 10), 151);
});
+27 -1
View File
@@ -10,7 +10,7 @@ function release() {
return buildFinalReleaseRecord({
appVersion: "0.0.0",
browsers: [
{ brand: "Google Chrome", fullVersion: "150.0.7871.187" },
{ brand: "Google Chrome", fullVersion: "150.0.7871.187", supportedMajorVersions: [150, 151] },
{ brand: "Microsoft Edge", fullVersion: "151.0.4129.59" },
],
buildCommit: "a".repeat(40),
@@ -26,6 +26,32 @@ test("TDD-WP7-REL-001 creates a browser-gate compatible first-version record", (
assert.equal(record.fixedPort, 43121);
assert.deepEqual(record.deferredExternalTasks, ["TASK-WP7-03", "TASK-WP7-04"]);
assert.deepEqual(record.browsers.map(({ brand }) => brand).sort(), ["Google Chrome", "Microsoft Edge"]);
assert.deepEqual(record.browsers[0].supportedMajorVersions, [150, 151]);
});
test("TDD-WP7-REL-001 rejects unsafe or duplicate browser major lists", () => {
assert.throws(() => buildFinalReleaseRecord({
appVersion: "0.0.0",
browsers: [
{ brand: "Google Chrome", fullVersion: "150.0.7871.187", supportedMajorVersions: [150, 150] },
{ brand: "Microsoft Edge", fullVersion: "151.0.4129.59" },
],
buildCommit: "a".repeat(40),
frozenFromCommit: "b".repeat(40),
recordedAt: "2026-08-04T06:00:00.000Z",
windows: { arch: "x64", build: "26200.8875", displayVersion: "25H2" },
}), /Google Chrome\.supportedMajorVersions/);
assert.throws(() => buildFinalReleaseRecord({
appVersion: "0.0.0",
browsers: [
{ brand: "Google Chrome", fullVersion: "150.0.7871.187", supportedMajorVersions: [150, 151, 152, 153, 154, 155, 156, 157] },
{ brand: "Microsoft Edge", fullVersion: "151.0.4129.59" },
],
buildCommit: "a".repeat(40),
frozenFromCommit: "b".repeat(40),
recordedAt: "2026-08-04T06:00:00.000Z",
windows: { arch: "x64", build: "26200.8875", displayVersion: "25H2" },
}), /supportedMajorVersions\.total/);
});
test("TDD-WP7-SEC-001 rejects credential shapes and absolute user paths", () => {
+32
View File
@@ -1,8 +1,11 @@
import { describe, expect, it } from "vitest";
import {
adjustBackgroundPixels,
backgroundDrawPlan,
CanvasEditHistory,
createEditorCanvasState,
cssFilterForBackground,
defaultBackgroundAdjustments,
deserializeFabricCanvas,
switchBackground,
@@ -24,6 +27,35 @@ const element = {
};
describe("TDD-WP4-BG-001/002 canvas boundary", () => {
it("builds valid filters and distinct contain/crop draw plans", () => {
const adjustments = { ...defaultBackgroundAdjustments(), brightness: 25, contrast: 15, saturation: -20 };
expect(cssFilterForBackground(adjustments)).toBe("brightness(125%) contrast(115%) saturate(80%)");
expect(cssFilterForBackground(defaultBackgroundAdjustments())).toBe("none");
expect(backgroundDrawPlan({ height: 200, width: 400 }, { height: 100, width: 100 }, { ...adjustments, fit: "fit" })).toEqual({
destination: { height: 50, width: 100, x: 0, y: 25 },
source: { height: 200, width: 400, x: 0, y: 0 },
});
expect(backgroundDrawPlan({ height: 200, width: 400 }, { height: 100, width: 100 }, { ...adjustments, fit: "crop" })).toEqual({
destination: { height: 100, width: 100, x: 0, y: 0 },
source: { height: 200, width: 200, x: 100, y: 0 },
});
});
it("changes pixels for temperature and sharpness without changing alpha", () => {
const flat = new Uint8ClampedArray([100, 100, 100, 255]);
expect([...adjustBackgroundPixels(flat, 1, 1, { sharpness: 0, temperature: 100 })]).toEqual([135, 108, 65, 255]);
const edged = new Uint8ClampedArray([
100, 100, 100, 255, 100, 100, 100, 255, 100, 100, 100, 255,
100, 100, 100, 255, 180, 180, 180, 255, 100, 100, 100, 255,
100, 100, 100, 255, 100, 100, 100, 255, 100, 100, 100, 255,
]);
const sharpened = adjustBackgroundPixels(edged, 3, 3, { sharpness: 100, temperature: 0 });
expect(sharpened[16]).toBe(255);
expect(sharpened[19]).toBe(255);
});
it("switches background without moving overlays, resets processing, and re-extracts palette", () => {
const initial = createEditorCanvasState({ assetId: "00000000-0000-4000-8000-000000000010", ratio: "3:4" });
const withOverlay = { ...initial, background: { ...initial.background, adjustments: { ...initial.background.adjustments, brightness: 42, filter: "mono" } }, elements: [element] };
+17
View File
@@ -73,6 +73,10 @@ describe("TDD-WP4-CAN-001 canvas selection and limit", () => {
identity(1).elementId,
identity(2).elementId,
]);
expect(controller.selectAt({ x: 0.2, y: 0.2 }, { append: true, preserveSelection: true })).toEqual([
identity(1).elementId,
identity(2).elementId,
]);
});
});
@@ -97,6 +101,19 @@ describe("TDD-WP4-STK-001 sticker transformations", () => {
});
});
it("uses one bounded delta for distant selected elements", () => {
const controller = new CanvasElementController(state([
sticker(1, { position: { x: 0.1, y: 0.2 } }),
sticker(2, { position: { x: 0.9, y: 0.8 } }),
]));
controller.selectIds([identity(1).elementId, identity(2).elementId]);
controller.moveSelected({ x: 0.2, y: 0.3 }, { snap: false });
expect(controller.value.elements.map(({ position }) => position)).toEqual([
{ x: 0.2, y: 0.4 },
{ x: 1, y: 1 },
]);
});
it("duplicates, reorders and deletes selected stickers while preserving stable resource identity", () => {
const controller = new CanvasElementController(state([sticker(1), sticker(2, { position: { x: 0.7, y: 0.7 } })]));
controller.selectById(identity(1).elementId);