[object Object]

← 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

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 →