[object Object]

← back to Wallco Ai

test: studio repeat-size slider — 5 cases covering UX shipped in 0bbf445

965dff103e780ae78b0b4d8d081075747dc4d336 · 2026-05-12 12:48:24 -0700 · SteveStudio2

M-1 default 72" markup
M-2 initial /api/room x3 with patternWidth=72
M-3 slider change re-fires with new inches
M-4 cells flip to loading state on slider change
M-5 rapid drags collapse to last value (AbortController honored)

Stubs compose + room endpoints so tests run in ~8s without touching the
real generator or upstream room renderer.

Files touched

Diff

commit 965dff103e780ae78b0b4d8d081075747dc4d336
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue May 12 12:48:24 2026 -0700

    test: studio repeat-size slider — 5 cases covering UX shipped in 0bbf445
    
    M-1 default 72" markup
    M-2 initial /api/room x3 with patternWidth=72
    M-3 slider change re-fires with new inches
    M-4 cells flip to loading state on slider change
    M-5 rapid drags collapse to last value (AbortController honored)
    
    Stubs compose + room endpoints so tests run in ~8s without touching the
    real generator or upstream room renderer.
---
 tests/integration/studio-repeat-size.spec.js | 247 +++++++++++++++++++++++++++
 1 file changed, 247 insertions(+)

diff --git a/tests/integration/studio-repeat-size.spec.js b/tests/integration/studio-repeat-size.spec.js
new file mode 100644
index 0000000..41aff9b
--- /dev/null
+++ b/tests/integration/studio-repeat-size.spec.js
@@ -0,0 +1,247 @@
+/**
+ * Studio repeat-size slider — verifies the post-compose room-mockup UX
+ * shipped 2026-05-12 (commit 08f1ae3).
+ *
+ * The slider lives inside the studio-payoff frame and only renders after a
+ * successful POST /api/studio/compose. To keep this test under 10s we stub
+ * the compose endpoint with route interception so we never spawn the real
+ * generator (which costs Replicate $$ and takes 30-120s).
+ *
+ * What we assert:
+ *   M-1  slider renders with default 72" after Compose
+ *   M-2  /api/room is called once per cell (3x) with patternWidth=patternHeight=72
+ *   M-3  changing the slider re-fires /api/room with the new inches
+ *   M-4  cells return to a loading state during the slider re-render
+ *   M-5  rapid slider drags don't pile up — only the latest value sticks
+ */
+'use strict';
+const { test, expect } = require('@playwright/test');
+
+const FAKE_DESIGN_ID = 999001;
+const FAKE_RUN_ID    = 555001;
+
+// Helper — install all the route stubs the /studio page hits so the
+// compose button produces a payoff frame without touching real services.
+async function installStubs(page) {
+  await page.route('**/api/studio/board?sid=*', (route) =>
+    route.fulfill({
+      status: 200, contentType: 'application/json',
+      body: JSON.stringify([{ slot_idx: 0, source_type: 'artist', source_id: '1',
+        title: 'Test Artist', artist: 'Test Artist', style_tags: ['baroque'],
+        color_hex_seeds: ['#1a1a1a'], selected_elements: [] }])
+    })
+  );
+  await page.route('**/api/studio/artists', (r) => r.fulfill({ status:200, contentType:'application/json', body:'[]' }));
+  await page.route('**/api/studio/styles',  (r) => r.fulfill({ status:200, contentType:'application/json', body:'[]' }));
+  await page.route('**/api/studio/elements',(r) => r.fulfill({ status:200, contentType:'application/json', body:'[]' }));
+  await page.route('**/api/studio/textures',(r) => r.fulfill({ status:200, contentType:'application/json', body:'[]' }));
+  await page.route('**/api/studio/suggest-palettes', (r) => r.fulfill({ status:200, contentType:'application/json', body:'{"palettes":[]}' }));
+  await page.route('**/api/studio/category-insights**', (r) => r.fulfill({ status:200, contentType:'application/json', body:'{"trends":[],"best_sellers":[]}' }));
+  await page.route('**/api/studio-renders**', (r) => r.fulfill({ status:200, contentType:'application/json', body:'{"ok":true,"rows":[]}' }));
+  await page.route('**/api/studio/interior-design-review', (r) =>
+    r.fulfill({ status:200, contentType:'application/json', body:'{"ok":true,"review":""}' })
+  );
+
+  await page.route('**/api/studio/compose', (route) =>
+    route.fulfill({
+      status: 200, contentType: 'application/json',
+      body: JSON.stringify({
+        ok: true,
+        prompt: 'A seamless wallpaper pattern, tileable, no text.',
+        design_id: FAKE_DESIGN_ID,
+        run_id:    FAKE_RUN_ID,
+        render_id: 777001,
+        image_url: '/designs/img/fake_tiger.png',
+        image_filename: 'fake_tiger.png',
+        error: null,
+      })
+    })
+  );
+}
+
+test.beforeEach(async ({ page }) => {
+  await page.addInitScript(() => {
+    try { localStorage.setItem('wallco-age-skipped', '1'); } catch {}
+    try { localStorage.setItem('wc-studio-sid', 'test-sid-repeat'); } catch {}
+  });
+  await installStubs(page);
+});
+
+test('M-1 · slider renders at default 72" after Compose', async ({ page }) => {
+  // Cells must resolve so the renderStudioRooms() promise chain completes
+  // and the page settles — fulfil rooms with a tiny success payload.
+  await page.route('**/api/room', (route) =>
+    route.fulfill({ status:200, contentType:'application/json',
+      body: JSON.stringify({ ok:true, room_id: 1, image_url:'/uploads/rooms/fake.png', roomType:'bedroom', design_id: FAKE_DESIGN_ID })
+    })
+  );
+
+  await page.goto('/studio', { waitUntil: 'commit' });
+  await page.click('#btn-compose');
+
+  const slider = page.locator('#studio-repeat-size');
+  await expect(slider).toBeVisible();
+  await expect(slider).toHaveValue('72');
+  await expect(page.locator('#studio-repeat-val')).toHaveText('72" wide');
+});
+
+test('M-2 · initial render fires /api/room x3 with patternWidth=72', async ({ page }) => {
+  const calls = [];
+  await page.route('**/api/room', async (route) => {
+    const post = route.request().postDataJSON();
+    calls.push(post);
+    await route.fulfill({ status:200, contentType:'application/json',
+      body: JSON.stringify({ ok:true, room_id: calls.length, image_url:`/uploads/rooms/fake_${calls.length}.png`, roomType: post.roomType, design_id: FAKE_DESIGN_ID })
+    });
+  });
+
+  await page.goto('/studio', { waitUntil: 'commit' });
+  await page.click('#btn-compose');
+  // Wait until all 3 cells have a backgroundImage set (i.e. all 3 fetches resolved).
+  await page.waitForFunction(() => {
+    const cells = document.querySelectorAll('#studio-rooms-grid .studio-room-cell');
+    return cells.length === 3 &&
+           Array.from(cells).every(c => /url\(/.test(c.style.backgroundImage));
+  }, null, { timeout: 8000 });
+
+  expect(calls).toHaveLength(3);
+  for (const c of calls) {
+    expect(c.design_id).toBe(FAKE_DESIGN_ID);
+    expect(c.patternWidth).toBe(72);
+    expect(c.patternHeight).toBe(72);
+  }
+  const rooms = calls.map(c => c.roomType).sort();
+  expect(rooms).toEqual(['bedroom', 'dining_room', 'living_room']);
+});
+
+test('M-3 · changing slider re-fires /api/room with new patternWidth', async ({ page }) => {
+  const calls = [];
+  await page.route('**/api/room', async (route) => {
+    const post = route.request().postDataJSON();
+    calls.push(post);
+    await route.fulfill({ status:200, contentType:'application/json',
+      body: JSON.stringify({ ok:true, room_id: calls.length, image_url:`/uploads/rooms/fake_${calls.length}.png`, roomType: post.roomType, design_id: FAKE_DESIGN_ID })
+    });
+  });
+
+  await page.goto('/studio', { waitUntil: 'commit' });
+  await page.click('#btn-compose');
+  await page.waitForFunction(() =>
+    document.querySelectorAll('#studio-rooms-grid .studio-room-cell').length === 3
+    && Array.from(document.querySelectorAll('#studio-rooms-grid .studio-room-cell'))
+        .every(c => /url\(/.test(c.style.backgroundImage)),
+    null, { timeout: 8000 }
+  );
+  expect(calls).toHaveLength(3);
+
+  // Move slider to 18" — emit `input` so the readout updates, then `change` to fire the renderer.
+  await page.evaluate(() => {
+    const s = document.getElementById('studio-repeat-size');
+    s.value = '18';
+    s.dispatchEvent(new Event('input',  { bubbles: true }));
+    s.dispatchEvent(new Event('change', { bubbles: true }));
+  });
+  await expect(page.locator('#studio-repeat-val')).toHaveText('18" wide');
+
+  // 250ms debounce + fetch turn-around — give it 4s.
+  await page.waitForFunction((n) =>
+    window.__noop || // satisfy eslint
+    (document.querySelectorAll('#studio-rooms-grid .studio-room-cell').length === 3),
+    null, { timeout: 4000 }
+  );
+  await page.waitForTimeout(900); // let the debounce + fetches resolve
+  expect(calls.length).toBeGreaterThanOrEqual(6);
+  const post18 = calls.slice(-3);
+  for (const c of post18) {
+    expect(c.patternWidth).toBe(18);
+    expect(c.patternHeight).toBe(18);
+    expect(c.design_id).toBe(FAKE_DESIGN_ID);
+  }
+});
+
+test('M-4 · cells flip back to loading state on slider change', async ({ page }) => {
+  // Slow the room responses so the loading state is visible long enough to assert.
+  await page.route('**/api/room', async (route) => {
+    const post = route.request().postDataJSON();
+    await new Promise(r => setTimeout(r, 400));
+    await route.fulfill({ status:200, contentType:'application/json',
+      body: JSON.stringify({ ok:true, room_id: 1, image_url:'/uploads/rooms/fake.png', roomType: post.roomType, design_id: FAKE_DESIGN_ID })
+    });
+  });
+
+  await page.goto('/studio', { waitUntil: 'commit' });
+  await page.click('#btn-compose');
+  // Wait for first batch to finish (background images set on all 3).
+  await page.waitForFunction(() =>
+    Array.from(document.querySelectorAll('#studio-rooms-grid .studio-room-cell'))
+        .every(c => /url\(/.test(c.style.backgroundImage)),
+    null, { timeout: 8000 }
+  );
+
+  // Bump the slider and immediately inspect — cells should re-enter loading state.
+  await page.evaluate(() => {
+    const s = document.getElementById('studio-repeat-size');
+    s.value = '24';
+    s.dispatchEvent(new Event('change', { bubbles: true }));
+  });
+  // Loading state appears 250ms after change (debounce). Wait then assert.
+  await page.waitForTimeout(280);
+  const loading = await page.evaluate(() =>
+    Array.from(document.querySelectorAll('#studio-rooms-grid .studio-room-cell'))
+      .map(c => ({ bg: c.style.backgroundImage, txt: c.textContent }))
+  );
+  // At least one cell should be in `loading…` text state with no background image.
+  const inLoading = loading.filter(x => /loading…/.test(x.txt) && !x.bg);
+  expect(inLoading.length).toBe(3);
+});
+
+test('M-5 · rapid slider drags collapse to the last value (no race)', async ({ page }) => {
+  const calls = [];
+  await page.route('**/api/room', async (route) => {
+    const post = route.request().postDataJSON();
+    calls.push({ ...post, ts: Date.now() });
+    await new Promise(r => setTimeout(r, 200));
+    await route.fulfill({ status:200, contentType:'application/json',
+      body: JSON.stringify({ ok:true, room_id: calls.length, image_url:`/uploads/rooms/fake_${calls.length}.png`, roomType: post.roomType, design_id: FAKE_DESIGN_ID })
+    });
+  });
+
+  await page.goto('/studio', { waitUntil: 'commit' });
+  await page.click('#btn-compose');
+  await page.waitForFunction(() =>
+    Array.from(document.querySelectorAll('#studio-rooms-grid .studio-room-cell'))
+        .every(c => /url\(/.test(c.style.backgroundImage)),
+    null, { timeout: 8000 }
+  );
+  const initial = calls.length; // 3
+
+  // Fire 4 change events in quick succession — only the last value should land.
+  await page.evaluate(() => {
+    const s = document.getElementById('studio-repeat-size');
+    for (const v of ['18','30','42','60']) {
+      s.value = v;
+      s.dispatchEvent(new Event('change', { bubbles: true }));
+    }
+  });
+
+  await page.waitForTimeout(1200);
+
+  // After debounce + abort, only the last `change` should have spawned 3 calls.
+  const afterDrag = calls.slice(initial);
+  // Some of the earlier kicked-off fetches may have been aborted in flight
+  // (we won't see them recorded because the stub fulfils synchronously
+  // before the abort signal flips). Practical guard: the FINAL 3 entries
+  // must all be at the last-set value (60").
+  const finalThree = afterDrag.slice(-3);
+  expect(finalThree).toHaveLength(3);
+  for (const c of finalThree) {
+    expect(c.patternWidth).toBe(60);
+    expect(c.patternHeight).toBe(60);
+  }
+  // Background images must end up populated again.
+  await page.waitForFunction(() =>
+    Array.from(document.querySelectorAll('#studio-rooms-grid .studio-room-cell'))
+        .every(c => /url\(/.test(c.style.backgroundImage)),
+    null, { timeout: 4000 }
+  );
+});

← 0bbf445 studio: add repeat-size slider + fresh room re-renders on ea  ·  back to Wallco Ai  ·  wallco.ai · nav basket-count chip auto-refreshes via custom 04336ef →