295 lines
11 KiB
JavaScript
295 lines
11 KiB
JavaScript
import { test, before, after } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
import * as os from "node:os";
|
|
import { fileURLToPath } from "node:url";
|
|
import { createAppServer } from "../server/index.js";
|
|
import { WHACK_CARDS } from "../server/whack_cards.js";
|
|
import { buildNanoBananaPrompt } from "../server/nano_banana.js";
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
const PUBLIC_WHACK_DIR = path.resolve(__dirname, "..", "public", "whack_pack");
|
|
|
|
let server;
|
|
let baseUrl;
|
|
let tmpDir;
|
|
|
|
before(async () => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "sparkvault-test-"));
|
|
const dbPath = path.join(tmpDir, "test-ideas.db");
|
|
const mockupsDir = path.join(tmpDir, "mockups");
|
|
|
|
const app = createAppServer({ dbPath, mockupsDir });
|
|
server = app.server;
|
|
|
|
await new Promise((resolve) => {
|
|
server.listen(0, "127.0.0.1", () => {
|
|
const addr = server.address();
|
|
baseUrl = `http://127.0.0.1:${addr.port}`;
|
|
resolve();
|
|
});
|
|
});
|
|
});
|
|
|
|
after(async () => {
|
|
if (server) {
|
|
await new Promise((resolve) => server.close(resolve));
|
|
}
|
|
});
|
|
|
|
test("All 64 Creative Whack Pack cards are indexed and have local JPG scans in public/whack_pack", async () => {
|
|
assert.equal(WHACK_CARDS.length, 64, "Should have exactly 64 Creative Whack Pack cards");
|
|
|
|
const suitsCount = { explorer: 0, artist: 0, judge: 0, warrior: 0 };
|
|
for (const card of WHACK_CARDS) {
|
|
suitsCount[card.suit]++;
|
|
const diskPath = path.join(PUBLIC_WHACK_DIR, card.file);
|
|
assert.ok(fs.existsSync(diskPath), `Missing card scan on disk: ${card.file}`);
|
|
assert.ok(card.promptQuestion.length > 10, `Card #${card.number} should have a guiding promptQuestion`);
|
|
}
|
|
|
|
assert.deepEqual(suitsCount, {
|
|
explorer: 16,
|
|
artist: 16,
|
|
judge: 16,
|
|
warrior: 16
|
|
});
|
|
|
|
const res = await fetch(`${baseUrl}/api/whack-cards`);
|
|
assert.equal(res.status, 200);
|
|
const data = await res.json();
|
|
assert.equal(data.cards.length, 64);
|
|
});
|
|
|
|
test("Quick Capture parses inline #tags, @category (@tshirt), and !high priority boost", async () => {
|
|
const res = await fetch(`${baseUrl}/api/ideas`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
title: "Retro Red Rocks Amphitheatre Tee @tshirt #colorado #screenprint !high"
|
|
})
|
|
});
|
|
assert.equal(res.status, 201);
|
|
const { idea } = await res.json();
|
|
|
|
assert.equal(idea.title, "Retro Red Rocks Amphitheatre Tee");
|
|
assert.equal(idea.category, "creative");
|
|
assert.equal(idea.status, "spark");
|
|
assert.equal(idea.rating_excitement, 5);
|
|
assert.ok(idea.tags.includes("tshirt"));
|
|
assert.ok(idea.tags.includes("colorado"));
|
|
assert.ok(idea.tags.includes("screenprint"));
|
|
});
|
|
|
|
test("Creative Whack Pack Draw, 4-Role Spread, Idea Collider, and Logging a Whack reflection work", async () => {
|
|
// 1. 4-Role Spread
|
|
const spreadRes = await fetch(`${baseUrl}/api/whack-cards/draw?mode=spread`);
|
|
assert.equal(spreadRes.status, 200);
|
|
const spreadData = await spreadRes.json();
|
|
assert.equal(spreadData.cards.length, 4);
|
|
assert.deepEqual(
|
|
spreadData.cards.map((c) => c.suit),
|
|
["explorer", "artist", "judge", "warrior"]
|
|
);
|
|
|
|
// 2. Idea Collider
|
|
const colliderRes = await fetch(`${baseUrl}/api/whack-cards/draw?mode=collider&suit=artist`);
|
|
assert.equal(colliderRes.status, 200);
|
|
const colliderData = await colliderRes.json();
|
|
assert.equal(colliderData.card.suit, "artist");
|
|
assert.ok(colliderData.idea && colliderData.idea.id);
|
|
|
|
// 3. Log a Whack breakthrough onto the collided idea
|
|
const targetId = colliderData.idea.id;
|
|
const whackRes = await fetch(`${baseUrl}/api/ideas/${targetId}/whack`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
cardNumber: 27,
|
|
reflection: "Reverse the color scheme: print cream ink onto a heavyweight forest green shirt.",
|
|
addChecklistItem: true
|
|
})
|
|
});
|
|
assert.equal(whackRes.status, 200);
|
|
const whackUpdated = await whackRes.json();
|
|
assert.equal(whackUpdated.idea.whack_history[0].cardNumber, 27);
|
|
assert.equal(whackUpdated.idea.whack_history[0].cardTitle, "Reverse");
|
|
assert.ok(
|
|
whackUpdated.idea.checklist.some((c) => c.text.includes("[Card #27 Reverse]")),
|
|
"Should append reflection to checklist when addChecklistItem is true"
|
|
);
|
|
});
|
|
|
|
test("Visual Mockup Studio saves canvas T-shirt mockups and builds Nano Banana 2.1 prompts", async () => {
|
|
// Verify Nano Banana prompt builder for Left-Chest T-Shirt + Whack Card
|
|
const prompt = buildNanoBananaPrompt({
|
|
rawPrompt: "Minimalist Alpine Ibex emblem in burnt orange and gold",
|
|
ideaTitle: "Ibex Mountain Tee",
|
|
template: "tshirt-left-chest",
|
|
garmentColorName: "Vintage Cream",
|
|
garmentHex: "#F5E8D3",
|
|
whackCard: WHACK_CARDS[21] // #22 Exaggerate
|
|
});
|
|
assert.ok(prompt.includes("5-inch left-chest"));
|
|
assert.ok(prompt.includes("Vintage Cream"));
|
|
assert.ok(prompt.includes("Exaggerate"));
|
|
|
|
// Save a 1x1 PNG data URL via /api/ideas/1/mockups/canvas
|
|
const samplePngDataUrl =
|
|
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
|
|
|
|
const saveRes = await fetch(`${baseUrl}/api/ideas/1/mockups/canvas`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
title: "Left-Chest Cream Proof",
|
|
template: "tshirt-left-chest",
|
|
garmentColor: "#F5E8D3",
|
|
garmentColorName: "Vintage Cream",
|
|
placement: "left-chest",
|
|
imageDataUrl: samplePngDataUrl
|
|
})
|
|
});
|
|
assert.equal(saveRes.status, 201);
|
|
const saveData = await saveRes.json();
|
|
assert.equal(saveData.mockup.placement, "left-chest");
|
|
assert.equal(saveData.idea.cover_mockup_url, saveData.mockup.imageUrl);
|
|
|
|
// Verify the saved mockup image is served over HTTP
|
|
const imgRes = await fetch(`${baseUrl}${saveData.mockup.imageUrl}`);
|
|
assert.equal(imgRes.status, 200);
|
|
assert.equal(imgRes.headers.get("content-type"), "image/png");
|
|
});
|
|
|
|
test("Head-to-Head Arena and Triage Review endpoints update arena_wins and composite_score", async () => {
|
|
const revRes = await fetch(`${baseUrl}/api/review/next`);
|
|
assert.equal(revRes.status, 200);
|
|
const revData = await revRes.json();
|
|
assert.ok(revData.triageQueue.length >= 2);
|
|
assert.equal(revData.arenaPair.length, 2);
|
|
|
|
const [first, second] = revData.arenaPair;
|
|
const beforeWins = first.arena_wins;
|
|
|
|
const arenaRes = await fetch(`${baseUrl}/api/review/arena`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ winnerId: first.id, loserId: second.id })
|
|
});
|
|
assert.equal(arenaRes.status, 200);
|
|
const arenaData = await arenaRes.json();
|
|
assert.equal(arenaData.winner.arena_wins, beforeWins + 1);
|
|
});
|
|
|
|
test("External notes and images can be attached to a spark and promoted to Mockup Studio", async () => {
|
|
const samplePngDataUrl =
|
|
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
|
|
|
|
const attRes = await fetch(`${baseUrl}/api/ideas/1/attachments`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
title: "Vintage Ski Patch Photo",
|
|
textContent: "Found this 1978 Chamonix felt patch as color reference.",
|
|
sourceUrl: "https://example.com/chamonix-patch",
|
|
imageDataUrl: samplePngDataUrl,
|
|
appendToNotes: true,
|
|
setAsCover: true
|
|
})
|
|
});
|
|
assert.equal(attRes.status, 201);
|
|
const attData = await attRes.json();
|
|
assert.equal(attData.attachment.title, "Vintage Ski Patch Photo");
|
|
assert.equal(attData.attachment.type, "image");
|
|
assert.ok(attData.idea.notes_markdown.includes("1978 Chamonix felt patch"));
|
|
assert.ok(attData.idea.links.some((l) => l.url === "https://example.com/chamonix-patch"));
|
|
|
|
// Promote attachment to Mockup Studio
|
|
const promRes = await fetch(
|
|
`${baseUrl}/api/ideas/1/attachments/${attData.attachment.id}/promote-mockup`,
|
|
{ method: "POST" }
|
|
);
|
|
assert.equal(promRes.status, 201);
|
|
const promData = await promRes.json();
|
|
assert.equal(promData.idea.mockups[0].title, "Vintage Ski Patch Photo");
|
|
});
|
|
|
|
test("Personalized Random Idea Generator creates a tailored idea from background + Whack Pack card", async () => {
|
|
const res = await fetch(`${baseUrl}/api/ideas/generate-random`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
category: "creative"
|
|
})
|
|
});
|
|
assert.equal(res.status, 201);
|
|
const data = await res.json();
|
|
assert.ok(data.idea && data.idea.id);
|
|
assert.ok(data.idea.title.length > 5);
|
|
assert.ok(data.card && data.card.number >= 1 && data.card.number <= 64);
|
|
assert.equal(data.idea.inspired_by_card, data.card.number);
|
|
assert.ok(data.idea.whack_history.length >= 1);
|
|
});
|
|
|
|
test("Suggest Names endpoint returns 6 creative name and tagline suggestions for an idea", async () => {
|
|
const res = await fetch(`${baseUrl}/api/ideas/1/suggest-names`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" }
|
|
});
|
|
assert.equal(res.status, 200);
|
|
const data = await res.json();
|
|
assert.ok(Array.isArray(data.suggestions));
|
|
assert.equal(data.suggestions.length, 6);
|
|
assert.ok(data.suggestions[0].name);
|
|
assert.ok(data.suggestions[0].tagline);
|
|
});
|
|
|
|
test("Development Handoff endpoint compiles full Spark spec and submits to Antigravity or Codex with model picker", async () => {
|
|
const specRes = await fetch(`${baseUrl}/api/ideas/1/spec.md?target=codex&model=gpt-5.6-sol`);
|
|
assert.equal(specRes.status, 200);
|
|
const specText = await specRes.text();
|
|
assert.match(specText, /Development Handoff Specification/);
|
|
assert.match(specText, /gpt-5\.6-sol/);
|
|
|
|
const handoffRes = await fetch(`${baseUrl}/api/ideas/1/dev-handoff`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
targetApp: "codex",
|
|
model: "gpt-5.6-sol",
|
|
includeHomelab: true,
|
|
moveStatusToBuilding: true
|
|
})
|
|
});
|
|
assert.equal(handoffRes.status, 200);
|
|
const handoffData = await handoffRes.json();
|
|
assert.equal(handoffData.handoff.targetApp, "codex");
|
|
assert.equal(handoffData.handoff.model, "gpt-5.6-sol");
|
|
assert.match(handoffData.handoff.deepLinkUrl, /^codex:\/\/threads\/new/);
|
|
assert.equal(handoffData.idea.status, "building");
|
|
|
|
// Verify Antigravity handoff with updated models (Gemini 3.8 Flash default)
|
|
const agyRes = await fetch(`${baseUrl}/api/ideas/1/dev-handoff`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
targetApp: "antigravity",
|
|
model: "gemini-3.8-flash",
|
|
includeHomelab: true,
|
|
moveStatusToBuilding: true
|
|
})
|
|
});
|
|
assert.equal(agyRes.status, 200);
|
|
const agyData = await agyRes.json();
|
|
assert.equal(agyData.handoff.targetApp, "antigravity");
|
|
assert.equal(agyData.handoff.model, "gemini-3.8-flash");
|
|
assert.equal(agyData.handoff.modelLabel, "Gemini 3.8 Flash (Medium)");
|
|
assert.match(agyData.handoff.deepLinkUrl, /^antigravity:\/\/new\?model=gemini-3\.8-flash/);
|
|
});
|
|
|
|
|
|
|
|
|