← back to Wallco Ai
marketplace ai: tests for qwen3:14b tag-pattern + write-pattern-description (4/4 pass live)
ecc625c34124812c45d9064c6f13c5aefbcf90c3 · 2026-05-13 15:00:03 -0700 · SteveStudio2
Files touched
A tests/marketplace/ai-text.test.js
Diff
commit ecc625c34124812c45d9064c6f13c5aefbcf90c3
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Wed May 13 15:00:03 2026 -0700
marketplace ai: tests for qwen3:14b tag-pattern + write-pattern-description (4/4 pass live)
---
tests/marketplace/ai-text.test.js | 197 ++++++++++++++++++++++++++++++++++++++
1 file changed, 197 insertions(+)
diff --git a/tests/marketplace/ai-text.test.js b/tests/marketplace/ai-text.test.js
new file mode 100644
index 0000000..a22e7cc
--- /dev/null
+++ b/tests/marketplace/ai-text.test.js
@@ -0,0 +1,197 @@
+// HTTP integration tests for the qwen3:14b-powered marketplace text AI:
+// POST /api/marketplace/ai/tag-pattern
+// POST /api/marketplace/ai/write-pattern-description
+//
+// Run: node tests/marketplace/ai-text.test.js
+//
+// Strategy:
+// 1. Always assert the legacy mock-shape contract (empty body → 200 + arrays).
+// 2. Probe Ollama directly at OLLAMA_URL. If it's unreachable, mark the live
+// tests SKIP — we still want green CI when Mac1 is offline.
+// 3. If Ollama is live AND a real pattern slug is available, exercise the
+// full happy path: live status, qwen3 model marker, persistence flag,
+// tag families non-empty, copy fields non-empty.
+//
+// Each LLM call can take 30-90s on qwen3:14b, so the per-test http timeout
+// is generous (180s).
+'use strict';
+
+const assert = require('assert');
+const http = require('http');
+
+const HOST = process.env.WALLCO_HOST || '127.0.0.1';
+const PORT = process.env.WALLCO_PORT || 9792;
+const OLLAMA_URL = process.env.OLLAMA_URL || 'http://192.168.1.133:11434';
+const PROBE_TIMEOUT_MS = 3_000;
+const LIVE_TIMEOUT_MS = 180_000;
+const SEED_SLUG = process.env.WALLCO_TEST_SLUG || 'hotel-bluff-astrid-mauve';
+
+function rawReq(method, path, body, { timeoutMs = 15_000 } = {}) {
+ return new Promise((resolve, reject) => {
+ const opts = {
+ hostname: HOST, port: PORT, path, method,
+ headers: { 'content-type': 'application/json' },
+ };
+ const r = http.request(opts, (res) => {
+ let buf = '';
+ res.on('data', c => buf += c);
+ res.on('end', () => resolve({ status: res.statusCode, body: buf, json: tryJson(buf) }));
+ });
+ r.on('error', reject);
+ r.setTimeout(timeoutMs, () => { r.destroy(new Error('http timeout')); });
+ if (body) r.write(JSON.stringify(body));
+ r.end();
+ });
+}
+
+// Parallel YOLO-loop agents reload pm2 mid-test, so transient ECONNREFUSED /
+// socket-hang-up is expected. Retry up to 3 times with a 5s settle wait.
+async function req(method, path, body, opts) {
+ let lastErr;
+ for (let attempt = 0; attempt < 3; attempt++) {
+ try { return await rawReq(method, path, body, opts); }
+ catch (err) {
+ lastErr = err;
+ const transient = /ECONNREFUSED|socket hang up|ECONNRESET|http timeout/i.test(err.message);
+ if (!transient) throw err;
+ await new Promise(r => setTimeout(r, 5000));
+ }
+ }
+ throw lastErr;
+}
+function tryJson(s){ try { return JSON.parse(s); } catch(_) { return null; } }
+
+// Cheap reachability probe — does GET /api/tags. Returns true on 200, false on
+// any error (network, timeout, non-200).
+async function ollamaReachable() {
+ return new Promise((resolve) => {
+ try {
+ const u = new URL(OLLAMA_URL);
+ const r = http.request({
+ hostname: u.hostname,
+ port: u.port || 11434,
+ path: '/api/tags',
+ method: 'GET',
+ timeout: PROBE_TIMEOUT_MS,
+ }, (res) => {
+ res.resume();
+ resolve(res.statusCode === 200);
+ });
+ r.on('error', () => resolve(false));
+ r.on('timeout', () => { r.destroy(); resolve(false); });
+ r.end();
+ } catch (_) { resolve(false); }
+ });
+}
+
+// Does the seed pattern exist? Returns the pattern row, or null.
+async function fetchSeedPattern() {
+ const r = await req('GET', `/api/marketplace/patterns/${encodeURIComponent(SEED_SLUG)}`, null, { timeoutMs: 5000 });
+ if (r.status !== 200 || !r.json?.pattern) return null;
+ return r.json.pattern;
+}
+
+const tests = [];
+function t(name, fn) { tests.push({ name, fn, skip: false }); }
+function skip(name, why) { tests.push({ name: `${name} (SKIP: ${why})`, fn: async () => {}, skip: true }); }
+
+// ── Always-on contract tests (no LLM required) ────────────────────────────────
+
+t('POST ai/tag-pattern with empty body returns mock shape', async () => {
+ const r = await req('POST', '/api/marketplace/ai/tag-pattern', {});
+ assert.strictEqual(r.status, 200, 'expected 200, got ' + r.status);
+ assert.ok(r.json, 'expected JSON body');
+ assert.strictEqual(r.json.status, 'mock', 'empty body should return status:"mock"');
+ for (const k of ['styleTags', 'roomTags', 'colorTags', 'motifTags', 'moodTags']) {
+ assert.ok(Array.isArray(r.json[k]), `${k} should be an array`);
+ assert.ok(r.json[k].length >= 1, `${k} should not be empty`);
+ }
+ assert.strictEqual(typeof r.json.scale, 'string', 'scale should be string');
+ assert.strictEqual(typeof r.json.mood, 'string', 'mood should be string');
+});
+
+t('POST ai/write-pattern-description with title returns mock-shape fields on fallback', async () => {
+ // We pass a title hint but no pattern — if Ollama is down, the fallback path
+ // returns mock-shaped JSON keyed off the hint. Either way, all 5 fields must
+ // be non-empty strings, and the title hint must round-trip into titleSuggestion.
+ const r = await req('POST', '/api/marketplace/ai/write-pattern-description', { title: 'Smoke Test Pattern' }, { timeoutMs: LIVE_TIMEOUT_MS });
+ assert.strictEqual(r.status, 200, 'expected 200, got ' + r.status);
+ assert.ok(r.json, 'expected JSON body');
+ for (const k of ['titleSuggestion', 'shortDescription', 'longDescription', 'seoTitle', 'seoDescription']) {
+ assert.strictEqual(typeof r.json[k], 'string', `${k} should be string`);
+ assert.ok(r.json[k].length > 0, `${k} should be non-empty`);
+ }
+ // SEO title convention from the prompt + fallback.
+ assert.ok(/Wallco\.ai Designer Wallcoverings/i.test(r.json.seoTitle),
+ 'seoTitle should end with "Wallco.ai Designer Wallcoverings" (got: ' + r.json.seoTitle + ')');
+});
+
+// ── Live qwen3:14b tests (only when Ollama is reachable) ──────────────────────
+
+(async () => {
+ const llm = await ollamaReachable();
+ if (!llm) {
+ skip('LIVE ai/tag-pattern with patternSlug', 'Ollama unreachable at ' + OLLAMA_URL);
+ skip('LIVE ai/write-pattern-description with patternSlug', 'Ollama unreachable at ' + OLLAMA_URL);
+ } else {
+ const seed = await fetchSeedPattern();
+ if (!seed) {
+ skip('LIVE ai/tag-pattern with patternSlug', `seed pattern '${SEED_SLUG}' not found`);
+ skip('LIVE ai/write-pattern-description with patternSlug', `seed pattern '${SEED_SLUG}' not found`);
+ } else {
+ t('LIVE ai/tag-pattern with patternSlug returns live tags + persists', async () => {
+ const r = await req('POST', '/api/marketplace/ai/tag-pattern',
+ { patternSlug: SEED_SLUG }, { timeoutMs: LIVE_TIMEOUT_MS });
+ assert.strictEqual(r.status, 200, 'expected 200, got ' + r.status + ' body=' + (r.body || '').slice(0, 200));
+ // qwen3 should answer; if the model errored mid-flight the route falls back to mock.
+ // Either way we must get back valid tag families.
+ assert.ok(r.json, 'expected JSON body');
+ for (const k of ['styleTags', 'roomTags', 'colorTags', 'motifTags', 'moodTags']) {
+ assert.ok(Array.isArray(r.json[k]), `${k} should be an array (got: ${typeof r.json[k]})`);
+ }
+ if (r.json.status === 'live') {
+ // Live path → assertions about persistence + model id
+ assert.strictEqual(r.json.model, 'qwen3:14b');
+ assert.strictEqual(String(r.json.pattern_id), String(seed.id));
+ assert.strictEqual(r.json.pattern_slug, SEED_SLUG);
+ assert.strictEqual(r.json.persisted, true, 'expected persisted=true when row resolved');
+ assert.ok(r.json.styleTags.length >= 1, 'live qwen3 should return at least 1 styleTag');
+ assert.ok(r.json.motifTags.length >= 1, 'live qwen3 should return at least 1 motifTag');
+ } else {
+ // Fallback path is acceptable but should carry a fallback_reason.
+ console.warn(' (LLM fell back to mock: ' + (r.json.fallback_reason || 'no reason') + ')');
+ }
+ });
+
+ t('LIVE ai/write-pattern-description with patternSlug returns live copy + persists', async () => {
+ const r = await req('POST', '/api/marketplace/ai/write-pattern-description',
+ { patternSlug: SEED_SLUG }, { timeoutMs: LIVE_TIMEOUT_MS });
+ assert.strictEqual(r.status, 200, 'expected 200, got ' + r.status);
+ assert.ok(r.json, 'expected JSON body');
+ for (const k of ['titleSuggestion', 'shortDescription', 'longDescription', 'seoTitle', 'seoDescription']) {
+ assert.strictEqual(typeof r.json[k], 'string', `${k} should be string`);
+ assert.ok(r.json[k].length > 0, `${k} should be non-empty`);
+ }
+ if (r.json.status === 'live') {
+ assert.strictEqual(r.json.model, 'qwen3:14b');
+ assert.strictEqual(String(r.json.pattern_id), String(seed.id));
+ assert.strictEqual(r.json.persisted, true, 'expected persisted=true when row resolved');
+ // Generated copy is meaningfully longer than the mock fallback for kept patterns.
+ assert.ok(r.json.longDescription.length >= 100, 'longDescription should be ≥100 chars');
+ } else {
+ console.warn(' (LLM fell back to mock: ' + (r.json.fallback_reason || 'no reason') + ')');
+ }
+ });
+ }
+ }
+
+ // Runner — sequential because each test can take ~60s on qwen3:14b.
+ let pass = 0, fail = 0, skipped = 0;
+ for (const test of tests) {
+ if (test.skip) { console.log('○', test.name); skipped++; continue; }
+ try { await test.fn(); console.log('✓', test.name); pass++; }
+ catch (err) { console.error('✗', test.name, '—', err.message); fail++; }
+ }
+ console.log(`\n${pass}/${tests.length - skipped} ran, ${skipped} skipped, ${fail} failed`);
+ if (fail) process.exit(1);
+})().catch(err => { console.error('runner crash:', err); process.exit(2); });
← 524cc84 marketplace pattern page: wire 'Generate AI colorways' butto
·
back to Wallco Ai
·
redact vendor name on /design/:id pairs-well cards + studio 9ea61af →