diff --git a/app/fe_walk_b1.py b/app/fe_walk_b1.py new file mode 100644 index 0000000..4d310f3 --- /dev/null +++ b/app/fe_walk_b1.py @@ -0,0 +1,519 @@ +"""B1 FE walk — headless-chromium proof of UI-SPEC §11 on the real page. + +Not part of `app.selfcheck` (needs playwright + a live server); run it after +starting the scratch server (the brief's isolation recipe): + + .venv/bin/python -m app.fe_walk_b1 setup # optional — auto at start + + env -u OPENROUTER_API_KEY .venv/bin/python -m app.serve \ + --db /var/home/admin/dev/bike-tinder-b1/test-walk.sqlite3 \ + --img-dir /var/home/admin/dev/bike-tinder-b1/test-img \ + --host 127.0.0.1 --port 9454 # in another shell (mock forge) + + .venv/bin/python -m app.fe_walk_b1 # walk (this file) + +Setup is deterministic and idempotent: a fresh copy of the pristine B1 copy +DB (test-copy.sqlite3) each run, synthetic multi-photo listings (ids 701+) +with local image files staged into test-img from media-sample/, and one +swiped return listing (705) whose stored last_photo_idx drives §11.5 +rotation. Everything walks the REAL page: real pointers, real DOM, no +stubbed APIs. +""" +import json +import os +import re +import shutil +import sqlite3 +import sys +import time +import urllib.request + +ARENA = '/var/home/admin/dev/bike-tinder-b1' +PRISTINE = os.path.join(ARENA, 'test-copy.sqlite3') +WALK_DB = os.path.join(ARENA, 'test-walk.sqlite3') +IMG_DIR = os.path.join(ARENA, 'test-img') +MEDIA = os.path.join(ARENA, 'media-sample') +BASE_URL = 'http://127.0.0.1:9454' + +CDN = ('https://media.kijiji.ca/api/v1/ca-prod-dealer-ads/images/x/%s' + '?rule=kijijica-640-webp') + +# synthetic fixtures: listing_id -> (n_photos, label) +FIX = { + 701: (8, 'gallery-8'), + 702: (3, 'gallery-3'), + 703: (2, 'gallery-2'), + 704: (1, 'single-photo'), + 705: (8, 'return-rotation'), # pre-swiped: last_photo_idx=0 -> lead 1 + 706: (0, 'zero-photo'), +} +SCORE_BASE = 2000 + + +def stage_img_files(): + """{id}_0..{id}_{n-1}.webp (and {id}.webp for the legacy single) from + media-sample's real webp files — no network needed.""" + os.makedirs(IMG_DIR, exist_ok=True) + names = sorted(m for m in os.listdir(MEDIA) if m.endswith('.webp')) + assert len(names) == 12, names + k = 0 # cycle through the 12 real files + for lid in sorted(FIX): + n = FIX[lid][0] + if n == 0: + continue + if n == 1: + shutil.copy2(os.path.join(MEDIA, names[k % len(names)]), + os.path.join(IMG_DIR, '%d.webp' % lid)) + k += 1 + continue + for i in range(n): + shutil.copy2(os.path.join(MEDIA, names[k % len(names)]), + os.path.join(IMG_DIR, '%d_%d.webp' % (lid, i))) + k += 1 + + +def setup_db(): + shutil.copy2(PRISTINE, WALK_DB) + from app import db as dbmod + conn = dbmod.connect(WALK_DB) + dbmod.init_db(conn) # B1 migrations + for lid in sorted(FIX): + n = FIX[lid][0] + imgs = [CDN % ('x%d-%d' % (lid, i)) for i in range(n)] + img = imgs[0] if imgs else None + score = SCORE_BASE + (750 - lid) # 701 highest -> top of the deck + conn.execute( + "DELETE FROM listings WHERE id=?", (lid,)) + conn.execute( + "INSERT INTO listings (id, region, src_id, title, url, status," + " img_url, imgs, score, first_seen, desc) VALUES" + " (?, 'ns', ?, ?, ?, 'active', ?, ?, ?," + " '2026-11-11T00:00:00Z', ?)", + (lid, 'b1fix%d' % lid, 'B1 test %s' % FIX[lid][1], + 'https://www.kijiji.ca/v-motorcycles/b1/%d' % lid, + img, json.dumps(imgs) if imgs else None, score, 'B1 fixture')) + conn.execute( + "INSERT OR REPLACE INTO swipes (listing_id, action, created_at," + " last_photo_idx) VALUES (705, 'like', '2026-11-08T00:00:00Z', 0)") + # Close live-copied sections: the first /api/section read then composes + # a fresh 25 on the walk DB (deterministic fixture ordering). + conn.execute( + "UPDATE sections SET status='closed' WHERE status != 'closed'") + # A round-1 event per fixture keeps synthetics out of the wildcard + # pool (wildcards must be unseen); new-pool eligibility is unaffected. + # 705's event matches its stored verdict (round 1, like). + for lid in FIX: + conn.execute( + "INSERT INTO swipe_events (listing_id, action, round," + " section_id) VALUES (?, ?, 1, NULL)", + (lid, 'like' if lid == 705 else 'maybe')) + conn.commit() + n = conn.execute('SELECT COUNT(*) AS n FROM listings').fetchone()['n'] + conn.close() + print('setup: walk DB at %s (%s listings; 701-706 synthetic, 705 = liked' + ' return w/ last_photo_idx=0)' % (WALK_DB, n)) + + +def need_server(): + try: + with urllib.request.urlopen(BASE_URL + '/api/health', + timeout=3) as r: + return r.status == 200 + except Exception: + return False + + +# --- walk helpers (module-level `page` assigned inside walk) ---------------- + +def wait(selector, timeout=8000): + page.wait_for_selector(selector, timeout=timeout) + + +def card_box(): + b = page.evaluate("""() => { const el=document.querySelector('.topcard'); + if(!el) return null; const r=el.getBoundingClientRect(); + return {x:r.x,y:r.y,w:r.width,h:r.height}; }""") + assert b, 'no .topcard in DOM' + return b + + +def top_state(): + return page.evaluate("""() => { + const t=document.querySelector('.topcard'); + const r={topcards:document.querySelectorAll('.topcard').length, + hasSegs:!!t.classList.contains('hasSegs'), segs:0, segOn:-1, + photo:null, nophoto:false, band:null, + stampLike:0, stampNope:0, stampMaybe:0}; + const s=t.querySelector('.segments'); + if(s){ r.segs=s.children.length; + [...s.children].forEach((x,k)=>{ if(x.classList.contains('on')) + r.segOn=k; }); } + const p=t.querySelector('.phwrap img.photo'); + if(p) r.photo=p.getAttribute('src'); + else if(t.querySelector('.nophoto')) r.nophoto=true; + const b=t.querySelector('.band'); if(b) r.band=b.textContent; + const sl=t.querySelector('.stamp.like'); + if(sl) r.stampLike=sl.style.opacity; + const sn=t.querySelector('.stamp.nope'); + if(sn) r.stampNope=sn.style.opacity; + const sm=t.querySelector('.stamp.maybe'); + if(sm) r.stampMaybe=sm.style.opacity; + return r; }""") + + +def tap_third(which): + b = card_box() + y = b['y'] + b['h'] * 0.72 + x = b['x'] + b['w'] * (0.16 if which == 'left' + else (0.84 if which == 'right' else 0.5)) + page.mouse.click(x, y) + time.sleep(0.08) + + +def assert_item(cond, msg): + if not cond: + print('FAIL ' + msg) + sys.exit(1) + print('ok ' + msg) + + +def lead_of(item): + n = item.get('n_photos') or 0 + li = item.get('last_photo_idx') + if not n or n < 2 or li is None: + return 0 + return (li + 1) % n + + +def photo_url(item, i): + if not item.get('img'): + return None + return item['img'] if i == 0 else '/img/%s?i=%s' % (item['id'], i) + + +def viewer_on(): + return page.evaluate( + "() => document.getElementById('viewer').classList.contains('on')") + + +def vimg_src(): + return page.evaluate( + "() => document.getElementById('vimg').getAttribute('src')") + + +def vimg_transform(): + return page.evaluate( + "() => document.getElementById('vimg').style.transform") + + +# --- the walk --------------------------------------------------------------- + +def walk(): + from playwright.sync_api import sync_playwright + with sync_playwright() as pw: + exe = os.path.expanduser( + '~/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome') + if not os.path.exists(exe): + exe = None + browser = pw.chromium.launch(headless=True, executable_path=exe, + args=['--no-sandbox']) + ctx = browser.new_context(viewport={'width': 390, 'height': 844}, + has_touch=True, is_mobile=True) + global page + page = ctx.new_page() + page.goto(BASE_URL + '/', wait_until='networkidle') + wait('.topcard') + time.sleep(0.6) + + section = None + for _ in range(40): + body = page.evaluate( + "() => fetch('/api/section').then(r=>r.json())") + if body and body.get('items'): + section = body + break + time.sleep(0.5) + assert section, 'no section payload' + items = list(section['items']) + print('section: %d items — recipe %s' % (len(items), json.dumps( + section['section']['recipe'], sort_keys=True))) + by_id = {i['id']: i for i in items} + for lid in (701, 702, 703, 704, 706): + assert lid in by_id, 'fixture %s missing from section' % lid + assert by_id[lid]['slot'] == 'new', (lid, by_id[lid]['slot']) + assert by_id[705]['slot'] == 'return', by_id[705]['slot'] + + seen = [] + round_no = 0 + while True: + remaining = [i['id'] for i in items if i['id'] not in seen] + if not remaining: + break + t = top_state() + top_id = None + m = re.search(r'/img/(\d+)(?:\?i=\d+)?$', t['photo'] or '') + if m: + top_id = int(m.group(1)) + else: + for i in items: # zero-photo card: no src to match + if i['id'] not in seen and not i.get('img'): + top_id = i['id'] + break + assert top_id in by_id, 'cannot identify top card' + item = by_id[top_id] + seen.append(top_id) + round_no += 1 + n = item.get('n_photos') or 0 + exp_lead = lead_of(item) + exp_src = photo_url(item, exp_lead) + print('-- top id=%s [%s] n=%d lead=%d slot=%s --' + % (top_id, item['title'][:26], n, exp_lead, item['slot'])) + assert_item(t['topcards'] == 1, + 'exactly one .topcard before gestures') + if n >= 2: + assert_item(t['hasSegs'] and t['segs'] == n, + 'segments count == n (%d)' % n) + assert_item(t['segOn'] == exp_lead, + 'segment idx == rotation lead (%d)' % exp_lead) + assert_item(t['photo'] == exp_src, + 'photo src == expected lead url') + else: + assert_item(not t['hasSegs'] and t['segs'] == 0, + 'no segments for n=%d' % n) + if n == 1: + assert_item(t['photo'] == photo_url(item, 0), + 'single photo shows the primary') + else: + assert_item(t['nophoto'], + 'zero-photo card shows the placeholder') + if item['slot'] == 'return' and item['id'] == 705: + assert_item(exp_lead == 1, 'rotation lead: (0+1)%%8 = 1') + assert_item(t['band'] and 'SECOND LOOK' in t['band'], + 'return card carries the SECOND LOOK band ' + '(with segments)') + + if n >= 2: + # --- flip: advance + clamp both ends (no wrap) ---------- + for i in range(n + 2): + tap_third('right') + st = top_state() + assert_item(st['topcards'] == 1, + 'tap kept the one-.topcard invariant') + want = min(exp_lead + i + 1, n - 1) + assert_item(st['segOn'] == want, + 'right-tap %d -> idx %d (clamp)' % (i + 1, + want)) + for i in range(n + 2): + tap_third('left') + st = top_state() + assert_item(st['topcards'] == 1, + 'tap kept the one-.topcard invariant') + want = max(n - 1 - (i + 1), 0) + assert_item(st['segOn'] == want, + 'left-tap %d -> idx %d (clamp)' % (i + 1, + want)) + st = top_state() + assert_item(st['topcards'] == 1 + and st['stampLike'] in ('0', '') + and st['stampNope'] in ('0', '') + and st['stampMaybe'] in ('0', ''), + 'taps never commit: no stamps, no flight') + # --- viewer: open on current photo, nav + clamp, zoom, + # pull-close restores the same card+photo ------------ + want_idx = 1 + while st['segOn'] > want_idx: + tap_third('left') + st = top_state() + if st['segOn'] < want_idx: + tap_third('right') + st = top_state() + before_src = st['photo'] + tap_third('center') + assert_item(viewer_on(), 'center tap opens the viewer') + vsegs = page.evaluate( + "() => document.getElementById('vsegs')" + ".querySelectorAll('i').length") + assert_item(vsegs == n, 'viewer segments == n (%d)' % n) + assert_item(vimg_src() == before_src, + 'viewer opens on the current photo') + vp = page.locator('#vphoto') + vb = vp.bounding_box() + al, ar = (vb['x'] + vb['width'] * 0.75, + vb['x'] + vb['width'] * 0.15) + ay = vb['y'] + vb['height'] * 0.5 + page.mouse.move(al, ay) + page.mouse.down() + page.mouse.move(ar, ay, steps=6) + page.mouse.up() + time.sleep(0.1) + land = min(want_idx + 1, n - 1) + assert_item(vimg_src() == photo_url(item, land), + 'viewer swipe-left advances + clamps') + page.mouse.move(ar, ay) + page.mouse.down() + page.mouse.move(al, ay, steps=6) + page.mouse.up() + time.sleep(0.1) + back = max(land - 1, 0) + assert_item(vimg_src() == photo_url(item, back), + 'viewer swipe-right steps back (clamped)') + page.mouse.dblclick(vb['x'] + vb['width'] * 0.5, + vb['y'] + vb['height'] * 0.5) + time.sleep(0.1) + assert_item('scale(2.5)' in vimg_transform(), + 'double-tap zooms to 2.5x') + page.mouse.dblclick(vb['x'] + vb['width'] * 0.5, + vb['y'] + vb['height'] * 0.5) + time.sleep(0.1) + assert_item('scale(2.5)' not in vimg_transform(), + 'double-tap again returns to 1x') + # pinch via synthetic pointer events (real handler path) + page.evaluate("""() => { + const v=document.getElementById('vphoto'); + const r=v.getBoundingClientRect(); + const mk=(t,id,x,y)=>{ v.dispatchEvent(new PointerEvent(t, + {pointerId:id, clientX:x, clientY:y, bubbles:true})); }; + const x=r.width/2, y=r.height/2; + mk('pointerdown',10,x-25,y); mk('pointerdown',11,x+25,y); + for(let s=0;s<6;s++){ mk('pointermove',10,x-25-s*22,y); + mk('pointermove',11,x+25+s*22,y); } + mk('pointerup',10,x-25-6*22,y); + mk('pointerup',11,x+25+6*22,y); + }""") + assert_item('scale(4)' in vimg_transform(), + 'pinch-zoom clamps at 4x') + page.mouse.dblclick(vb['x'] + vb['width'] * 0.5, + vb['y'] + vb['height'] * 0.5) + time.sleep(0.1) # back to 1x for the pull gesture + page.mouse.move(vb['x'] + vb['width'] * 0.5, + vb['y'] + vb['height'] * 0.35) + page.mouse.down() + page.mouse.move(vb['x'] + vb['width'] * 0.5, + vb['y'] + vb['height'] * 0.35 + 170, steps=8) + page.mouse.up() + time.sleep(0.2) + assert_item(not viewer_on(), + 'pull-down (>110px) closes the viewer') + st = top_state() + assert_item(st['segOn'] == want_idx + and st['photo'] == before_src, + 'close returns the same card + photo position') + else: + if n == 1: + tap_third('center') + assert_item(viewer_on(), + 'center tap opens the viewer (single photo)') + page.locator('#vclose').click() + time.sleep(0.15) + if n == 0: + tap_third('center') + assert_item(not viewer_on(), 'zero-photo: no viewer') + + # --- ⓘ beats the zones -------------------------------------- + page.locator('.topcard .ibtn').click() + time.sleep(0.15) + assert_item(page.evaluate( + "() => document.getElementById('detail')" + ".classList.contains('on')"), + 'ⓘ opens the dossier (zones do not fire)') + pre = top_state() + page.locator('#dclose').click() + time.sleep(0.2) + post = top_state() + assert_item(pre['photo'] == post['photo'] + and pre['segOn'] == post['segOn'], + 'dossier close leaves the photo position intact') + if n >= 1: + # dossier hero -> viewer at photo 0 (dossier stays open) + page.locator('.topcard .ibtn').click() + time.sleep(0.15) + hb = page.locator('.dhero').bounding_box() + page.mouse.click(hb['x'] + hb['width'] / 2, + hb['y'] + hb['height'] / 2) + time.sleep(0.15) + assert_item(viewer_on() and page.evaluate( + "() => document.getElementById('detail')" + ".classList.contains('on')"), + 'dossier hero tap opens the viewer over the dossier') + assert_item(vimg_src() == photo_url(item, 0), + 'dossier viewer starts at photo 0') + page.locator('#vclose').click() + time.sleep(0.15) + assert_item(not viewer_on() and page.evaluate( + "() => document.getElementById('detail')" + ".classList.contains('on')"), + 'viewer ✕ returns to the still-open dossier') + page.locator('#dclose').click() + time.sleep(0.15) + + # single commit per round (fast chain runs after the report) + page.locator('#btnNope').click() + time.sleep(0.07) + + # --- P3 regression: report -> undo x3 -> fast chain -> forge ----- + wait('#report.on', 15000) + print('report visible: %s' % page.locator('#repCounts').inner_text()) + assert_item('Section complete' in page.locator('#report').inner_text(), + 'report screen after the full section') + for _ in range(4): + page.locator('#btnUndo').click() + time.sleep(0.08) + assert_item(page.evaluate( + "() => document.querySelectorAll('.topcard').length") == 1, + 'undo x4 from the report re-opens the deck (one .topcard)') + for k in range(3): + b = card_box() + y = b['y'] + b['h'] * 0.6 + page.mouse.move(b['x'] + b['w'] * 0.5, y) + page.mouse.down() + page.mouse.move(b['x'] + b['w'] * 0.5 + 320, y, steps=5) + page.mouse.up() + if k < 2: + time.sleep(0.07) + tc = page.evaluate( + "() => document.querySelectorAll('.topcard').length") + assert tc == 1, 'fast chain: topcard=%s (must be 1)' % tc + assert_item(True, '3 like-commits @70ms: one .topcard, no lockout') + page.locator('#btnNope').click() # finish the 4th restored card + time.sleep(0.1) + wait('#report.on', 15000) + page.locator('#btnForge').click() + wait('.topcard', 15000) + time.sleep(0.4) + sect = page.evaluate( + "() => document.getElementById('sect').textContent.trim()") + assert_item(re.match(r'^\d+/25$', sect) is not None, + 'forge hydrates a fresh section (progress=%s)' % sect) + # --- List + Trends tabs render with P3 elements intact ---------- + page.locator('.tab[data-tab="list"]').click() + wait('.row') + tanks = page.locator('.row .tank').count() + rows = page.locator('.row').count() + assert_item(tanks > 0 and rows > 0, + 'list: %d rows, %d tank gauges (P3 intact)' + % (rows, tanks)) + page.locator('.tab[data-tab="trends"]').click() + wait('#tLead') + assert_item((page.locator('#tLead').text_content() or '').strip() + != '', 'trends tab renders') + print('WALK COMPLETE — all B1 FE checks green') + browser.close() + + +def main(argv=None): + if 'setup' in sys.argv or not os.path.exists(WALK_DB): + if not os.path.exists(PRISTINE): + print('pristine copy DB missing: %s' % PRISTINE) + return 1 + setup_db() + stage_img_files() + print('fixtures staged in %s' % IMG_DIR) + if not need_server(): + print('NO SERVER on %s — start it (see module docstring).' % BASE_URL) + return 1 + walk() + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/app/static/index.html b/app/static/index.html index c677791..48f3eb0 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -500,11 +500,14 @@ function leadFor(it){ // §11.5 repeat rotation: (last+1) % n return (li+1)%n; } function photoSrc(it,i){ if(!it||!it.img) return null; return i>0?('/img/'+it.id+'?i='+i):it.img; } +function segBars(n,cur){ // the bars only (no wrapper) + let h=''; + for(let k=0;k`; + return h; +} function segHTML(n,cur){ if(!n||n<2) return ''; - let h='
'; - for(let k=0;k`; - return h+'
'; + return `
${segBars(n,cur)}
`; } function phwrapHTML(src){ return `
${src?``:`
Photo pending — thumbnail backfill queued
`}
`; @@ -856,8 +859,8 @@ let vLastTap=0; function vCount(){ return vCtx&&vCtx.item?nPhotos(vCtx.item):0; } function vApply(){ if(!vCtx) return; if(vScale<=1){ vTx=0; vTy=0; } $('vimg').style.transform=`translate(${vTx}px,${vTy}px) scale(${vScale})`; } function vPaint(){ - const item=vCtx.item, i=vCtx.idx; - $('vsegs').innerHTML=segHTML(vCount(),i); + const item=vCtx.item, i=vCtx.idx, n=vCount(); + $('vsegs').innerHTML= n>=2?segBars(n,i):''; const im=$('vimg'), s=photoSrc(item,i); if(s){ im.src=s; im.style.opacity=1; } else { im.removeAttribute('src'); im.style.opacity=.25; } diff --git a/requirements.txt b/requirements.txt index 45032c7..67e9531 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ fastapi uvicorn httpx +playwright==1.54.0 # B1 dev/test only: headless FE walk (app/fe_walk_b1.py)