From 17810886408fad2216c692c25b62a52ef89d3877 Mon Sep 17 00:00:00 2001 From: czj <13261895355@163.com> Date: Wed, 1 Jul 2026 14:58:13 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=A0=B7=E4=BE=8B=E6=96=87?= =?UTF-8?q?=E7=AB=A0=E5=8A=A0=E8=BD=BD=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/e2e/sample-flow/sample-loader.ts | 117 ++++++++++++++++++++++++ tests/e2e/sample-flow/types.ts | 97 ++++++++++++++++++++ tests/sample-flow/sample-loader.test.ts | 105 +++++++++++++++++++++ 3 files changed, 319 insertions(+) create mode 100644 tests/e2e/sample-flow/sample-loader.ts create mode 100644 tests/e2e/sample-flow/types.ts create mode 100644 tests/sample-flow/sample-loader.test.ts diff --git a/tests/e2e/sample-flow/sample-loader.ts b/tests/e2e/sample-flow/sample-loader.ts new file mode 100644 index 0000000..85aef32 --- /dev/null +++ b/tests/e2e/sample-flow/sample-loader.ts @@ -0,0 +1,117 @@ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { basename, join } from "node:path"; + +import { + type ArticleSample, + type LoadedArticleSamples, + supportedPlatforms, + type SamplePlatform, +} from "./types"; + +interface RawSample { + name?: unknown; + input?: { + title?: unknown; + body?: unknown; + image_lines?: unknown; + platform?: unknown; + user_instructions?: unknown; + }; + expectedHardFailures?: unknown; + expectedWarnings?: unknown; +} + +export function loadArticleSamples(samplesDir: string): LoadedArticleSamples { + if (!existsSync(samplesDir)) { + return { valid: [], invalid: [] }; + } + + const files = readdirSync(samplesDir) + .filter((fileName) => fileName.endsWith(".json")) + .sort((left, right) => left.localeCompare(right)); + + const valid: ArticleSample[] = []; + const invalid: LoadedArticleSamples["invalid"] = []; + + for (const fileName of files) { + const filePath = join(samplesDir, fileName); + try { + const raw = JSON.parse(readFileSync(filePath, "utf8")) as RawSample; + const sample = normalizeSample(raw, filePath, fileName); + valid.push(sample); + } catch (error) { + invalid.push({ + filePath, + fileName, + reason: + error instanceof Error ? error.message : "sample could not be parsed", + }); + } + } + + return { valid, invalid }; +} + +function normalizeSample( + raw: RawSample, + filePath: string, + fileName: string, +): ArticleSample { + if (!raw || typeof raw !== "object") { + throw new Error("sample must be a JSON object"); + } + if (!raw.input || typeof raw.input !== "object") { + throw new Error("input must be an object"); + } + if (typeof raw.input.body !== "string" || raw.input.body.trim().length === 0) { + throw new Error("input.body must be a non-empty string"); + } + if (!isSupportedPlatform(raw.input.platform)) { + throw new Error( + `input.platform must be one of ${supportedPlatforms.join(", ")}`, + ); + } + + return { + filePath, + fileName, + slug: slugFromFileName(fileName), + name: + typeof raw.name === "string" && raw.name.trim().length > 0 + ? raw.name.trim() + : basename(fileName, ".json"), + input: { + title: stringOrEmpty(raw.input.title), + body: raw.input.body, + image_lines: stringOrEmpty(raw.input.image_lines), + platform: raw.input.platform, + user_instructions: stringOrEmpty(raw.input.user_instructions), + }, + expectedHardFailures: stringArrayOrEmpty(raw.expectedHardFailures), + expectedWarnings: stringArrayOrEmpty(raw.expectedWarnings), + }; +} + +function isSupportedPlatform(value: unknown): value is SamplePlatform { + return ( + typeof value === "string" && + supportedPlatforms.includes(value as SamplePlatform) + ); +} + +function stringOrEmpty(value: unknown) { + return typeof value === "string" ? value : ""; +} + +function stringArrayOrEmpty(value: unknown) { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string") + : []; +} + +function slugFromFileName(fileName: string) { + return basename(fileName, ".json") + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/^-+|-+$/g, ""); +} diff --git a/tests/e2e/sample-flow/types.ts b/tests/e2e/sample-flow/types.ts new file mode 100644 index 0000000..479d384 --- /dev/null +++ b/tests/e2e/sample-flow/types.ts @@ -0,0 +1,97 @@ +export const supportedPlatforms = [ + "official_site", + "media_article", + "comparison_review", + "recommendation_list", +] as const; + +export type SamplePlatform = (typeof supportedPlatforms)[number]; + +export interface ArticleSampleInput { + title: string; + body: string; + image_lines: string; + platform: SamplePlatform; + user_instructions: string; +} + +export interface ArticleSample { + filePath: string; + fileName: string; + slug: string; + name: string; + input: ArticleSampleInput; + expectedHardFailures: string[]; + expectedWarnings: string[]; +} + +export interface InvalidArticleSample { + filePath: string; + fileName: string; + reason: string; +} + +export interface LoadedArticleSamples { + valid: ArticleSample[]; + invalid: InvalidArticleSample[]; +} + +export type SampleStatus = "passed" | "failed" | "skipped"; + +export type FailureCategory = + | "preflight_failed" + | "sample_invalid" + | "page_flow_failed" + | "stream_timeout" + | "stream_failed" + | "llm_failed" + | "export_failed" + | "console_error" + | "unknown_failed"; + +export interface ExportValidationResult { + fileName: "optimized.md" | "optimized.docx" | "qa_report.json"; + status: "passed" | "failed"; + statusCode?: number; + error?: string; +} + +export interface SampleResult { + file: string; + name: string; + slug: string; + status: SampleStatus; + duration_ms: number; + job_id?: string; + qa_status?: "pass" | "warn" | "fail"; + qa_fail_rules: string[]; + qa_warn_rules: string[]; + expected_hard_failures: string[]; + expected_warnings: string[]; + exports: Record; + llm_tasks: string[]; + failure_category?: FailureCategory; + failure_message?: string; + artifacts: { + final_screenshot?: string; + failure_screenshot?: string; + trace?: string; + }; +} + +export interface RunSummary { + started_at: string; + finished_at: string; + mode: "live"; + provider: string; + model: string; + base_url: string; + report_dir: string; + totals: { + passed: number; + failed: number; + skipped: number; + }; + samples: SampleResult[]; + invalid_samples: InvalidArticleSample[]; +} diff --git a/tests/sample-flow/sample-loader.test.ts b/tests/sample-flow/sample-loader.test.ts new file mode 100644 index 0000000..09bbaf3 --- /dev/null +++ b/tests/sample-flow/sample-loader.test.ts @@ -0,0 +1,105 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { loadArticleSamples } from "../e2e/sample-flow/sample-loader"; + +describe("loadArticleSamples", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "geo-samples-")); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("loads valid samples in filename order and normalizes optional fields", () => { + writeFileSync( + join(tempDir, "b-sample.json"), + JSON.stringify({ + name: "B sample", + input: { + body: "第二篇文章正文", + platform: "media_article", + }, + }), + ); + writeFileSync( + join(tempDir, "a-sample.json"), + JSON.stringify({ + name: "A sample", + input: { + title: "标题", + body: "第一篇文章正文", + image_lines: "图一", + platform: "official_site", + user_instructions: "保持事实准确", + }, + expectedHardFailures: ["body_quality"], + expectedWarnings: ["context_sensitive_terms"], + }), + ); + + const result = loadArticleSamples(tempDir); + + expect(result.valid.map((sample) => sample.fileName)).toEqual([ + "a-sample.json", + "b-sample.json", + ]); + expect(result.invalid).toEqual([]); + expect(result.valid[1]).toEqual( + expect.objectContaining({ + name: "B sample", + input: expect.objectContaining({ + title: "", + image_lines: "", + user_instructions: "", + }), + expectedHardFailures: [], + expectedWarnings: [], + }), + ); + }); + + it("returns invalid entries for malformed samples without throwing", () => { + writeFileSync( + join(tempDir, "empty-body.json"), + JSON.stringify({ + name: "Empty body", + input: { + body: " ", + platform: "official_site", + }, + }), + ); + writeFileSync( + join(tempDir, "bad-platform.json"), + JSON.stringify({ + name: "Bad platform", + input: { + body: "正文", + platform: "unknown", + }, + }), + ); + + const result = loadArticleSamples(tempDir); + + expect(result.valid).toEqual([]); + expect(result.invalid).toEqual([ + expect.objectContaining({ + fileName: "bad-platform.json", + reason: + "input.platform must be one of official_site, media_article, comparison_review, recommendation_list", + }), + expect.objectContaining({ + fileName: "empty-body.json", + reason: "input.body must be a non-empty string", + }), + ]); + }); +});