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":[]}'), /形式が不正/);
});