Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef6950c5df | ||
|
|
2cd85cd7cd | ||
|
|
9de0d2a63c | ||
|
|
fd4cd277cc | ||
|
|
910e32917f | ||
|
|
83fe57f319 | ||
|
|
51d613f459 | ||
|
|
3779cfbadc | ||
|
|
e8ce1d9031 |
+2
-1
@@ -3,7 +3,8 @@
|
||||
"browsers": [
|
||||
{
|
||||
"brand": "Google Chrome",
|
||||
"fullVersion": "150.0.7871.187"
|
||||
"fullVersion": "150.0.7871.187",
|
||||
"supportedMajorVersions": [150, 151]
|
||||
},
|
||||
{
|
||||
"brand": "Microsoft Edge",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { P0A_COLOR_CARDS, createColorCardElement, drawColorCard, type ColorCardDefinition } from "./palette-provider.js";
|
||||
import { COLOR_CARD_HALF_SIZES, P0A_COLOR_CARDS, createColorCardElement, drawColorCard, type ColorCardDefinition } from "./palette-provider.js";
|
||||
|
||||
const previewPalette = ["#04D960", "#0ABF58", "#5FD994", "#A0F2C4", "#D5F2E2"] as const;
|
||||
const previewHalfSize = {
|
||||
style_01: { height: 76, width: 26 }, style_02: { height: 77, width: 18 },
|
||||
style_08: { height: 10, width: 73 }, style_16: { height: 9, width: 78 },
|
||||
} as const;
|
||||
|
||||
function ColorCardPreview({ definition }: { definition: ColorCardDefinition }) {
|
||||
const ref = useRef<HTMLCanvasElement>(null);
|
||||
useEffect(() => {
|
||||
@@ -18,7 +13,8 @@ function ColorCardPreview({ definition }: { definition: ColorCardDefinition }) {
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.fillStyle = "#30343b";
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
const half = previewHalfSize[definition.styleId];
|
||||
const half = COLOR_CARD_HALF_SIZES[definition.styleId];
|
||||
if (!half) return;
|
||||
const scale = Math.min(1, 146 / (half.width * 2), 62 / (half.height * 2));
|
||||
context.translate(canvas.width / 2, canvas.height / 2);
|
||||
context.scale(scale, scale);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { P0A_DYNAMIC_STICKER_IDS } from "@dada/template-registry";
|
||||
|
||||
import { DYNAMIC_RESOURCE_VERSION } from "./dynamic-render-models.js";
|
||||
import type { CanvasElementIdentity } from "./editor-elements.js";
|
||||
import complexAssetCatalog from "./generated/complex-assets.json";
|
||||
|
||||
type CanvasElement = CanvasState["elements"][number];
|
||||
|
||||
@@ -45,23 +46,17 @@ export function dyn012DisplayParts(element: CanvasElement) {
|
||||
} as const;
|
||||
}
|
||||
|
||||
const dynamicDefinitions: readonly DynamicStickerDefinition[] = [
|
||||
{ category: "location", displayName: "地点标题", requiresLocationConsent: false, templateId: "DYN001" },
|
||||
{ category: "location", displayName: "英文地点", requiresLocationConsent: false, templateId: "DYN002" },
|
||||
{ category: "location", displayName: "城市地点", requiresLocationConsent: false, templateId: "DYN003" },
|
||||
{ category: "location", displayName: "经纬地点", requiresLocationConsent: true, templateId: "DYN004" },
|
||||
{ category: "other", displayName: "用户名组合", requiresLocationConsent: false, templateId: "DYN007" },
|
||||
{ category: "time", displayName: "月与时间", requiresLocationConsent: false, templateId: "DYN008" },
|
||||
{ category: "time", displayName: "完整日期", requiresLocationConsent: false, templateId: "DYN011" },
|
||||
{ category: "time", displayName: "数字时间", requiresLocationConsent: false, templateId: "DYN012" },
|
||||
{ category: "identity", displayName: "创作署名", requiresLocationConsent: false, templateId: "DYN015" },
|
||||
{ category: "identity", displayName: "社交 ID", requiresLocationConsent: false, templateId: "DYN016" },
|
||||
] as const;
|
||||
const dynamicCatalogById = new Map(complexAssetCatalog.dynamic_stickers.map((item) => [item.template_id, item]));
|
||||
|
||||
export const P0A_DYNAMIC_STICKERS: readonly DynamicStickerDefinition[] = P0A_DYNAMIC_STICKER_IDS.map((templateId) => {
|
||||
const definition = dynamicDefinitions.find((item) => item.templateId === templateId);
|
||||
if (!definition) throw new Error(`missing dynamic sticker definition ${templateId}`);
|
||||
return definition;
|
||||
const item = dynamicCatalogById.get(templateId);
|
||||
if (!item) throw new Error(`missing dynamic sticker definition ${templateId}`);
|
||||
return {
|
||||
category: item.category as DynamicCategory,
|
||||
displayName: item.display_name,
|
||||
requiresLocationConsent: item.requires_location_consent,
|
||||
templateId,
|
||||
};
|
||||
});
|
||||
|
||||
function twoDigits(value: number) {
|
||||
@@ -96,7 +91,29 @@ function snapshotFor(templateId: DynamicTemplateId, context: DynamicProviderCont
|
||||
if (templateId === "DYN011") return { fields: { day, month, year }, value: `${year}.${month}.${day}` };
|
||||
if (templateId === "DYN012") return { fields: { font_substitution: "FONT081", hour, minute }, value: `${hour}:${minute}` };
|
||||
if (templateId === "DYN015") return { fields: { nickname: context.profile.creatorName }, value: context.profile.creatorName };
|
||||
return { fields: { nickname: normalizeSocialId(context.profile.socialId) }, value: normalizeSocialId(context.profile.socialId) };
|
||||
if (templateId === "DYN016") return { fields: { nickname: normalizeSocialId(context.profile.socialId) }, value: normalizeSocialId(context.profile.socialId) };
|
||||
|
||||
const definition = dynamicCatalogById.get(templateId);
|
||||
if (!definition) throw new Error("dynamic_template_unavailable");
|
||||
const location = context.location?.formattedValue ?? "输入地点";
|
||||
const values: Record<string, string | number> = {
|
||||
city: location,
|
||||
city_en: location.toUpperCase(),
|
||||
day,
|
||||
hour,
|
||||
latitude: context.location?.latitude ?? 0,
|
||||
longitude: context.location?.longitude ?? 0,
|
||||
minute,
|
||||
month,
|
||||
nickname: normalizeSocialId(context.profile.socialId),
|
||||
title: location,
|
||||
year,
|
||||
};
|
||||
const fields = Object.fromEntries(definition.required_fields.map((field) => [field, values[field] ?? ""]));
|
||||
if (definition.category === "identity") return { fields, value: normalizeSocialId(context.profile.socialId) };
|
||||
if (definition.category === "location") return { fields, value: location };
|
||||
if (definition.category === "time") return { fields, value: `${year}.${month}.${day} ${hour}:${minute}` };
|
||||
return { fields, value: context.profile.creatorName };
|
||||
}
|
||||
|
||||
export function createDynamicStickerElement(
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { CanvasState } from "@dada/shared-contracts";
|
||||
import { P0A_COMPLEX_RELEASE_VERSION } from "@dada/template-registry";
|
||||
import { P0A_COMPLEX_RELEASE_VERSION, P0A_DYNAMIC_STICKER_IDS } from "@dada/template-registry";
|
||||
|
||||
import { fontOption, type FontOption } from "./text-assets.js";
|
||||
import type { DynamicTemplateId } from "./dynamic-provider.js";
|
||||
import complexAssetCatalog from "./generated/complex-assets.json";
|
||||
|
||||
type CanvasElement = CanvasState["elements"][number];
|
||||
|
||||
@@ -55,17 +56,13 @@ const dynamicFont = (fontId: string): FontOption => ({
|
||||
url: `/api/v1/assets/public/${DYNAMIC_RESOURCE_VERSION}/${fontId}`,
|
||||
});
|
||||
|
||||
export const DYNAMIC_FONT_OPTIONS: readonly FontOption[] = [
|
||||
dynamicFont("15974853bc3294ef68e7e6d58fe74fd7"),
|
||||
dynamicFont("46f8336813e4c48d06a1aef294fdccf6"),
|
||||
dynamicFont("53ca6b704728520da50c145eabb2e635"),
|
||||
dynamicFont("cca5efc0e02fb1bf62349bd68ef30fc1"),
|
||||
dynamicFont("dd25b35dcb7ba4476cbaa9a9592e39e2"),
|
||||
dynamicFont("e4210c9872f0c279b35273f230809821"),
|
||||
dynamicFont("f4bfd4132df2d6be97ceabadf3853505"),
|
||||
] as const;
|
||||
const dynamicFontIds = [...new Set(complexAssetCatalog.dynamic_stickers.flatMap((item) => item.font_ids))]
|
||||
.filter((fontId) => fontId !== "FONT081")
|
||||
.toSorted();
|
||||
|
||||
export const DYNAMIC_RENDER_MODELS: Readonly<Record<DynamicTemplateId, DynamicRenderModel>> = {
|
||||
export const DYNAMIC_FONT_OPTIONS: readonly FontOption[] = dynamicFontIds.map(dynamicFont);
|
||||
|
||||
const EXACT_DYNAMIC_RENDER_MODELS: Readonly<Record<string, DynamicRenderModel>> = {
|
||||
DYN001: {
|
||||
halfSize: { height: 42, width: 130 },
|
||||
imageLayers: [{ assetId: "DYN001-image28", height: 67, width: 219, x: -17.562, y: 2.203 }],
|
||||
@@ -145,6 +142,38 @@ export const DYNAMIC_RENDER_MODELS: Readonly<Record<DynamicTemplateId, DynamicRe
|
||||
},
|
||||
};
|
||||
|
||||
const dynamicCatalogById = new Map(complexAssetCatalog.dynamic_stickers.map((item) => [item.template_id, item]));
|
||||
|
||||
function genericTextValue(item: (typeof complexAssetCatalog.dynamic_stickers)[number]): DynamicTextValue {
|
||||
if (item.category === "identity") return "nickname";
|
||||
if (item.category === "location") return item.required_fields.includes("title") ? "title" : "city";
|
||||
if (item.category === "time") return item.required_fields.includes("hour") && item.required_fields.includes("minute") ? "time" : "day";
|
||||
return "nickname";
|
||||
}
|
||||
|
||||
function genericDynamicModel(templateId: string): DynamicRenderModel {
|
||||
const item = dynamicCatalogById.get(templateId);
|
||||
if (!item) throw new Error(`missing dynamic render model ${templateId}`);
|
||||
return {
|
||||
halfSize: { height: 42, width: 170 },
|
||||
imageLayers: [],
|
||||
sourceCandidateId: item.source_candidate_id,
|
||||
textLayers: [{
|
||||
align: "center",
|
||||
color: "#FFFFFF",
|
||||
fontId: item.font_ids[0] ?? "FONT081",
|
||||
fontSize: 34,
|
||||
value: genericTextValue(item),
|
||||
x: 0,
|
||||
y: 0,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
export const DYNAMIC_RENDER_MODELS: Readonly<Record<DynamicTemplateId, DynamicRenderModel>> = Object.fromEntries(
|
||||
P0A_DYNAMIC_STICKER_IDS.map((templateId) => [templateId, EXACT_DYNAMIC_RENDER_MODELS[templateId] ?? genericDynamicModel(templateId)]),
|
||||
);
|
||||
|
||||
export function dynamicFontOptionsFor(templateId: string) {
|
||||
if (templateId === "DYN012") {
|
||||
const replacement = fontOption("FONT081");
|
||||
@@ -153,7 +182,8 @@ export function dynamicFontOptionsFor(templateId: string) {
|
||||
const model = DYNAMIC_RENDER_MODELS[templateId as DynamicTemplateId];
|
||||
if (!model) return [];
|
||||
const ids = new Set(model.textLayers.map((layer) => layer.fontId));
|
||||
return DYNAMIC_FONT_OPTIONS.filter((option) => ids.has(option.fontId));
|
||||
return [...ids].map((fontId) => fontOption(fontId) ?? DYNAMIC_FONT_OPTIONS.find((option) => option.fontId === fontId))
|
||||
.filter((option): option is FontOption => option !== undefined);
|
||||
}
|
||||
|
||||
export function dynamicImageUrl(resourceVersion: string, assetId: string) {
|
||||
|
||||
@@ -22,6 +22,7 @@ function DynamicPreview(props: { fontStatuses: Readonly<Record<string, ArchivedF
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const model = DYNAMIC_RENDER_MODELS[props.templateId];
|
||||
if (!model) return () => { active = false; };
|
||||
const element = createDynamicStickerElement(props.templateId, {
|
||||
location: { formattedValue: "温州", latitude: 27.9943, longitude: 120.6994 },
|
||||
now: new Date("2026-08-03T09:07:00+08:00"), profile: { creatorName: "Dada Creator", socialId: "@dada" },
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -399,6 +399,7 @@
|
||||
.editor-template-grid button:disabled { border-style: dashed; background: #e8e8e5; color: #62625d; cursor: not-allowed; }
|
||||
.editor-template-grid strong { overflow: hidden; font-family: Consolas, monospace; font-size: 10px; text-overflow: ellipsis; }
|
||||
.editor-template-grid small { color: #8f1d14; font-size: 9px; }
|
||||
.editor-template-preview { width: 100%; height: 44px; object-fit: contain; border: 1px solid #111111; background: #30343b; }
|
||||
.editor-template-mark { display: grid; width: 100%; height: 44px; place-items: center; border: 1px solid #111111; background: #f2f400; font-size: 18px; font-weight: 800; }
|
||||
.editor-template-mark.title { background: #111111; color: #ffffff; }
|
||||
.editor-template-mark.tag { background: #dbeafe; }
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
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";
|
||||
import type { CanvasPoint, CanvasRect } from "./editor-elements.js";
|
||||
import { fontFamilyName, type ArchivedFontStatus } from "./text-font-loader.js";
|
||||
import { fontIdForTextElement } from "./text-assets.js";
|
||||
import { drawColorCard } from "./palette-provider.js";
|
||||
import { COLOR_CARD_HALF_SIZES, drawColorCard } from "./palette-provider.js";
|
||||
|
||||
interface Gesture {
|
||||
append: boolean;
|
||||
@@ -186,10 +186,8 @@ function elementSelectionHalfSize(
|
||||
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>,
|
||||
) {
|
||||
if (element.type === "color_card") {
|
||||
if (element.style_id === "style_01") return { height: 76 * element.scale.y, width: 26 * element.scale.x };
|
||||
if (element.style_id === "style_02") return { height: 77 * element.scale.y, width: 18 * element.scale.x };
|
||||
if (element.style_id === "style_08") return { height: 10 * element.scale.y, width: 73 * element.scale.x };
|
||||
return { height: 9 * element.scale.y, width: 78 * element.scale.x };
|
||||
const half = COLOR_CARD_HALF_SIZES[element.style_id ?? ""] ?? { height: 24, width: 78 };
|
||||
return { height: half.height * element.scale.y, width: half.width * element.scale.x };
|
||||
}
|
||||
if (element.type === "dynamic_sticker") {
|
||||
const model = DYNAMIC_RENDER_MODELS[element.template_or_asset_id as DynamicTemplateId];
|
||||
@@ -307,9 +305,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);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -40,6 +40,7 @@
|
||||
.reference-input,
|
||||
.editor-sticker-preview,
|
||||
.editor-template-mark,
|
||||
.editor-template-preview,
|
||||
.editor-color-card-preview,
|
||||
.editor-dynamic-preview,
|
||||
.editor-source-preview-canvas,
|
||||
@@ -114,6 +115,7 @@
|
||||
}
|
||||
|
||||
.editor-template-grid button:not(:disabled):hover .editor-template-mark,
|
||||
.editor-template-grid button:not(:disabled):hover .editor-template-preview,
|
||||
.editor-provider-grid button:not(:disabled):hover > :first-child {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
@@ -142,6 +144,7 @@
|
||||
.reference-input,
|
||||
.editor-sticker-preview,
|
||||
.editor-template-mark,
|
||||
.editor-template-preview,
|
||||
.editor-color-card-preview,
|
||||
.editor-dynamic-preview,
|
||||
.editor-source-preview-canvas,
|
||||
|
||||
@@ -27,6 +27,17 @@ export const COLOR_CARD_SOURCE_GEOMETRY = {
|
||||
style_16: { bounds: { bottom: 9, left: -78, right: 78, top: -9 } },
|
||||
} as const;
|
||||
|
||||
export const COLOR_CARD_HALF_SIZES: Readonly<Record<string, { height: number; width: number }>> = {
|
||||
style_01: { height: 76, width: 26 }, style_02: { height: 77, width: 18 },
|
||||
style_03: { height: 75, width: 22 }, style_04: { height: 75, width: 18 },
|
||||
style_05: { height: 18, width: 78 }, style_06: { height: 16, width: 78 },
|
||||
style_07: { height: 20, width: 78 }, style_08: { height: 10, width: 73 },
|
||||
style_09: { height: 75, width: 58 }, style_10: { height: 75, width: 60 },
|
||||
style_11: { height: 18, width: 78 }, style_12: { height: 34, width: 70 },
|
||||
style_13: { height: 75, width: 18 }, style_14: { height: 28, width: 60 },
|
||||
style_15: { height: 16, width: 78 }, style_16: { height: 9, width: 78 },
|
||||
};
|
||||
|
||||
function normalizedHex(value: string) {
|
||||
return value.toUpperCase();
|
||||
}
|
||||
@@ -139,6 +150,65 @@ export function drawColorCard(context: CanvasRenderingContext2D, element: Canvas
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (element.style_id === "style_03") {
|
||||
colors.forEach((color, index) => {
|
||||
context.fillStyle = color;
|
||||
context.fillRect(-22, -75 + index * 30, 44, 29);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (element.style_id === "style_04") {
|
||||
context.strokeStyle = "#ffffff";
|
||||
context.lineWidth = 2;
|
||||
context.beginPath();
|
||||
context.moveTo(0, -75);
|
||||
context.lineTo(0, 75);
|
||||
context.stroke();
|
||||
colors.forEach((color, index) => {
|
||||
context.fillStyle = color;
|
||||
context.beginPath();
|
||||
context.arc(0, -60 + index * 30, 9, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (["style_05", "style_06", "style_07", "style_11"].includes(element.style_id ?? "")) {
|
||||
if (element.style_id === "style_11") {
|
||||
context.strokeStyle = "#ffffff";
|
||||
context.lineWidth = 3;
|
||||
context.strokeRect(-78, -18, 156, 36);
|
||||
}
|
||||
colors.forEach((color, index) => {
|
||||
const left = -72 + index * 29;
|
||||
context.fillStyle = color;
|
||||
context.fillRect(left, -10, 28, 20);
|
||||
if (element.style_id === "style_07") {
|
||||
context.fillStyle = "#ffffff";
|
||||
context.beginPath();
|
||||
context.arc(left + 14, -16, 4, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
}
|
||||
});
|
||||
if (element.style_id === "style_05") {
|
||||
context.fillStyle = "#111111";
|
||||
context.fillRect(-78, -18, 20, 36);
|
||||
context.fillStyle = "#ffffff";
|
||||
context.font = "700 7px Arial, sans-serif";
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "middle";
|
||||
context.fillText("C", -68, 0);
|
||||
}
|
||||
if (element.style_id === "style_06") {
|
||||
context.fillStyle = "#ffffff";
|
||||
context.beginPath();
|
||||
context.moveTo(-4, -16);
|
||||
context.lineTo(4, -16);
|
||||
context.lineTo(0, -10);
|
||||
context.closePath();
|
||||
context.fill();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (element.style_id === "style_08") {
|
||||
colors.forEach((color, index) => {
|
||||
const left = -73 + index * 29.2;
|
||||
@@ -159,6 +229,61 @@ export function drawColorCard(context: CanvasRenderingContext2D, element: Canvas
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (element.style_id === "style_09" || element.style_id === "style_10") {
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(-58, -75, 116, 150);
|
||||
colors.forEach((color, index) => {
|
||||
context.fillStyle = color;
|
||||
context.fillRect(-50, -64 + index * 23, 100, 22);
|
||||
});
|
||||
context.fillStyle = "#111111";
|
||||
context.font = "700 8px Arial, sans-serif";
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "middle";
|
||||
context.fillText(element.style_id === "style_09" ? "COLOR PALETTE" : "FIVE COLORS", 0, 63);
|
||||
return;
|
||||
}
|
||||
if (element.style_id === "style_12") {
|
||||
const positions = [[-52, -18], [0, -18], [52, -18], [-26, 18], [26, 18]] as const;
|
||||
colors.forEach((color, index) => {
|
||||
const position = positions[index]!;
|
||||
context.fillStyle = color;
|
||||
context.fillRect(position[0] - 24, position[1] - 14, 48, 28);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (element.style_id === "style_13") {
|
||||
colors.forEach((color, index) => {
|
||||
context.fillStyle = color;
|
||||
context.beginPath();
|
||||
context.arc(0, -60 + index * 30, 11, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (element.style_id === "style_14") {
|
||||
colors.forEach((color, index) => {
|
||||
context.globalAlpha = 0.9;
|
||||
context.fillStyle = color;
|
||||
context.beginPath();
|
||||
context.arc(-40 + index * 20, 0, 24, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
});
|
||||
context.globalAlpha = 1;
|
||||
return;
|
||||
}
|
||||
if (element.style_id === "style_15") {
|
||||
colors.forEach((color, index) => {
|
||||
context.fillStyle = color;
|
||||
context.strokeStyle = "#ffffff";
|
||||
context.lineWidth = 3;
|
||||
context.beginPath();
|
||||
context.arc(-58 + index * 29, 0, 12, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
context.stroke();
|
||||
});
|
||||
return;
|
||||
}
|
||||
context.fillStyle = "#ffffff";
|
||||
context.beginPath();
|
||||
context.moveTo(-78, -9);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
+24
-66
@@ -2,6 +2,7 @@ import type { CanvasState } from "@dada/shared-contracts";
|
||||
import { P0A_COMPLEX_RELEASE_VERSION, P0A_REQUIRED_FONT_PANEL_IDS, P0A_TEXT_TEMPLATE_IDS } from "@dada/template-registry";
|
||||
|
||||
import type { CanvasElementIdentity } from "./editor-elements.js";
|
||||
import complexAssetCatalog from "./generated/complex-assets.json";
|
||||
|
||||
type CanvasElement = CanvasState["elements"][number];
|
||||
|
||||
@@ -17,6 +18,7 @@ export interface TextTemplateDefinition {
|
||||
defaultText: string;
|
||||
displayName: string;
|
||||
fontUrl?: string;
|
||||
previewUrl?: string;
|
||||
resourceClass: "parameter_only" | "zip_template";
|
||||
resourceVersion: string;
|
||||
templateId: string;
|
||||
@@ -56,82 +58,38 @@ const defaults = {
|
||||
text_align: "center",
|
||||
} as const;
|
||||
|
||||
type CatalogSeed = [id: string, category: TextTemplateCategory, displayName: string, defaultText: string, defaultFontId: string, available?: boolean, resourceClass?: "parameter_only"];
|
||||
|
||||
const seeds: readonly CatalogSeed[] = [
|
||||
["FLOWER001", "flower", "春日计划", "春日计划", "FONT011", true],
|
||||
["FLOWER002", "flower", "笑不活了", "笑不活了", "FLOWER002_FONT"],
|
||||
["FLOWER003", "flower", "人生照片", "人生照片", "FONT008"],
|
||||
["FLOWER004", "flower", "我的日常生活", "我的日常生活", "FLOWER004_FONT"],
|
||||
["FLOWER005", "flower", "碎片生活", "碎片生活", "FONT008"],
|
||||
["FLOWER006", "flower", "闪光瞬间", "闪光瞬间", "FLOWER006_FONT"],
|
||||
["FLOWER007", "flower", "好柿花生", "好柿花生", "FONT046", false, "parameter_only"],
|
||||
["FLOWER008", "flower", "Vlog.", "Vlog.", "FONT005"],
|
||||
["H001", "title", "电影生活记录", "电影生活记录", "H001_FONT"],
|
||||
["H002", "title", "30°C", "30°C", "H002_FONT"],
|
||||
["H003", "title", "生活分享家", "生活分享家", "FONT039", true],
|
||||
["H004", "title", "快乐充值成功", "快乐充值成功", "FONT046"],
|
||||
["H005", "title", "日常的镜头", "日常的镜头", "H005_FONT"],
|
||||
["H006", "title", "慢生活指南", "慢生活指南", "FONT052"],
|
||||
["H007", "title", "做个有闲人", "做个有闲人", "H007_FONT"],
|
||||
["H008", "title", "海滩日记", "海滩日记", "H008_FONT"],
|
||||
["TAG001", "tag", "自定义标签", "自定义标签", "FONT027"],
|
||||
["TAG002", "tag", "自定义标签", "自定义标签", "FONT043"],
|
||||
["TAG003", "tag", "打卡x1", "打卡x1", "FONT043"],
|
||||
["TAG004", "tag", "自定义标签", "自定义标签", "TAG004_FONT"],
|
||||
["TAG005", "tag", "自定义标签", "自定义标签", "FONT008"],
|
||||
["TAG006", "tag", "City Walk", "City Walk", "TAG006_FONT"],
|
||||
["TAG007", "tag", "打卡x1", "打卡x1", "FONT043"],
|
||||
["TAG051", "tag", "自定义标签", "自定义标签", "FONT022"],
|
||||
["SIMPLE001", "simple", "碎片回忆录", "碎片回忆录", "SIMPLE001_FONT"],
|
||||
["SIMPLE002", "simple", "秋天的信笺", "秋天的信笺", "SIMPLE002_FONT"],
|
||||
["SIMPLE003", "simple", "返航时海鸟追着船盘旋", "返航时海鸟追着船盘旋", "SIMPLE003_FONT"],
|
||||
["SIMPLE004", "simple", "下段旅程,幸福丰盛。", "下段旅程,幸福丰盛。", "SIMPLE002_FONT"],
|
||||
["SIMPLE005", "simple", "见信好。", "见信好。", "SIMPLE005_FONT"],
|
||||
["SIMPLE006", "simple", "万物回春", "万物回春", "SIMPLE001_FONT"],
|
||||
["SIMPLE007", "simple", "周而复始。", "周而复始。", "SIMPLE007_FONT"],
|
||||
["SIMPLE008", "simple", "周五愉快", "周五愉快", "SIMPLE008_FONT"],
|
||||
];
|
||||
|
||||
const seedById = new Map(seeds.map((seed) => [seed[0], seed]));
|
||||
const textCatalogById = new Map(complexAssetCatalog.text_templates.map((item) => [item.template_id, item]));
|
||||
|
||||
export const P0A_TEXT_TEMPLATES: readonly TextTemplateDefinition[] = P0A_TEXT_TEMPLATE_IDS.map((templateId, catalogOrder) => {
|
||||
const seed = seedById.get(templateId);
|
||||
if (!seed) throw new Error(`missing text template definition ${templateId}`);
|
||||
const item = textCatalogById.get(templateId);
|
||||
if (!item) throw new Error(`missing text template definition ${templateId}`);
|
||||
return {
|
||||
available: seed[5] === true,
|
||||
available: item.available,
|
||||
catalogOrder,
|
||||
category: seed[1],
|
||||
defaultFontId: seed[4],
|
||||
defaultFontSize: 48,
|
||||
defaultText: seed[3],
|
||||
displayName: seed[2],
|
||||
...(seed[5] === true ? { fontUrl: `/api/v1/assets/public/${resourceVersion}/${seed[4]}` } : {}),
|
||||
resourceClass: seed[6] ?? "zip_template",
|
||||
category: item.category as TextTemplateCategory,
|
||||
defaultFontId: item.default_font_id,
|
||||
defaultFontSize: item.default_font_size,
|
||||
defaultText: item.default_text,
|
||||
displayName: item.display_name,
|
||||
fontUrl: `/api/v1/assets/public/${resourceVersion}/${item.default_font_id}`,
|
||||
...(item.preview_asset_id ? { previewUrl: `/api/v1/assets/public/${resourceVersion}/${item.preview_asset_id}` } : {}),
|
||||
resourceClass: item.resource_class as "parameter_only" | "zip_template",
|
||||
resourceVersion,
|
||||
templateId,
|
||||
};
|
||||
});
|
||||
|
||||
const fontOptionDefinitions: Readonly<Record<typeof P0A_REQUIRED_FONT_PANEL_IDS[number], string>> = {
|
||||
FONT005: "Rammetto",
|
||||
FONT008: "正圆体",
|
||||
FONT011: "默陌手写",
|
||||
FONT021: "喜月体",
|
||||
FONT022: "素白体",
|
||||
FONT027: "锐正圆",
|
||||
FONT039: "字由油漆",
|
||||
FONT043: "喜脉体",
|
||||
FONT046: "可口可乐",
|
||||
FONT052: "Oraqle Script",
|
||||
FONT081: "Lexend Deca",
|
||||
};
|
||||
const fontCatalogById = new Map(complexAssetCatalog.font_panel_items.map((item) => [item.font_id, item]));
|
||||
|
||||
export const P0A_FONT_OPTIONS: readonly FontOption[] = P0A_REQUIRED_FONT_PANEL_IDS.map((fontId) => ({
|
||||
displayName: fontOptionDefinitions[fontId],
|
||||
fontId,
|
||||
url: `/api/v1/assets/public/${resourceVersion}/${fontId}`,
|
||||
}));
|
||||
export const P0A_FONT_OPTIONS: readonly FontOption[] = P0A_REQUIRED_FONT_PANEL_IDS.map((fontId) => {
|
||||
const item = fontCatalogById.get(fontId);
|
||||
if (!item) throw new Error(`missing font panel definition ${fontId}`);
|
||||
return {
|
||||
displayName: item.display_name,
|
||||
fontId,
|
||||
url: `/api/v1/assets/public/${resourceVersion}/${fontId}`,
|
||||
};
|
||||
});
|
||||
|
||||
export function fontOption(fontId: string) {
|
||||
return P0A_FONT_OPTIONS.find((option) => option.fontId === fontId);
|
||||
|
||||
@@ -33,7 +33,9 @@ export function TextTemplatePanel(props: {
|
||||
const status = props.fontStatuses[template.defaultFontId] ?? "idle";
|
||||
const unavailable = !template.available || status === "unavailable";
|
||||
return <button aria-label={`${template.templateId} ${template.displayName}${unavailable ? " 素材暂不可用" : ""}`} disabled={!props.canAdd || unavailable || status === "loading"} key={template.templateId} onClick={() => props.onAdd(template)} type="button">
|
||||
<span className={`editor-template-mark ${template.category}`}>{template.displayName.slice(0, 2)}</span>
|
||||
{template.previewUrl
|
||||
? <img alt="" className="editor-template-preview" decoding="async" loading="lazy" src={template.previewUrl} />
|
||||
: <span className={`editor-template-mark ${template.category}`}>{template.displayName.slice(0, 2)}</span>}
|
||||
<strong>{template.templateId}</strong>
|
||||
<span>{template.displayName}</span>
|
||||
{unavailable ? <small>素材暂不可用</small> : status === "loading" ? <small>正在加载字体</small> : null}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,9 +20,11 @@
|
||||
"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",
|
||||
"assets:browser-catalog": "pnpm build:workspace-packages && node scripts/generate-complex-browser-catalog.mjs",
|
||||
"generate:openapi": "node scripts/generate-openapi.mjs",
|
||||
"check:openapi": "node scripts/check-openapi.mjs",
|
||||
"validate:tdd-trace": "node scripts/validate-tdd-trace.mjs",
|
||||
|
||||
@@ -4,8 +4,8 @@ export interface ColorCardDefinition {
|
||||
cardId: typeof P0A_COLOR_CARD_IDS[number];
|
||||
displayName: string;
|
||||
mappingStatus: "confirmed_native_mapping" | "stable_web_style_native_mapping_provisional";
|
||||
rendererName: "horizontal_line" | "ticket_strip" | "vertical_stack" | "vertical_ticket";
|
||||
styleId: "style_01" | "style_02" | "style_08" | "style_16";
|
||||
rendererName: string;
|
||||
styleId: string;
|
||||
}
|
||||
|
||||
export type FiveColorPalette = readonly [string, string, string, string, string];
|
||||
@@ -17,7 +17,19 @@ export interface ColorCardRenderPlan extends ColorCardDefinition {
|
||||
export const P0A_COLOR_CARD_DEFINITIONS: readonly ColorCardDefinition[] = [
|
||||
{ cardId: "COLOR001", displayName: "纵向票据", mappingStatus: "confirmed_native_mapping", rendererName: "vertical_ticket", styleId: "style_01" },
|
||||
{ cardId: "COLOR002", displayName: "纵向色阶", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "vertical_stack", styleId: "style_02" },
|
||||
{ cardId: "COLOR003", displayName: "纵向色条", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "vertical_strip", styleId: "style_03" },
|
||||
{ cardId: "COLOR004", displayName: "纵向标线", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "vertical_line", styleId: "style_04" },
|
||||
{ cardId: "COLOR005", displayName: "横向标签", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "horizontal_label_strip", styleId: "style_05" },
|
||||
{ cardId: "COLOR006", displayName: "指示色条", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "indicator_strip", styleId: "style_06" },
|
||||
{ cardId: "COLOR007", displayName: "图钉色条", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "pinned_strip", styleId: "style_07" },
|
||||
{ cardId: "COLOR008", displayName: "横向标尺", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "horizontal_line", styleId: "style_08" },
|
||||
{ cardId: "COLOR009", displayName: "色彩海报", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "color_poster", styleId: "style_09" },
|
||||
{ cardId: "COLOR010", displayName: "标题海报", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "caption_poster", styleId: "style_10" },
|
||||
{ cardId: "COLOR011", displayName: "边框色条", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "framed_strip", styleId: "style_11" },
|
||||
{ cardId: "COLOR012", displayName: "OTTO 色块", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "otto_blocks", styleId: "style_12" },
|
||||
{ cardId: "COLOR013", displayName: "纵向圆点", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "vertical_dots", styleId: "style_13" },
|
||||
{ cardId: "COLOR014", displayName: "三色圆环", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "three_circles", styleId: "style_14" },
|
||||
{ cardId: "COLOR015", displayName: "描边圆点", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "outlined_dots", styleId: "style_15" },
|
||||
{ cardId: "COLOR016", displayName: "横向票条", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "ticket_strip", styleId: "style_16" },
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -83,7 +83,7 @@ export const ErrorDetailsSchema = Type.Object(
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
{ maxItems: 2 },
|
||||
{ maxItems: 8 },
|
||||
),
|
||||
),
|
||||
capacity_status: Type.Optional(
|
||||
|
||||
@@ -3,25 +3,20 @@ import type { StaticStickerCatalog, StaticStickerCatalogItem } from "@dada/stati
|
||||
export const P0A_COMPLEX_RELEASE_VERSION = "p0a-complex-v1";
|
||||
export const P0A_STATIC_STICKER_RELEASE_VERSION = "p0a-static-v1";
|
||||
|
||||
export const P0A_TEXT_TEMPLATE_IDS = [
|
||||
"FLOWER001", "FLOWER002", "FLOWER003", "FLOWER004", "FLOWER005", "FLOWER006", "FLOWER007", "FLOWER008",
|
||||
"H001", "H002", "H003", "H004", "H005", "H006", "H007", "H008",
|
||||
"TAG001", "TAG002", "TAG003", "TAG004", "TAG005", "TAG006", "TAG007", "TAG051",
|
||||
"SIMPLE001", "SIMPLE002", "SIMPLE003", "SIMPLE004", "SIMPLE005", "SIMPLE006", "SIMPLE007", "SIMPLE008",
|
||||
] as const;
|
||||
function numberedIds(prefix: string, count: number) {
|
||||
return Object.freeze(Array.from({ length: count }, (_, index) => `${prefix}${String(index + 1).padStart(3, "0")}`));
|
||||
}
|
||||
|
||||
// Derived from exact package-hash matches between the 32 frozen templates and the 86-item font panel.
|
||||
export const P0A_REQUIRED_FONT_PANEL_IDS = [
|
||||
"FONT005", "FONT008", "FONT011", "FONT021", "FONT022", "FONT027",
|
||||
"FONT039", "FONT043", "FONT046", "FONT052", "FONT081",
|
||||
] as const;
|
||||
export const P0A_TEXT_TEMPLATE_IDS = Object.freeze([
|
||||
...numberedIds("FLOWER", 145),
|
||||
...numberedIds("H", 119),
|
||||
...numberedIds("TAG", 51),
|
||||
...numberedIds("SIMPLE", 17),
|
||||
]);
|
||||
|
||||
export const P0A_COLOR_CARD_IDS = ["COLOR001", "COLOR002", "COLOR008", "COLOR016"] as const;
|
||||
|
||||
export const P0A_DYNAMIC_STICKER_IDS = [
|
||||
"DYN001", "DYN002", "DYN003", "DYN004", "DYN007",
|
||||
"DYN008", "DYN011", "DYN012", "DYN015", "DYN016",
|
||||
] as const;
|
||||
export const P0A_REQUIRED_FONT_PANEL_IDS = numberedIds("FONT", 86);
|
||||
export const P0A_COLOR_CARD_IDS = numberedIds("COLOR", 16);
|
||||
export const P0A_DYNAMIC_STICKER_IDS = numberedIds("DYN", 35);
|
||||
|
||||
export const P0A_DYNAMIC_RUNTIME_FONT_SOURCES = [
|
||||
{ assetId: "15974853bc3294ef68e7e6d58fe74fd7", sourceReference: "fonts/15974853bc3294ef68e7e6d58fe74fd7", templateId: "DYN002" },
|
||||
@@ -76,12 +71,12 @@ export interface P0aPublicManifest {
|
||||
text_templates: PublicComplexAsset[];
|
||||
};
|
||||
counts: {
|
||||
color_cards: 4;
|
||||
dynamic_stickers: 10;
|
||||
font_panel_items: 11;
|
||||
color_cards: 16;
|
||||
dynamic_stickers: 35;
|
||||
font_panel_items: 86;
|
||||
static_parts: 25;
|
||||
static_stickers: 1407;
|
||||
text_templates: 32;
|
||||
text_templates: 332;
|
||||
};
|
||||
release_tier: "alpha_whitelist";
|
||||
release_version: string;
|
||||
@@ -140,15 +135,6 @@ function validateStaticCatalog(catalog: StaticStickerCatalog) {
|
||||
if (Object.keys(catalog.part_counts).length !== 25) throw new Error("static sticker part counts must contain 25 parts");
|
||||
}
|
||||
|
||||
function fontIdsForTemplates(templates: readonly RegisteredComplexAsset[]) {
|
||||
const referenced = new Set<string>();
|
||||
for (const template of templates) {
|
||||
for (const fontId of template.font_panel_references ?? []) referenced.add(fontId);
|
||||
}
|
||||
referenced.add("FONT081");
|
||||
return [...referenced].sort((left, right) => Number(left.slice(4)) - Number(right.slice(4)));
|
||||
}
|
||||
|
||||
export function createP0aPublicManifest(input: {
|
||||
complexManifest: ComplexRegistryManifest;
|
||||
staticCatalog: StaticStickerCatalog;
|
||||
@@ -157,11 +143,7 @@ export function createP0aPublicManifest(input: {
|
||||
validateStaticCatalog(input.staticCatalog);
|
||||
|
||||
const textTemplates = orderedItems(input.complexManifest.items, P0A_TEXT_TEMPLATE_IDS, "text_template");
|
||||
const derivedFontIds = fontIdsForTemplates(textTemplates);
|
||||
if (JSON.stringify(derivedFontIds) !== JSON.stringify(P0A_REQUIRED_FONT_PANEL_IDS)) {
|
||||
throw new Error(`P0-A referenced font panel mismatch: received ${derivedFontIds.join(",")}`);
|
||||
}
|
||||
const fontPanelItems = orderedItems(input.complexManifest.items, derivedFontIds, "font_panel");
|
||||
const fontPanelItems = orderedItems(input.complexManifest.items, P0A_REQUIRED_FONT_PANEL_IDS, "font_panel");
|
||||
const colorCards = orderedItems(input.complexManifest.items, P0A_COLOR_CARD_IDS, "color_card");
|
||||
const dynamicStickers = orderedItems(input.complexManifest.items, P0A_DYNAMIC_STICKER_IDS, "interactive_sticker");
|
||||
|
||||
@@ -174,12 +156,12 @@ export function createP0aPublicManifest(input: {
|
||||
text_templates: textTemplates.map(publicItem),
|
||||
},
|
||||
counts: {
|
||||
color_cards: 4,
|
||||
dynamic_stickers: 10,
|
||||
font_panel_items: 11,
|
||||
color_cards: 16,
|
||||
dynamic_stickers: 35,
|
||||
font_panel_items: 86,
|
||||
static_parts: 25,
|
||||
static_stickers: 1_407,
|
||||
text_templates: 32,
|
||||
text_templates: 332,
|
||||
},
|
||||
release_tier: "alpha_whitelist",
|
||||
release_version: input.complexManifest.release_version,
|
||||
|
||||
@@ -90,17 +90,17 @@ const familyCounts = Object.fromEntries(["text_template", "font_panel", "color_c
|
||||
]));
|
||||
const fullP0Enabled = complex.manifest.items.filter((item) => item.release_tier === "full_p0" && item.release_status === "enabled").length;
|
||||
const publicJson = JSON.stringify(manifest);
|
||||
const hiddenIds = ["FLOWER009", "H009", "TAG008", "SIMPLE009", "COLOR003", "DYN005"];
|
||||
const completionIds = ["FLOWER145", "H119", "TAG051", "SIMPLE017", "FONT086", "COLOR016", "DYN035"];
|
||||
const response = {
|
||||
counts: manifest.counts,
|
||||
full_p0_enabled: fullP0Enabled,
|
||||
hidden_ids_absent: hiddenIds.every((id) => !publicJson.includes(id)),
|
||||
complete_catalog_present: completionIds.every((id) => publicJson.includes(id)),
|
||||
no_absolute_paths: !/[A-Za-z]:[\\/]/.test(publicJson),
|
||||
release_tier: manifest.release_tier,
|
||||
status: "passed",
|
||||
};
|
||||
const registrationValidation = {
|
||||
allowlist: {
|
||||
public_catalog: {
|
||||
color_cards: P0A_COLOR_CARD_IDS,
|
||||
dynamic_stickers: P0A_DYNAMIC_STICKER_IDS,
|
||||
font_panel_items: P0A_REQUIRED_FONT_PANEL_IDS,
|
||||
@@ -112,7 +112,7 @@ const registrationValidation = {
|
||||
source_mutations: complex.report.source_mutations + staticResult.report.source_mutations,
|
||||
static_parts: Object.keys(staticResult.catalog.part_counts).length,
|
||||
static_stickers: staticResult.catalog.count,
|
||||
status: response.hidden_ids_absent && response.no_absolute_paths && fullP0Enabled === 0 ? "passed" : "failed",
|
||||
status: response.complete_catalog_present && response.no_absolute_paths && fullP0Enabled === 0 ? "passed" : "failed",
|
||||
};
|
||||
if (registrationValidation.status !== "passed") throw new Error("P0-A registration validation failed");
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
||||
|
||||
import {
|
||||
P0A_DYNAMIC_STICKER_IDS,
|
||||
P0A_REQUIRED_FONT_PANEL_IDS,
|
||||
P0A_TEXT_TEMPLATE_IDS,
|
||||
} from "../packages/template-registry/dist/index.js";
|
||||
|
||||
function option(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
}
|
||||
|
||||
function readJson(path) {
|
||||
return JSON.parse(readFileSync(path, "utf8"));
|
||||
}
|
||||
|
||||
function oneDirectoryWithPrefix(root, prefix) {
|
||||
const matches = readdirSync(root, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && entry.name.startsWith(`${prefix}_`));
|
||||
if (matches.length !== 1) throw new Error(`complex_catalog_directory_invalid:${prefix}`);
|
||||
return join(root, matches[0].name);
|
||||
}
|
||||
|
||||
function sha256(path) {
|
||||
return createHash("sha256").update(readFileSync(path)).digest("hex").toUpperCase();
|
||||
}
|
||||
|
||||
function textTemplateDirectory(root, templateId) {
|
||||
const family = templateId.startsWith("FLOWER") ? "花字"
|
||||
: templateId.startsWith("SIMPLE") ? "简约"
|
||||
: templateId.startsWith("TAG") ? "标签"
|
||||
: "标题";
|
||||
return join(root, family, "templates", templateId);
|
||||
}
|
||||
|
||||
function normalizedTextCategory(value) {
|
||||
if (value === "花字") return "flower";
|
||||
if (value === "简约") return "simple";
|
||||
if (value === "标签") return "tag";
|
||||
return "title";
|
||||
}
|
||||
|
||||
function normalizedDynamicCategory(value) {
|
||||
if (value === "user") return "identity";
|
||||
if (value === "location" || value === "time") return value;
|
||||
return "other";
|
||||
}
|
||||
|
||||
const replicationRoot = resolve(option("--replication-root") ?? join(homedir(), "Desktop", "sticker_web_replication_assets"));
|
||||
if (!isAbsolute(replicationRoot) || !existsSync(replicationRoot)) throw new Error("replication_asset_root_unavailable");
|
||||
const outputPath = resolve(option("--output") ?? "apps/web/src/generated/complex-assets.json");
|
||||
const fontPackagesRoot = join(replicationRoot, "sticker_text", "字体", "面板全量采集", "font_panel_full_20260722", "resources", "font_packages");
|
||||
const textRoot = join(replicationRoot, "sticker_text", "模板", "单模板归档");
|
||||
const dynamicRoot = join(replicationRoot, "sticker_interactive", "单模板归档", "templates");
|
||||
|
||||
const fontHashes = new Map();
|
||||
const fontPanelItems = P0A_REQUIRED_FONT_PANEL_IDS.map((fontId, displayOrder) => {
|
||||
const metadata = readJson(join(oneDirectoryWithPrefix(fontPackagesRoot, fontId), "metadata.json"));
|
||||
if (typeof metadata.local_sha256 !== "string") throw new Error(`complex_catalog_font_hash_missing:${fontId}`);
|
||||
fontHashes.set(metadata.local_sha256.toUpperCase(), fontId);
|
||||
return {
|
||||
display_name: String(metadata.display_name ?? fontId),
|
||||
display_order: displayOrder,
|
||||
font_id: fontId,
|
||||
};
|
||||
});
|
||||
|
||||
const textTemplates = P0A_TEXT_TEMPLATE_IDS.map((templateId, catalogOrder) => {
|
||||
const directory = textTemplateDirectory(textRoot, templateId);
|
||||
const metadata = readJson(join(directory, "metadata.json"));
|
||||
const fontReferences = Array.isArray(metadata.files?.fonts) ? metadata.files.fonts : [];
|
||||
const matchedFontIds = [...new Set(fontReferences
|
||||
.map((reference) => fontHashes.get(sha256(join(directory, ...reference.split("/")))))
|
||||
.filter(Boolean))];
|
||||
const previewReference = typeof metadata.files?.preview === "string" ? metadata.files.preview : undefined;
|
||||
const hasPreview = previewReference ? existsSync(join(directory, ...previewReference.split("/"))) : false;
|
||||
return {
|
||||
available: true,
|
||||
catalog_order: catalogOrder,
|
||||
category: normalizedTextCategory(metadata.category),
|
||||
default_font_id: matchedFontIds[0] ?? "FONT081",
|
||||
default_font_size: 48,
|
||||
default_text: String(metadata.default_text ?? metadata.display_name ?? templateId),
|
||||
display_name: String(metadata.display_name || metadata.default_text || templateId),
|
||||
font_match_status: matchedFontIds.length > 0 ? "exact_panel_hash" : "catalog_fallback",
|
||||
...(hasPreview ? { preview_asset_id: `TEXT-PREVIEW-${templateId}` } : {}),
|
||||
resource_class: metadata.resource_class === "parameter_only" ? "parameter_only" : "zip_template",
|
||||
template_id: templateId,
|
||||
};
|
||||
});
|
||||
|
||||
const dynamicStickers = P0A_DYNAMIC_STICKER_IDS.map((templateId, catalogOrder) => {
|
||||
const metadata = readJson(join(dynamicRoot, templateId, "metadata.json"));
|
||||
const requiredFields = Array.isArray(metadata.dynamic_keys) ? metadata.dynamic_keys.map(String) : [];
|
||||
const fontIds = Array.isArray(metadata.files?.fonts)
|
||||
? metadata.files.fonts.map((reference) => basename(reference))
|
||||
: [];
|
||||
return {
|
||||
catalog_order: catalogOrder,
|
||||
category: normalizedDynamicCategory(metadata.category),
|
||||
display_name: String(metadata.display_name ?? templateId),
|
||||
font_ids: fontIds.length > 0 ? fontIds : ["FONT081"],
|
||||
required_fields: requiredFields,
|
||||
requires_location_consent: requiredFields.includes("latitude") || requiredFields.includes("longitude"),
|
||||
source_candidate_id: String(metadata.source_candidate_id ?? metadata.display_name ?? templateId),
|
||||
template_id: templateId,
|
||||
};
|
||||
});
|
||||
|
||||
const catalog = {
|
||||
dynamic_stickers: dynamicStickers,
|
||||
font_panel_items: fontPanelItems,
|
||||
schema_version: "DadaComplexBrowserCatalog/v1",
|
||||
text_templates: textTemplates,
|
||||
};
|
||||
|
||||
const serialized = `${JSON.stringify(catalog, null, 2)}\n`;
|
||||
if (/[A-Z]:[\\/]/i.test(serialized)) throw new Error("complex_catalog_absolute_path_detected");
|
||||
mkdirSync(dirname(outputPath), { recursive: true });
|
||||
writeFileSync(outputPath, serialized);
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
dynamic_stickers: dynamicStickers.length,
|
||||
font_panel_items: fontPanelItems.length,
|
||||
output: outputPath,
|
||||
text_previews: textTemplates.filter((item) => item.preview_asset_id).length,
|
||||
text_templates: textTemplates.length,
|
||||
})}\n`);
|
||||
@@ -64,6 +64,7 @@ function derivedCounts(entries) {
|
||||
dynamic_images: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^DYN\d{3}-/.test(entry.assetId)).length,
|
||||
font_panel_items: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^FONT\d{3}$/.test(entry.assetId)).length,
|
||||
static_stickers: entries.filter((entry) => entry.resourceVersion === "p0a-static-v1" && /^STK\d{3,4}$/.test(entry.assetId)).length,
|
||||
text_previews: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^TEXT-PREVIEW-/.test(entry.assetId)).length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -196,6 +197,12 @@ function entryFor(sourcePath, assetId, resourceVersion, relativePath, mimeType)
|
||||
};
|
||||
}
|
||||
|
||||
function safeRuntimeComponent(value) {
|
||||
const normalized = value.normalize("NFKD").replaceAll(/[^A-Za-z0-9_-]+/g, "-").replaceAll(/^-+|-+$/g, "");
|
||||
if (normalized === value) return normalized;
|
||||
return `${normalized || "asset"}-${sha256(value).slice(0, 8).toLowerCase()}`;
|
||||
}
|
||||
|
||||
function dynamicMetadata(templateRoot, descriptor, field) {
|
||||
const templateDirectory = join(templateRoot, descriptor.templateId);
|
||||
const metadata = JSON.parse(readFileSync(join(templateDirectory, "metadata.json"), "utf8"));
|
||||
@@ -205,6 +212,14 @@ function dynamicMetadata(templateRoot, descriptor, field) {
|
||||
return templateDirectory;
|
||||
}
|
||||
|
||||
function textTemplateDirectory(root, templateId) {
|
||||
const family = templateId.startsWith("FLOWER") ? "花字"
|
||||
: templateId.startsWith("SIMPLE") ? "简约"
|
||||
: templateId.startsWith("TAG") ? "标签"
|
||||
: "标题";
|
||||
return join(root, family, "templates", templateId);
|
||||
}
|
||||
|
||||
export async function buildP0aRuntimeAssetPlan({ replicationRoot }) {
|
||||
const [{ compileStaticStickerCatalog }, registry] = await Promise.all([
|
||||
import("../../packages/asset-compiler/dist/index.js"),
|
||||
@@ -230,6 +245,19 @@ export async function buildP0aRuntimeAssetPlan({ replicationRoot }) {
|
||||
if (entry.sha256 !== item.sha256.toLowerCase()) throw new Error(`static_sticker_hash_invalid:${item.stable_id}`);
|
||||
return { entry, sourcePath };
|
||||
});
|
||||
const resourceKeys = new Map(resources.map((resource) => [`${resource.entry.resourceVersion}\u0000${resource.entry.assetId}`, resource]));
|
||||
const addResource = (resource) => {
|
||||
const key = `${resource.entry.resourceVersion}\u0000${resource.entry.assetId}`;
|
||||
const existing = resourceKeys.get(key);
|
||||
if (existing) {
|
||||
if (existing.entry.sha256 !== resource.entry.sha256 || existing.entry.mimeType !== resource.entry.mimeType) {
|
||||
throw new Error(`runtime_asset_duplicate_conflict:${resource.entry.assetId}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
resourceKeys.set(key, resource);
|
||||
resources.push(resource);
|
||||
};
|
||||
|
||||
const fontPackagesRoot = join(
|
||||
replicationRoot,
|
||||
@@ -244,7 +272,7 @@ export async function buildP0aRuntimeAssetPlan({ replicationRoot }) {
|
||||
const packageDirectory = oneDirectoryWithPrefix(fontPackagesRoot, assetId);
|
||||
const sourcePath = oneSupportedFont(join(packageDirectory, "font_files"));
|
||||
const extension = extname(sourcePath).toLowerCase();
|
||||
resources.push({
|
||||
addResource({
|
||||
entry: entryFor(
|
||||
sourcePath,
|
||||
assetId,
|
||||
@@ -257,33 +285,59 @@ export async function buildP0aRuntimeAssetPlan({ replicationRoot }) {
|
||||
}
|
||||
|
||||
const templateRoot = join(replicationRoot, "sticker_interactive", "单模板归档", "templates");
|
||||
for (const descriptor of registry.P0A_DYNAMIC_RUNTIME_FONT_SOURCES) {
|
||||
const templateDirectory = dynamicMetadata(templateRoot, descriptor, "fonts");
|
||||
const sourcePath = oneSupportedFont(join(templateDirectory, ...descriptor.sourceReference.split("/")));
|
||||
const extension = extname(sourcePath).toLowerCase();
|
||||
resources.push({
|
||||
entry: entryFor(
|
||||
for (const templateId of registry.P0A_DYNAMIC_STICKER_IDS) {
|
||||
const templateDirectory = join(templateRoot, templateId);
|
||||
const metadata = JSON.parse(readFileSync(join(templateDirectory, "metadata.json"), "utf8"));
|
||||
for (const sourceReference of metadata.files?.fonts ?? []) {
|
||||
const sourcePath = oneSupportedFont(join(templateDirectory, ...sourceReference.split("/")));
|
||||
const extension = extname(sourcePath).toLowerCase();
|
||||
const assetId = basename(sourceReference);
|
||||
addResource({
|
||||
entry: entryFor(
|
||||
sourcePath,
|
||||
assetId,
|
||||
registry.P0A_COMPLEX_RELEASE_VERSION,
|
||||
`${registry.P0A_COMPLEX_RELEASE_VERSION}/${assetId}${extension}`,
|
||||
fontMimeTypes.get(extension),
|
||||
),
|
||||
sourcePath,
|
||||
descriptor.assetId,
|
||||
registry.P0A_COMPLEX_RELEASE_VERSION,
|
||||
`${registry.P0A_COMPLEX_RELEASE_VERSION}/${descriptor.assetId}${extension}`,
|
||||
fontMimeTypes.get(extension),
|
||||
),
|
||||
sourcePath,
|
||||
});
|
||||
}
|
||||
for (const descriptor of registry.P0A_DYNAMIC_RUNTIME_IMAGE_SOURCES) {
|
||||
const templateDirectory = dynamicMetadata(templateRoot, descriptor, "images");
|
||||
const sourcePath = join(templateDirectory, ...descriptor.sourceReference.split("/"));
|
||||
if (!existsSync(sourcePath) || extname(sourcePath).toLowerCase() !== ".png") {
|
||||
throw new Error(`runtime_dynamic_image_invalid:${descriptor.assetId}`);
|
||||
});
|
||||
}
|
||||
resources.push({
|
||||
for (const sourceReference of metadata.files?.images ?? []) {
|
||||
const sourcePath = join(templateDirectory, ...sourceReference.split("/"));
|
||||
const extension = extname(sourcePath).toLowerCase();
|
||||
const assetId = `${templateId}-${safeRuntimeComponent(basename(sourceReference, extension))}`;
|
||||
if (!existsSync(sourcePath) || extension !== ".png") throw new Error(`runtime_dynamic_image_invalid:${assetId}`);
|
||||
addResource({
|
||||
entry: entryFor(
|
||||
sourcePath,
|
||||
assetId,
|
||||
registry.P0A_COMPLEX_RELEASE_VERSION,
|
||||
`${registry.P0A_COMPLEX_RELEASE_VERSION}/${assetId}.png`,
|
||||
"image/png",
|
||||
),
|
||||
sourcePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const textRoot = join(replicationRoot, "sticker_text", "模板", "单模板归档");
|
||||
for (const templateId of registry.P0A_TEXT_TEMPLATE_IDS) {
|
||||
const templateDirectory = textTemplateDirectory(textRoot, templateId);
|
||||
const metadata = JSON.parse(readFileSync(join(templateDirectory, "metadata.json"), "utf8"));
|
||||
const previewReference = metadata.files?.preview;
|
||||
if (typeof previewReference !== "string" || previewReference.length === 0) continue;
|
||||
const sourcePath = join(templateDirectory, ...previewReference.split("/"));
|
||||
if (!existsSync(sourcePath) || extname(sourcePath).toLowerCase() !== ".png") {
|
||||
throw new Error(`runtime_text_preview_invalid:${templateId}`);
|
||||
}
|
||||
const assetId = `TEXT-PREVIEW-${templateId}`;
|
||||
addResource({
|
||||
entry: entryFor(
|
||||
sourcePath,
|
||||
descriptor.assetId,
|
||||
assetId,
|
||||
registry.P0A_COMPLEX_RELEASE_VERSION,
|
||||
`${registry.P0A_COMPLEX_RELEASE_VERSION}/${descriptor.assetId}.png`,
|
||||
`${registry.P0A_COMPLEX_RELEASE_VERSION}/${assetId}.png`,
|
||||
"image/png",
|
||||
),
|
||||
sourcePath,
|
||||
@@ -292,12 +346,7 @@ export async function buildP0aRuntimeAssetPlan({ replicationRoot }) {
|
||||
|
||||
const manifestPath = join(replicationRoot, "sticker_web_handoff", "sticker_web_catalog_manifest.json");
|
||||
const manifest = createRuntimeAssetManifest({
|
||||
counts: {
|
||||
dynamic_fonts: registry.P0A_DYNAMIC_RUNTIME_FONT_SOURCES.length,
|
||||
dynamic_images: registry.P0A_DYNAMIC_RUNTIME_IMAGE_SOURCES.length,
|
||||
font_panel_items: registry.P0A_REQUIRED_FONT_PANEL_IDS.length,
|
||||
static_stickers: staticResult.catalog.count,
|
||||
},
|
||||
counts: derivedCounts(resources.map((resource) => resource.entry)),
|
||||
entries: resources.map((resource) => resource.entry),
|
||||
sourceManifestSha256: fileSha256(manifestPath),
|
||||
});
|
||||
|
||||
@@ -112,7 +112,7 @@ export function validateWp5FinalManifest(path) {
|
||||
const raw = readFileSync(path, "utf8");
|
||||
if (raw.includes(WP4_07_RED_RESOURCE_VERSION) || raw.includes("fixture-v1")) throw new Error("WP4_07_PLACEHOLDER_ASSET_REJECTED");
|
||||
const manifest = JSON.parse(raw);
|
||||
const expectedCounts = { color_cards: 4, dynamic_stickers: 10, font_panel_items: 11, static_parts: 25, static_stickers: 1_407, text_templates: 32 };
|
||||
const expectedCounts = { color_cards: 16, dynamic_stickers: 35, font_panel_items: 86, static_parts: 25, static_stickers: 1_407, text_templates: 332 };
|
||||
for (const [key, expected] of Object.entries(expectedCounts)) {
|
||||
if (manifest.counts?.[key] !== expected) throw new Error(`WP4_07_FINAL_MANIFEST_COUNT_MISMATCH:${key}`);
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ function findFiles(directory, name) {
|
||||
|
||||
if (phase === "green") {
|
||||
const traces = findFiles(outputDirectory, "trace.zip");
|
||||
const whiteTrace = traces.find((path) => path.toLowerCase().includes("wp5-white-001") || path.toLowerCase().includes("p0-a-public-allowlist"));
|
||||
const whiteTrace = traces.find((path) => path.toLowerCase().includes("wp5-white-001") || path.toLowerCase().includes("complete-complex-asset-catalog"));
|
||||
const colorTrace = traces.find((path) => path.toLowerCase().includes("wp5-col-001") || path.toLowerCase().includes("shared-five-color"));
|
||||
if (whiteTrace) copyFileSync(whiteTrace, resolve(whiteDirectory, "trace.zip"));
|
||||
if (colorTrace) copyFileSync(colorTrace, resolve(colorDirectory, "trace.zip"));
|
||||
|
||||
@@ -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" }));
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -32,7 +32,7 @@ function fixture() {
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
};
|
||||
const manifest = {
|
||||
counts: { dynamic_fonts: 0, dynamic_images: 0, font_panel_items: 0, static_stickers: 1 },
|
||||
counts: { dynamic_fonts: 0, dynamic_images: 0, font_panel_items: 0, static_stickers: 1, text_previews: 0 },
|
||||
entries: [entry],
|
||||
root_ref: "p0a_runtime_assets",
|
||||
schema_version: "DadaRuntimeAssets/v1",
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -76,24 +76,28 @@ async function routeEditor(page: Page, projectId: string, backend: Backend) {
|
||||
backend.recent = [item, ...backend.recent.filter((entry) => entry.asset_id !== item.asset_id)];
|
||||
await route.fulfill({ body: JSON.stringify({ status: "recorded" }), contentType: "application/json", status: 200 });
|
||||
});
|
||||
await page.route("**/api/v1/assets/public/p0a-complex-v1/*", (route) => route.fulfill({ body: fontBytes, contentType: "font/ttf", status: 200 }));
|
||||
const previewPng = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64");
|
||||
await page.route("**/api/v1/assets/public/p0a-complex-v1/*", (route) => {
|
||||
const preview = new URL(route.request().url()).pathname.includes("TEXT-PREVIEW-");
|
||||
return route.fulfill(preview ? { body: previewPng, contentType: "image/png", status: 200 } : { body: fontBytes, contentType: "font/ttf", status: 200 });
|
||||
});
|
||||
}
|
||||
|
||||
test("TDD-WP4-TXT-003 exposes the frozen catalog, display-name search and account recent use", async ({ page }) => {
|
||||
test("TDD-WP4-TXT-003 exposes the complete catalog, display-name search and account recent use", async ({ page }) => {
|
||||
const projectId = uuid(730);
|
||||
const backend: Backend = { canvas: emptyCanvas(), recent: [], saves: 0, version: 2 };
|
||||
await routeEditor(page, projectId, backend);
|
||||
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
||||
await page.getByRole("button", { name: "文字模板", exact: true }).click();
|
||||
await expect(page.locator(".editor-template-grid button")).toHaveCount(32);
|
||||
for (const [label, count] of [["花字", 8], ["标题", 8], ["标签", 8], ["简约", 8]] as const) {
|
||||
await expect(page.locator(".editor-template-grid button")).toHaveCount(332);
|
||||
for (const [label, count] of [["花字", 145], ["标题", 119], ["标签", 51], ["简约", 17]] as const) {
|
||||
await page.getByRole("button", { name: label, exact: true }).click();
|
||||
await expect(page.locator(".editor-template-grid button")).toHaveCount(count);
|
||||
}
|
||||
await page.getByRole("button", { name: "全部", exact: true }).click();
|
||||
const search = page.getByPlaceholder("搜索文字模板显示名称");
|
||||
await search.fill("生活");
|
||||
await expect(page.locator(".editor-template-grid button")).toHaveCount(5);
|
||||
await expect(page.locator(".editor-template-grid button")).toHaveCount(9);
|
||||
await search.fill("FLOWER001");
|
||||
await expect(page.locator(".editor-template-grid button")).toHaveCount(0);
|
||||
await search.fill("");
|
||||
@@ -101,7 +105,7 @@ test("TDD-WP4-TXT-003 exposes the frozen catalog, display-name search and accoun
|
||||
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 expect(flowerTemplate.locator(".editor-template-preview")).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" }]);
|
||||
@@ -110,8 +114,8 @@ test("TDD-WP4-TXT-003 exposes the frozen catalog, display-name search and accoun
|
||||
await expect(page.getByLabel("最近使用文字模板").getByText("FLOWER001", { exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "普通贴纸", exact: true }).click();
|
||||
expect(page.getByPlaceholder("搜索普通贴纸")).toHaveCount(0);
|
||||
writeEvidence("TDD-WP4-TXT-003-catalog-search-recent", "catalog.json", { categories: { flower: 8, simple: 8, tag: 8, title: 8 }, count: 32, first: "FLOWER001", last: "SIMPLE008" });
|
||||
writeEvidence("TDD-WP4-TXT-003-catalog-search-recent", "response.json", { public_count: 32, recent: backend.recent, unavailable_is_disabled: true });
|
||||
writeEvidence("TDD-WP4-TXT-003-catalog-search-recent", "catalog.json", { categories: { flower: 145, simple: 17, tag: 51, title: 119 }, count: 332, first: "FLOWER001", last: "SIMPLE017" });
|
||||
writeEvidence("TDD-WP4-TXT-003-catalog-search-recent", "response.json", { public_count: 332, recent: backend.recent, unavailable_count: 0 });
|
||||
writeEvidence("TDD-WP4-TXT-003-catalog-search-recent", "db-diff.json", { account_user_id: userId, recent: backend.recent, search_did_not_write: true });
|
||||
});
|
||||
|
||||
@@ -228,7 +232,7 @@ test("POSTV1-07 keeps the canvas anchored when the text template panel opens", a
|
||||
if (!before) throw new Error("Canvas geometry is unavailable before opening text templates.");
|
||||
|
||||
await page.getByRole("button", { name: "文字模板", exact: true }).click();
|
||||
await expect(page.locator(".editor-template-grid button")).toHaveCount(32);
|
||||
await expect(page.locator(".editor-template-grid button")).toHaveCount(332);
|
||||
const after = await stage.boundingBox();
|
||||
if (!after) throw new Error("Canvas geometry is unavailable after opening text templates.");
|
||||
const assetsPanelScroll = await page.getByLabel("素材与底图来源").evaluate((panel) => ({
|
||||
|
||||
@@ -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") },
|
||||
@@ -136,7 +137,7 @@ test("TDD-WP4-COL-001 extracts once from raw pixels and refreshes only for a new
|
||||
await routeEditor(page, projectId, backend);
|
||||
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
||||
await page.getByRole("button", { name: "色卡", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: /^添加色卡/ })).toHaveCount(4);
|
||||
await expect(page.getByRole("button", { name: /^添加色卡/ })).toHaveCount(16);
|
||||
await expect(page.getByRole("button", { name: "色卡说明" })).toHaveAttribute("title", "色卡基于原始底图,更换底图时更新,不随裁剪、调色和滤镜变化");
|
||||
const placements = [
|
||||
[{ key: "ArrowLeft", times: 15 }, { key: "ArrowUp", times: 14 }],
|
||||
|
||||
@@ -64,7 +64,10 @@ async function routeEditor(page: Page, backend: Backend) {
|
||||
});
|
||||
await page.route("**/api/v1/assets/recent?asset_kind=text_template", (route) => route.fulfill({ body: JSON.stringify({ items: [] }), contentType: "application/json" }));
|
||||
await page.route("**/api/v1/assets/recent", (route) => route.fulfill({ body: JSON.stringify({ status: "recorded" }), contentType: "application/json" }));
|
||||
await page.route("**/api/v1/assets/public/p0a-complex-v1/*", (route) => route.fulfill({ body: fontBytes, contentType: "font/ttf" }));
|
||||
await page.route("**/api/v1/assets/public/p0a-complex-v1/*", (route) => {
|
||||
const preview = new URL(route.request().url()).pathname.includes("TEXT-PREVIEW-");
|
||||
return route.fulfill(preview ? { body: png, contentType: "image/png" } : { body: fontBytes, contentType: "font/ttf" });
|
||||
});
|
||||
await page.route("**/api/v1/assets/public/p0a-static-v1/*", (route) => route.fulfill({ body: png, contentType: "image/png" }));
|
||||
await page.route(`**/api/v1/private-assets/projects/${projectId}/images/${backgroundId}`, (route) => route.fulfill({ body: rawSvg(), contentType: "image/svg+xml" }));
|
||||
}
|
||||
@@ -91,7 +94,7 @@ test.beforeAll(async () => {
|
||||
|
||||
test.afterAll(async () => vite.close());
|
||||
|
||||
test("TDD-WP5-WHITE-001 exposes only the P0-A public allowlist", async ({ page }) => {
|
||||
test("POSTV1-ASSET-ALL-16 exposes the complete complex asset catalog", async ({ page }) => {
|
||||
const backend: Backend = { canvas: canvas(), saves: 0, version: 1 };
|
||||
await routeEditor(page, backend);
|
||||
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
||||
@@ -99,7 +102,6 @@ test("TDD-WP5-WHITE-001 exposes only the P0-A public allowlist", async ({ page }
|
||||
await page.getByRole("button", { name: "文字模板", exact: true }).click();
|
||||
const textIds = await page.locator(".editor-template-grid button strong").allTextContents();
|
||||
expect(textIds).toEqual(P0A_TEXT_TEMPLATE_IDS);
|
||||
expect(textIds).not.toContain("FLOWER009");
|
||||
await page.getByRole("button", { name: /FLOWER001 春日计划/ }).click();
|
||||
await expect(page.getByText("对象 1 / 50")).toBeVisible();
|
||||
await expect(page.getByLabel("字体覆盖")).toBeVisible();
|
||||
@@ -109,12 +111,12 @@ test("TDD-WP5-WHITE-001 exposes only the P0-A public allowlist", async ({ page }
|
||||
await page.getByRole("button", { name: "色卡", exact: true }).click();
|
||||
const colorIds = await page.locator(".editor-provider-grid button strong").allTextContents();
|
||||
expect(colorIds).toEqual(P0A_COLOR_CARD_IDS);
|
||||
expect(colorIds).not.toContain("COLOR003");
|
||||
|
||||
await page.getByRole("button", { name: "动态贴纸", exact: true }).click();
|
||||
const dynamicIds = await page.locator(".editor-provider-grid button strong").allTextContents();
|
||||
expect([...dynamicIds].sort()).toEqual([...P0A_DYNAMIC_STICKER_IDS].sort());
|
||||
expect(dynamicIds).not.toContain("DYN005");
|
||||
await page.getByRole("button", { name: /添加动态贴纸 DYN035/ }).click();
|
||||
await expect.poll(() => backend.canvas.elements.some((element) => element.template_or_asset_id === "DYN035")).toBe(true);
|
||||
|
||||
await page.getByRole("button", { name: "普通贴纸", exact: true }).click();
|
||||
await expect(page.getByText("共 1,407 张", { exact: true })).toBeVisible();
|
||||
@@ -130,27 +132,22 @@ test("TDD-WP5-WHITE-001 exposes only the P0-A public allowlist", async ({ page }
|
||||
}
|
||||
});
|
||||
|
||||
test("TDD-WP5-COL-001 renders four layouts from one shared five-color snapshot", async ({ page }) => {
|
||||
test("POSTV1-ASSET-ALL-16 renders sixteen layouts from one shared five-color snapshot", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
const backend: Backend = { canvas: canvas(), saves: 0, version: 1 };
|
||||
await routeEditor(page, backend);
|
||||
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
||||
await page.getByRole("button", { name: "色卡", exact: true }).click();
|
||||
const placements = [
|
||||
[["ArrowLeft", 10], ["ArrowUp", 8]],
|
||||
[["ArrowRight", 10], ["ArrowUp", 8]],
|
||||
[["ArrowLeft", 10], ["ArrowDown", 8]],
|
||||
[["ArrowRight", 10], ["ArrowDown", 8]],
|
||||
] as const;
|
||||
for (const [index, id] of P0A_COLOR_CARD_IDS.entries()) {
|
||||
await page.getByRole("button", { name: new RegExp(`添加色卡 ${id}`) }).click();
|
||||
await expect.poll(() => backend.canvas.elements.length).toBe(index + 1);
|
||||
for (const [key, times] of placements[index]!) {
|
||||
for (let press = 0; press < times; press += 1) await page.getByLabel("编辑画布").press(`Shift+${key}`);
|
||||
}
|
||||
await expect(page.getByText(`对象 ${index + 1} / 50`)).toBeVisible();
|
||||
}
|
||||
await expect.poll(() => backend.canvas.elements.length, { timeout: 10_000 }).toBe(16);
|
||||
const palettes = backend.canvas.elements.map((element) => element.colors);
|
||||
expect(palettes.every((palette) => JSON.stringify(palette) === JSON.stringify(palettes[0]))).toBe(true);
|
||||
expect(backend.canvas.elements.map((element) => element.style_id)).toEqual(["style_01", "style_02", "style_08", "style_16"]);
|
||||
expect(backend.canvas.elements.map((element) => element.style_id)).toEqual(
|
||||
Array.from({ length: 16 }, (_, index) => `style_${String(index + 1).padStart(2, "0")}`),
|
||||
);
|
||||
const pixels = await page.getByLabel("编辑画布").evaluate((stage: HTMLCanvasElement) => {
|
||||
const context = stage.getContext("2d");
|
||||
if (!context) throw new Error("canvas context unavailable");
|
||||
@@ -161,7 +158,7 @@ test("TDD-WP5-COL-001 renders four layouts from one shared five-color snapshot",
|
||||
});
|
||||
mergeEvidence(evidencePath("color", "palette.json"), { browser_palettes: palettes, same_palette_snapshot: true });
|
||||
mergeEvidence(evidencePath("color", "pixel-diff.json"), {
|
||||
...pixels, four_renderers_visible: true, significant_pixel_ratio: 0, status: pixels.opaque_pixels > 0 ? "passed" : "failed",
|
||||
...pixels, sixteen_renderers_available: true, significant_pixel_ratio: 0, status: pixels.opaque_pixels > 0 ? "passed" : "failed",
|
||||
});
|
||||
const screenshot = evidencePath("color", "screenshots/color-cards.png");
|
||||
if (screenshot) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -15,12 +15,13 @@ import {
|
||||
test("committed P0-A runtime manifest covers the frozen first-version binary assets", () => {
|
||||
const manifest = readRuntimeAssetManifest("config/runtime-assets-manifest.json");
|
||||
assert.deepEqual(manifest.counts, {
|
||||
dynamic_fonts: 7,
|
||||
dynamic_images: 8,
|
||||
font_panel_items: 11,
|
||||
dynamic_fonts: 18,
|
||||
dynamic_images: 43,
|
||||
font_panel_items: 86,
|
||||
static_stickers: 1407,
|
||||
text_previews: 261,
|
||||
});
|
||||
assert.equal(manifest.entries.length, 1433);
|
||||
assert.equal(manifest.entries.length, 1815);
|
||||
assert.doesNotMatch(serializeRuntimeAssetManifest(manifest), /[A-Za-z]:[\\/]/);
|
||||
});
|
||||
|
||||
@@ -42,7 +43,7 @@ test("runtime asset deployment creates verified hardlinks and a path-free manife
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
};
|
||||
const manifest = createRuntimeAssetManifest({
|
||||
counts: { dynamic_fonts: 0, dynamic_images: 0, font_panel_items: 0, static_stickers: 1 },
|
||||
counts: { dynamic_fonts: 0, dynamic_images: 0, font_panel_items: 0, static_stickers: 1, text_previews: 0 },
|
||||
entries: [entry],
|
||||
});
|
||||
|
||||
@@ -75,7 +76,7 @@ test("runtime asset deployment refuses a mismatched existing target", async (t)
|
||||
sha256: createHash("sha256").update("expected").digest("hex"),
|
||||
};
|
||||
const manifest = createRuntimeAssetManifest({
|
||||
counts: { dynamic_fonts: 0, dynamic_images: 0, font_panel_items: 0, static_stickers: 1 },
|
||||
counts: { dynamic_fonts: 0, dynamic_images: 0, font_panel_items: 0, static_stickers: 1, text_previews: 0 },
|
||||
entries: [entry],
|
||||
});
|
||||
|
||||
|
||||
@@ -114,12 +114,13 @@ test("portable package serves the product and keeps SQLite data across API resta
|
||||
assert.ok(existsSync(join(packageRoot, "web", "index.html")));
|
||||
const runtimeAssetManifest = JSON.parse(await readFile(join(packageRoot, "asset-metadata", "manifest.json"), "utf8"));
|
||||
assert.deepEqual(runtimeAssetManifest.counts, {
|
||||
dynamic_fonts: 7,
|
||||
dynamic_images: 8,
|
||||
font_panel_items: 11,
|
||||
dynamic_fonts: 18,
|
||||
dynamic_images: 43,
|
||||
font_panel_items: 86,
|
||||
static_stickers: 1407,
|
||||
text_previews: 261,
|
||||
});
|
||||
assert.equal(runtimeAssetManifest.entries.length, 1433);
|
||||
assert.equal(runtimeAssetManifest.entries.length, 1815);
|
||||
const packagedWorker = await readFile(join(packageRoot, "server", "worker", "dist", "worker.js"), "utf8");
|
||||
const packagedOneApiAdapter = await readFile(join(packageRoot, "server", "worker", "dist", "oneapi-generation-adapter.js"), "utf8");
|
||||
assert.match(packagedWorker, /GenerationProcessor/);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import complexAssetCatalog from "../../apps/web/src/generated/complex-assets.json";
|
||||
import { P0A_DYNAMIC_STICKERS } from "../../apps/web/src/dynamic-provider.js";
|
||||
import { DYNAMIC_RENDER_MODELS } from "../../apps/web/src/dynamic-render-models.js";
|
||||
import { P0A_COLOR_CARDS } from "../../apps/web/src/palette-provider.js";
|
||||
import { P0A_FONT_OPTIONS, P0A_TEXT_TEMPLATES } from "../../apps/web/src/text-assets.js";
|
||||
import {
|
||||
P0A_COLOR_CARD_IDS,
|
||||
P0A_DYNAMIC_STICKER_IDS,
|
||||
P0A_REQUIRED_FONT_PANEL_IDS,
|
||||
P0A_TEXT_TEMPLATE_IDS,
|
||||
} from "../../packages/template-registry/src/index.js";
|
||||
|
||||
describe("POSTV1-ASSET-ALL-16 complete asset catalog", () => {
|
||||
it("publishes every normalized complex asset instead of the original alpha subset", () => {
|
||||
expect(P0A_TEXT_TEMPLATE_IDS).toHaveLength(332);
|
||||
expect(P0A_TEXT_TEMPLATE_IDS[0]).toBe("FLOWER001");
|
||||
expect(P0A_TEXT_TEMPLATE_IDS.at(-1)).toBe("SIMPLE017");
|
||||
|
||||
expect(P0A_REQUIRED_FONT_PANEL_IDS).toHaveLength(86);
|
||||
expect(P0A_REQUIRED_FONT_PANEL_IDS[0]).toBe("FONT001");
|
||||
expect(P0A_REQUIRED_FONT_PANEL_IDS.at(-1)).toBe("FONT086");
|
||||
|
||||
expect(P0A_COLOR_CARD_IDS).toHaveLength(16);
|
||||
expect(P0A_COLOR_CARD_IDS[0]).toBe("COLOR001");
|
||||
expect(P0A_COLOR_CARD_IDS.at(-1)).toBe("COLOR016");
|
||||
|
||||
expect(P0A_DYNAMIC_STICKER_IDS).toHaveLength(35);
|
||||
expect(P0A_DYNAMIC_STICKER_IDS[0]).toBe("DYN001");
|
||||
expect(P0A_DYNAMIC_STICKER_IDS.at(-1)).toBe("DYN035");
|
||||
});
|
||||
|
||||
it("generates a sanitized browser catalog for every text, font, and dynamic definition", () => {
|
||||
expect(complexAssetCatalog.schema_version).toBe("DadaComplexBrowserCatalog/v1");
|
||||
expect(complexAssetCatalog.text_templates).toHaveLength(332);
|
||||
expect(complexAssetCatalog.font_panel_items).toHaveLength(86);
|
||||
expect(complexAssetCatalog.dynamic_stickers).toHaveLength(35);
|
||||
expect(complexAssetCatalog.text_templates.filter((item) => item.preview_asset_id)).toHaveLength(261);
|
||||
expect(complexAssetCatalog.text_templates.every((item) => item.available)).toBe(true);
|
||||
expect(JSON.stringify(complexAssetCatalog)).not.toMatch(/[A-Z]:[\\/]/i);
|
||||
});
|
||||
|
||||
it("exposes every generated definition to the editor", () => {
|
||||
expect(P0A_TEXT_TEMPLATES).toHaveLength(332);
|
||||
expect(P0A_TEXT_TEMPLATES.every((item) => item.available && item.fontUrl)).toBe(true);
|
||||
expect(P0A_FONT_OPTIONS).toHaveLength(86);
|
||||
expect(P0A_COLOR_CARDS).toHaveLength(16);
|
||||
expect(P0A_DYNAMIC_STICKERS).toHaveLength(35);
|
||||
expect(Object.keys(DYNAMIC_RENDER_MODELS)).toHaveLength(35);
|
||||
});
|
||||
});
|
||||
@@ -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] };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { CanvasState } from "@dada/shared-contracts";
|
||||
|
||||
import {
|
||||
P0A_TEXT_TEMPLATES,
|
||||
P0A_FONT_OPTIONS,
|
||||
TextEditSession,
|
||||
createTextTemplateElement,
|
||||
effectiveFontSize,
|
||||
@@ -13,19 +14,15 @@ import { elementHalfExtents } from "../../apps/web/src/editor-elements.js";
|
||||
const identity = { createdAt: "2026-08-03T03:00:00.000Z", elementId: "00000000-0000-4000-8000-000000000701" };
|
||||
|
||||
describe("TASK-WP4-03 text templates and properties", () => {
|
||||
it("keeps the frozen 32-template allowlist in catalog order and searches display names only", () => {
|
||||
expect(P0A_TEXT_TEMPLATES).toHaveLength(32);
|
||||
expect(P0A_TEXT_TEMPLATES.map((template) => template.templateId)).toEqual([
|
||||
"FLOWER001", "FLOWER002", "FLOWER003", "FLOWER004", "FLOWER005", "FLOWER006", "FLOWER007", "FLOWER008",
|
||||
"H001", "H002", "H003", "H004", "H005", "H006", "H007", "H008",
|
||||
"TAG001", "TAG002", "TAG003", "TAG004", "TAG005", "TAG006", "TAG007", "TAG051",
|
||||
"SIMPLE001", "SIMPLE002", "SIMPLE003", "SIMPLE004", "SIMPLE005", "SIMPLE006", "SIMPLE007", "SIMPLE008",
|
||||
]);
|
||||
it("keeps the complete 332-template catalog order and searches display names only", () => {
|
||||
expect(P0A_TEXT_TEMPLATES).toHaveLength(332);
|
||||
expect(P0A_TEXT_TEMPLATES[0]?.templateId).toBe("FLOWER001");
|
||||
expect(P0A_TEXT_TEMPLATES.at(-1)?.templateId).toBe("SIMPLE017");
|
||||
expect(P0A_TEXT_TEMPLATES.reduce<Record<string, number>>((counts, template) => {
|
||||
counts[template.category] = (counts[template.category] ?? 0) + 1;
|
||||
return counts;
|
||||
}, {})).toEqual({ flower: 8, simple: 8, tag: 8, title: 8 });
|
||||
expect(searchTextTemplates(P0A_TEXT_TEMPLATES, { query: "生活" }).map((item) => item.templateId)).toEqual(["FLOWER004", "FLOWER005", "H001", "H003", "H006"]);
|
||||
}, {})).toEqual({ flower: 145, simple: 17, tag: 51, title: 119 });
|
||||
expect(searchTextTemplates(P0A_TEXT_TEMPLATES, { query: "生活" }).map((item) => item.templateId)).toEqual(expect.arrayContaining(["FLOWER004", "FLOWER005", "H001", "H003", "H006"]));
|
||||
expect(searchTextTemplates(P0A_TEXT_TEMPLATES, { category: "tag", query: "TAG006" })).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -100,11 +97,11 @@ describe("TASK-WP4-03 text templates and properties", () => {
|
||||
expect(bounds.y).toBeGreaterThan(0.16);
|
||||
});
|
||||
|
||||
it("does not add unavailable templates or silently replace their archived font", () => {
|
||||
const unavailable = P0A_TEXT_TEMPLATES.find((template) => !template.available)!;
|
||||
expect(unavailable).toBeDefined();
|
||||
expect(() => createTextTemplateElement(unavailable, identity, 0)).toThrowError("text_template_unavailable");
|
||||
const available = P0A_TEXT_TEMPLATES.find((template) => template.available)!;
|
||||
it("adds every template with a registered catalog font", () => {
|
||||
expect(P0A_TEXT_TEMPLATES.every((template) => template.available && template.fontUrl)).toBe(true);
|
||||
const registeredFonts = new Set(P0A_FONT_OPTIONS.map((font) => font.fontId));
|
||||
expect(P0A_TEXT_TEMPLATES.every((template) => registeredFonts.has(template.defaultFontId))).toBe(true);
|
||||
const available = P0A_TEXT_TEMPLATES[0]!;
|
||||
const element = createTextTemplateElement(available, identity, 0);
|
||||
expect(element.font_override).toBeUndefined();
|
||||
expect((element.style_parameters as Record<string, unknown>).default_font_id).toBe(available.defaultFontId);
|
||||
|
||||
@@ -45,8 +45,10 @@ describe("TASK-WP4-04 deterministic color cards", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the four-item P0-A allowlist and produces a stable five-color MMCQ palette", () => {
|
||||
expect(P0A_COLOR_CARDS.map((item) => item.cardId)).toEqual(["COLOR001", "COLOR002", "COLOR008", "COLOR016"]);
|
||||
it("uses all sixteen color-card layouts and produces a stable five-color MMCQ palette", () => {
|
||||
expect(P0A_COLOR_CARDS).toHaveLength(16);
|
||||
expect(P0A_COLOR_CARDS[0]?.cardId).toBe("COLOR001");
|
||||
expect(P0A_COLOR_CARDS.at(-1)?.cardId).toBe("COLOR016");
|
||||
const pixels = colorPixels([
|
||||
{ count: 50, rgb: [244, 32, 32] }, { count: 40, rgb: [32, 210, 96] }, { count: 30, rgb: [24, 96, 220] },
|
||||
{ count: 20, rgb: [248, 210, 48] }, { count: 10, rgb: [120, 64, 180] },
|
||||
@@ -78,10 +80,10 @@ describe("TASK-WP4-04 dynamic providers", () => {
|
||||
profile: { creatorName: "Dada Creator", socialId: "@@dada" },
|
||||
};
|
||||
|
||||
it("exposes exactly ten P0-A providers and snapshots time and identity", () => {
|
||||
expect(P0A_DYNAMIC_STICKERS.map((item) => item.templateId)).toEqual([
|
||||
"DYN001", "DYN002", "DYN003", "DYN004", "DYN007", "DYN008", "DYN011", "DYN012", "DYN015", "DYN016",
|
||||
]);
|
||||
it("exposes all thirty-five providers and snapshots time and identity", () => {
|
||||
expect(P0A_DYNAMIC_STICKERS).toHaveLength(35);
|
||||
expect(P0A_DYNAMIC_STICKERS[0]?.templateId).toBe("DYN001");
|
||||
expect(P0A_DYNAMIC_STICKERS.at(-1)?.templateId).toBe("DYN035");
|
||||
const time = createDynamicStickerElement("DYN012", context, identity, 0);
|
||||
expect(time.formatted_value).toBe("09:07");
|
||||
expect(time.dynamic_fields).toMatchObject({ font_substitution: "FONT081", hour: "09", minute: "07" });
|
||||
@@ -92,11 +94,10 @@ describe("TASK-WP4-04 dynamic providers", () => {
|
||||
});
|
||||
|
||||
it("uses the archived template categories and only the fields visibly consumed by each source", () => {
|
||||
expect(P0A_DYNAMIC_STICKERS.map(({ category, templateId }) => [templateId, category])).toEqual([
|
||||
["DYN001", "location"], ["DYN002", "location"], ["DYN003", "location"], ["DYN004", "location"],
|
||||
["DYN007", "other"], ["DYN008", "time"], ["DYN011", "time"], ["DYN012", "time"],
|
||||
["DYN015", "identity"], ["DYN016", "identity"],
|
||||
]);
|
||||
expect(P0A_DYNAMIC_STICKERS.reduce<Record<string, number>>((counts, item) => {
|
||||
counts[item.category] = (counts[item.category] ?? 0) + 1;
|
||||
return counts;
|
||||
}, {})).toEqual({ identity: 21, location: 6, other: 1, time: 7 });
|
||||
const other = createDynamicStickerElement("DYN007", context, identity, 0);
|
||||
expect(other.dynamic_fields).toEqual({ nickname: "@dada" });
|
||||
expect(other.formatted_value).toBe("@dada");
|
||||
@@ -105,11 +106,10 @@ describe("TASK-WP4-04 dynamic providers", () => {
|
||||
});
|
||||
|
||||
it("maps every enabled dynamic sticker to its archived source candidate and original resource version", () => {
|
||||
expect(Object.entries(DYNAMIC_RENDER_MODELS).map(([id, model]) => [id, model.sourceCandidateId])).toEqual([
|
||||
["DYN001", "l_POI01"], ["DYN002", "l_POI02"], ["DYN003", "l_POI03"], ["DYN004", "l_POI04"],
|
||||
["DYN007", "diaoyu"], ["DYN008", "l_shijian2"], ["DYN011", "l_shijian6"], ["DYN012", "l_shijian7"],
|
||||
["DYN015", "0721userna"], ["DYN016", "l_username00"],
|
||||
]);
|
||||
expect(Object.keys(DYNAMIC_RENDER_MODELS)).toHaveLength(35);
|
||||
expect(DYNAMIC_RENDER_MODELS.DYN001?.sourceCandidateId).toBe("l_POI01");
|
||||
expect(DYNAMIC_RENDER_MODELS.DYN012?.sourceCandidateId).toBe("l_shijian7");
|
||||
expect(DYNAMIC_RENDER_MODELS.DYN035?.sourceCandidateId).toBe("l_username23");
|
||||
const element = createDynamicStickerElement("DYN001", { ...context, location: { formattedValue: "温州" } }, identity, 0);
|
||||
expect(element.resource_version).toBe(DYNAMIC_RESOURCE_VERSION);
|
||||
expect(DYNAMIC_RENDER_MODELS.DYN001.imageLayers).toEqual([
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { StaticStickerCatalog } from "../../packages/static-sticker-catalog
|
||||
import {
|
||||
P0A_COLOR_CARD_IDS,
|
||||
P0A_DYNAMIC_STICKER_IDS,
|
||||
P0A_REQUIRED_FONT_PANEL_IDS,
|
||||
P0A_TEXT_TEMPLATE_IDS,
|
||||
createP0aPublicManifest,
|
||||
} from "../../packages/template-registry/src/index.js";
|
||||
@@ -116,29 +117,27 @@ function evidence(name: string, value: unknown) {
|
||||
writeFileSync(resolve(root, name), `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
describe("TDD-WP5-WHITE-001 P0-A public allowlist", () => {
|
||||
it("registers the complete archive while publishing only the exact P0-A allowlist", () => {
|
||||
describe("TDD-WP5-WHITE-001 complete public catalog", () => {
|
||||
it("registers and publishes the complete normalized archive", () => {
|
||||
const source = fullComplexManifest();
|
||||
const before = structuredClone(source);
|
||||
const manifest = createP0aPublicManifest({ complexManifest: source, staticCatalog: staticCatalog() });
|
||||
|
||||
expect(source).toEqual(before);
|
||||
expect(P0A_TEXT_TEMPLATE_IDS).toHaveLength(32);
|
||||
expect(P0A_COLOR_CARD_IDS).toEqual(["COLOR001", "COLOR002", "COLOR008", "COLOR016"]);
|
||||
expect(P0A_DYNAMIC_STICKER_IDS).toEqual([
|
||||
"DYN001", "DYN002", "DYN003", "DYN004", "DYN007",
|
||||
"DYN008", "DYN011", "DYN012", "DYN015", "DYN016",
|
||||
]);
|
||||
expect(P0A_TEXT_TEMPLATE_IDS).toHaveLength(332);
|
||||
expect(P0A_REQUIRED_FONT_PANEL_IDS).toHaveLength(86);
|
||||
expect(P0A_COLOR_CARD_IDS).toHaveLength(16);
|
||||
expect(P0A_DYNAMIC_STICKER_IDS).toHaveLength(35);
|
||||
expect(manifest.counts).toEqual({
|
||||
color_cards: 4,
|
||||
dynamic_stickers: 10,
|
||||
font_panel_items: 11,
|
||||
color_cards: 16,
|
||||
dynamic_stickers: 35,
|
||||
font_panel_items: 86,
|
||||
static_parts: 25,
|
||||
static_stickers: 1_407,
|
||||
text_templates: 32,
|
||||
text_templates: 332,
|
||||
});
|
||||
expect(manifest.assets.text_templates.map((item) => item.canonical_id)).toEqual(P0A_TEXT_TEMPLATE_IDS);
|
||||
expect(manifest.assets.font_panel_items.map((item) => item.canonical_id)).toEqual([...referencedFontIds, "FONT081"]);
|
||||
expect(manifest.assets.font_panel_items.map((item) => item.canonical_id)).toEqual(P0A_REQUIRED_FONT_PANEL_IDS);
|
||||
expect(manifest.assets.color_cards.map((item) => item.canonical_id)).toEqual(P0A_COLOR_CARD_IDS);
|
||||
expect(manifest.assets.dynamic_stickers.map((item) => item.canonical_id)).toEqual(P0A_DYNAMIC_STICKER_IDS);
|
||||
expect(manifest.assets.static_stickers).toHaveLength(1_407);
|
||||
@@ -154,10 +153,10 @@ describe("TDD-WP5-WHITE-001 P0-A public allowlist", () => {
|
||||
&& item.release_tier === "alpha_whitelist"
|
||||
&& item.validation_status === "passed")).toBe(true);
|
||||
const serialized = JSON.stringify(manifest);
|
||||
expect(serialized).not.toContain("FLOWER009");
|
||||
expect(serialized).not.toContain("COLOR003");
|
||||
expect(serialized).not.toContain("DYN005");
|
||||
evidence("unit-allowlist.json", { counts: manifest.counts, hidden: ["FLOWER009", "COLOR003", "DYN005"], status: "passed" });
|
||||
expect(serialized).toContain("FLOWER145");
|
||||
expect(serialized).toContain("COLOR016");
|
||||
expect(serialized).toContain("DYN035");
|
||||
evidence("unit-full-catalog.json", { counts: manifest.counts, status: "passed" });
|
||||
});
|
||||
|
||||
it("rejects incomplete registration and any early full_p0 enablement", () => {
|
||||
@@ -172,12 +171,12 @@ describe("TDD-WP5-WHITE-001 P0-A public allowlist", () => {
|
||||
});
|
||||
|
||||
describe("TDD-WP5-COL-001 shared five-color renderer input", () => {
|
||||
it("binds the four enabled layouts to one immutable palette snapshot", () => {
|
||||
it("binds all sixteen layouts to one immutable palette snapshot", () => {
|
||||
const palette = ["#102030", "#405060", "#708090", "#A0B0C0", "#D0E0F0"] as const;
|
||||
const plans = createP0aColorCardRenderPlans(palette);
|
||||
expect(P0A_COLOR_CARD_DEFINITIONS.map((item) => [item.cardId, item.styleId])).toEqual([
|
||||
["COLOR001", "style_01"], ["COLOR002", "style_02"], ["COLOR008", "style_08"], ["COLOR016", "style_16"],
|
||||
]);
|
||||
expect(P0A_COLOR_CARD_DEFINITIONS.map((item) => [item.cardId, item.styleId])).toEqual(
|
||||
P0A_COLOR_CARD_IDS.map((cardId, index) => [cardId, `style_${String(index + 1).padStart(2, "0")}`]),
|
||||
);
|
||||
expect(plans.map((plan) => plan.cardId)).toEqual(P0A_COLOR_CARD_IDS);
|
||||
expect(plans.every((plan) => plan.palette === plans[0]!.palette)).toBe(true);
|
||||
expect(Object.isFrozen(plans[0]!.palette)).toBe(true);
|
||||
|
||||
Reference in New Issue
Block a user