test(v1.1): fe_walk_v11 harness (89 checks, 3 staged catalogs) + polish from the walk

- 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).
This commit is contained in:
Torres
2026-11-13 14:04:11 -04:00
parent 08f945a029
commit 35138bdc22
3 changed files with 869 additions and 3 deletions

View File

@@ -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()

849
app/fe_walk_v11.py Normal file
View File

@@ -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())

View File

@@ -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');
}