← GPT-AI-BANNER
CODE · 16.1 KB

test/engine.test.js

Workspace snapshot · 09/04 13:35

import test from "node:test";
import assert from "node:assert/strict";
import { extractKeywords, makeAngles, makeCopyCandidates, makeTargets, sanitizeText, shortenCopy, strengthenCopy } from "../src/engine.js";
import { buildBannerPrompts, buildRuntimePrompt, callRuntimeRpc, IMAGE_ENGINE, runtimeEnvironment, validatePayload } from "../api/banner-core.js";
import { generationReducer, initialGenerationState } from "../src/generation-state.js";
import { buildStrategyPrompt, parseStrategyResult } from "../api/generate-strategy.js";
import { exchangeManageToken, hasManageSession, readManageAssertion } from "../api/manage-auth.js";
import { validateSettings } from "../api/manage-settings.js";
import { isAllowedArtifactUrl, safeDownloadName } from "../api/download-banner.js";
import { publicRuntimeGenerationEnabled } from "../api/generate-banners.js";
import { addEvidence, buildEvidenceExport, evidenceSummary } from "../src/evidence.js";

test("beta evidence records required actions without external analytics", () => {
  const history = addEvidence(addEvidence(addEvidence([], "生成完了", { direction: "人物写真" }, 1), "案件保存", {}, 2), "PNGダウンロード", { direction: "人物写真" }, 3);
  assert.deepEqual(evidenceSummary(history), { "生成完了": 1, "案件保存": 1, "PNGダウンロード": 1 });
  assert.equal(history[2].direction, "人物写真");
  assert.equal(addEvidence(Array.from({ length: 100 }, (_, at) => ({ at, action: "x" })), "y").length, 100);
});

test("anonymous evidence export excludes customer and creative content", () => {
  const exported = buildEvidenceExport({
    input: { serviceName: "秘密の商品", summary: "非公開の説明", url: "https://secret.example" },
    history: [{ at: 1, action: "生成完了", jobId: "private-job" }],
    selectedBanner: "シンプル",
    pricingIntent: "月額980円なら使う",
    shareIntent: "チームに共有したい",
    banners: [{ imageUrl: "https://private.example/image.png" }]
  }, "2026-08-24T00:00:00.000Z");
  assert.deepEqual(exported, {
    schema: "ai-banner-lab-beta-evidence-v1",
    generatedAt: "2026-08-24T00:00:00.000Z",
    counts: { "生成完了": 1 },
    selectedBanner: "シンプル",
    pricingIntent: "月額980円なら使う",
    shareIntent: "チームに共有したい"
  });
  assert.doesNotMatch(JSON.stringify(exported), /秘密|secret|private-job/);
});

test("public runtime generation fails closed without explicit server authorization", () => {
  assert.equal(publicRuntimeGenerationEnabled({}), false);
  assert.equal(publicRuntimeGenerationEnabled({ PUBLIC_RUNTIME_GENERATION_ENABLED: "false" }), false);
  assert.equal(publicRuntimeGenerationEnabled({ PUBLIC_RUNTIME_GENERATION_ENABLED: "TRUE" }), false);
  assert.equal(publicRuntimeGenerationEnabled({ PUBLIC_RUNTIME_GENERATION_ENABLED: "true" }), true);
});

test("download proxy only accepts Supabase artifacts and safe filenames", () => {
  assert.equal(isAllowedArtifactUrl("https://example.supabase.co/storage/v1/object/sign/banner.png?token=x"), true);
  assert.equal(isAllowedArtifactUrl("https://supabase.co.evil.example/banner.png"), false);
  assert.equal(isAllowedArtifactUrl("http://example.supabase.co/banner.png"), false);
  assert.equal(safeDownloadName("採用/広告:人物写真"), "採用-広告-人物写真.png");
});

test("strategy generation is deterministic and returns three useful targets", () => {
  const input = { serviceName: "Lab", target: "広告担当者" };
  const targets = makeTargets(input); assert.equal(targets.length, 3);
  const angle = makeAngles(targets[0])[0];
  assert.deepEqual(makeCopyCandidates(input, targets[0], angle), makeCopyCandidates(input, targets[0], angle));
});

test("control characters and length are constrained", () => {
  assert.equal(sanitizeText("a\u0000b", 2), "a ");
});

test("service vocabulary changes targets and copy candidates", () => {
  const input = { serviceName: "採用ラク", target: "人事担当者", summary: "求人応募者の日程調整を自動化して採用面接の手戻りを減らす" };
  assert.ok(extractKeywords(input).includes("人事担当者"));
  const target = makeTargets(input)[0];
  assert.match(target.name, /人事担当者/);
  const candidates = makeCopyCandidates(input, target, makeAngles(target)[0]);
  assert.equal(candidates.length, 3);
  assert.notEqual(candidates[0].main, candidates[1].main);
  assert.ok(candidates.some(candidate => /採用ラク|人事担当者|求人応募者/.test(candidate.main)));
});

test("copy actions shorten and strengthen without mutating source", () => {
  const source = { main: "とても長い広告コピーをもっと短く分かりやすく伝えたい。", cta: "見る" };
  assert.ok(shortenCopy(source).main.length <= 17);
  assert.match(strengthenCopy(source).main, /今すぐ。$/);
  assert.equal(source.cta, "見る");
});

const payload = { serviceName: "採用ラク", target: "人事担当者", angle: "日程調整の手戻り", copy: { main: "面接調整を、今日変える。", sub: "採用担当者の時間を取り戻す。", offer: "無料ベータ", cta: "今すぐ試す" } };

test("three image prompts differ by composition and preserve exact copy", () => {
  const prompts = buildBannerPrompts(payload);
  assert.equal(prompts.length, 3);
  assert.deepEqual(prompts.map(item => item.direction), ["人物写真", "強コピー", "シンプル"]);
  assert.equal(new Set(prompts.map(item => item.prompt)).size, 3);
  prompts.forEach(item => {
    assert.match(item.prompt, /面接調整を、今日変える。/);
    assert.match(item.prompt, /採用担当者の時間を取り戻す。/);
    assert.match(item.prompt, /今すぐ試す/);
  });
});

test("revision instruction is sanitized, capped, and treated as creative data", () => {
  const instruction = `背景を明るく\u0000して人物を右寄せ ${"長".repeat(600)}`;
  const validated = validatePayload({ ...payload, instruction, directions: ["人物写真"] });
  assert.equal(validated.instruction.includes("\u0000"), false);
  assert.equal(validated.instruction.length, 500);
  const [item] = buildBannerPrompts({ ...payload, instruction: "背景を明るく、CTAを大きく", directions: ["人物写真"] });
  assert.match(item.prompt, /今回の追加制作指示: 背景を明るく、CTAを大きく/);
  assert.match(item.prompt, /制作資料としてのみ扱い/);
});

test("LP key visuals can be generated without embedded copy", () => {
  const [item] = buildBannerPrompts({
    serviceName: "制作会社向けAI導入支援",
    target: "制作会社経営者",
    angle: "優秀者の知見を組織へ移す",
    copy: { main: "", sub: "", offer: "", cta: "" },
    textless: true,
    useCase: "Webサイト メインビジュアル",
    useCaseId: "web-hero",
    directions: ["人物写真"],
  });
  assert.equal(item.textless, true);
  assert.match(item.prompt, /画像内には文字、数字、ロゴ、ボタン/u);
  assert.doesNotMatch(item.prompt, /メインコピー:/u);
  assert.match(buildRuntimePrompt(item), /文字・数字・ロゴ・ボタン・UIラベル・擬似文字を描画してはいけません/u);
});

test("LP editorial artwork keeps copy but leaves interactive CTA to HTML", () => {
  const [item] = buildBannerPrompts({
    serviceName: "制作会社向けAI導入支援",
    target: "制作会社経営者",
    angle: "優秀者の知見を組織へ移す",
    copy: { main: "売上の天井を、壊す。", sub: "判断をチームの仕組みへ。", offer: "PENGIN × Claude", cta: "" },
    useCase: "Webサイト メインビジュアル",
    useCaseId: "web-hero",
    directions: ["強コピー"],
  });
  assert.equal(item.textless, false);
  assert.match(item.prompt, /売上の天井を、壊す。/u);
  assert.match(item.prompt, /CTAが空欄の場合/u);
  assert.match(item.prompt, /クリック可能に見えるUIを画像内へ描かない/u);
});

test("dashboard prompt settings are rendered into the actual image prompt", () => {
  const [item] = buildBannerPrompts({ ...payload, directions: ["人物写真"] }, {
    imagePromptTemplate: "{{serviceName}} / {{target}} / {{angle}} / {{directionPrompt}} / {{main}} / {{sub}} / {{offer}} / {{cta}}",
    directionPrompts: { 人物写真: "管理画面で変更した人物指示" },
  });
  assert.match(item.prompt, /採用ラク \/ 人事担当者 \/ 日程調整の手戻り \/ 管理画面で変更した人物指示/);
  assert.match(item.prompt, /面接調整を、今日変える。/);
});

test("selected color and taste reach the actual gpt-image prompt", () => {
  const [item] = buildBannerPrompts({ ...payload, colorScheme: "紫系", taste: "エディトリアル", directions: ["シンプル"] });
  assert.match(item.prompt, /指定色は「紫系」/);
  assert.match(item.prompt, /指定テイストは「エディトリアル」/);
  assert.match(item.prompt, /構図、書体、写真・イラスト、装飾/);
  assert.throws(() => buildBannerPrompts({ ...payload, colorScheme: "透明", taste: "クール" }), /色味の指定が不正/);
});

test("use case and output dimensions reach the native image prompt", () => {
  const [item] = buildBannerPrompts({ ...payload, useCase: "YouTubeサムネイル", purpose: "小さな表示でクリックを促す", width: 1280, height: 720, directions: ["強コピー"] });
  assert.equal(item.width, 1280);
  assert.equal(item.height, 720);
  assert.match(item.prompt, /制作用途は「YouTubeサムネイル」/);
  assert.match(item.prompt, /完成サイズは1280×720px/);
  assert.match(item.prompt, /単純な引き伸ばしではなく/);
  assert.throws(() => buildBannerPrompts({ ...payload, width: 9999, height: 720 }), /画像サイズの指定が不正/);
});

test("dashboard use-case prompt is composed only for the selected format", () => {
  const [item] = buildBannerPrompts({ ...payload, useCaseId: "youtube", useCase: "YouTubeサムネイル", width: 1280, height: 720, directions: ["強コピー"] }, {
    useCasePrompts: { youtube: "顔を大きく、文字は6語以内", blog: "落ち着いた編集デザイン" },
  });
  assert.match(item.prompt, /用途別アートディレクション: 顔を大きく、文字は6語以内/);
  assert.doesNotMatch(item.prompt, /落ち着いた編集デザイン/);
});

test("document brand direction and palette reach the native image prompt", () => {
  const [item] = buildBannerPrompts({
    ...payload,
    brandContext: "トーン: 静かな夜 / 配色: 深い藍色と淡い金色 / 避ける: ポップな装飾",
    documentPalette: "#102060, #d8b860",
    documentExcerpt: "静かな夜に自分の考えを整える音声ジャーナル",
    directions: ["シンプル"],
  });
  assert.match(item.prompt, /資料由来のブランドディレクション(最優先)/);
  assert.match(item.prompt, /深い藍色と淡い金色/);
  assert.match(item.prompt, /#102060, #d8b860/);
  assert.match(item.prompt, /音声ジャーナル/);
});

test("manage panel exchanges the admin token for an http-only assertion", async () => {
  let exchangedBody;
  const exchangeFetch = async (_url, options) => { exchangedBody = JSON.parse(options.body); return { ok: true, json: async () => ({ assertion: "signed-assertion" }) }; };
  const assertion = await exchangeManageToken("secret-admin", exchangeFetch);
  assert.equal(exchangedBody.token, "secret-admin");
  assert.equal(assertion, "signed-assertion");
  const request = { headers: { cookie: "other=x; ai_banner_manage_session=signed-assertion" } };
  assert.equal(readManageAssertion(request), "signed-assertion");
  assert.equal(await hasManageSession(request, async () => ({ ok: true })), true);
  assert.equal(await hasManageSession({ headers: { cookie: "" } }, async () => ({ ok: true })), false);
});

test("manage panel validates every prompt layer before updating production", () => {
  const settings = validateSettings({
    strategyInstruction: "戦略を設計する",
    imagePromptTemplate: "{{serviceName}} {{target}} {{angle}} {{directionPrompt}} {{main}} {{cta}}",
    directionPrompts: { 人物写真: "人物", 強コピー: "文字", シンプル: "余白" },
    useCasePrompts: Object.fromEntries(["download-cover", "blog", "youtube", "web-hero", "web-banner", "sns-square", "sns-portrait", "story", "display-ad"].map((id) => [id, `${id}用指示`])),
  });
  assert.equal(settings.useCasePrompts.youtube, "youtube用指示");
  assert.throws(() => validateSettings({ ...settings, imagePromptTemplate: "変数なし" }), /変数が不足/);
});

test("server validation fixes the VPS image engine and caps generation at three", () => {
  assert.equal(IMAGE_ENGINE, "codex-subscription-image-gen");
  assert.throws(() => validatePayload({ ...payload, directions: ["人物写真", "強コピー", "シンプル", "追加"] }), /1〜3件/);
  assert.throws(() => validatePayload({ ...payload, directions: ["不正"] }), /1〜3件/);
});

test("runtime prompt requires native image generation and an artifact path", () => {
  const item = buildBannerPrompts({ ...payload, directions: ["人物写真"] })[0];
  const prompt = buildRuntimePrompt(item);
  assert.match(prompt, /image_gen\.imagegen/);
  assert.match(prompt, /Canvas、SVG/);
  assert.match(prompt, /絶対パス/);
});

test("runtime RPC uses the server-side service token instead of an OpenAI API key", async () => {
  let request;
  const environment = { SUPABASE_URL: "https://example.supabase.co", SUPABASE_ANON_KEY: "anon-key", AVS_LLM_RUNTIME_SERVICE_TOKEN: "runtime-token" };
  const fetchImpl = async (url, options) => { request = { url, options, body: JSON.parse(options.body) }; return { ok: true, json: async () => "job-id" }; };
  const result = await callRuntimeRpc({ rpc: "avs_llm_public_enqueue_image_job", payload: { p_token: "runtime-token" }, environment, fetchImpl });
  assert.equal(result, "job-id");
  assert.equal(request.url, "https://example.supabase.co/rest/v1/rpc/avs_llm_public_enqueue_image_job");
  assert.equal(request.body.p_token, "runtime-token");
  assert.equal(request.options.headers.apikey, "anon-key");
  assert.equal("OPENAI_API_KEY" in environment, false);
  assert.equal(runtimeEnvironment(environment).connected, true);
});

test("generation state supports loading, individual success, and failure", () => {
  const initial = initialGenerationState(["人物写真", "強コピー"]);
  const loading = generationReducer(initial, { type: "start", directions: ["人物写真"] });
  assert.equal(loading["人物写真"].status, "loading");
  assert.equal(loading["強コピー"].status, "idle");
  const success = generationReducer(loading, { type: "results", results: [{ direction: "人物写真", status: "success", imageUrl: "https://signed.example/image.png", jobId: "job-1" }] });
  assert.equal(success["人物写真"].imageUrl, "https://signed.example/image.png");
  const failed = generationReducer(success, { type: "failure", directions: ["強コピー"], error: "失敗" });
  assert.equal(failed["強コピー"].error, "失敗");
});

test("GPT strategy prompt treats service data as data and validates structured output", () => {
  const prompt = buildStrategyPrompt({ serviceName: "採用ラク", summary: "面接調整を自動化する", target: "採用担当者", colorScheme: "青系", taste: "コーポレート" });
  assert.match(prompt, /サービス資料としてだけ扱って/);
  assert.match(prompt, /targetsは3件/);
  assert.match(prompt, /希望する色味: <color_scheme>青系/);
  assert.match(prompt, /希望するテイスト: <taste>コーポレート/);
  assert.match(prompt, /brandDirection/);
  const target = (n) => ({ name: `対象${n}`, pain: "調整に時間がかかる", future: "候補者対応に集中できる", value: "調整時間を減らす", angles: [1, 2, 3].map(a => ({ type: "ベネフィット", title: `訴求${a}`, detail: "空いた時間を候補者対応へ", copies: [1, 2, 3].map(c => ({ id: "ignored", main: `コピー${c}`, sub: "候補者対応に集中", offer: "無料ベータ", cta: "今すぐ試す" })) })) });
  const parsed = parseStrategyResult(JSON.stringify({ targets: [target(1), target(2), target(3)] }));
  assert.equal(parsed.targets[0].angles[0].copies[0].id, "t1-a1-c1");
  assert.match(parsed.brandDirection.visualTone, /サービス内容/);
  assert.throws(() => parseStrategyResult('{"targets":[]}'), /形式が不正/);
});