From 35138bdc22109f023ea6a3a85c0134b149c1ec90 Mon Sep 17 00:00:00 2001 From: Torres Date: Fri, 13 Nov 2026 14:04:11 -0400 Subject: [PATCH] test(v1.1): fe_walk_v11 harness (89 checks, 3 staged catalogs) + polish from the walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app/fe_walk_v11.py: self-contained headless walk — stages rich / returns-only / dead-catalog Dbs, boots the scratch server on 9455 (OPENROUTER_API_KEY unset), and verifies all ten items on the real page: keyboard map + guards + no-scroll, tap flow + Esc chains (deck and list paths), contain/blur computed styles, minimal rubric + debug toggle round-trip, the 0/25 -> 25/25 counter with undos, dry messaging, fresh pill, loud empty2 + nightly-recovery, violet bands, B1 regressions (flip clamps, one .topcard, no-lock storm) and the full P3 loop. - Keyboard: ArrowDown/Backspace now no-op while a sheet is open (arrows work on the deck only — spec note); found by the walk. - get_section: an empty composed section (fully drained catalog) recomposes on read once pool_counts shows stock again — the nightly import re-lights the deck without a manual forge (item 8 recovery). --- app/api.py | 16 + app/fe_walk_v11.py | 849 ++++++++++++++++++++++++++++++++++++++++++ app/static/index.html | 7 +- 3 files changed, 869 insertions(+), 3 deletions(-) create mode 100644 app/fe_walk_v11.py diff --git a/app/api.py b/app/api.py index f4a57d1..d070939 100644 --- a/app/api.py +++ b/app/api.py @@ -501,6 +501,22 @@ def create_app(db_path, img_dir, state_dir=None): conn, engine, size=25, profile_version=prof['version'] if prof else None, note='cold compose (first read)') + else: + # item 8: an empty composed section (the catalog was fully + # drained) recomposes on the read once stock exists — the + # nightly-import recovery path. Zero items + zero pool + # keeps serving the empty section (loud empty2). + n_items = conn.execute( + 'SELECT COUNT(*) AS n FROM section_items' + ' WHERE section_id=?', (sec['id'],)).fetchone()['n'] + if n_items == 0 and any(secmod.pool_counts(conn).values()): + prof = secmod.latest_profile(conn) + engine = secmod.build_engine(conn, prof, now=utcnow()) + secmod.close_section(conn, sec, utcnow(), status='closed') + sec = secmod.compose_section( + conn, engine, size=25, + profile_version=prof['version'] if prof else None, + note='refill compose (stock returned)') return section_state(conn, sec) finally: conn.close() diff --git a/app/fe_walk_v11.py b/app/fe_walk_v11.py new file mode 100644 index 0000000..a4ebb25 --- /dev/null +++ b/app/fe_walk_v11.py @@ -0,0 +1,849 @@ +"""v1.1 FE walk — headless-chromium proof of every v1.1 item on the real page. + +Self-contained: stages its own temp DBs (never the live file), boots the +scratch server on 127.0.0.1:9455 (never 9453), runs the walks, kills the +server. Mock forge only (OPENROUTER_API_KEY is not consulted — the server +is started with the key unset). + + cd /var/home/admin/dev/bike-tinder-v11/build + .venv/bin/python -m app.fe_walk_v11 setup # (auto-runs at walk start) + .venv/bin/python -m app.fe_walk_v11 # full walk + +Phases: + WALK1 (rich: pristine catalog + synthetic multi-photo fixtures 701-706, + 705 a liked return) — items 1, 2, 3, 4, 7, 10, B1 §11 regressions + (flip clamps / one .topcard / no-lock swipe storm), full P3 loop + (compose -> strike all -> report -> forge), item 6 non-dry. + WALK2 (returns-only: every active pre-swiped maybe) — item 5 counter + (0/N -> 1/N -> undo -> sweep -> 25/25 hot -> report counts), + item 6 dry caption + dry forge status. + WALK3 (0/0/0: returns one-shot-burned) — item 8 loud empty2, nightly- + import recovery (a fresh listing re-lights the deck). +""" +import json +import os +import re +import shutil +import sqlite3 +import subprocess +import sys +import time +import urllib.request + +ARENA = '/var/home/admin/dev/bike-tinder-v11' +REPO = os.path.join(ARENA, 'build') +PRISTINE = os.path.join(ARENA, 'scratch-v11-pristine.sqlite3') +WALK1 = os.path.join(ARENA, 'scratch-v11-walk1.sqlite3') +WALK2 = os.path.join(ARENA, 'scratch-v11-walk2.sqlite3') +WALK3 = os.path.join(ARENA, 'scratch-v11-walk3.sqlite3') +IMG = os.path.join(ARENA, 'img-v11') +MEDIA = os.path.join(ARENA, 'media-sample-v11') +SHOTS = os.path.join(ARENA, 'shots-v11') +BASE_URL = 'http://127.0.0.1:9455' +PORT = 9455 + +CDN = ('https://media.kijiji.ca/api/v1/ca-prod-dealer-ads/images/x/%s' + '?rule=kijijica-640-webp') + +FIX = { + 701: (8, 'gallery-8'), + 702: (3, 'gallery-3'), + 703: (2, 'gallery-2'), + 704: (1, 'single-photo'), + 705: (8, 'return-rotation'), + 706: (0, 'zero-photo'), +} +SCORE_BASE = 2000 + +server_proc = None +page = None + + +# --- staging --------------------------------------------------------------- + +def stage_img_files(): + os.makedirs(IMG, exist_ok=True) + names = sorted(m for m in os.listdir(MEDIA) if m.endswith('.webp')) + assert len(names) == 12, names + k = 0 + 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, '%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, '%d_%d.webp' % (lid, i))) + k += 1 + + +def seeds(): + """Fixture rows + return candidates on a temp copy; returns the maybe-pair.""" + from app import db as dbmod + conn = sqlite3.connect(WALK1) + conn.row_factory = sqlite3.Row + try: + dbmod.init_db(conn) # guarded B1/P3 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) + 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, 'v11fix%d' % lid, 'V1.1 test %s' % FIX[lid][1], + 'https://www.kijiji.ca/v-motorcycles/v11/%d' % lid, + img, json.dumps(imgs) if imgs else None, score, + 'V1.1 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)") + # fresh fixtures: round-1 events keep them OUT of the wildcard pool + 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')) + # second reserved maybe-return: the highest-scored existing swiped + # active swings to maybe so 705 keeps its reserved return slot + x = conn.execute( + "SELECT s.listing_id FROM swipes s" + " JOIN listings l ON l.id = s.listing_id" + " WHERE l.status='active' ORDER BY l.score DESC LIMIT 1" + ).fetchone()['listing_id'] + conn.execute("UPDATE swipes SET action='maybe' WHERE listing_id=?", (x,)) + conn.execute( + "UPDATE swipe_events SET action='maybe' WHERE listing_id=?" + " AND round=1", (x,)) + conn.execute("UPDATE sections SET status='closed'" + " WHERE status!='closed'") + conn.commit() + return x + finally: + conn.close() + + +def setup(): + assert os.path.exists(PRISTINE), 'pristine copy missing' + os.makedirs(SHOTS, exist_ok=True) + stage_img_files() + shutil.copy2(PRISTINE, WALK1) + seeds() + shutil.copy2(PRISTINE, WALK2) + conn = sqlite3.connect(WALK2) + try: + conn.execute('DELETE FROM section_items') + conn.execute("UPDATE sections SET status='closed'" + " WHERE status!='closed'") + conn.execute( + """INSERT INTO swipes (listing_id, action, created_at) + SELECT id, 'maybe', '2026-11-01T00:00:00Z' FROM listings + WHERE status='active' + AND id NOT IN (SELECT listing_id FROM swipes)""") + conn.execute( + """INSERT INTO swipe_events (listing_id, action, round, section_id) + SELECT id, 'maybe', 1, NULL FROM listings + WHERE status='active' + AND id NOT IN (SELECT listing_id FROM swipe_events)""") + conn.commit() + finally: + conn.close() + print('setup: walk DBs staged (%s, %s)' % (WALK1, WALK2)) + + +# --- server ---------------------------------------------------------------- + +def start_server(db_path): + global server_proc + env = dict(os.environ) + env.pop('OPENROUTER_API_KEY', None) # mock forge pass only + server_proc = subprocess.Popen( + [os.path.join(REPO, '.venv/bin/python'), '-m', 'app.serve', + '--db', db_path, '--img-dir', IMG, '--host', '127.0.0.1', + '--port', str(PORT), '--state-dir', + os.path.join(ARENA, 'state-v11')], + cwd=REPO, env=env, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + for _ in range(60): + try: + with urllib.request.urlopen(BASE_URL + '/api/health', + timeout=2) as r: + if r.status == 200: + return + except Exception: + pass + time.sleep(0.25) + raise RuntimeError('scratch server did not come up') + + +def stop_server(): + global server_proc + if server_proc: + server_proc.terminate() + try: + server_proc.wait(timeout=5) + except subprocess.TimeoutExpired: + server_proc.kill() + server_proc = None + + +# --- walk helpers ---------------------------------------------------------- + +CHECKS = [0, 0] + + +def wait(selector, timeout=10000): + page.wait_for_selector(selector, timeout=timeout) + + +def ok(msg): + CHECKS[0] += 1 + print('ok ' + msg) + + +def bad(msg): + CHECKS[1] += 1 + print('FAIL ' + msg) + page.screenshot(path=os.path.join(SHOTS, 'fail-%s.png' % int(time.time()))) + raise SystemExit(1) + + +def check(cond, msg): + if cond: + ok(msg) + else: + bad(msg) + + +def top_state(): + return page.evaluate("""() => { + const t=document.querySelector('.topcard'); + const r={topcards:document.querySelectorAll('.topcard').length, + photo:null, segOn:-1, segs:0, band:null, bandClass:null}; + if(!t) return r; + 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'); + const b=t.querySelector('.band'); + if(b){ r.band=b.textContent; r.bandClass=b.className; } + return r; }""") + + +def top_id(): + t = top_state() + if not t['photo']: + return None + m = re.search(r'/img/(\d+)(?:\?i=\d+)?$', t['photo'] or '') + return int(m.group(1)) if m else None + + +def sect_pill(): + return page.evaluate("() => ({txt:document.getElementById('sect').textContent," + " hot:document.getElementById('sect').classList.contains('hot')})") + + +def pile_len(): + return page.evaluate("() => document.querySelectorAll('.topcard').length +" + " + [...document.querySelectorAll('#stage .card')].filter(" + " e=>!e.classList.contains('topcard')).length") + + +def key(k): + page.keyboard.press(k) + time.sleep(0.08) + + +def api(path): + with urllib.request.urlopen(BASE_URL + path, timeout=10) as r: + return json.loads(r.read()) + + +def api_post(path, body): + req = urllib.request.Request(BASE_URL + path, + data=json.dumps(body).encode(), + headers={'Content-Type': 'application/json'}) + with urllib.request.urlopen(req, timeout=30) as r: + return json.loads(r.read()) + + +def section_body(): + return api('/api/section') + + +def flick(ms=350): + time.sleep(ms / 1000.0) + + +def tap_center(): + b = page.evaluate("""() => { const el=document.querySelector('.topcard'); + const r=el.getBoundingClientRect(); + return {x:r.x+r.width/2,y:r.y+r.height*0.72}; }""") + page.mouse.click(b['x'], b['y']) + time.sleep(0.08) + + +def tap_side(side): + b = page.evaluate("""() => { const el=document.querySelector('.topcard'); + const r=el.getBoundingClientRect(); + return {x:r.x, w:r.width, y:r.y+r.height*0.72}; }""") + x = b['x'] + (0.16 if side == 'left' else 0.84) * b['w'] + page.mouse.click(x, b['y']) + time.sleep(0.08) + + +def taps(clicks, gap=40): + # real commit storm through the like button: the no-lock law must + # survive button-speed swipes + for _ in range(clicks): + page.evaluate("() => document.getElementById('btnLike').click()") + time.sleep(gap / 1000.0) + + +def dossier_on(): + return page.evaluate( + "() => document.getElementById('detail').classList.contains('on')") + + +def viewer_on(): + return page.evaluate( + "() => document.getElementById('viewer').classList.contains('on')") + + +def dhero_click(): + b = page.evaluate("""() => { const el=document.getElementById('dhero')|| + document.querySelector('.dhero'); + const r=el.getBoundingClientRect(); + return {x:r.x+r.width/2,y:r.y+r.height/2}; }""") + page.mouse.click(b['x'], b['y']) + time.sleep(0.15) + + +# --- WALK1: rich deck ------------------------------------------------------ + +def walk_rich(): + check(page.url.startswith(BASE_URL), 'page served from the app root') + wait('.topcard') + flick(800) + body = section_body() + check(len(body['items']) == 25, 'rich compose serves 25 items') + check(body['recipe']['new'] == 21 and body['recipe']['return'] == 2 + and body['recipe']['wildcard'] == 2, + 'recipe 21/2/2 on the rich pool (the sign-off shape)') + by_id = {i['id']: i for i in body['items']} + for lid in (701, 702, 703, 704, 706): + check(lid in by_id and by_id[lid]['slot'] == 'new', + 'fixture %s sits in the fresh slots' % lid) + check(by_id[705]['slot'] == 'return', '705 composes into a return slot') + + t = top_state() + check(t['topcards'] == 1, 'exactly one .topcard on load') + check(top_id() == 701, 'top card is the highest-scored fixture (701)') + + # item 3: computed styles on the top card (fit + backdrop) + css = page.evaluate("""() => { const c=document.querySelector('.topcard'); + const p=c.querySelector('.phwrap img.photo'); + const b=c.querySelector('.phwrap .phblur'); + return {fit:getComputedStyle(p).objectFit, + blur:!!b, bg:b?b.style.backgroundImage:null, + src:p.getAttribute('src')}; }""") + check(css['fit'] == 'contain', 'item 3: card photo object-fit == contain') + check(bool(css['blur']) and css['src'] in (css['bg'] or ''), + 'item 3: card backdrop exists and mirrors the photo URL') + page.screenshot(path=os.path.join(SHOTS, 'item3-card-fit.png')) + + # --- item 5+1: strikes through the real key map ------------------------ + check(sect_pill()['txt'] == '0/25', 'fresh rich section starts 0/25') + key('ArrowUp') + check(sect_pill()['txt'] == '1/25', 'ArrowUp (maybe) -> 1/25') + key('ArrowDown') + check(sect_pill()['txt'] == '0/25', 'ArrowDown (undo) -> 0/25') + key('ArrowUp') + check(sect_pill()['txt'] == '1/25', 're-decide -> 1/25') + key('Backspace') + check(sect_pill()['txt'] == '0/25', 'Backspace is the undo alias') + pill_before = sect_pill()['txt'] + # scroll prevention spy + page.evaluate("""() => { window.__prev=[]; document.addEventListener( + 'keydown',e=>window.__prev.push([e.key,e.defaultPrevented])); }""") + key('ArrowLeft') + key('ArrowRight') + prevented = page.evaluate("""() => window.__prev.filter( + p=>p[0].startsWith('Arrow')).map(p=>p[1])""") + check(all(prevented), 'item 1: arrows defaultPrevented (no page scroll)') + pn = sect_pill()['txt'] + check(int(pn.split('/')[0]) == int(pill_before.split('/')[0]) + 2, + 'ArrowLeft/ArrowRight commit through the normal path (%s -> %s)' + % (pill_before, pn)) + + # --- item 1: text-input guard (list search) --------------------------- + page.evaluate("() => document.querySelector('.tab[data-tab=list]').click()") + wait('#lsearch') + page.evaluate("""() => { const s=document.getElementById('lsearch'); + s.focus(); }""") + page.keyboard.type('abc') + before_search = page.evaluate( + "() => document.getElementById('lsearch').value") + check(before_search == 'abc', 'search input accepts typing (focus guard on)') + for kk in ('ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'): + page.keyboard.press(kk) + flick(60) + check(page.evaluate( + "() => document.getElementById('lsearch').value") == 'abc', + 'arrows inside the search input never swipe (text + value unchanged)') + check(sect_pill()['txt'] == pn, + 'input-focused arrows leave the section counter untouched') + page.keyboard.press('Backspace') + check(page.evaluate( + "() => document.getElementById('lsearch').value") == 'ab', + 'Backspace edits text while an input owns focus (no undo fired)') + page.evaluate("() => document.querySelector('.tab[data-tab=deck]').click()") + flick(300) + + # --- item 2: center tap -> dossier -> hero -> viewer; Esc/Es ---------- + tid_before = top_id() + src_before = top_state()['photo'] + pill_pre_tap = sect_pill()['txt'] + tap_center() + check(sect_pill()['txt'] == pill_pre_tap, + 'item 2: center tap commits nothing') + check(dossier_on(), 'item 2: center tap opens the dossier') + check(not viewer_on(), 'item 2: no viewer on center tap') + check(bool(page.evaluate( + "() => document.getElementById('dtitle').textContent")), + 'dossier carries the card title') + dhero_click() + check(viewer_on(), 'dossier hero tap opens the viewer (photo 0)') + vidx = page.evaluate("""() => { const im=document.getElementById('vimg'); + return {src:im.getAttribute('src'), + fot:getComputedStyle(im).objectFit}; }""") + check(vidx['fot'] == 'contain', 'item 3: viewer vimg stays contain (no diff)') + page.screenshot(path=os.path.join(SHOTS, 'item3-viewer.png')) + # arrows are inert while the viewer is open + pill_now = sect_pill()['txt'] + for kk in ('ArrowLeft', 'ArrowRight', 'ArrowUp'): + page.keyboard.press(kk) + check(sect_pill()['txt'] == pill_now, + 'arrows are inert while the viewer is open') + key('Escape') + check(not viewer_on() and dossier_on(), + 'Esc closes the viewer only (dossier stays open)') + key('Escape') + check(not dossier_on() and not viewer_on(), + 'second Esc closes the dossier to the deck') + check(top_id() == tid_before and top_state()['photo'] == src_before, + 'walk-back leaves the same card on the same photo') + + # item 2 A5: list row -> dossier -> viewer -> Esc -> dossier -> Esc -> list + page.evaluate("() => document.querySelector('.tab[data-tab=list]').click()") + wait('#lrows .row') + flick(400) + thumb_fit = page.evaluate("""() => { const t=document.querySelector( + '.thumb'); return t?getComputedStyle(t).objectFit:null; }""") + check(thumb_fit == 'cover', 'item 3: list thumbs stay cover (no diff)') + page.evaluate("""() => document.querySelector('#lrows .row').click()""") + flick(400) + check(dossier_on(), 'list row tap opens the dossier') + dhero_click() + check(viewer_on(), 'list dossier hero opens the viewer') + key('Escape') + check(not viewer_on() and dossier_on(), 'Esc: viewer -> dossier (list)') + key('Escape') + check(not dossier_on() + and page.evaluate("() => document.getElementById('listScreen')" + ".classList.contains('on')"), + 'second Esc returns to the list screen') + page.evaluate("() => document.querySelector('.tab[data-tab=deck]').click()") + flick(300) + + # arrows inert while the dossier is open + tap_center() + check(dossier_on(), 'dossier open for the key-inertness check') + before_pill = sect_pill()['txt'] + for kk in ('ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'): + page.keyboard.press(kk) + flick(40) + check(sect_pill()['txt'] == before_pill, + 'arrow keys are inert while the dossier is open') + + # item 10+4: dossier band + minimal rubric on the fresh card + db = page.evaluate("""() => { const b=document.getElementById('dband'); + if(b.style.display==='none') return {off:true}; + return {off:false, cls:b.className}; }""") + check(bool(db.get('off')), 'dossier on a fresh card shows no band') + drank = page.evaluate("""() => { const d=document.getElementById('drank'); + return {shown:d.style.display!=='none', + pts:document.getElementById('dpts').textContent, + lines:document.getElementById('dlines').innerHTML, + foot:document.getElementById('dfoot').textContent}; }""") + check(re.search(r'^\d+%$', drank['pts']), + 'item 4: rank block shows gauge + NN%% (%s)' % drank['pts']) + check(not drank['lines'] and not drank['foot'], + 'item 4: breakdown lines + footer hidden with rubric_debug off') + dc = page.evaluate("""() => { const p=document.getElementById('dphoto'); + const b=document.getElementById('dblur'); + return {fit:getComputedStyle(p).objectFit, + bg:b.style.backgroundImage}; }""") + check(dc['fit'] == 'contain' and bool(dc['bg']), + 'item 3: dossier hero fits contain over its backdrop') + page.screenshot(path=os.path.join(SHOTS, 'item3-dossier.png')) + key('Escape') + + # item 2: side taps still flip (clamped), never open a sheet + before_photo = top_state()['photo'] + tap_side('left') + check(top_state()['photo'] == before_photo, 'left tap clamps at photo 0') + check(not dossier_on() and not viewer_on(), + 'side taps never open sheets (B1 §11.2 regression)') + tap_side('right') + check(top_state()['photo'] != before_photo, 'right tap flips forward') + + # --- item 4: rubric_debug on -> breakdown appears ---------------------- + api_post('/api/prefs', {'key': 'rubric_debug', 'value': 'on'}) + page.reload(wait_until='networkidle') + wait('.topcard') + flick(400) + tap_center() + check(dossier_on(), 'dossier open for the debug-mode check') + drank = page.evaluate("""() => { const d=document.getElementById('drank'); + if(d.style.display==='none') return {shown:false}; + return {shown:true, + lines:document.getElementById('dlines').textContent, + foot:document.getElementById('dfoot').textContent}; }""") + check(bool(drank['shown']) and bool(drank['lines']), + 'item 4: rubric_debug on -> breakdown lines render') + page.screenshot(path=os.path.join(SHOTS, 'item4-debug-on.png')) + key('Escape') + page.evaluate("() => document.querySelector('.tab[data-tab=trends]').click()") + flick(300) + page.evaluate("() => document.getElementById('tRubOff').click()") + flick(400) + check(api('/api/section').get('prefs', {}).get('rubric_debug') == 'off', + 'Trends toggle wrote rubric_debug=off to the server') + heck = page.evaluate("""() => document.getElementById( + 'tRubOff').classList.contains('on')""") + check(bool(heck), 'toggle UI paints the off state') + page.evaluate("() => document.querySelector('.tab[data-tab=deck]').click()") + flick(300) + + # --- item 7: header pill + stats = api('/api/stats') + pill_txt = page.evaluate( + "() => document.getElementById('pill').textContent") + check(('%d live' % stats['live']) in pill_txt + and ('%d fresh' % stats['fresh']) in pill_txt, + 'item 7: pill reads live + fresh (%s)' % pill_txt.replace('\n', ' ')) + + # --- B1 no-lock: rapid commit storm (no sheet, no lock) ---------------- + before = sect_pill()['txt'] + taps(2, 40) + flick(500) + t = top_state() + check(t['topcards'] == 1, 'no-lock storm keeps exactly one .topcard') + after = sect_pill()['txt'] + check(int(after.split('/')[0]) - int(before.split('/')[0]) == 2, + 'both storm swipes landed (%s -> %s)' % (before, after)) + + # --- item 10 + P3 loop: sweep to the return band and exhaust ------------ + bands = {} + for _ in range(30): + tid = top_id() + if tid is not None and tid in by_id: + slot = by_id[tid]['slot'] + if slot in ('return', 'wildcard') and slot not in bands: + bands[slot] = page.evaluate("""() => { const b=document.querySelector( + '.topcard .band'); if(!b) return null; + const s=getComputedStyle(b); + return {cls:b.className, color:s.color, bg:s.backgroundColor, + text:b.textContent}; }""") + if not page.evaluate("() => document.querySelector('.topcard')"): + break + key('ArrowRight') + flick(80) + check(bool(bands.get('return')), + 'item 10: a SECOND LOOK card topped and carried its band') + if bands.get('return'): + check(bands['return']['color'] == 'rgb(196, 181, 253)', + 'item 10: SL band text is the violet tint') + check(bands['return']['bg'] == 'rgba(124, 58, 237, 0.22)', + 'item 10: SL band fill is violet 22%') + check('SECOND LOOK' in bands['return']['text'], + 'item 10: SL band label intact') + page.screenshot(path=os.path.join(SHOTS, 'item10-card-band.png')) + if bands.get('wildcard'): + check('wild' in bands['wildcard']['cls'], + 'item 10: wildcard band keeps its amber variant') + # dossier band on a return item: drive the real openDetail path + ret_item = next((i for i in body['items'] if i['slot'] == 'return'), None) + db = page.evaluate("""(item) => { openDetail(item, {type:'deck'}); + const b=document.getElementById('dband'); + const s=getComputedStyle(b); + return {off:b.style.display==='none', color:s.color, + bg:s.backgroundColor, text:b.textContent}; }""", ret_item) + check(not db['off'] and db['color'] == 'rgb(196, 181, 253)' + and 'SECOND LOOK' in db['text'], + 'item 10: dossier mirrors the violet SECOND LOOK band') + key('Escape') + + # finish the section -> report -> forge + for _ in range(10): + if not page.evaluate("() => document.querySelector('.topcard')"): + break + key('ArrowRight') + flick(80) + wait('#report.on, #empty2.on', timeout=10000) + rep = page.evaluate("""() => ({report:document.getElementById('report') + .classList.contains('on'), + dry:document.getElementById('dryCap').style.display!=='none', + counts:document.getElementById('repCounts').textContent, + empty2:document.getElementById('empty2').classList.contains('on')})""") + check(bool(rep['report']), 'P3 loop: report card owns the exhausted deck') + check(not rep['dry'], 'item 6: rich batch shows no dry caption') + check(not rep['empty2'], 'item 8: empty2 stays hidden while stock remains') + check(sect_pill()['hot'], 'pill is hot at the section end') + page.screenshot(path=os.path.join(SHOTS, 'p3-report.png')) + sec_before = section_body()['section']['id'] + page.evaluate("() => document.getElementById('btnForge').click()") + wait('.topcard', timeout=20000) + check(section_body()['section']['id'] != sec_before, + 'forge opened a brand-new section') + pill = sect_pill() + check(re.search(r'^\d+/25$', pill['txt']), + 'new section pill starts at a plain N/25 (%s)' % pill['txt']) + check(not dossier_on() and not viewer_on(), 'forge leaves no sheets open') + print('rich walk complete') + + +# --- WALK2: returns-only (counter fix + dry messaging) --------------------- + +def walk_returns_only(): + wait('.topcard') + flick(800) + body = section_body() + check(len(body['items']) == 25 and body['recipe']['new'] == 0, + 'returns-only catalog composes 25 second looks (recipe %s)' + % json.dumps(body['recipe'])) + check(body['progress']['decided'] == 0 + and body['progress']['total'] == 25, + 'item 5: fresh batch reads 0/25 (old code painted 25/25)') + pill = sect_pill() + check(pill['txt'] == '0/25' and not pill['hot'], + 'pill shows 0/25, no premature hot state') + + # one real strike -> 1/25 (in-section event, not lifetime verdict) + key('ArrowRight') + pill = sect_pill() + check(pill['txt'] == '1/25', 'first strike -> 1/25 (%s)' % pill['txt']) + check(section_body()['progress']['decided'] == 1, + 'server truth agrees: one in-section decision') + key('ArrowUp') + check(sect_pill()['txt'] == '2/25', 'ArrowUp maybe -> 2/25') + key('ArrowDown') + check(sect_pill()['txt'] == '1/25', 'ArrowDown undo -> 1/25') + key('Backspace') + check(sect_pill()['txt'] == '0/25', 'Backspace undo -> 0/25') + + # mixed actions -> report counts proven in-section only + key('ArrowLeft') + key('ArrowUp') + key('ArrowRight') + check(sect_pill()['txt'] == '3/25', 'three strikes -> 3/25 (clamped)') + + # sweep to exhaustion + for _ in range(30): + if not page.evaluate("() => document.querySelector('.topcard')"): + break + key('ArrowRight') + flick(60) + wait('#report.on', timeout=10000) + pill = sect_pill() + check(pill['txt'] == '25/25' and pill['hot'], + 'pill clamps at 25/25 and goes hot (never 26/25)') + counts = api('/api/section')['counts'] + check(counts['likes'] + counts['maybes'] + counts['nopes'] == 25, + 'report counts the 25 in-section verdicts (not lifetime)') + rep = page.evaluate("""() => ({dry:document.getElementById('dryCap') + .style.display!=='none', + status:document.getElementById('forgeStatus').textContent})""") + check(bool(rep['dry']), 'item 6: dry caption shows on the 0-fresh report') + + # dry forge -> the sentence + a fresh 0/25 returns-only deck + page.evaluate("() => document.getElementById('btnForge').click()") + for _ in range(40): + st = page.evaluate( + "() => document.getElementById('forgeStatus').textContent") + if 'Catalog fully reviewed' in st: + break + flick(250) + st = page.evaluate( + "() => document.getElementById('forgeStatus').textContent") + check('Catalog fully reviewed — fresh listings arrive with the next' + ' 08:30 import; showing second looks' in st, + 'item 6: dry forge status shows the exact BACKLOG sentence') + wait('.topcard', timeout=15000) + body = section_body() + check(body['recipe']['new'] == 0 and body['progress']['decided'] == 0, + 'dry forge decks another 25 second looks at 0/25') + print('returns-only walk complete') + + +# --- WALK3: dead catalog (loud empty2) ------------------------------------- + +def burn_returns(db_path): + conn = sqlite3.connect(db_path) + try: + conn.execute('DELETE FROM section_items') + conn.execute("UPDATE sections SET status='closed'" + " WHERE status!='closed'") + conn.execute( + """INSERT INTO section_items (section_id, listing_id, slot, + position) + SELECT (SELECT id FROM sections ORDER BY id DESC LIMIT 1), + listing_id, 'return', 1 FROM swipes + WHERE listing_id IN (SELECT id FROM listings + WHERE status='active')""") + conn.commit() + finally: + conn.close() + + +def walk_dead_catalog(): + body = section_body() + check(body['pool'] == {'fresh': 0, 'returns': 0, 'wildcards': 0}, + 'item 8: drained catalog reports pool 0/0/0') + check(body['items'] == [], 'compose emits zero items on a dead pool') + wait('#empty2.on', timeout=10000) + st = page.evaluate("""() => { const e=document.getElementById('empty2'); + return {on:e.classList.contains('on'), + txt:e.textContent, + report:document.getElementById('report').classList.contains('on')}; }""") + check(bool(st['on']), 'item 8: loud empty2 owns the stage') + check('No more new bikes!' in st['txt'] and '08:30' in st['txt'], + 'item 8: exact title + next-import line') + check(not st['report'], 'item 8: report card masked on the dead catalog') + page.screenshot(path=os.path.join(SHOTS, 'item8-empty2.png')) + + # forge on the dead catalog: still empty2, no wasted Forge invitation + page.evaluate("() => document.getElementById('btnForge').click()") + flick(1500) + st = page.evaluate("""() => { const e=document.getElementById('empty2'); + return {on:e.classList.contains('on'), + report:document.getElementById('report').classList.contains('on')}; }""") + check(bool(st['on']) and not st['report'], + 'forge on 0/0/0 leaves empty2 (report never re-invites)') + + # nightly import simulated: a brand-new listing re-lights the deck + stop_server() + conn = sqlite3.connect(WALK3) + try: + lid = 990 + conn.execute("DELETE FROM listings WHERE id=?", (lid,)) + conn.execute( + "INSERT INTO listings (id, region, src_id, title, url, status," + " img_url, score, first_seen, desc) VALUES" + " (?, 'ns', 'v11night', 'Fresh overnight listing'," + " 'https://www.kijiji.ca/v-motorcycles/v11/990', 'active'," + " NULL, 50, '2026-11-13T08:30:00Z', 'arrived with the import')", + (lid,)) + conn.commit() + finally: + conn.close() + start_server(WALK3) + page.reload(wait_until='networkidle') + wait('.topcard', timeout=15000) + st = page.evaluate("""() => ({empty2:document.getElementById('empty2') + .classList.contains('on'), top:!!document.querySelector('.topcard')})""") + check(bool(st['top']) and not st['empty2'], + 'item 8: one fresh listing clears empty2 and relights the deck') + print('dead-catalog walk complete') + + +# --- WALK0: hand the rich DB a quick dry run through items 1 acceptance --- + +def main(): + global page + setup() # always: fresh staged DBs per run + # clear any stray scratch server left by an aborted run + import signal + for line in subprocess.run( + ['ps', '-eo', 'pid,args'], capture_output=True, + text=True).stdout.splitlines(): + if 'app.serve' in line and str(PORT) in line: + try: + os.kill(int(line.split()[0]), signal.SIGTERM) + except (ValueError, ProcessLookupError): + pass + time.sleep(0.5) + try: + from playwright.sync_api import sync_playwright + except ImportError: + print('playwright missing — run: .venv/bin/python -m app.fe_walk_v11') + return 1 + + 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 + + # phase 1: rich + start_server(WALK1) + try: + 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) + page = ctx.new_page() + page.goto(BASE_URL + '/', wait_until='networkidle') + walk_rich() + ctx.close() + finally: + stop_server() + + # phase 2: returns-only (uses WALK2 fresh from setup) + start_server(WALK2) + try: + 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) + page = ctx.new_page() + page.goto(BASE_URL + '/', wait_until='networkidle') + walk_returns_only() + ctx.close() + finally: + stop_server() + + # phase 3: dead catalog + shutil.copy2(WALK2, WALK3) + burn_returns(WALK3) + start_server(WALK3) + try: + 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) + page = ctx.new_page() + page.goto(BASE_URL + '/', wait_until='networkidle') + walk_dead_catalog() + ctx.close() + finally: + stop_server() + + print('---') + print('%d/%d walk checks passed' % (CHECKS[0], CHECKS[0] + CHECKS[1])) + return 1 if CHECKS[1] else 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/app/static/index.html b/app/static/index.html index 82a6963..75e71ae 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -1382,11 +1382,12 @@ document.addEventListener('keydown',e=>{ else if($('detail').classList.contains('on')) closeDetail(); return; // Esc never forges or navigates tabs } - if(k==='ArrowDown'){ e.preventDefault(); undo(); return; } - if(k==='Backspace'){ undo(); return; } + const sheetOpen=()=>$('detail').classList.contains('on')||$('viewer').classList.contains('on'); + if(k==='ArrowDown'){ e.preventDefault(); if(!sheetOpen()) undo(); return; } + if(k==='Backspace'){ if(!sheetOpen()) undo(); return; } if(k==='ArrowLeft'||k==='ArrowRight'||k==='ArrowUp'){ e.preventDefault(); - if($('detail').classList.contains('on')||$('viewer').classList.contains('on')) return; + if(sheetOpen()) return; const el=pile[0]&&pile[0].el; if(el) commit(el, k==='ArrowLeft'?'nope':k==='ArrowRight'?'like':'maybe'); }