← GPT-AI-LP
CODE · 22.9 KB

test/lp-model.test.js

Workspace snapshot · 08/31 23:51

import test from "node:test";
import assert from "node:assert/strict";
import {
  answerQuestion,
  createComment,
  exportHtml,
  getActiveQuestions,
  getNextQuestion,
  initialProject,
  initialSections,
  markdownToSections,
  mergeTargetedSections,
  rewindAnswers,
  sectionsToMarkdown,
  stages,
  toneDirections,
} from "../src/lp-model.js";
import { auditWireframe } from "../api/quality-gate.js";
import { authorizeRequest, publicError } from "../api/security.js";
import { buildExportBundle } from "../src/export-bundle.js";
import JSZip from "jszip";
import {
  buildLpPrompt,
  parseBriefResult,
  parseHeadlineResult,
  parseLpResult,
  parseQualityGate,
  parseToneResult,
} from "../src/runtime-prompt.js";
import {
  normalizeWireframeType,
  resolveWireframeLayout,
  wireframeLayoutCatalog,
} from "../src/wireframe-layouts.js";
import { contentModelIssues } from "../src/section-content.js";
import { normalizeDocumentText } from "../src/document-text.js";
import { buildVisualPlan, hasMeaningfulImageBrief, mergeVisualRecords, selectVisualSections } from "../src/visual-strategy.js";

test("document normalization preserves paragraphs and reports truncation", () => {
  const preserved = normalizeDocumentText("見出し\r\n\r\n本文   A\n次の段落", 100);
  assert.equal(preserved.text, "見出し\n\n本文 A\n次の段落");
  assert.equal(preserved.truncated, false);

  const truncated = normalizeDocumentText("あ".repeat(101), 100);
  assert.equal(truncated.text.length, 100);
  assert.equal(truncated.originalLength, 101);
  assert.equal(truncated.truncated, true);
});

test("hearing covers PENGIN production inputs", () => {
  const ids = getActiveQuestions({ goal: "download" }).map((item) => item.id);
  for (const id of [
    "service",
    "category",
    "industry",
    "role",
    "problem",
    "differentiator",
    "traffic",
    "proof",
    "price",
    "offerFacts",
    "conversionSettings",
    "brandSettings",
    "complianceSettings",
    "launchSettings",
    "toneIntent",
  ])
    assert.ok(ids.includes(id));
  assert.ok(ids.length >= 16);
});
test("hearing choices adapt to earlier answers", () => {
  const saas = getActiveQuestions({ goal: "trial", category: "saas" });
  const service = getActiveQuestions({ goal: "demo", category: "service" });
  assert.ok(
    saas
      .find((item) => item.id === "industry")
      .options.some((item) => item.id === "backoffice"),
  );
  assert.ok(
    service
      .find((item) => item.id === "industry")
      .options.some((item) => item.id === "professional"),
  );
  assert.ok(
    saas
      .find((item) => item.id === "traffic")
      .options.some((item) => item.id === "product"),
  );
});
test("next question moves from goal to service description", () => {
  let answers = {};
  const goal = getNextQuestion(answers);
  answers = answerQuestion(answers, goal, "trial");
  assert.equal(getNextQuestion(answers).id, "service");
});
test("hearing can move back without deleting the previous answer", () => {
  const answers = {
    goal: "trial",
    service: "進行管理SaaS",
    category: "saas",
    toneIntent: "clear",
  };
  const rewound = rewindAnswers(answers);
  assert.equal(rewound.category, "saas");
  assert.equal(rewound.toneIntent, "clear");
  assert.equal(rewound._cursor, "category");
  assert.equal(getNextQuestion(rewound).id, "category");
  const revised = answerQuestion(rewound, getNextQuestion(rewound), "service");
  assert.equal(revised.category, "service");
  assert.equal(revised._cursor, undefined);
});
test("document brief pre-fills only values returned from source", () => {
  const brief = parseBriefResult(
    JSON.stringify({
      projectName: "資料LP",
      summary: "法人向けサービス",
      answers: {
        goal: "demo",
        service: "営業支援",
        traffic: ["sales"],
        problem: "",
      },
      filled: ["goal", "service", "traffic"],
      needsConfirmation: ["価格"],
    }),
  );
  assert.equal(brief.answers.service, "営業支援");
  assert.deepEqual(brief.answers.traffic, ["sales"]);
  assert.equal(brief.answers.problem, undefined);
});
test("comments retain source without mutating it", () => {
  assert.equal(createComment("hero", 20, 30, "コピーを強く").status, "open");
});
test("export escapes source content and uses selected tone", () => {
  const html = exportHtml(
    { ...initialProject, name: "<script>" },
    initialSections,
    toneDirections[1],
  );
  assert.doesNotMatch(html, /<title><script>/);
  assert.match(html, /#ff4f00/);
  assert.match(html, /#2447ff/);
});
test("export assigns generated palette by contrast instead of array position", () => {
  const html = exportHtml(initialProject, initialSections, {
    type: "実写編集型",
    palette: ["#1F1A17", "#F4F1EA", "#D94B2B"],
  });
  assert.match(html, /--ink:#1F1A17;--accent:#D94B2B;--paper:#F4F1EA/u);
});
test("exported LP has a valid CTA target and keyboard accessibility contract", () => {
  const html = exportHtml(initialProject, initialSections, toneDirections[0]);
  assert.match(html, /href="#contact"/);
  assert.match(html, /id="contact"/);
  assert.match(html, /class="skip" href="#content"/);
  assert.match(html, /:focus-visible/);
  assert.match(html, /prefers-reduced-motion:reduce/);
  assert.match(html, /aria-label="メインナビゲーション"/);
});
test("exported LP keeps hero identity, mobile navigation, and social image metadata usable", () => {
  const serviceName = "制作会社専用 Claude導入・伴走支援";
  const project = {
    name: serviceName,
    answers: {
      formalServiceName: serviceName,
      service: "従業員5〜50名の制作会社向けに業務を移管する伴走支援",
      canonicalUrl: "https://example.com/services/claude/",
    },
  };
  const sections = [
    { ...initialSections[0], id: "hero", type: "hero", label: serviceName, layoutVariant: "hero-form" },
    { id: "mechanism", type: "mechanism", label: "解決の仕組み", title: "仕組み", content: "- 手順", layoutVariant: "step-flow" },
  ];
  const html = exportHtml(
    project,
    sections,
    toneDirections[0],
    [{ sectionId: "hero", src: "images/hero.png" }],
    { publicUrl: "https://example.com/services/claude/" },
  );
  assert.doesNotMatch(html, /01 \/ 制作会社専用 Claude導入・伴走支援/u);
  assert.match(html, /従業員5〜50名の制作会社へ/u);
  assert.match(html, /class="mobile-nav"/u);
  assert.match(html, /aria-label="モバイルセクションナビゲーション"/u);
  assert.match(html, /<meta property="og:image" content="https:\/\/example\.com\/services\/claude\/images\/hero\.png">/u);
  assert.match(html, /<meta name="twitter:image" content="https:\/\/example\.com\/services\/claude\/images\/hero\.png">/u);
  assert.match(html, /\.table-wrap table\{width:max-content;min-width:720px\}/u);
});
test("delivery zip owns images and remains independent from signed urls", async () => {
  const project = {
    ...initialProject,
    answers: {
      formFields: "会社名,メールアドレス",
      formEndpoint: "https://example.com/forms/lead",
      privacyUrl: "https://example.com/privacy",
      formSuccess: "1営業日以内にご連絡します",
    },
  };
  const sections = initialSections.map((section, index) => index === 0
    ? { ...section, imageBrief: { subject: "製品画面", composition: "横長" } }
    : section);
  const blob = await buildExportBundle({
    project,
    sections,
    tone: toneDirections[0],
    images: ["https://signed.example.com/temporary.png"],
    fetcher: async () => new Response(new Uint8Array([137, 80, 78, 71]), { headers: { "content-type": "image/png" } }),
  });
  const zip = await JSZip.loadAsync(await blob.arrayBuffer());
  const html = await zip.file("index.html").async("string");
  assert.ok(zip.file("thanks.html"));
  assert.ok(zip.file("images/hero-content.png"));
  assert.match(html, /src="images\/hero-content.png"/u);
  assert.doesNotMatch(html, /signed\.example\.com/u);
  assert.equal((html.match(/<h1[ >]/gu) || []).length, 1);
  assert.match(html, /action="https:\/\/example\.com\/forms\/lead"/u);
  assert.match(html, /個人情報の取り扱い/u);
});
test("visual strategy creates distinct section jobs instead of a hero-only image", () => {
  const sections = [
    { id: "hero", type: "hero", title: "判断を仕組みに", body: "制作会社向け", imageBrief: { scene: "制作現場", subject: "ディレクター", composition: "左に余白" } },
    { id: "problem", type: "problem", title: "判断が集中する", body: "一人に案件が集まる", imageBrief: {} },
    { id: "mechanism", type: "mechanism", title: "知見を移す", body: "業務を分解する", imageBrief: {} },
    { id: "usecase", type: "usecase", title: "WFを確認する", body: "若手が判断できる", imageBrief: {} },
    { id: "mechanism-2", type: "mechanism", title: "運用する", body: "定着させる", imageBrief: {} },
    { id: "risk", type: "risk", title: "教育が止まる", body: "依存が続く", imageBrief: {} },
    { id: "process", type: "process", title: "3か月で移す", body: "段階導入", imageBrief: {} },
    { id: "different", type: "differentiation", title: "現場資産を使う", body: "固有の方法", imageBrief: {} },
    { id: "proof", type: "proof", title: "実績", body: "証拠", imageBrief: {} },
    { id: "message", type: "message", title: "次の仕事へ", body: "余力をつくる", imageBrief: {} },
    { id: "form", type: "form", title: "相談", body: "入力", imageBrief: {} },
  ];
  assert.equal(hasMeaningfulImageBrief(sections[0]), true);
  assert.equal(hasMeaningfulImageBrief(sections[1]), false);
  assert.deepEqual(selectVisualSections(sections).map((section) => section.id), ["hero", "problem", "risk", "mechanism", "usecase", "different", "process"]);
  const plan = buildVisualPlan({ project: { name: "テスト", answers: {} }, sections, direction: { concept: "編集写真", palette: ["#111111", "#ffffff", "#ff4f00"] } });
  assert.equal(plan.length, 21);
  assert.equal(new Set(plan.map((item) => item.sectionId)).size, sections.length);
  assert.equal(new Set(plan.map((item) => item.visualId)).size, plan.length);
  assert.ok(plan.filter((item) => item.layer === "content").every((item) => /日本語広告ビジュアル/u.test(item.instruction)));
  assert.ok(plan.filter((item) => item.layer === "background").every((item) => /背景専用画像/u.test(item.instruction)));
  assert.ok(plan.filter((item) => item.layer === "content").every((item) => item.textIntegrated === true && item.copy.main));
  assert.ok(plan.filter((item) => item.layer === "background").every((item) => item.textIntegrated === false));
  assert.ok(plan.every((item) => item.copy.cta === ""));
  assert.ok(plan.filter((item) => item.layer === "content").every((item) => /ボタン、CTAバッジ/u.test(item.instruction)));
  assert.ok(plan.every((item) => item.instruction.length <= 500));
  assert.ok(plan.some((item) => item.sectionId === "proof" && item.layer === "content"));
  assert.ok(plan.some((item) => item.sectionId === "form" && item.layer === "background"));
});
test("targeted image revisions keep untouched section images", () => {
  const merged = mergeVisualRecords(
    [{ sectionId: "hero", src: "old-hero" }, { sectionId: "process", src: "old-process" }],
    [{ sectionId: "hero", src: "new-hero" }],
  );
  assert.deepEqual(merged, [
    { sectionId: "process", src: "old-process" },
    { sectionId: "hero", src: "new-hero" },
  ]);
});
test("runtime prompt remains grounded and requires PENGIN depth", () => {
  const prompt = buildLpPrompt({
    ...initialProject,
    answers: {
      traffic: ["ads"],
      role: "manager",
      goal: "trial",
      service: "進行管理SaaS",
    },
  });
  assert.match(prompt, /<source>/);
  assert.match(prompt, /PENGIN WF品質ゲート/);
  assert.match(prompt, /layoutVariant/);
  assert.match(prompt, /contentModel/);
  assert.match(prompt, /句読点で分解/u);
  const sections = Array.from({ length: 12 }, (_, index) => ({
    ...initialSections[index % initialSections.length],
    id: `s${index}`,
    type: index === 0 ? "hero" : "proof",
    imageBrief:
      index === 0
        ? {
            subject: "製品利用場面",
            composition: "横長",
            constraints: "文字と重ねない",
          }
        : null,
  }));
  sections[1].layoutVariant = "metric-case";
  const parsed = parseLpResult(JSON.stringify({ sections }));
  assert.equal(parsed.length, 12);
  assert.equal(parsed[1].layoutVariant, "metric-case");
});

test("typed section content renders exact evidence without placeholder copy", () => {
  const sections = [
    { ...initialSections[0], contentModel: {} },
    {
      id: "comparison", type: "comparison", label: "比較", role: "選択", userQuestion: "何が違うか",
      title: "判断基準で比べる", body: "導入条件を同じ軸で比較します。", proof: "公開仕様に基づく", content: "比較表",
      implementation: "横スクロール対応", cta: "相談する", contentModel: {
        comparison: { columns: ["本サービス", "従来手段"], rows: [{ label: "初期準備", cells: ["資料から下書き", "白紙から作成"] }] },
      },
    },
    {
      id: "faq", type: "faq", label: "FAQ", role: "不安解消", userQuestion: "何を確認すべきか",
      title: "導入前の確認", body: "公開前に判断したい点です。", proof: "提供条件", content: "FAQ",
      implementation: "details要素", contentModel: { faqs: [
        { question: "資料だけでも始められますか?", answer: "資料から下書きを作り、不足項目だけ確認します。" },
        { question: "修正できますか?", answer: "構成・WF・実装の各段階で修正できます。" },
      ] },
    },
  ];
  const html = exportHtml(initialProject, sections, toneDirections[0]);
  assert.match(html, /資料から下書き/u);
  assert.match(html, /白紙から作成/u);
  assert.match(html, /資料だけでも始められますか/u);
  assert.doesNotMatch(html, /強みを記載|違いを記載|回答を入力|項目1/u);
});

test("typed quality gate rejects missing section-specific data", () => {
  assert.deepEqual(
    contentModelIssues({ type: "comparison", contentModel: { items: [{ title: "曖昧な項目" }] } }),
    ["comparison-data-missing"],
  );
  assert.deepEqual(
    contentModelIssues({ type: "faq", contentModel: { faqs: [{ question: "Q", answer: "A" }] } }),
    ["faq-data-missing"],
  );
});
test("runtime parser repairs only a prematurely closed known section field", () => {
  const tail = Array.from({ length: 11 }, (_, index) => ({
    id: `s${index + 2}`,
    type: "message",
    title: `見出し${index + 2}`,
  }));
  const malformed = `{"headlines":[{"id":"h1","copy":"案1"},{"id":"h2","copy":"案2"},{"id":"h3","copy":"案3"}],"sections":[{"id":"hero","type":"hero","title":"主見出し"},"cta":"相談する"},${tail.map((item) => JSON.stringify(item)).join(",")}],"qualityGate":{}}`;
  const sections = parseLpResult(malformed);
  assert.equal(sections.length, 12);
  assert.equal(sections[0].cta, "相談する");
  assert.equal(parseHeadlineResult(malformed).length, 3);
  assert.throws(
    () => parseLpResult('{"sections":[{"id":"hero"},"unknown":"x"}]}'),
    SyntaxError,
  );
});
test("trust prose is preserved as one typed evidence item without inventing facts", () => {
  const sections = Array.from({ length: 12 }, (_, index) => ({
    id: `s${index}`,
    type: index === 1 ? "trust" : "message",
    title: index === 1 ? "運用実績" : `見出し${index}`,
    body: index === 1 ? "顧客台帳で確認できる導入履歴です。" : "本文",
    proof: index === 1 ? "顧客台帳" : "",
    contentModel: {},
  }));
  const parsed = parseLpResult(JSON.stringify({ sections }));
  assert.deepEqual(parsed[1].contentModel.items, [{
    eyebrow: "信頼材料",
    title: "運用実績",
    description: "顧客台帳で確認できる導入履歴です。",
    proof: "顧客台帳",
  }]);
  assert.deepEqual(contentModelIssues(parsed[1]), []);
});
test("wireframe layout catalog provides multiple evidence-based compositions", () => {
  for (const type of ["hero", "problem", "risk", "mechanism", "process", "proof", "pricing", "cta"])
    assert.ok(wireframeLayoutCatalog[type].length >= 3, `${type} needs variants`);
  assert.equal(normalizeWireframeType({ type: "content", layoutVariant: "case-study-featured" }), "proof");
  assert.equal(resolveWireframeLayout({ type: "hero", asset: "製品画面" }, 0), "hero-product-stage");
  assert.equal(resolveWireframeLayout({ type: "pricing", content: "要件に応じて見積" }, 0), "estimate-explainer");
  assert.equal(resolveWireframeLayout({ type: "problem", layoutVariant: "pain-narrative" }, 0), "pain-narrative");
});
test("generated sections are reflected in the editable structure", () => {
  const markdown = sectionsToMarkdown([
    {
      label: "FIRST VIEW",
      role: "対象と価値を伝える",
      title: "判断を前へ",
      body: "説明文",
      proof: "導入実績",
      cta: "相談する",
    },
  ]);
  assert.match(markdown, /FIRST VIEW/);
  assert.match(markdown, /判断を前へ/);
  assert.match(markdown, /導入実績/);
});
test("user review flow has exactly five required stages", () => {
  assert.deepEqual(
    stages.map((stage) => stage.id),
    ["hearing", "structure", "wireframe", "tone", "implementation"],
  );
});
test("edited markdown propagates into wireframe sections", () => {
  const markdown = sectionsToMarkdown(initialSections).replace(
    "進捗確認を、仕事にしない。",
    "確認作業を、ゼロへ。",
  );
  assert.equal(
    markdownToSections(markdown, initialSections)[0].title,
    "確認作業を、ゼロへ。",
  );
});
test("tone generation prompt and parser require three distinct directions", () => {
  const promptInput = {
    ...initialProject,
    kind: "tone",
    markdown: "# 構成",
    answers: { traffic: "ads", role: "manager", goal: "trial" },
  };
  assert.match(buildLpPrompt(promptInput), /色違いではない3つ/);
  const directions = parseToneResult(
    JSON.stringify({
      directions: [1, 2, 3].map((index) => ({
        id: `d${index}`,
        name: `案${index}`,
        concept: "概念",
        copy: "コピー",
        palette: ["#111111", "#ffffff", "#ff4f00"],
        type: "ゴシック",
        image: "人物写真",
        layout: "非対称",
        motion: "滑らか",
        avoid: "テンプレ",
      })),
    }),
  );
  assert.equal(directions.length, 3);
});
test("structure stage exposes three selectable headlines", () => {
  const headlines = parseHeadlineResult(
    JSON.stringify({
      headlines: [1, 2, 3].map((index) => ({
        id: `h${index}`,
        copy: `コピー${index}`,
        reason: "選定理由",
      })),
    }),
  );
  assert.equal(headlines.length, 3);
});
test("position comments retain section element and coordinates", () => {
  const comment = createComment("hero", 22.5, 47.5, "コピーを短く", "headline");
  assert.equal(comment.element, "headline");
  assert.equal(comment.x, 22.5);
});
test("wireframe audit requires generated KV brief and conditional business sections", () => {
  const sections = Array.from({ length: 12 }, (_, index) => ({
    id: `s${index}`,
    type:
      index === 0
        ? "hero"
        : index === 4
          ? "comparison"
          : index === 8
            ? "pricing"
            : index === 9
              ? "faq"
            : index === 10
              ? "proof"
              : index === 11
                ? "form"
                : "mechanism",
    title: `見出し${index}`,
    body: "具体的な本文",
    role: "読者の判断を進める",
    userQuestion: "何を判断するか",
    proof: "資料内の根拠",
    content: index === 9 ? "Q: 導入期間はどのくらいですか\nA: 標準は3か月です" : "具体コンテンツ",
    implementation: "PC/SP対応",
    cta: index % 3 === 0 ? "無料相談する" : "",
    imageBrief:
      index === 0
        ? {
            subject: "制作チーム",
            composition: "横長の編集写真",
            constraints: "文字可読性を確保",
          }
        : null,
  }));
  const result = auditWireframe(sections, {
    competition: "Excel",
    priceAmount: "月額9800円",
    evidenceDetails: "導入企業12社(顧客台帳)",
    formFields: "会社名,メールアドレス",
    formEndpoint: "https://example.com/forms/lead",
    privacyUrl: "https://example.com/privacy",
    formalCompanyName: "株式会社テスト",
    formalServiceName: "テストサービス",
    brandAssets: "ロゴとブランドカラーあり",
    complianceSettings: "景品表示法を確認",
    compliance: "景品表示法を確認",
    forbiddenClaims: "No.1禁止",
    excludedAudience: "個人利用",
    conversionDefinition: "フォーム送信完了",
  });
  assert.equal(result.mustViolations.length, 0);
  assert.equal(result.humanChecks.length, 5);
});
test("targeted comments replace only their exact sections", () => {
  const current = [
    { id: "hero", title: "元FV" },
    { id: "proof", title: "元根拠" },
  ];
  const generated = [
    { id: "hero", title: "新FV" },
    { id: "proof", title: "変更されても使わない" },
  ];
  const merged = mergeTargetedSections(current, generated, [
    { sectionId: "hero" },
  ]);
  assert.equal(merged[0].title, "新FV");
  assert.equal(merged[1], current[1]);
});
test("targeted comments tolerate normalized id drift and skip missing targets", () => {
  const current = [{ id: "hero-main", title: "元FV" }, { id: "proof", title: "元根拠" }];
  const generated = [{ id: "Hero Main", title: "新FV" }];
  const merged = mergeTargetedSections(current, generated, [
    { sectionId: "HERO_MAIN" },
    { sectionId: "missing" },
  ]);
  assert.equal(merged[0].title, "新FV");
  assert.equal(merged[1], current[1]);
});
test("generation endpoints require auth and rate limit only mutation calls", () => {
  const env = { NODE_ENV: "production", AI_LP_ACCESS_TOKEN: "secret" };
  const headers = {
    "x-ai-lp-access-token": "secret",
    "x-ai-lp-session": "session-1234567890",
    "x-forwarded-for": "203.0.113.1",
  };
  assert.equal(authorizeRequest({ headers: {} }, env).status, 401);
  assert.equal(authorizeRequest({ headers }, env, 1, { rateLimit: false }).ok, true);
  for (let index = 0; index < 12; index += 1)
    assert.equal(authorizeRequest({ headers }, env, 10 + index).ok, true);
  assert.equal(authorizeRequest({ headers }, env, 30).status, 429);
  assert.doesNotMatch(publicError(new Error("DATABASE_PASSWORD=secret")).error, /secret/u);
});
test("quality gate keeps provenance categories separate", () => {
  const gate = parseQualityGate(
    JSON.stringify({
      qualityGate: {
        applied: ["LP項目"],
        skipped: ["サイト用ナビ"],
        known: ["資料内の事実"],
        hypotheses: ["仮説"],
        unconfirmed: ["未確認"],
      },
    }),
  );
  assert.deepEqual(gate.known, ["資料内の事実"]);
  assert.deepEqual(gate.hypotheses, ["仮説"]);
  assert.deepEqual(gate.unconfirmed, ["未確認"]);
});