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:
16
app/api.py
16
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()
|
||||
|
||||
849
app/fe_walk_v11.py
Normal file
849
app/fe_walk_v11.py
Normal 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())
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user