This commit is contained in:
@@ -0,0 +1,30 @@
|
|||||||
|
name: Web Quality
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
- "codex/**"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verify:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "24"
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Verify
|
||||||
|
run: npm run verify
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
coverage
|
||||||
|
tmp
|
||||||
|
.codex-tmp
|
||||||
|
.codex-logs
|
||||||
|
screenshots
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
|
package-lock.json
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import js from "@eslint/js";
|
||||||
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
"dist/**",
|
||||||
|
"node_modules/**",
|
||||||
|
"coverage/**",
|
||||||
|
"tmp/**",
|
||||||
|
".codex-tmp/**",
|
||||||
|
".codex-logs/**",
|
||||||
|
"screenshots/**",
|
||||||
|
"*.log",
|
||||||
|
"*.tmp",
|
||||||
|
"vite-*.log",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
{
|
||||||
|
files: [
|
||||||
|
"src/**/*.{ts,tsx}",
|
||||||
|
"scripts/**/*.mjs",
|
||||||
|
"vite.config.ts",
|
||||||
|
"eslint.config.js",
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: "latest",
|
||||||
|
sourceType: "module",
|
||||||
|
globals: {
|
||||||
|
AbortController: "readonly",
|
||||||
|
AbortSignal: "readonly",
|
||||||
|
Blob: "readonly",
|
||||||
|
clearInterval: "readonly",
|
||||||
|
clearTimeout: "readonly",
|
||||||
|
console: "readonly",
|
||||||
|
crypto: "readonly",
|
||||||
|
document: "readonly",
|
||||||
|
File: "readonly",
|
||||||
|
fetch: "readonly",
|
||||||
|
FormData: "readonly",
|
||||||
|
Headers: "readonly",
|
||||||
|
HTMLTextAreaElement: "readonly",
|
||||||
|
localStorage: "readonly",
|
||||||
|
navigator: "readonly",
|
||||||
|
process: "readonly",
|
||||||
|
React: "readonly",
|
||||||
|
RequestInit: "readonly",
|
||||||
|
Response: "readonly",
|
||||||
|
setInterval: "readonly",
|
||||||
|
setTimeout: "readonly",
|
||||||
|
URL: "readonly",
|
||||||
|
URLSearchParams: "readonly",
|
||||||
|
window: "readonly",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
"react-hooks": reactHooks,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"react-hooks/rules-of-hooks": "error",
|
||||||
|
"react-hooks/exhaustive-deps": "warn",
|
||||||
|
"@typescript-eslint/no-explicit-any": "error",
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"warn",
|
||||||
|
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||||
|
],
|
||||||
|
"@typescript-eslint/no-unused-expressions": "warn",
|
||||||
|
"no-empty": ["error", { allowEmptyCatch: true }],
|
||||||
|
"no-undef": "off",
|
||||||
|
"no-useless-escape": "warn",
|
||||||
|
"prefer-const": "warn",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
Generated
+1367
-102
File diff suppressed because it is too large
Load Diff
+10
-1
@@ -7,7 +7,11 @@
|
|||||||
"dev": "vite --host 127.0.0.1",
|
"dev": "vite --host 127.0.0.1",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview --host 127.0.0.1",
|
"preview": "vite preview --host 127.0.0.1",
|
||||||
|
"test": "node scripts/run-unit-tests.mjs",
|
||||||
"type-check": "tsc -p tsconfig.json --noEmit",
|
"type-check": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"format:check": "prettier --check .github/workflows/web-quality.yml eslint.config.js scripts/run-unit-tests.mjs src/test src/api/generationConcurrency.test.ts src/utils/enterpriseVideoPolicy.test.ts src/utils/taskLifecycle.test.ts",
|
||||||
|
"verify": "npm run test && npm run type-check && npm run lint && npm run format:check && npm run governance:check && npm run style:check && npm run build",
|
||||||
"governance:check": "node scripts/check-governance.mjs",
|
"governance:check": "node scripts/check-governance.mjs",
|
||||||
"style:check": "node scripts/check-style-governance.mjs",
|
"style:check": "node scripts/check-style-governance.mjs",
|
||||||
"smoke:generation:mocked": "node scripts/smoke-generation-mocked.mjs"
|
"smoke:generation:mocked": "node scripts/smoke-generation-mocked.mjs"
|
||||||
@@ -20,13 +24,18 @@
|
|||||||
"zustand": "5.0.13"
|
"zustand": "5.0.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
"@types/react": "18.2.0",
|
"@types/react": "18.2.0",
|
||||||
"@types/react-dom": "18.2.0",
|
"@types/react-dom": "18.2.0",
|
||||||
"@vitejs/plugin-react": "4.2.1",
|
"@vitejs/plugin-react": "4.2.1",
|
||||||
|
"eslint": "^10.4.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"playwright": "1.60.0",
|
"playwright": "1.60.0",
|
||||||
|
"prettier": "^3.8.3",
|
||||||
"sharp": "0.34.5",
|
"sharp": "0.34.5",
|
||||||
"typescript": "5.3.3",
|
"typescript": "5.3.3",
|
||||||
"vite": "5.1.0",
|
"typescript-eslint": "^8.60.1",
|
||||||
|
"vite": "5.4.21",
|
||||||
"vite-plugin-compression2": "2.5.3"
|
"vite-plugin-compression2": "2.5.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import process from "node:process";
|
||||||
|
import { createServer } from "vite";
|
||||||
|
|
||||||
|
const repoRoot = process.cwd();
|
||||||
|
|
||||||
|
function normalizePath(value) {
|
||||||
|
return value.replace(/\\/g, "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTestFiles(dir) {
|
||||||
|
const result = [];
|
||||||
|
if (!fs.existsSync(dir)) return result;
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
if (entry.name === "node_modules" || entry.name === "dist") continue;
|
||||||
|
const fullPath = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
result.push(...findTestFiles(fullPath));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/\.test\.tsx?$/.test(entry.name)) result.push(fullPath);
|
||||||
|
}
|
||||||
|
return result.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = await createServer({
|
||||||
|
configFile: path.join(repoRoot, "vite.config.ts"),
|
||||||
|
appType: "custom",
|
||||||
|
logLevel: "silent",
|
||||||
|
server: { middlewareMode: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const harness = await server.ssrLoadModule("/src/test/testHarness");
|
||||||
|
const testFiles = findTestFiles(path.join(repoRoot, "src"));
|
||||||
|
|
||||||
|
if (testFiles.length === 0) {
|
||||||
|
console.error("No test files found.");
|
||||||
|
process.exitCode = 1;
|
||||||
|
} else {
|
||||||
|
console.log(`Running ${testFiles.length} test files`);
|
||||||
|
|
||||||
|
for (const file of testFiles) {
|
||||||
|
const modulePath = `/${normalizePath(path.relative(repoRoot, file))}`;
|
||||||
|
await server.ssrLoadModule(modulePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await harness.runRegisteredTests();
|
||||||
|
console.log(`Unit test result: ${result.passed}/${result.total} passed`);
|
||||||
|
if (result.failed > 0) process.exitCode = 1;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
@@ -3,6 +3,19 @@ import { buildApiUrl, buildAuthHeaders } from "./serverConnection";
|
|||||||
const TEXT_MODELS = ["qwen-max", "qwen-plus", "qwen-turbo"];
|
const TEXT_MODELS = ["qwen-max", "qwen-plus", "qwen-turbo"];
|
||||||
const VISION_MODELS = ["qwen3.7-plus", "qwen-vl-plus", "qwen-vl-max"];
|
const VISION_MODELS = ["qwen3.7-plus", "qwen-vl-plus", "qwen-vl-max"];
|
||||||
|
|
||||||
|
function combineAbortSignals(signal: AbortSignal | undefined, timeoutSignal: AbortSignal): AbortSignal {
|
||||||
|
if (!signal) return timeoutSignal;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const abort = () => controller.abort();
|
||||||
|
if (signal.aborted || timeoutSignal.aborted) {
|
||||||
|
abort();
|
||||||
|
return controller.signal;
|
||||||
|
}
|
||||||
|
signal.addEventListener("abort", abort, { once: true });
|
||||||
|
timeoutSignal.addEventListener("abort", abort, { once: true });
|
||||||
|
return controller.signal;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdVideoUserConfig {
|
export interface AdVideoUserConfig {
|
||||||
platform: string;
|
platform: string;
|
||||||
aspectRatio: string;
|
aspectRatio: string;
|
||||||
@@ -162,9 +175,7 @@ async function chat(
|
|||||||
{ role: "user", content: userContent },
|
{ role: "user", content: userContent },
|
||||||
];
|
];
|
||||||
const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
|
const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
|
||||||
const combinedSignal = options?.signal
|
const combinedSignal = combineAbortSignals(options?.signal, timeoutSignal);
|
||||||
? AbortSignal.any([options.signal, timeoutSignal])
|
|
||||||
: timeoutSignal;
|
|
||||||
const res = await fetch(buildApiUrl("ai/chat"), {
|
const res = await fetch(buildApiUrl("ai/chat"), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: buildAuthHeaders(),
|
headers: buildAuthHeaders(),
|
||||||
@@ -210,9 +221,7 @@ async function visionChat(
|
|||||||
let lastError: Error | null = null;
|
let lastError: Error | null = null;
|
||||||
for (const model of VISION_MODELS) {
|
for (const model of VISION_MODELS) {
|
||||||
const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
|
const timeoutSignal = AbortSignal.timeout(CHAT_TIMEOUT_MS);
|
||||||
const combinedSignal = signal
|
const combinedSignal = combineAbortSignals(signal, timeoutSignal);
|
||||||
? AbortSignal.any([signal, timeoutSignal])
|
|
||||||
: timeoutSignal;
|
|
||||||
try {
|
try {
|
||||||
const out = await retryOnTransient(async () => {
|
const out = await retryOnTransient(async () => {
|
||||||
const res = await fetch(buildApiUrl("ai/chat"), {
|
const res = await fetch(buildApiUrl("ai/chat"), {
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "../test/testHarness";
|
||||||
|
|
||||||
|
import {
|
||||||
|
__resetGenerationConcurrencyForTests,
|
||||||
|
claimGenerationSlot,
|
||||||
|
getActiveGenerationTaskCount,
|
||||||
|
getEffectiveGenerationLimit,
|
||||||
|
getGenerationUserKey,
|
||||||
|
releaseGenerationSlot,
|
||||||
|
setUserMaxConcurrency,
|
||||||
|
} from "./generationConcurrency";
|
||||||
|
|
||||||
|
describe("generationConcurrency", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
__resetGenerationConcurrencyForTests();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the default generation limit until the server provides a user-specific limit", () => {
|
||||||
|
expect(getEffectiveGenerationLimit()).toBe(3);
|
||||||
|
|
||||||
|
setUserMaxConcurrency(5);
|
||||||
|
expect(getEffectiveGenerationLimit()).toBe(5);
|
||||||
|
|
||||||
|
setUserMaxConcurrency(0);
|
||||||
|
expect(getEffectiveGenerationLimit()).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("claims and releases local generation slots so the submit button can recover", () => {
|
||||||
|
const userKey = getGenerationUserKey("user-1");
|
||||||
|
|
||||||
|
const releaseFirst = claimGenerationSlot({
|
||||||
|
userKey,
|
||||||
|
kind: "image",
|
||||||
|
id: "slot-1",
|
||||||
|
});
|
||||||
|
claimGenerationSlot({ userKey, kind: "video", id: "slot-2" });
|
||||||
|
|
||||||
|
expect(getActiveGenerationTaskCount(userKey)).toBe(2);
|
||||||
|
|
||||||
|
releaseFirst();
|
||||||
|
expect(getActiveGenerationTaskCount(userKey)).toBe(1);
|
||||||
|
|
||||||
|
releaseGenerationSlot("slot-2");
|
||||||
|
expect(getActiveGenerationTaskCount(userKey)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces per-user limits without blocking other users", () => {
|
||||||
|
setUserMaxConcurrency(1);
|
||||||
|
|
||||||
|
claimGenerationSlot({ userKey: "alice", kind: "image", id: "alice-slot" });
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
claimGenerationSlot({
|
||||||
|
userKey: "alice",
|
||||||
|
kind: "video",
|
||||||
|
id: "alice-slot-2",
|
||||||
|
}),
|
||||||
|
).toThrow("最多生成 1 个图片/视频任务");
|
||||||
|
expect(() =>
|
||||||
|
claimGenerationSlot({ userKey: "bob", kind: "video", id: "bob-slot" }),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -75,3 +75,8 @@ export function releaseGenerationSlot(id: string | undefined | null): void {
|
|||||||
if (!id) return;
|
if (!id) return;
|
||||||
activeSlots.delete(id);
|
activeSlots.delete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function __resetGenerationConcurrencyForTests(): void {
|
||||||
|
activeSlots.clear();
|
||||||
|
userMaxConcurrency = null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent, type JSX } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent, type ReactElement } from "react";
|
||||||
import "../../styles/pages/assets.css";
|
import "../../styles/pages/assets.css";
|
||||||
import { assetClient, type ServerAssetItem } from "../../api/assetClient";
|
import { assetClient, type ServerAssetItem } from "../../api/assetClient";
|
||||||
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
||||||
@@ -36,7 +36,7 @@ interface AssetsPageProps {
|
|||||||
onOpenLogin: () => void;
|
onOpenLogin: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const typeTabs: Array<{ key: AssetTypeFilter; label: string; icon: JSX.Element | null }> = [
|
const typeTabs: Array<{ key: AssetTypeFilter; label: string; icon: ReactElement | null }> = [
|
||||||
{ key: "all", label: "全部", icon: null },
|
{ key: "all", label: "全部", icon: null },
|
||||||
{ key: "character", label: "人物", icon: <UserOutlined /> },
|
{ key: "character", label: "人物", icon: <UserOutlined /> },
|
||||||
{ key: "scene", label: "场景", icon: <FileImageOutlined /> },
|
{ key: "scene", label: "场景", icon: <FileImageOutlined /> },
|
||||||
|
|||||||
@@ -715,7 +715,7 @@ async function createUploadedImageItems(files: File[], limit: number, prefix: st
|
|||||||
|
|
||||||
const items = await Promise.all(selectedFiles.map(async (file, index) => {
|
const items = await Promise.all(selectedFiles.map(async (file, index) => {
|
||||||
const localPreviewUrl = URL.createObjectURL(file);
|
const localPreviewUrl = URL.createObjectURL(file);
|
||||||
let dimensions: { width?: number; height?: number } = {};
|
let dimensions: { width?: number; height?: number };
|
||||||
try {
|
try {
|
||||||
dimensions = await readImageDimensions(localPreviewUrl);
|
dimensions = await readImageDimensions(localPreviewUrl);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -522,7 +522,6 @@ function WorkbenchPage({
|
|||||||
[conversations],
|
[conversations],
|
||||||
);
|
);
|
||||||
const hasSidebarRecords = conversationRecords.length > 0;
|
const hasSidebarRecords = conversationRecords.length > 0;
|
||||||
const hasActivatedWorkspace = workspaceStarted || isGenerating || hasConversationRecords;
|
|
||||||
|
|
||||||
const activeConversationTitle = useMemo(() => {
|
const activeConversationTitle = useMemo(() => {
|
||||||
if (!activeConversationId) return "";
|
if (!activeConversationId) return "";
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
type TestFn = () => unknown | Promise<unknown>;
|
||||||
|
|
||||||
|
interface TestCase {
|
||||||
|
name: string;
|
||||||
|
run: TestFn;
|
||||||
|
afterEachFns: TestFn[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const tests: TestCase[] = [];
|
||||||
|
const suiteStack: string[] = [];
|
||||||
|
const afterEachStack: TestFn[][] = [[]];
|
||||||
|
|
||||||
|
function formatValue(value: unknown): string {
|
||||||
|
return typeof value === "string" ? `"${value}"` : JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(message: string): never {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertThrows(
|
||||||
|
actual: unknown,
|
||||||
|
expectedMessage?: string,
|
||||||
|
inverted = false,
|
||||||
|
): void {
|
||||||
|
if (typeof actual !== "function") {
|
||||||
|
fail("Expected value to be a function");
|
||||||
|
}
|
||||||
|
|
||||||
|
let thrown: unknown;
|
||||||
|
try {
|
||||||
|
actual();
|
||||||
|
} catch (error) {
|
||||||
|
thrown = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inverted) {
|
||||||
|
if (thrown)
|
||||||
|
fail(
|
||||||
|
`Expected function not to throw, but it threw ${thrown instanceof Error ? thrown.message : String(thrown)}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!thrown) fail("Expected function to throw");
|
||||||
|
if (expectedMessage) {
|
||||||
|
const message = thrown instanceof Error ? thrown.message : String(thrown);
|
||||||
|
if (!message.includes(expectedMessage)) {
|
||||||
|
fail(
|
||||||
|
`Expected thrown message to include "${expectedMessage}", got "${message}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function describe(name: string, run: () => void): void {
|
||||||
|
suiteStack.push(name);
|
||||||
|
afterEachStack.push([]);
|
||||||
|
try {
|
||||||
|
run();
|
||||||
|
} finally {
|
||||||
|
afterEachStack.pop();
|
||||||
|
suiteStack.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function it(name: string, run: TestFn): void {
|
||||||
|
tests.push({
|
||||||
|
name: [...suiteStack, name].join(" > "),
|
||||||
|
run,
|
||||||
|
afterEachFns: afterEachStack.flat(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function afterEach(run: TestFn): void {
|
||||||
|
afterEachStack[afterEachStack.length - 1].push(run);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expect(actual: unknown) {
|
||||||
|
return {
|
||||||
|
toBe(expected: unknown): void {
|
||||||
|
if (!Object.is(actual, expected)) {
|
||||||
|
fail(`Expected ${formatValue(actual)} to be ${formatValue(expected)}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
toEqual(expected: unknown): void {
|
||||||
|
const actualJson = JSON.stringify(actual);
|
||||||
|
const expectedJson = JSON.stringify(expected);
|
||||||
|
if (actualJson !== expectedJson) {
|
||||||
|
fail(`Expected ${actualJson} to equal ${expectedJson}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
toThrow(expectedMessage?: string): void {
|
||||||
|
assertThrows(actual, expectedMessage);
|
||||||
|
},
|
||||||
|
not: {
|
||||||
|
toThrow(): void {
|
||||||
|
assertThrows(actual, undefined, true);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runRegisteredTests(): Promise<{
|
||||||
|
passed: number;
|
||||||
|
failed: number;
|
||||||
|
total: number;
|
||||||
|
}> {
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (const test of tests) {
|
||||||
|
let failure: unknown;
|
||||||
|
try {
|
||||||
|
await test.run();
|
||||||
|
} catch (error) {
|
||||||
|
failure = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const hook of [...test.afterEachFns].reverse()) {
|
||||||
|
try {
|
||||||
|
await hook();
|
||||||
|
} catch (error) {
|
||||||
|
failure = failure || error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failure) {
|
||||||
|
failed += 1;
|
||||||
|
console.error(`FAIL ${test.name}`);
|
||||||
|
console.error(
|
||||||
|
failure instanceof Error
|
||||||
|
? ` ${failure.message}`
|
||||||
|
: ` ${String(failure)}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
passed += 1;
|
||||||
|
console.log(`PASS ${test.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = passed + failed;
|
||||||
|
if (total === 0) {
|
||||||
|
console.error("No unit tests were registered.");
|
||||||
|
return { passed, failed: 1, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n${passed} passed, ${failed} failed`);
|
||||||
|
return { passed, failed, total };
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "../testHarness";
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, expect, it } from "../test/testHarness";
|
||||||
|
|
||||||
|
import {
|
||||||
|
calculateEnterpriseVideoCredits,
|
||||||
|
getEnterpriseVideoCreditRate,
|
||||||
|
normalizeEnterpriseResolution,
|
||||||
|
} from "./enterpriseVideoPolicy";
|
||||||
|
|
||||||
|
describe("enterpriseVideoPolicy", () => {
|
||||||
|
it("keeps video billing at 1 CNY to 100 credits", () => {
|
||||||
|
expect(
|
||||||
|
calculateEnterpriseVideoCredits({
|
||||||
|
model: "happyhorse-1.0",
|
||||||
|
resolution: "1080P",
|
||||||
|
durationSeconds: 5,
|
||||||
|
}),
|
||||||
|
).toBe(640);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
calculateEnterpriseVideoCredits({
|
||||||
|
model: "wan2.7-i2v",
|
||||||
|
resolution: "720P",
|
||||||
|
durationSeconds: 5,
|
||||||
|
}),
|
||||||
|
).toBe(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rounds duration up to the next second before billing", () => {
|
||||||
|
expect(
|
||||||
|
calculateEnterpriseVideoCredits({
|
||||||
|
model: "vidu-q3-turbo",
|
||||||
|
resolution: "1080P",
|
||||||
|
durationSeconds: 5.2,
|
||||||
|
}),
|
||||||
|
).toBe(600);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes unsupported resolutions to 1080P", () => {
|
||||||
|
expect(normalizeEnterpriseResolution("4K")).toBe("1080P");
|
||||||
|
expect(
|
||||||
|
getEnterpriseVideoCreditRate({
|
||||||
|
model: "pixverse-c1",
|
||||||
|
resolution: "4K",
|
||||||
|
durationSeconds: 5,
|
||||||
|
}),
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, expect, it } from "../test/testHarness";
|
||||||
|
|
||||||
|
import {
|
||||||
|
TEXT_INPUT_CREDITS_PER_MILLION,
|
||||||
|
TEXT_OUTPUT_CREDITS_PER_MILLION,
|
||||||
|
estimateTextTokenCredits,
|
||||||
|
getTaskTimeoutPolicy,
|
||||||
|
isTaskLocallyTimedOut,
|
||||||
|
} from "./taskLifecycle";
|
||||||
|
|
||||||
|
describe("taskLifecycle", () => {
|
||||||
|
it("keeps text token billing at 1 CNY to 100 credits", () => {
|
||||||
|
expect(TEXT_INPUT_CREDITS_PER_MILLION).toBe(200);
|
||||||
|
expect(TEXT_OUTPUT_CREDITS_PER_MILLION).toBe(500);
|
||||||
|
expect(
|
||||||
|
estimateTextTokenCredits({
|
||||||
|
promptTokens: 1_000_000,
|
||||||
|
completionTokens: 1_000_000,
|
||||||
|
}),
|
||||||
|
).toBe(700);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores negative token counts when estimating text billing", () => {
|
||||||
|
expect(
|
||||||
|
estimateTextTokenCredits({
|
||||||
|
promptTokens: -100,
|
||||||
|
completionTokens: 500_000,
|
||||||
|
}),
|
||||||
|
).toBe(250);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks unstarted tasks locally timed out after submit timeout", () => {
|
||||||
|
const policy = getTaskTimeoutPolicy({ kind: "image" });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isTaskLocallyTimedOut({
|
||||||
|
startedAt: 1_000,
|
||||||
|
lastProgressAt: 1_000,
|
||||||
|
now: 1_000 + policy.submitTimeoutMs,
|
||||||
|
policy,
|
||||||
|
progress: 0,
|
||||||
|
}),
|
||||||
|
).toBe("no_progress");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks running tasks locally timed out when progress stops", () => {
|
||||||
|
const policy = getTaskTimeoutPolicy({ kind: "video", model: "wan2.7-i2v" });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
isTaskLocallyTimedOut({
|
||||||
|
startedAt: 1_000,
|
||||||
|
lastProgressAt: 2_000,
|
||||||
|
now: 2_000 + policy.noProgressTimeoutMs,
|
||||||
|
policy,
|
||||||
|
progress: 40,
|
||||||
|
}),
|
||||||
|
).toBe("no_progress");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user