feat(*): 模型编辑与画布 - 统一编辑弹窗并稳定网格和后台切页
This commit is contained in:
@@ -34,10 +34,7 @@ test("announcement editor preserves image and pinned fields through edit and sav
|
||||
});
|
||||
|
||||
test("plugin upload owns native drops and price availability text remains readable", async () => {
|
||||
const [pluginSource, adminCss] = await Promise.all([
|
||||
Bun.file(new URL("../src/pages/plugins/plugin-documentation-modals.tsx", import.meta.url)).text(),
|
||||
Bun.file(new URL("../src/styles/admin-ui.css", import.meta.url)).text(),
|
||||
]);
|
||||
const [pluginSource, adminCss] = await Promise.all([Bun.file(new URL("../src/pages/plugins/plugin-documentation-modals.tsx", import.meta.url)).text(), Bun.file(new URL("../src/styles/admin-ui.css", import.meta.url)).text()]);
|
||||
const toggleCss = sourceSection(adminCss, ".admin-price-tier-toggle span {", ".admin-model-editor-add-tier.ant-btn {");
|
||||
|
||||
expect(pluginSource).toContain("event.preventDefault()");
|
||||
@@ -46,12 +43,36 @@ test("plugin upload owns native drops and price availability text remains readab
|
||||
expect(pluginSource).toContain("点击选择插件文件,也可拖拽到此处");
|
||||
expect(pluginSource).toContain("释放文件以上传插件");
|
||||
expect(pluginSource).toContain("isDraggingPlugin");
|
||||
expect(compactSource(adminCss)).toContain(".admin-price-tier-controls { display: grid !important; grid-template-columns: minmax(0, 1fr);");
|
||||
expect(compactSource(adminCss)).toContain(".admin-price-tier-controls { margin-left: auto;");
|
||||
expect(toggleCss).toContain("overflow-wrap: anywhere;");
|
||||
expect(toggleCss).toContain("white-space: normal;");
|
||||
expect(toggleCss).not.toContain("text-overflow: ellipsis;");
|
||||
});
|
||||
|
||||
test("model reference limits use compact rows only inside the admin editor", async () => {
|
||||
const css = await Bun.file(new URL("../src/styles/admin-ui.css", import.meta.url)).text();
|
||||
const numberField = sourceSection(css, ".admin-model-editor-references .admin-capability-number-field {", ".admin-model-editor-references .admin-capability-boolean-field {");
|
||||
expect(numberField).toContain("grid-template-columns: minmax(0, 1fr) 80px;");
|
||||
expect(numberField).toContain("min-height: 32px;");
|
||||
expect(numberField).toContain("align-items: center;");
|
||||
const switches = sourceSection(css, ".admin-model-editor-references .admin-capability-boolean-field label {", "@media (min-width: 601px)");
|
||||
expect(switches).toContain("display: flex;");
|
||||
expect(compactSource(css)).toContain(".admin-model-editor-references .admin-capability-reference-grid { align-items: start;");
|
||||
expect(compactSource(css)).toContain(".admin-model-editor-modal .admin-capability-reference-grid { grid-template-columns: minmax(0, 1fr);");
|
||||
});
|
||||
|
||||
test("model editor presents protocols in a searchable inline radio browser instead of a dropdown", async () => {
|
||||
const [source, css] = await Promise.all([
|
||||
Bun.file(new URL("../src/pages/admin/components/channel-model-editor.tsx", import.meta.url)).text(),
|
||||
Bun.file(new URL("../src/styles/admin-ui.css", import.meta.url)).text(),
|
||||
]);
|
||||
const protocolSection = sourceSection(source, '<Form.Item\n className="admin-model-protocol-field"', "{protocolError && (");
|
||||
|
||||
expect(protocolSection).toContain("<ModelProtocolBrowser");
|
||||
expect(protocolSection).not.toContain("<Select");
|
||||
expect(compactSource(css)).toContain(".admin-model-protocol-field { grid-column: 1 / -1;");
|
||||
});
|
||||
|
||||
test("channel model fetch requires explicit selection before import", async () => {
|
||||
const [componentSource, apiSource, adminCssSource] = await Promise.all([
|
||||
Bun.file(new URL("../src/pages/admin/components/channel-model-manager.tsx", import.meta.url)).text(),
|
||||
@@ -60,8 +81,8 @@ test("channel model fetch requires explicit selection before import", async () =
|
||||
]);
|
||||
const component = compactSource(componentSource);
|
||||
|
||||
expect(apiSource).toContain('api.post(`/admin/channels/${encodeURIComponent(channelId)}/models/fetch`)');
|
||||
expect(apiSource).toContain('api.post(`/admin/channels/${encodeURIComponent(channelId)}/models/import`, { models })');
|
||||
expect(apiSource).toContain("api.post(`/admin/channels/${encodeURIComponent(channelId)}/models/fetch`)");
|
||||
expect(apiSource).toContain("api.post(`/admin/channels/${encodeURIComponent(channelId)}/models/import`, { models })");
|
||||
expect(component).toContain('title="选择要导入的模型"');
|
||||
expect(component).toContain("默认已全选");
|
||||
expect(component).toContain("setFetchPreviewOpen(true)");
|
||||
|
||||
89
web/test/canvas-grid-viewport.test.tsx
Normal file
89
web/test/canvas-grid-viewport.test.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createRef } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { InfiniteCanvas } from "../src/components/canvas/infinite-canvas";
|
||||
import { canvasAppearanceForTheme } from "../src/lib/canvas/canvas-appearance";
|
||||
import { applyCanvasLiveViewport, CANVAS_GRAPHICS_VIEWPORT_PREVIEW_EVENT, CANVAS_VIEWPORT_PREVIEW_EVENT } from "../src/lib/canvas/canvas-live-viewport";
|
||||
import type { CanvasBackgroundMode } from "../src/lib/canvas-theme";
|
||||
import type { ViewportTransform } from "../src/types/canvas";
|
||||
|
||||
const viewports: ViewportTransform[] = [
|
||||
{ x: 0, y: 0, k: 1 },
|
||||
{ x: 389.5, y: -207.25, k: 0.05 },
|
||||
{ x: -17.125, y: 20.875, k: 0.119 },
|
||||
{ x: -17.125, y: 20.875, k: 0.12 },
|
||||
{ x: 150.2, y: -40.1, k: 0.666 },
|
||||
{ x: 150.2, y: -40.1, k: 0.667 },
|
||||
{ x: -1800.5, y: 3100.75, k: 2 },
|
||||
];
|
||||
|
||||
function renderGrid(mode: CanvasBackgroundMode, viewport: ViewportTransform, theme: "light" | "dark" = "dark") {
|
||||
const markup = renderToStaticMarkup(
|
||||
<InfiniteCanvas containerRef={createRef<HTMLDivElement>()} viewport={viewport} onViewportChange={() => {}} backgroundMode={mode} appearance={canvasAppearanceForTheme(theme)}>
|
||||
<div data-test-node />
|
||||
</InfiniteCanvas>,
|
||||
);
|
||||
return markup.match(/<div data-canvas-grid-layer[^>]*>/)?.[0] ?? null;
|
||||
}
|
||||
|
||||
describe("canvas screen-space background", () => {
|
||||
for (const mode of ["lines", "dots"] as const) {
|
||||
test(`${mode} keeps fixed geometry across zoom, pan and committed renders`, () => {
|
||||
for (const theme of ["light", "dark"] as const) {
|
||||
const initial = renderGrid(mode, viewports[0], theme);
|
||||
expect(initial).not.toBeNull();
|
||||
expect(initial).toContain("background-size:48px 48px");
|
||||
expect(initial).toContain("inset:0");
|
||||
expect(initial).not.toContain("transform:");
|
||||
expect(initial).not.toContain("var(--canvas-");
|
||||
if (mode === "dots") expect(initial).toContain("0.8px, transparent 1px");
|
||||
for (const viewport of viewports) expect(renderGrid(mode, viewport, theme)).toBe(initial);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("blank mode renders no grid at any viewport", () => {
|
||||
for (const viewport of viewports) expect(renderGrid("blank", viewport)).toBeNull();
|
||||
});
|
||||
|
||||
test("live updates transform the world and emit events without mutating the background", () => {
|
||||
const properties = new Map<string, string>([["--canvas-committed-scale", "0.5"]]);
|
||||
const gridWrites: string[] = [];
|
||||
const world = { style: { transform: "", transformOrigin: "", willChange: "" } };
|
||||
const grid = { style: { setProperty: (name: string) => gridWrites.push(name) } };
|
||||
const container = Object.assign(new EventTarget(), {
|
||||
style: {
|
||||
getPropertyValue: (name: string) => properties.get(name) ?? "",
|
||||
setProperty: (name: string, value: string) => properties.set(name, value),
|
||||
},
|
||||
dataset: { canvasViewportInteracting: "true" },
|
||||
querySelector: (selector: string) => selector === "[data-canvas-world-layer]" ? world : grid,
|
||||
});
|
||||
const graphics: ViewportTransform[] = [];
|
||||
const previews: ViewportTransform[] = [];
|
||||
let scrolls = 0;
|
||||
container.addEventListener(CANVAS_GRAPHICS_VIEWPORT_PREVIEW_EVENT, (event) => graphics.push((event as CustomEvent<ViewportTransform>).detail));
|
||||
container.addEventListener(CANVAS_VIEWPORT_PREVIEW_EVENT, (event) => previews.push((event as CustomEvent<ViewportTransform>).detail));
|
||||
container.addEventListener("scroll", () => scrolls++);
|
||||
|
||||
for (const viewport of viewports) {
|
||||
applyCanvasLiveViewport(container as unknown as HTMLDivElement, viewport, false);
|
||||
expect(world.style.transform).toBe(`translate3d(${viewport.x}px, ${viewport.y}px, 0) scale(${viewport.k / 0.5})`);
|
||||
expect(properties.get("--canvas-live-scale")).toBe(String(viewport.k));
|
||||
expect(properties.get("--canvas-live-inverse-scale")).toBe(String(1 / viewport.k));
|
||||
expect(gridWrites).toEqual([]);
|
||||
}
|
||||
expect(graphics).toEqual(viewports);
|
||||
expect(previews).toEqual([]);
|
||||
expect(scrolls).toBe(0);
|
||||
expect(world.style.willChange).toBe("transform");
|
||||
|
||||
container.dataset.canvasViewportInteracting = "false";
|
||||
applyCanvasLiveViewport(container as unknown as HTMLDivElement, viewports[0]);
|
||||
expect(world.style.willChange).toBe("");
|
||||
expect(previews).toEqual([viewports[0]]);
|
||||
expect(scrolls).toBe(1);
|
||||
expect(gridWrites).toEqual([]);
|
||||
});
|
||||
});
|
||||
88
web/test/channel-model-editor-form.test.ts
Normal file
88
web/test/channel-model-editor-form.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities";
|
||||
import type { ModelProtocolDefinition } from "../src/lib/model-protocols";
|
||||
import { changeChannelModelCapability, editorSectionForField, initialChannelModelValues, validateChannelModelPrices, validateChannelModelProtocol } from "../src/pages/admin/components/channel-model-editor-form";
|
||||
import { defaultPriceTier } from "../src/pages/admin/components/channel-model-price-tier-form";
|
||||
|
||||
const definition = (value: string, capability: ModelProtocolDefinition["capability"], enabled = true): ModelProtocolDefinition => ({ value, capability, enabled, label: value, create: "POST /test", contentType: "application/json", media: "url" });
|
||||
const protocols = [definition("disabled-text", "text", false), definition("text", "text"), definition("image", "image"), definition("video", "video")];
|
||||
|
||||
describe("channel model editor drafts", () => {
|
||||
test("new drafts select an enabled protocol and never share price state", () => {
|
||||
const first = initialChannelModelValues(null, protocols);
|
||||
first.priceTiers[0].unitPrice = 42;
|
||||
const second = initialChannelModelValues(null, protocols);
|
||||
expect(second.protocol).toBe("text");
|
||||
expect(second.priceTiers[0].unitPrice).toBe(0);
|
||||
expect(second.modelKey).toBe("");
|
||||
});
|
||||
test("missing catalogs do not invent a protocol", () => {
|
||||
expect(initialChannelModelValues(null, []).protocol).toBeUndefined();
|
||||
expect(() => validateChannelModelProtocol("text", "text", [])).toThrow("已启用");
|
||||
});
|
||||
test("rejects disabled, missing and capability-mismatched protocols", () => {
|
||||
for (const protocol of [undefined, "missing", "disabled-text", "image"]) {
|
||||
expect(() => validateChannelModelProtocol("text", protocol, protocols)).toThrow();
|
||||
}
|
||||
expect(() => validateChannelModelProtocol("text", "text", protocols)).not.toThrow();
|
||||
});
|
||||
test("capability changes clear old selectors but preserve money and billing units", () => {
|
||||
const draft = initialChannelModelValues(null, protocols);
|
||||
const tier = { ...defaultPriceTier("advanced"), billingMode: "per_second" as const, unitPrice: 2.5, operation: "text_to_video", resolution: "1080p", videoSeconds: 5, imageCount: 2 };
|
||||
const next = changeChannelModelCapability({ ...draft, capability: "image", protocol: "video", providerModelKey: "gpt-image-2", priceTiers: [tier] }, protocols);
|
||||
expect(next.protocol).toBe("image");
|
||||
expect(next.capabilityConfig).toEqual(defaultModelCapabilityConfig("image", "gpt-image-2"));
|
||||
expect(next.priceTiers[0]).toMatchObject({ unitPrice: 2.5, billingMode: "per_second", operation: "*", resolution: "*", videoSeconds: 0, imageCount: 0 });
|
||||
expect(tier.operation).toBe("text_to_video");
|
||||
expect(() => validateChannelModelPrices(next)).toThrow("按秒");
|
||||
});
|
||||
test("audio does not retain image/video configuration", () => {
|
||||
const draft = initialChannelModelValues(null, protocols);
|
||||
expect(changeChannelModelCapability({ ...draft, capability: "audio" }, protocols).capabilityConfig).toBeUndefined();
|
||||
});
|
||||
test("validation errors map to their mounted tab", () => {
|
||||
expect(editorSectionForField(["priceTiers", 2, "unitPrice"])).toBe("pricing");
|
||||
expect(editorSectionForField(["capabilityConfig"])).toBe("capabilities");
|
||||
expect(editorSectionForField(["protocol"])).toBe("identity");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pricing write validation", () => {
|
||||
const draft = initialChannelModelValues(null, protocols);
|
||||
test("accepts an explicit free/default price", () => expect(() => validateChannelModelPrices(draft)).not.toThrow());
|
||||
test("requires prices and exactly one or zero fallback tiers", () => {
|
||||
expect(() => validateChannelModelPrices({ ...draft, priceTiers: [] })).toThrow("至少");
|
||||
expect(() => validateChannelModelPrices({ ...draft, priceTiers: [defaultPriceTier(), defaultPriceTier()] })).toThrow("只能");
|
||||
});
|
||||
test("rejects empty or stale advanced selectors", () => {
|
||||
expect(() => validateChannelModelPrices({ ...draft, priceTiers: [defaultPriceTier("advanced")] })).toThrow("匹配条件");
|
||||
expect(() => validateChannelModelPrices({ ...draft, priceTiers: [{ ...defaultPriceTier("advanced"), operation: "text_to_video" }] })).toThrow("不匹配");
|
||||
});
|
||||
test("rejects invalid active prices rather than silently sending zero", () => {
|
||||
for (const unitPrice of [NaN, Infinity, -1, 1000001, null, undefined]) {
|
||||
expect(() => validateChannelModelPrices({ ...draft, priceTiers: [{ ...defaultPriceTier(), unitPrice: unitPrice as number }] })).toThrow("有效数值");
|
||||
}
|
||||
});
|
||||
test("a new protocol cannot silently convert Token pricing to per request", () => {
|
||||
expect(() => validateChannelModelPrices({ ...draft, capability: "video", protocol: "video", priceTiers: [{ ...defaultPriceTier(), billingMode: "token", outputTokenPrice: 8 }] })).toThrow("不支持 Token");
|
||||
});
|
||||
test("validates all text Token prices and the video Token minimum", () => {
|
||||
expect(() => validateChannelModelPrices({ ...draft, priceTiers: [{ ...defaultPriceTier(), billingMode: "token", inputTokenPrice: NaN }] })).toThrow();
|
||||
const video = { ...draft, capability: "video" as const, protocol: "volcengine-ark-video", priceTiers: [{ ...defaultPriceTier(), billingMode: "token" as const }] };
|
||||
expect(() => validateChannelModelPrices(video)).toThrow("至少为");
|
||||
video.priceTiers[0].outputTokenPrice = 0.000001;
|
||||
expect(() => validateChannelModelPrices(video)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
test("admin lazy routes share a persistent boundary instead of replaying page loaders", async () => {
|
||||
const router = await Bun.file(new URL("../src/router.tsx", import.meta.url)).text();
|
||||
const shell = await Bun.file(new URL("../src/pages/admin/components/admin-shell.tsx", import.meta.url)).text();
|
||||
const pages = await Bun.file(new URL("../src/pages/admin/admin-route-pages.tsx", import.meta.url)).text();
|
||||
const css = await Bun.file(new URL("../src/styles/admin-ui.css", import.meta.url)).text();
|
||||
const admin = router.slice(router.indexOf('path: "/admin"'), router.indexOf('{ path: "*"'));
|
||||
expect(admin.match(/deferred\(/g)).toHaveLength(1);
|
||||
expect(shell).toMatch(/<Suspense[^]*?<Outlet \/>\s*<\/Suspense>/);
|
||||
expect(pages).not.toContain("<Suspense");
|
||||
expect(css).not.toContain("admin-page-enter");
|
||||
});
|
||||
Reference in New Issue
Block a user