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, ["未確認"]);
});