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