diff --git a/app/api.py b/app/api.py index 38ca71b..d070939 100644 --- a/app/api.py +++ b/app/api.py @@ -238,6 +238,14 @@ def create_app(db_path, img_dir, state_dir=None): live = conn.execute( "SELECT COUNT(*) AS n FROM listings WHERE status='active'" ).fetchone()['n'] + # item 7: fresh = active with no swipe row (the deck's new-pool + # predicate, verbatim). A swipe of ANY age removes freshness. + fresh = conn.execute( + "SELECT COUNT(*) AS n FROM listings l" + " WHERE l.status='active'" + " AND NOT EXISTS (SELECT 1 FROM swipes s" + " WHERE s.listing_id = l.id)" + ).fetchone()['n'] badge = get_pref(conn, 'badge_seen_at') or '' new = conn.execute( "SELECT COUNT(*) AS n FROM listings" @@ -253,6 +261,7 @@ def create_app(db_path, img_dir, state_dir=None): ' ORDER BY id DESC LIMIT 1').fetchone() return { 'live': live, + 'fresh': fresh, 'new': new, 'likes': per['like'], 'maybes': per['maybe'], @@ -451,11 +460,20 @@ def create_app(db_path, img_dir, state_dir=None): sec = conn.execute('SELECT * FROM sections WHERE id=?', (sec['id'],)).fetchone() mode = get_pref(conn, 'forge_mode', 'ondemand') + decided_ids = secmod.section_decided_ids(conn, sec) items = [p3_payload(conn, engine, r, slot=r['slot'], include_breakdown=True) for r in data['items']] + for p in items: + # item 5: decided IN this section (real verdict), not the joined + # lifetime swipes decision — present on EVERY item so the FE's + # local-intent branch can't misfire + p['decided_this_section'] = p['id'] in decided_ids data['items'] = items data['mode'] = mode + data['recipe'] = (json.loads(sec['recipe']) if sec['recipe'] else {}) + data['pool'] = secmod.pool_counts(conn) + data['prefs'] = {'rubric_debug': get_pref(conn, 'rubric_debug', 'off')} data['can_forge'] = (mode != 'strict' or secmod.fresh_profile_exists(conn, sec)) data['counts'] = secmod.section_counts(conn, sec) @@ -483,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() @@ -522,6 +556,11 @@ def create_app(db_path, img_dir, state_dir=None): conn, engine, size=size, profile_version=prof['version'] if prof else None, note=note) + # v1.1 item 6: label the dry-catalog state on the forge note + recipe = (json.loads(newsec['recipe']) + if newsec['recipe'] else {}) + if recipe.get('new') == 0: + note = '%s · catalog fully reviewed' % (note or '') return section_state(conn, newsec, note=note) finally: conn.close() @@ -653,7 +692,8 @@ def create_app(db_path, img_dir, state_dir=None): def set_pref_endpoint(body: dict): key = body.get('key') value = body.get('value') - allowed = {'forge_mode': ('ondemand', 'strict')} + allowed = {'forge_mode': ('ondemand', 'strict'), + 'rubric_debug': ('off', 'on')} if key not in allowed or value not in allowed[key]: raise HTTPException(status_code=400, detail='unknown or invalid pref') diff --git a/app/fe_walk_v11.py b/app/fe_walk_v11.py new file mode 100644 index 0000000..a4ebb25 --- /dev/null +++ b/app/fe_walk_v11.py @@ -0,0 +1,849 @@ +"""v1.1 FE walk — headless-chromium proof of every v1.1 item on the real page. + +Self-contained: stages its own temp DBs (never the live file), boots the +scratch server on 127.0.0.1:9455 (never 9453), runs the walks, kills the +server. Mock forge only (OPENROUTER_API_KEY is not consulted — the server +is started with the key unset). + + cd /var/home/admin/dev/bike-tinder-v11/build + .venv/bin/python -m app.fe_walk_v11 setup # (auto-runs at walk start) + .venv/bin/python -m app.fe_walk_v11 # full walk + +Phases: + WALK1 (rich: pristine catalog + synthetic multi-photo fixtures 701-706, + 705 a liked return) — items 1, 2, 3, 4, 7, 10, B1 §11 regressions + (flip clamps / one .topcard / no-lock swipe storm), full P3 loop + (compose -> strike all -> report -> forge), item 6 non-dry. + WALK2 (returns-only: every active pre-swiped maybe) — item 5 counter + (0/N -> 1/N -> undo -> sweep -> 25/25 hot -> report counts), + item 6 dry caption + dry forge status. + WALK3 (0/0/0: returns one-shot-burned) — item 8 loud empty2, nightly- + import recovery (a fresh listing re-lights the deck). +""" +import json +import os +import re +import shutil +import sqlite3 +import subprocess +import sys +import time +import urllib.request + +ARENA = '/var/home/admin/dev/bike-tinder-v11' +REPO = os.path.join(ARENA, 'build') +PRISTINE = os.path.join(ARENA, 'scratch-v11-pristine.sqlite3') +WALK1 = os.path.join(ARENA, 'scratch-v11-walk1.sqlite3') +WALK2 = os.path.join(ARENA, 'scratch-v11-walk2.sqlite3') +WALK3 = os.path.join(ARENA, 'scratch-v11-walk3.sqlite3') +IMG = os.path.join(ARENA, 'img-v11') +MEDIA = os.path.join(ARENA, 'media-sample-v11') +SHOTS = os.path.join(ARENA, 'shots-v11') +BASE_URL = 'http://127.0.0.1:9455' +PORT = 9455 + +CDN = ('https://media.kijiji.ca/api/v1/ca-prod-dealer-ads/images/x/%s' + '?rule=kijijica-640-webp') + +FIX = { + 701: (8, 'gallery-8'), + 702: (3, 'gallery-3'), + 703: (2, 'gallery-2'), + 704: (1, 'single-photo'), + 705: (8, 'return-rotation'), + 706: (0, 'zero-photo'), +} +SCORE_BASE = 2000 + +server_proc = None +page = None + + +# --- staging --------------------------------------------------------------- + +def stage_img_files(): + os.makedirs(IMG, exist_ok=True) + names = sorted(m for m in os.listdir(MEDIA) if m.endswith('.webp')) + assert len(names) == 12, names + k = 0 + for lid in sorted(FIX): + n = FIX[lid][0] + if n == 0: + continue + if n == 1: + shutil.copy2(os.path.join(MEDIA, names[k % len(names)]), + os.path.join(IMG, '%d.webp' % lid)) + k += 1 + continue + for i in range(n): + shutil.copy2(os.path.join(MEDIA, names[k % len(names)]), + os.path.join(IMG, '%d_%d.webp' % (lid, i))) + k += 1 + + +def seeds(): + """Fixture rows + return candidates on a temp copy; returns the maybe-pair.""" + from app import db as dbmod + conn = sqlite3.connect(WALK1) + conn.row_factory = sqlite3.Row + try: + dbmod.init_db(conn) # guarded B1/P3 migrations + for lid in sorted(FIX): + n = FIX[lid][0] + imgs = [CDN % ('x%d-%d' % (lid, i)) for i in range(n)] + img = imgs[0] if imgs else None + score = SCORE_BASE + (750 - lid) + conn.execute("DELETE FROM listings WHERE id=?", (lid,)) + conn.execute( + "INSERT INTO listings (id, region, src_id, title, url, status," + " img_url, imgs, score, first_seen, desc) VALUES" + " (?, 'ns', ?, ?, ?, 'active', ?, ?, ?," + " '2026-11-11T00:00:00Z', ?)", + (lid, 'v11fix%d' % lid, 'V1.1 test %s' % FIX[lid][1], + 'https://www.kijiji.ca/v-motorcycles/v11/%d' % lid, + img, json.dumps(imgs) if imgs else None, score, + 'V1.1 fixture')) + conn.execute( + "INSERT OR REPLACE INTO swipes (listing_id, action, created_at," + " last_photo_idx) VALUES (705, 'like', '2026-11-08T00:00:00Z', 0)") + # fresh fixtures: round-1 events keep them OUT of the wildcard pool + for lid in FIX: + conn.execute( + "INSERT INTO swipe_events (listing_id, action, round," + " section_id) VALUES (?, ?, 1, NULL)", + (lid, 'like' if lid == 705 else 'maybe')) + # second reserved maybe-return: the highest-scored existing swiped + # active swings to maybe so 705 keeps its reserved return slot + x = conn.execute( + "SELECT s.listing_id FROM swipes s" + " JOIN listings l ON l.id = s.listing_id" + " WHERE l.status='active' ORDER BY l.score DESC LIMIT 1" + ).fetchone()['listing_id'] + conn.execute("UPDATE swipes SET action='maybe' WHERE listing_id=?", (x,)) + conn.execute( + "UPDATE swipe_events SET action='maybe' WHERE listing_id=?" + " AND round=1", (x,)) + conn.execute("UPDATE sections SET status='closed'" + " WHERE status!='closed'") + conn.commit() + return x + finally: + conn.close() + + +def setup(): + assert os.path.exists(PRISTINE), 'pristine copy missing' + os.makedirs(SHOTS, exist_ok=True) + stage_img_files() + shutil.copy2(PRISTINE, WALK1) + seeds() + shutil.copy2(PRISTINE, WALK2) + conn = sqlite3.connect(WALK2) + try: + conn.execute('DELETE FROM section_items') + conn.execute("UPDATE sections SET status='closed'" + " WHERE status!='closed'") + conn.execute( + """INSERT INTO swipes (listing_id, action, created_at) + SELECT id, 'maybe', '2026-11-01T00:00:00Z' FROM listings + WHERE status='active' + AND id NOT IN (SELECT listing_id FROM swipes)""") + conn.execute( + """INSERT INTO swipe_events (listing_id, action, round, section_id) + SELECT id, 'maybe', 1, NULL FROM listings + WHERE status='active' + AND id NOT IN (SELECT listing_id FROM swipe_events)""") + conn.commit() + finally: + conn.close() + print('setup: walk DBs staged (%s, %s)' % (WALK1, WALK2)) + + +# --- server ---------------------------------------------------------------- + +def start_server(db_path): + global server_proc + env = dict(os.environ) + env.pop('OPENROUTER_API_KEY', None) # mock forge pass only + server_proc = subprocess.Popen( + [os.path.join(REPO, '.venv/bin/python'), '-m', 'app.serve', + '--db', db_path, '--img-dir', IMG, '--host', '127.0.0.1', + '--port', str(PORT), '--state-dir', + os.path.join(ARENA, 'state-v11')], + cwd=REPO, env=env, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + for _ in range(60): + try: + with urllib.request.urlopen(BASE_URL + '/api/health', + timeout=2) as r: + if r.status == 200: + return + except Exception: + pass + time.sleep(0.25) + raise RuntimeError('scratch server did not come up') + + +def stop_server(): + global server_proc + if server_proc: + server_proc.terminate() + try: + server_proc.wait(timeout=5) + except subprocess.TimeoutExpired: + server_proc.kill() + server_proc = None + + +# --- walk helpers ---------------------------------------------------------- + +CHECKS = [0, 0] + + +def wait(selector, timeout=10000): + page.wait_for_selector(selector, timeout=timeout) + + +def ok(msg): + CHECKS[0] += 1 + print('ok ' + msg) + + +def bad(msg): + CHECKS[1] += 1 + print('FAIL ' + msg) + page.screenshot(path=os.path.join(SHOTS, 'fail-%s.png' % int(time.time()))) + raise SystemExit(1) + + +def check(cond, msg): + if cond: + ok(msg) + else: + bad(msg) + + +def top_state(): + return page.evaluate("""() => { + const t=document.querySelector('.topcard'); + const r={topcards:document.querySelectorAll('.topcard').length, + photo:null, segOn:-1, segs:0, band:null, bandClass:null}; + if(!t) return r; + const s=t.querySelector('.segments'); + if(s){ r.segs=s.children.length; + [...s.children].forEach((x,k)=>{ if(x.classList.contains('on')) + r.segOn=k; }); } + const p=t.querySelector('.phwrap img.photo'); + if(p) r.photo=p.getAttribute('src'); + const b=t.querySelector('.band'); + if(b){ r.band=b.textContent; r.bandClass=b.className; } + return r; }""") + + +def top_id(): + t = top_state() + if not t['photo']: + return None + m = re.search(r'/img/(\d+)(?:\?i=\d+)?$', t['photo'] or '') + return int(m.group(1)) if m else None + + +def sect_pill(): + return page.evaluate("() => ({txt:document.getElementById('sect').textContent," + " hot:document.getElementById('sect').classList.contains('hot')})") + + +def pile_len(): + return page.evaluate("() => document.querySelectorAll('.topcard').length +" + " + [...document.querySelectorAll('#stage .card')].filter(" + " e=>!e.classList.contains('topcard')).length") + + +def key(k): + page.keyboard.press(k) + time.sleep(0.08) + + +def api(path): + with urllib.request.urlopen(BASE_URL + path, timeout=10) as r: + return json.loads(r.read()) + + +def api_post(path, body): + req = urllib.request.Request(BASE_URL + path, + data=json.dumps(body).encode(), + headers={'Content-Type': 'application/json'}) + with urllib.request.urlopen(req, timeout=30) as r: + return json.loads(r.read()) + + +def section_body(): + return api('/api/section') + + +def flick(ms=350): + time.sleep(ms / 1000.0) + + +def tap_center(): + b = page.evaluate("""() => { const el=document.querySelector('.topcard'); + const r=el.getBoundingClientRect(); + return {x:r.x+r.width/2,y:r.y+r.height*0.72}; }""") + page.mouse.click(b['x'], b['y']) + time.sleep(0.08) + + +def tap_side(side): + b = page.evaluate("""() => { const el=document.querySelector('.topcard'); + const r=el.getBoundingClientRect(); + return {x:r.x, w:r.width, y:r.y+r.height*0.72}; }""") + x = b['x'] + (0.16 if side == 'left' else 0.84) * b['w'] + page.mouse.click(x, b['y']) + time.sleep(0.08) + + +def taps(clicks, gap=40): + # real commit storm through the like button: the no-lock law must + # survive button-speed swipes + for _ in range(clicks): + page.evaluate("() => document.getElementById('btnLike').click()") + time.sleep(gap / 1000.0) + + +def dossier_on(): + return page.evaluate( + "() => document.getElementById('detail').classList.contains('on')") + + +def viewer_on(): + return page.evaluate( + "() => document.getElementById('viewer').classList.contains('on')") + + +def dhero_click(): + b = page.evaluate("""() => { const el=document.getElementById('dhero')|| + document.querySelector('.dhero'); + const r=el.getBoundingClientRect(); + return {x:r.x+r.width/2,y:r.y+r.height/2}; }""") + page.mouse.click(b['x'], b['y']) + time.sleep(0.15) + + +# --- WALK1: rich deck ------------------------------------------------------ + +def walk_rich(): + check(page.url.startswith(BASE_URL), 'page served from the app root') + wait('.topcard') + flick(800) + body = section_body() + check(len(body['items']) == 25, 'rich compose serves 25 items') + check(body['recipe']['new'] == 21 and body['recipe']['return'] == 2 + and body['recipe']['wildcard'] == 2, + 'recipe 21/2/2 on the rich pool (the sign-off shape)') + by_id = {i['id']: i for i in body['items']} + for lid in (701, 702, 703, 704, 706): + check(lid in by_id and by_id[lid]['slot'] == 'new', + 'fixture %s sits in the fresh slots' % lid) + check(by_id[705]['slot'] == 'return', '705 composes into a return slot') + + t = top_state() + check(t['topcards'] == 1, 'exactly one .topcard on load') + check(top_id() == 701, 'top card is the highest-scored fixture (701)') + + # item 3: computed styles on the top card (fit + backdrop) + css = page.evaluate("""() => { const c=document.querySelector('.topcard'); + const p=c.querySelector('.phwrap img.photo'); + const b=c.querySelector('.phwrap .phblur'); + return {fit:getComputedStyle(p).objectFit, + blur:!!b, bg:b?b.style.backgroundImage:null, + src:p.getAttribute('src')}; }""") + check(css['fit'] == 'contain', 'item 3: card photo object-fit == contain') + check(bool(css['blur']) and css['src'] in (css['bg'] or ''), + 'item 3: card backdrop exists and mirrors the photo URL') + page.screenshot(path=os.path.join(SHOTS, 'item3-card-fit.png')) + + # --- item 5+1: strikes through the real key map ------------------------ + check(sect_pill()['txt'] == '0/25', 'fresh rich section starts 0/25') + key('ArrowUp') + check(sect_pill()['txt'] == '1/25', 'ArrowUp (maybe) -> 1/25') + key('ArrowDown') + check(sect_pill()['txt'] == '0/25', 'ArrowDown (undo) -> 0/25') + key('ArrowUp') + check(sect_pill()['txt'] == '1/25', 're-decide -> 1/25') + key('Backspace') + check(sect_pill()['txt'] == '0/25', 'Backspace is the undo alias') + pill_before = sect_pill()['txt'] + # scroll prevention spy + page.evaluate("""() => { window.__prev=[]; document.addEventListener( + 'keydown',e=>window.__prev.push([e.key,e.defaultPrevented])); }""") + key('ArrowLeft') + key('ArrowRight') + prevented = page.evaluate("""() => window.__prev.filter( + p=>p[0].startsWith('Arrow')).map(p=>p[1])""") + check(all(prevented), 'item 1: arrows defaultPrevented (no page scroll)') + pn = sect_pill()['txt'] + check(int(pn.split('/')[0]) == int(pill_before.split('/')[0]) + 2, + 'ArrowLeft/ArrowRight commit through the normal path (%s -> %s)' + % (pill_before, pn)) + + # --- item 1: text-input guard (list search) --------------------------- + page.evaluate("() => document.querySelector('.tab[data-tab=list]').click()") + wait('#lsearch') + page.evaluate("""() => { const s=document.getElementById('lsearch'); + s.focus(); }""") + page.keyboard.type('abc') + before_search = page.evaluate( + "() => document.getElementById('lsearch').value") + check(before_search == 'abc', 'search input accepts typing (focus guard on)') + for kk in ('ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'): + page.keyboard.press(kk) + flick(60) + check(page.evaluate( + "() => document.getElementById('lsearch').value") == 'abc', + 'arrows inside the search input never swipe (text + value unchanged)') + check(sect_pill()['txt'] == pn, + 'input-focused arrows leave the section counter untouched') + page.keyboard.press('Backspace') + check(page.evaluate( + "() => document.getElementById('lsearch').value") == 'ab', + 'Backspace edits text while an input owns focus (no undo fired)') + page.evaluate("() => document.querySelector('.tab[data-tab=deck]').click()") + flick(300) + + # --- item 2: center tap -> dossier -> hero -> viewer; Esc/Es ---------- + tid_before = top_id() + src_before = top_state()['photo'] + pill_pre_tap = sect_pill()['txt'] + tap_center() + check(sect_pill()['txt'] == pill_pre_tap, + 'item 2: center tap commits nothing') + check(dossier_on(), 'item 2: center tap opens the dossier') + check(not viewer_on(), 'item 2: no viewer on center tap') + check(bool(page.evaluate( + "() => document.getElementById('dtitle').textContent")), + 'dossier carries the card title') + dhero_click() + check(viewer_on(), 'dossier hero tap opens the viewer (photo 0)') + vidx = page.evaluate("""() => { const im=document.getElementById('vimg'); + return {src:im.getAttribute('src'), + fot:getComputedStyle(im).objectFit}; }""") + check(vidx['fot'] == 'contain', 'item 3: viewer vimg stays contain (no diff)') + page.screenshot(path=os.path.join(SHOTS, 'item3-viewer.png')) + # arrows are inert while the viewer is open + pill_now = sect_pill()['txt'] + for kk in ('ArrowLeft', 'ArrowRight', 'ArrowUp'): + page.keyboard.press(kk) + check(sect_pill()['txt'] == pill_now, + 'arrows are inert while the viewer is open') + key('Escape') + check(not viewer_on() and dossier_on(), + 'Esc closes the viewer only (dossier stays open)') + key('Escape') + check(not dossier_on() and not viewer_on(), + 'second Esc closes the dossier to the deck') + check(top_id() == tid_before and top_state()['photo'] == src_before, + 'walk-back leaves the same card on the same photo') + + # item 2 A5: list row -> dossier -> viewer -> Esc -> dossier -> Esc -> list + page.evaluate("() => document.querySelector('.tab[data-tab=list]').click()") + wait('#lrows .row') + flick(400) + thumb_fit = page.evaluate("""() => { const t=document.querySelector( + '.thumb'); return t?getComputedStyle(t).objectFit:null; }""") + check(thumb_fit == 'cover', 'item 3: list thumbs stay cover (no diff)') + page.evaluate("""() => document.querySelector('#lrows .row').click()""") + flick(400) + check(dossier_on(), 'list row tap opens the dossier') + dhero_click() + check(viewer_on(), 'list dossier hero opens the viewer') + key('Escape') + check(not viewer_on() and dossier_on(), 'Esc: viewer -> dossier (list)') + key('Escape') + check(not dossier_on() + and page.evaluate("() => document.getElementById('listScreen')" + ".classList.contains('on')"), + 'second Esc returns to the list screen') + page.evaluate("() => document.querySelector('.tab[data-tab=deck]').click()") + flick(300) + + # arrows inert while the dossier is open + tap_center() + check(dossier_on(), 'dossier open for the key-inertness check') + before_pill = sect_pill()['txt'] + for kk in ('ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'): + page.keyboard.press(kk) + flick(40) + check(sect_pill()['txt'] == before_pill, + 'arrow keys are inert while the dossier is open') + + # item 10+4: dossier band + minimal rubric on the fresh card + db = page.evaluate("""() => { const b=document.getElementById('dband'); + if(b.style.display==='none') return {off:true}; + return {off:false, cls:b.className}; }""") + check(bool(db.get('off')), 'dossier on a fresh card shows no band') + drank = page.evaluate("""() => { const d=document.getElementById('drank'); + return {shown:d.style.display!=='none', + pts:document.getElementById('dpts').textContent, + lines:document.getElementById('dlines').innerHTML, + foot:document.getElementById('dfoot').textContent}; }""") + check(re.search(r'^\d+%$', drank['pts']), + 'item 4: rank block shows gauge + NN%% (%s)' % drank['pts']) + check(not drank['lines'] and not drank['foot'], + 'item 4: breakdown lines + footer hidden with rubric_debug off') + dc = page.evaluate("""() => { const p=document.getElementById('dphoto'); + const b=document.getElementById('dblur'); + return {fit:getComputedStyle(p).objectFit, + bg:b.style.backgroundImage}; }""") + check(dc['fit'] == 'contain' and bool(dc['bg']), + 'item 3: dossier hero fits contain over its backdrop') + page.screenshot(path=os.path.join(SHOTS, 'item3-dossier.png')) + key('Escape') + + # item 2: side taps still flip (clamped), never open a sheet + before_photo = top_state()['photo'] + tap_side('left') + check(top_state()['photo'] == before_photo, 'left tap clamps at photo 0') + check(not dossier_on() and not viewer_on(), + 'side taps never open sheets (B1 §11.2 regression)') + tap_side('right') + check(top_state()['photo'] != before_photo, 'right tap flips forward') + + # --- item 4: rubric_debug on -> breakdown appears ---------------------- + api_post('/api/prefs', {'key': 'rubric_debug', 'value': 'on'}) + page.reload(wait_until='networkidle') + wait('.topcard') + flick(400) + tap_center() + check(dossier_on(), 'dossier open for the debug-mode check') + drank = page.evaluate("""() => { const d=document.getElementById('drank'); + if(d.style.display==='none') return {shown:false}; + return {shown:true, + lines:document.getElementById('dlines').textContent, + foot:document.getElementById('dfoot').textContent}; }""") + check(bool(drank['shown']) and bool(drank['lines']), + 'item 4: rubric_debug on -> breakdown lines render') + page.screenshot(path=os.path.join(SHOTS, 'item4-debug-on.png')) + key('Escape') + page.evaluate("() => document.querySelector('.tab[data-tab=trends]').click()") + flick(300) + page.evaluate("() => document.getElementById('tRubOff').click()") + flick(400) + check(api('/api/section').get('prefs', {}).get('rubric_debug') == 'off', + 'Trends toggle wrote rubric_debug=off to the server') + heck = page.evaluate("""() => document.getElementById( + 'tRubOff').classList.contains('on')""") + check(bool(heck), 'toggle UI paints the off state') + page.evaluate("() => document.querySelector('.tab[data-tab=deck]').click()") + flick(300) + + # --- item 7: header pill + stats = api('/api/stats') + pill_txt = page.evaluate( + "() => document.getElementById('pill').textContent") + check(('%d live' % stats['live']) in pill_txt + and ('%d fresh' % stats['fresh']) in pill_txt, + 'item 7: pill reads live + fresh (%s)' % pill_txt.replace('\n', ' ')) + + # --- B1 no-lock: rapid commit storm (no sheet, no lock) ---------------- + before = sect_pill()['txt'] + taps(2, 40) + flick(500) + t = top_state() + check(t['topcards'] == 1, 'no-lock storm keeps exactly one .topcard') + after = sect_pill()['txt'] + check(int(after.split('/')[0]) - int(before.split('/')[0]) == 2, + 'both storm swipes landed (%s -> %s)' % (before, after)) + + # --- item 10 + P3 loop: sweep to the return band and exhaust ------------ + bands = {} + for _ in range(30): + tid = top_id() + if tid is not None and tid in by_id: + slot = by_id[tid]['slot'] + if slot in ('return', 'wildcard') and slot not in bands: + bands[slot] = page.evaluate("""() => { const b=document.querySelector( + '.topcard .band'); if(!b) return null; + const s=getComputedStyle(b); + return {cls:b.className, color:s.color, bg:s.backgroundColor, + text:b.textContent}; }""") + if not page.evaluate("() => document.querySelector('.topcard')"): + break + key('ArrowRight') + flick(80) + check(bool(bands.get('return')), + 'item 10: a SECOND LOOK card topped and carried its band') + if bands.get('return'): + check(bands['return']['color'] == 'rgb(196, 181, 253)', + 'item 10: SL band text is the violet tint') + check(bands['return']['bg'] == 'rgba(124, 58, 237, 0.22)', + 'item 10: SL band fill is violet 22%') + check('SECOND LOOK' in bands['return']['text'], + 'item 10: SL band label intact') + page.screenshot(path=os.path.join(SHOTS, 'item10-card-band.png')) + if bands.get('wildcard'): + check('wild' in bands['wildcard']['cls'], + 'item 10: wildcard band keeps its amber variant') + # dossier band on a return item: drive the real openDetail path + ret_item = next((i for i in body['items'] if i['slot'] == 'return'), None) + db = page.evaluate("""(item) => { openDetail(item, {type:'deck'}); + const b=document.getElementById('dband'); + const s=getComputedStyle(b); + return {off:b.style.display==='none', color:s.color, + bg:s.backgroundColor, text:b.textContent}; }""", ret_item) + check(not db['off'] and db['color'] == 'rgb(196, 181, 253)' + and 'SECOND LOOK' in db['text'], + 'item 10: dossier mirrors the violet SECOND LOOK band') + key('Escape') + + # finish the section -> report -> forge + for _ in range(10): + if not page.evaluate("() => document.querySelector('.topcard')"): + break + key('ArrowRight') + flick(80) + wait('#report.on, #empty2.on', timeout=10000) + rep = page.evaluate("""() => ({report:document.getElementById('report') + .classList.contains('on'), + dry:document.getElementById('dryCap').style.display!=='none', + counts:document.getElementById('repCounts').textContent, + empty2:document.getElementById('empty2').classList.contains('on')})""") + check(bool(rep['report']), 'P3 loop: report card owns the exhausted deck') + check(not rep['dry'], 'item 6: rich batch shows no dry caption') + check(not rep['empty2'], 'item 8: empty2 stays hidden while stock remains') + check(sect_pill()['hot'], 'pill is hot at the section end') + page.screenshot(path=os.path.join(SHOTS, 'p3-report.png')) + sec_before = section_body()['section']['id'] + page.evaluate("() => document.getElementById('btnForge').click()") + wait('.topcard', timeout=20000) + check(section_body()['section']['id'] != sec_before, + 'forge opened a brand-new section') + pill = sect_pill() + check(re.search(r'^\d+/25$', pill['txt']), + 'new section pill starts at a plain N/25 (%s)' % pill['txt']) + check(not dossier_on() and not viewer_on(), 'forge leaves no sheets open') + print('rich walk complete') + + +# --- WALK2: returns-only (counter fix + dry messaging) --------------------- + +def walk_returns_only(): + wait('.topcard') + flick(800) + body = section_body() + check(len(body['items']) == 25 and body['recipe']['new'] == 0, + 'returns-only catalog composes 25 second looks (recipe %s)' + % json.dumps(body['recipe'])) + check(body['progress']['decided'] == 0 + and body['progress']['total'] == 25, + 'item 5: fresh batch reads 0/25 (old code painted 25/25)') + pill = sect_pill() + check(pill['txt'] == '0/25' and not pill['hot'], + 'pill shows 0/25, no premature hot state') + + # one real strike -> 1/25 (in-section event, not lifetime verdict) + key('ArrowRight') + pill = sect_pill() + check(pill['txt'] == '1/25', 'first strike -> 1/25 (%s)' % pill['txt']) + check(section_body()['progress']['decided'] == 1, + 'server truth agrees: one in-section decision') + key('ArrowUp') + check(sect_pill()['txt'] == '2/25', 'ArrowUp maybe -> 2/25') + key('ArrowDown') + check(sect_pill()['txt'] == '1/25', 'ArrowDown undo -> 1/25') + key('Backspace') + check(sect_pill()['txt'] == '0/25', 'Backspace undo -> 0/25') + + # mixed actions -> report counts proven in-section only + key('ArrowLeft') + key('ArrowUp') + key('ArrowRight') + check(sect_pill()['txt'] == '3/25', 'three strikes -> 3/25 (clamped)') + + # sweep to exhaustion + for _ in range(30): + if not page.evaluate("() => document.querySelector('.topcard')"): + break + key('ArrowRight') + flick(60) + wait('#report.on', timeout=10000) + pill = sect_pill() + check(pill['txt'] == '25/25' and pill['hot'], + 'pill clamps at 25/25 and goes hot (never 26/25)') + counts = api('/api/section')['counts'] + check(counts['likes'] + counts['maybes'] + counts['nopes'] == 25, + 'report counts the 25 in-section verdicts (not lifetime)') + rep = page.evaluate("""() => ({dry:document.getElementById('dryCap') + .style.display!=='none', + status:document.getElementById('forgeStatus').textContent})""") + check(bool(rep['dry']), 'item 6: dry caption shows on the 0-fresh report') + + # dry forge -> the sentence + a fresh 0/25 returns-only deck + page.evaluate("() => document.getElementById('btnForge').click()") + for _ in range(40): + st = page.evaluate( + "() => document.getElementById('forgeStatus').textContent") + if 'Catalog fully reviewed' in st: + break + flick(250) + st = page.evaluate( + "() => document.getElementById('forgeStatus').textContent") + check('Catalog fully reviewed — fresh listings arrive with the next' + ' 08:30 import; showing second looks' in st, + 'item 6: dry forge status shows the exact BACKLOG sentence') + wait('.topcard', timeout=15000) + body = section_body() + check(body['recipe']['new'] == 0 and body['progress']['decided'] == 0, + 'dry forge decks another 25 second looks at 0/25') + print('returns-only walk complete') + + +# --- WALK3: dead catalog (loud empty2) ------------------------------------- + +def burn_returns(db_path): + conn = sqlite3.connect(db_path) + try: + conn.execute('DELETE FROM section_items') + conn.execute("UPDATE sections SET status='closed'" + " WHERE status!='closed'") + conn.execute( + """INSERT INTO section_items (section_id, listing_id, slot, + position) + SELECT (SELECT id FROM sections ORDER BY id DESC LIMIT 1), + listing_id, 'return', 1 FROM swipes + WHERE listing_id IN (SELECT id FROM listings + WHERE status='active')""") + conn.commit() + finally: + conn.close() + + +def walk_dead_catalog(): + body = section_body() + check(body['pool'] == {'fresh': 0, 'returns': 0, 'wildcards': 0}, + 'item 8: drained catalog reports pool 0/0/0') + check(body['items'] == [], 'compose emits zero items on a dead pool') + wait('#empty2.on', timeout=10000) + st = page.evaluate("""() => { const e=document.getElementById('empty2'); + return {on:e.classList.contains('on'), + txt:e.textContent, + report:document.getElementById('report').classList.contains('on')}; }""") + check(bool(st['on']), 'item 8: loud empty2 owns the stage') + check('No more new bikes!' in st['txt'] and '08:30' in st['txt'], + 'item 8: exact title + next-import line') + check(not st['report'], 'item 8: report card masked on the dead catalog') + page.screenshot(path=os.path.join(SHOTS, 'item8-empty2.png')) + + # forge on the dead catalog: still empty2, no wasted Forge invitation + page.evaluate("() => document.getElementById('btnForge').click()") + flick(1500) + st = page.evaluate("""() => { const e=document.getElementById('empty2'); + return {on:e.classList.contains('on'), + report:document.getElementById('report').classList.contains('on')}; }""") + check(bool(st['on']) and not st['report'], + 'forge on 0/0/0 leaves empty2 (report never re-invites)') + + # nightly import simulated: a brand-new listing re-lights the deck + stop_server() + conn = sqlite3.connect(WALK3) + try: + lid = 990 + conn.execute("DELETE FROM listings WHERE id=?", (lid,)) + conn.execute( + "INSERT INTO listings (id, region, src_id, title, url, status," + " img_url, score, first_seen, desc) VALUES" + " (?, 'ns', 'v11night', 'Fresh overnight listing'," + " 'https://www.kijiji.ca/v-motorcycles/v11/990', 'active'," + " NULL, 50, '2026-11-13T08:30:00Z', 'arrived with the import')", + (lid,)) + conn.commit() + finally: + conn.close() + start_server(WALK3) + page.reload(wait_until='networkidle') + wait('.topcard', timeout=15000) + st = page.evaluate("""() => ({empty2:document.getElementById('empty2') + .classList.contains('on'), top:!!document.querySelector('.topcard')})""") + check(bool(st['top']) and not st['empty2'], + 'item 8: one fresh listing clears empty2 and relights the deck') + print('dead-catalog walk complete') + + +# --- WALK0: hand the rich DB a quick dry run through items 1 acceptance --- + +def main(): + global page + setup() # always: fresh staged DBs per run + # clear any stray scratch server left by an aborted run + import signal + for line in subprocess.run( + ['ps', '-eo', 'pid,args'], capture_output=True, + text=True).stdout.splitlines(): + if 'app.serve' in line and str(PORT) in line: + try: + os.kill(int(line.split()[0]), signal.SIGTERM) + except (ValueError, ProcessLookupError): + pass + time.sleep(0.5) + try: + from playwright.sync_api import sync_playwright + except ImportError: + print('playwright missing — run: .venv/bin/python -m app.fe_walk_v11') + return 1 + + with sync_playwright() as pw: + exe = os.path.expanduser( + '~/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome') + if not os.path.exists(exe): + exe = None + + # phase 1: rich + start_server(WALK1) + try: + browser = pw.chromium.launch(headless=True, executable_path=exe, + args=['--no-sandbox']) + ctx = browser.new_context(viewport={'width': 390, 'height': 844}, + has_touch=True, is_mobile=True) + page = ctx.new_page() + page.goto(BASE_URL + '/', wait_until='networkidle') + walk_rich() + ctx.close() + finally: + stop_server() + + # phase 2: returns-only (uses WALK2 fresh from setup) + start_server(WALK2) + try: + browser = pw.chromium.launch(headless=True, executable_path=exe, + args=['--no-sandbox']) + ctx = browser.new_context(viewport={'width': 390, 'height': 844}, + has_touch=True, is_mobile=True) + page = ctx.new_page() + page.goto(BASE_URL + '/', wait_until='networkidle') + walk_returns_only() + ctx.close() + finally: + stop_server() + + # phase 3: dead catalog + shutil.copy2(WALK2, WALK3) + burn_returns(WALK3) + start_server(WALK3) + try: + browser = pw.chromium.launch(headless=True, executable_path=exe, + args=['--no-sandbox']) + ctx = browser.new_context(viewport={'width': 390, 'height': 844}, + has_touch=True, is_mobile=True) + page = ctx.new_page() + page.goto(BASE_URL + '/', wait_until='networkidle') + walk_dead_catalog() + ctx.close() + finally: + stop_server() + + print('---') + print('%d/%d walk checks passed' % (CHECKS[0], CHECKS[0] + CHECKS[1])) + return 1 if CHECKS[1] else 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/app/sections.py b/app/sections.py index a4f407a..02f5707 100644 --- a/app/sections.py +++ b/app/sections.py @@ -1,8 +1,12 @@ """Bike-Tinder P3 section engine — compose / read / exhaust / forge. -The deck is one finite SECTION at a time (default 25 cards): -2 wildcards + <=5 returns (<=2 nopes, "surprising" only) + remainder new, -per docs/RANKING.md + docs/DATA-MODEL.md "P3 additions". +v1.1 batch formula (amends the frozen "<=5 returns" rule — one-shot, +re-nope and one-return rules unchanged): every batch targets 25 cards = +fresh first (up to 23 slots) + >=2 second looks whenever eligible stock +exists + the 2 wildcard slots; when fresh < the remainder, second looks +FILL the batch up to 25 (family cap relaxes to 8 for fill returns only). +Fresh exhausted -> returns-only batches; returns exhausted -> fresh-only; +both -> an empty composed section (the loud empty state, item 8). Pinned order within a section (position 1 first): new (points DESC) -> returns (maybe > like > nope, points DESC within @@ -11,10 +15,12 @@ Pinned order within a section (position 1 first): wildcard stretches sit at the back. (Wildcard placement inside the deck is not pinned anywhere in the frozen docs — noted in the P3 report.) -Constraints: <=4 per non-NULL family per section; >=3 price bands when the -pool allows (soft repair swaps new-slot items within the final list). -A re-nope (round>=2 nope event) rests an item: no more second looks. -Rounds cap at 2 for influence (taste.py); returns are offered once. +Constraints: <=4 per non-NULL family per section (fill-return slots relax +to <=8); >=3 price bands when the pool allows (soft repair swaps new-slot +items within the final list). <=2 re-nope returns per batch (reserved +nope slots must be "surprising"). A re-nope (round>=2 nope event) rests +an item: no more second looks. Rounds cap at 2 for influence (taste.py); +returns are offered once. Modes: 'ondemand' (default) — forge runs the agent pass then composes; 'strict' — forge refuses (409) unless a taste_profile strictly newer than @@ -26,9 +32,11 @@ from . import taste as tastemod from .taste import NOPE FAMILY_CAP = 4 -MAX_RETURNS = 5 +RETURN_FILL_FAMILY_CAP = 8 MAX_RETURN_NOPES = 2 WILDCARDS = 2 +FRESH_CAP = 23 +MIN_RETURNS = 2 DEFAULT_SIZE = 25 MIN_BANDS = 3 @@ -262,6 +270,11 @@ def compose_section(conn, engine, size=DEFAULT_SIZE, profile_version=None, Caller closes/reports any prior section first. `engine` scores the candidates (a TasteEngine over active listings + events + profile). + + v1.1 slot math (see module docstring; this amends the frozen "<=5 + returns" rule): reserved returns (>=2 when eligible stock exists) -> + wildcards (2) -> fresh (capped at 23) -> fill returns to `size` + (family cap 4 preferred, 8 rescue; <=2 re-nopes total). """ import datetime now = now or datetime.datetime.now(datetime.timezone.utc).replace( @@ -297,18 +310,18 @@ def compose_section(conn, engine, size=DEFAULT_SIZE, profile_version=None, top_bands = {tastemod.price_band(r['price_num']) for r in top3 if tastemod.price_band(r['price_num']) is not None} - # 2) returns: maybe > like > nope, points DESC within role; nopes must be - # "surprising" (points >=55 or net-positive family); <=5, <=2 nopes. - # Budget: wildcards keep their 2 slots; returns never push the - # section past `size`. - wanted_rets = min(MAX_RETURNS, max(0, size - min(WILDCARDS, size))) + # 2) reserved returns: the >=2 guarantee (role order maybe > like > nope, + # points DESC within role). Nopes must be "surprising" for RESERVED + # slots only (points >=55 or net-positive family); <=2 re-nope + # returns per batch overall; family cap 4 applies here. role_order = {'maybe': 0, 'like': 1, 'nope': 2} + ret_ordered = sorted(ret_cands, reverse=True, + key=lambda r: (-role_order.get(r['verdict'], 9), + _points_key(engine, r))) rets = [] nope_count = 0 - for cand in sorted(ret_cands, reverse=True, - key=lambda r: (-role_order.get(r['verdict'], 9), - _points_key(engine, r))): - if len(rets) >= wanted_rets: + for cand in ret_ordered: + if len(rets) >= min(MIN_RETURNS, size): break fam = cand['family'] if fam and fam_count.get(fam, 0) >= FAMILY_CAP: @@ -325,7 +338,8 @@ def compose_section(conn, engine, size=DEFAULT_SIZE, profile_version=None, if fam: fam_count[fam] = fam_count.get(fam, 0) + 1 - # 3) wildcards: min(2, size), preferred different family AND price band + # 3) wildcards: min(2, remaining slots), preferred different family AND + # price band (existing novel/preferred pick, unchanged) liked_ids = [r['listing_id'] for r in conn.execute( "SELECT listing_id FROM swipes WHERE action='like'")] nov = novelty(rows_by_id, liked_ids, @@ -339,7 +353,7 @@ def compose_section(conn, engine, size=DEFAULT_SIZE, profile_version=None, and (tastemod.price_band(r['price_num']) is None or tastemod.price_band(r['price_num']) not in top_bands)] - n_wilds = min(WILDCARDS, size) + n_wilds = min(WILDCARDS, max(0, size - len(rets))) wilds = [] avail = sorted([r for r in wild_cands if r['id'] not in picked_ids], key=wild_key) @@ -355,12 +369,41 @@ def compose_section(conn, engine, size=DEFAULT_SIZE, profile_version=None, picked_ids.add(cand['id']) if fam: fam_count[fam] = fam_count.get(fam, 0) + 1 - n_wilds = len(wilds) # pool shortfall reverts to new + n_wilds = len(wilds) # pool shortfall reverts to fresh/fill - # 4) new fills the remainder of the section (size - returns - wildcards) - want_new = max(0, size - len(rets) - n_wilds) + # 4) fresh fills the remainder (size - returns - wildcards), never + # beyond FRESH_CAP. Wildcards draw from the fresh pool but do NOT + # count against the fresh-slot cap (25 - 0 - 2 = 23 new + 2 wild on + # a returns-free rich day; 21 new + 2 wild + 2 returns when both + # guarantees hold — the sign-off shape). Pool-limited by what the + # wildcard pick left in new_cands; a dry wild pool reverts slots. + want_new = min(size - len(rets) - len(wilds), FRESH_CAP, + max(0, len(new_cands) - len(wilds))) picked_new = pick_new(engine, new_cands, want_new, fam_count, picked_ids) + # 5) fill returns to size: role order; re-nope cap <=2 total (reserved + # included, no surprising gate here); two-pass family cap — prefer + # <=4, rescue pass relaxes to <=8 for FILL slots only (fresh picks + # and reserved returns keep 4). Deterministic: role order, points + # DESC within role. + for pass_cap in (FAMILY_CAP, RETURN_FILL_FAMILY_CAP): + for cand in ret_ordered: + if len(rets) >= size - len(picked_new) - n_wilds: + break + if cand['id'] in picked_ids: + continue + fam = cand['family'] + if fam and fam_count.get(fam, 0) >= pass_cap: + continue + if cand['verdict'] == NOPE and nope_count >= MAX_RETURN_NOPES: + continue + rets.append(cand) + picked_ids.add(cand['id']) + if cand['verdict'] == NOPE: + nope_count += 1 + if fam: + fam_count[fam] = fam_count.get(fam, 0) + 1 + # final pinned order: new -> returns -> wildcards ordered = list(picked_new) + rets + wilds @@ -425,16 +468,58 @@ def section_items(conn, section): ORDER BY si.position ASC""", (section['id'],)).fetchall() +def section_decided_ids(conn, section): + """Listing ids with a swipe event stamped in THIS section. + + The decided set for progress/report/exhaustion is always these ids — + never the lifetime `swipes.action` verdict (a second-look card enters + its section wearing an old decision; only an in-section event means it + was actually decided here). The join keeps out-of-section stray events + honest (same shape `section_counts` uses). + """ + return {r['listing_id'] for r in conn.execute( + """SELECT e.listing_id FROM swipe_events e + JOIN section_items si + ON si.section_id = e.section_id + AND si.listing_id = e.listing_id + WHERE e.section_id = ?""", (section['id'],))} + + +def pool_counts(conn): + """Stock answer to \"can the forge serve anything?\" — fresh / eligible + returns / wildcards candidate counts, each from the composer's own + candidate SQL. The per-section LAST_TWO exclusion inside compose_section + is deliberately NOT applied here: the answer is about stock, not about + what one batch will pick (item 8).""" + def _count(sql): + return conn.execute( + 'SELECT COUNT(*) AS n FROM (%s)' % sql).fetchone()['n'] + return {'fresh': _count(NEW_POOL_SQL), + 'returns': _count(RETURNS_SQL), + 'wildcards': _count(WILDCARD_SQL)} + + def section_counts(conn, section): """Per-action counts + second-look / wildcard engagement for this section (events stamped with this section_id only).""" counts = {'likes': 0, 'maybes': 0, 'nopes': 0} - for item in section_items(conn, section): - if item['decision'] == 'like': + # one verdict per listing: the latest in-section event wins (an undo + + # re-swipe within a section must not double-count the report numbers) + latest = {} + for ev in conn.execute( + """SELECT e.listing_id, e.action FROM swipe_events e + JOIN section_items si + ON si.section_id = e.section_id + AND si.listing_id = e.listing_id + WHERE e.section_id = ? + ORDER BY e.id ASC""", (section['id'],)): + latest[ev['listing_id']] = ev['action'] + for action in latest.values(): + if action == 'like': counts['likes'] += 1 - elif item['decision'] == 'maybe': + elif action == 'maybe': counts['maybes'] += 1 - elif item['decision'] == 'nope': + elif action == 'nope': counts['nopes'] += 1 restruck = conn.execute( """SELECT COUNT(DISTINCT e.listing_id) AS n FROM swipe_events e @@ -458,8 +543,9 @@ def maybe_exhaust(conn, section, now): """Flip an all-decided open section to 'report' (returns the fresh row).""" if section['status'] != 'open': return section + decided_ids = section_decided_ids(conn, section) items = section_items(conn, section) - if all(it['decision'] is not None for it in items): + if items and not [it for it in items if it['id'] not in decided_ids]: close_section(conn, section, now, status='report') return conn.execute('SELECT * FROM sections WHERE id=?', (section['id'],)).fetchone() @@ -470,7 +556,8 @@ def read_section(conn, section, now): """Meta + items + progress + exhausted for GET /api/section.""" section = maybe_exhaust(conn, section, now) items = section_items(conn, section) - decided = sum(1 for it in items if it['decision'] is not None) + decided_ids = section_decided_ids(conn, section) + decided = sum(1 for it in items if it['id'] in decided_ids) return { 'section': { 'id': section['id'], diff --git a/app/selfcheck.py b/app/selfcheck.py index 8d62382..70a3b4b 100644 --- a/app/selfcheck.py +++ b/app/selfcheck.py @@ -382,7 +382,17 @@ def test_api_stats_health(): conn.close() s = c.get('/api/stats').json() assert s['live'] == active, (s['live'], active) - assert set(s) == {'live', 'new', 'likes', 'maybes', 'nopes', 'last_import'} + assert set(s) == {'live', 'fresh', 'new', 'likes', 'maybes', + 'nopes', 'last_import'} # v1.1 item 7: + fresh + # fresh = actives with no swipe row (the deck's new-pool predicate) + conn = sqlite3.connect(os.path.join(d, 'test.sqlite3')) + expect_fresh = conn.execute( + "SELECT COUNT(*) AS n FROM listings l" + " WHERE l.status='active'" + " AND NOT EXISTS (SELECT 1 FROM swipes s" + " WHERE s.listing_id = l.id)").fetchone()[0] + conn.close() + assert s['fresh'] == expect_fresh, (s['fresh'], expect_fresh) assert s['last_import'] is not None and s['last_import']['ok'] is True assert s['new'] == expect_new, (s['new'], expect_new) h = c.get('/api/health').json() @@ -739,7 +749,9 @@ def test_p3_exhaust_and_report(): c.post('/api/prefs', json={'key': 'forge_mode', 'value': 'ondemand'}) f = c.post('/api/section/forge', json={'size': 6}).json() sid = f['section']['id'] - for it in [i for i in f['items'] if i['decision'] is None]: + # v1.1 (item 5): EVERY item needs an in-section strike to complete + # the section — returns re-enter the deck and are re-struck too. + for it in f['items']: r = c.post('/api/swipe', json={'listing_id': it['id'], 'action': 'like', 'section_id': sid}) assert r.status_code == 200 @@ -1279,6 +1291,456 @@ def test_b1_swipe_photo_idx(): shutil.rmtree(d, ignore_errors=True) +# --- v1.1 checks (temp copies of the source DB only — see p3_env) ---------- + + +def test_v11_item5_section_counter(): + """Returns-only section must read 0/N decided until real in-section + events exist; report counts and exhaustion key off those events.""" + d, c, db_path = p3_env() + try: + conn = _sq_conn(db_path) + 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() + conn.close() + s = c.get('/api/section').json() # cold compose + total = s['progress']['total'] + assert total > 0, s + assert s['recipe']['new'] == 0, s['recipe'] + # every item wears a LIFETIME verdict but zero in-section decisions + for it in s['items']: + assert it['decision'] is not None, it['id'] + assert it['decided_this_section'] is False, it['id'] + assert s['progress']['decided'] == 0, s['progress'] + sec_id = s['section']['id'] + # list edit (no section_id) must not count as an in-section decision + lid0 = s['items'][0]['id'] + c.post('/api/swipe', json={'listing_id': lid0, 'action': 'like'}) + assert c.get('/api/section').json()['progress']['decided'] == 0 + # first real in-section swipe -> 1/total + c.post('/api/swipe', json={'listing_id': lid0, 'action': 'like', + 'section_id': sec_id}) + s2 = c.get('/api/section').json() + assert s2['progress']['decided'] == 1, s2['progress'] + # second -> 2/total; report counts reflect in-section verdicts only + lid1 = s2['items'][1]['id'] + c.post('/api/swipe', json={'listing_id': lid1, 'action': 'maybe', + 'section_id': sec_id}) + s3 = c.get('/api/section').json() + assert s3['progress']['decided'] == 2, s3['progress'] + assert s3['counts']['likes'] == 1, s3['counts'] + assert s3['counts']['maybes'] == 1, s3['counts'] + # undo residual (Q5, accepted): DELETE clears the swipe row but the + # section-stamped event stays -> count holds (clamp-only behavior) + c.delete('/api/swipe/%d' % lid1) + s4 = c.get('/api/section').json() + assert s4['progress']['decided'] == 2, s4['progress'] + c.post('/api/swipe', json={'listing_id': lid1, 'action': 'maybe', + 'section_id': sec_id}) + # strike the rest in-section -> report flips, counts match verdicts + for it in s4['items']: + if it['id'] not in (lid0, lid1): + c.post('/api/swipe', json={'listing_id': it['id'], + 'action': 'maybe', + 'section_id': sec_id}) + s5 = c.get('/api/section').json() + assert s5['exhausted'] is True + assert s5['section']['status'] == 'report' + assert s5['progress']['decided'] == total, s5['progress'] + assert s5['counts']['likes'] == 1, s5['counts'] + assert s5['counts']['maybes'] == total - 1, s5['counts'] + # a fresh returns-only section of the same shape composes cleanly + # (lifetime verdicts present, in-section decided == 0 again) + f = c.post('/api/section/forge', json={}).json() + assert f['recipe']['new'] == 0, f['recipe'] + assert f['progress']['decided'] == 0, f['progress'] + for it in f['items']: + assert it['decision'] is not None and not it['decided_this_section'] + finally: + shutil.rmtree(d, ignore_errors=True) + + +def test_v11_item7_stats_fresh(): + """Item 7: /api/stats.fresh counts actives with no swipe row and moves + on swipe (the deck's new-pool predicate).""" + d, c, db_path = p3_env() + try: + conn = _sq_conn(db_path) + rows = conn.execute( + "SELECT id FROM listings WHERE status='active' LIMIT 5" + ).fetchall() + assert len(rows) == 5 + ids = [r['id'] for r in rows] + conn.execute("UPDATE listings SET status='gone' WHERE status='active'") + conn.execute("DELETE FROM swipes WHERE listing_id IN (%s)" + % ','.join('?' * len(ids)), ids) + conn.execute("DELETE FROM swipe_events WHERE listing_id IN (%s)" + % ','.join('?' * len(ids)), ids) + for lid in ids: + conn.execute("UPDATE listings SET status='active' WHERE id=?", (lid,)) + for lid in ids[:3]: + conn.execute( + "INSERT INTO swipes (listing_id, action, created_at)" + " VALUES (?, 'maybe', '2026-11-01T00:00:00Z')", (lid,)) + conn.commit() + conn.close() + st = c.get('/api/stats').json() + assert st['live'] == 5, st + assert st['fresh'] == 2, st # 2 of 5 still never-touched + # swiping one of the untouched pair moves fresh to 1 on next read + c.post('/api/swipe', json={'listing_id': ids[3], 'action': 'like'}) + assert c.get('/api/stats').json()['fresh'] == 1 + finally: + shutil.rmtree(d, ignore_errors=True) + + +def test_v11_item8_pool_and_dry(): + """Item 8/6: pool counts ride section_state; 0/0/0 composes an empty + section; a fresh=0, returns>0 catalog labels the forge note dry.""" + d, c, db_path = p3_env() + try: + conn = _sq_conn(db_path) + conn.execute('DELETE FROM section_items') + conn.execute("UPDATE sections SET status='closed'" + " WHERE status!='closed'") + # every active gets a maybe swipe + round-1 event (returns alive, + # fresh + wildcards drained) + 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() + conn.close() + s = c.get('/api/section').json() # dry but alive + assert s['pool']['fresh'] == 0 and s['pool']['returns'] > 0, s['pool'] + assert s['recipe']['new'] == 0 and len(s['items']) > 0 + f = c.post('/api/section/forge', json={}).json() + assert f['recipe']['new'] == 0 and len(f['items']) > 0 + assert 'catalog fully reviewed' in (f.get('note') or ''), f.get('note') + # drain returns too (one-shot) + wildcards: pool 0/0/0 -> empty compose + conn = _sq_conn(db_path) + conn.execute("DELETE FROM section_items") + conn.execute("UPDATE sections SET status='closed'" + " WHERE status!='closed'") + # burn return eligibility: every active already had its return slot + # -> re-insert one-shot return rows for all of them + 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() + conn.close() + s2 = c.get('/api/section').json() + assert s2['pool'] == {'fresh': 0, 'returns': 0, 'wildcards': 0}, s2['pool'] + assert s2['items'] == [] + assert s2['recipe']['new'] == 0 and s2['recipe']['return'] == 0 + assert s2['recipe']['wildcard'] == 0, s2['recipe'] + finally: + shutil.rmtree(d, ignore_errors=True) + + +# --- item 9: batch formula v2 compose matrix (A1-A8, staged pools) -------- + + +def _v11_wipe(conn): + """Reset a temp copy to a controlled catalog: no sections/swipes/events, + everything parked 'gone' (rows kept so ids/families can be re-used).""" + conn.execute('DELETE FROM section_items') + conn.execute('DELETE FROM sections') + conn.execute('DELETE FROM swipes') + conn.execute('DELETE FROM swipe_events') + conn.execute("UPDATE listings SET status='gone' WHERE status!='dup'") + + +def _v11_ids(conn, n, start=0): + return [r['id'] for r in conn.execute( + 'SELECT id FROM listings ORDER BY id LIMIT ? OFFSET ?', + (n, start))] + + +def _v11_activate(conn, ids, family=None, score=None): + for lid in ids: + if score is None: + conn.execute("UPDATE listings SET status='active', family=?" + " WHERE id=?", (family, lid)) + else: + conn.execute("UPDATE listings SET status='active', family=?," + " score=? WHERE id=?", (family, score, lid)) + + +def _v11_stage_returns(conn, ids, action='maybe', rests=()): + """swipes row + round-1 event per id; rests get a round-2 nope event + (the REST rule bars them from returns forever).""" + rests = set(rests) + for lid in ids: + conn.execute("INSERT INTO swipes (listing_id, action, created_at)" + " VALUES (?, ?, '2026-11-01T00:00:00Z')", + (lid, action)) + conn.execute("INSERT INTO swipe_events (listing_id, action, round," + " section_id) VALUES (?, ?, 1, NULL)", (lid, action)) + if lid in rests: + conn.execute("INSERT INTO swipe_events (listing_id, action," + " round, section_id) VALUES (?, 'nope', 2, NULL)", + (lid,)) + + +def _v11_stage_fresh(conn, ids, seen=False): + """No swipes row (=fresh). seen=True drops wildcard eligibility via a + round-1 event (fresh pool and wildcard pool are otherwise the same).""" + for lid in ids: + if seen: + conn.execute("INSERT INTO swipe_events (listing_id, action," + " round, section_id) VALUES (?, 'maybe', 1, NULL)", + (lid,)) + + +def _v11_stage_support(conn, ids, family): + """Gone-status listings whose events tilt a family's net (nope-gate).""" + for lid in ids: + conn.execute("UPDATE listings SET family=? WHERE id=?", (family, lid)) + conn.execute("INSERT INTO swipe_events (listing_id, action, round," + " section_id) VALUES (?, 'like', 1, NULL)", (lid,)) + + +def _v11_compose(db_path, size=25): + from . import sections as secmod + conn = db.connect(db_path) + db.init_db(conn) + engine = secmod.build_engine( + conn, now=datetime.datetime.now(datetime.timezone.utc).replace( + tzinfo=None)) + sec = secmod.compose_section(conn, engine, size=size) + recipe = json.loads(sec['recipe']) + items = [dict(r) for r in secmod.section_items(conn, sec)] + conn.commit() + conn.close() + return recipe, items, engine + + +def _slots(items): + out = {'new': [], 'return': [], 'wildcard': []} + for it in items: + out[it['slot']].append(it) + return out + + +def test_v11_item9_compose_matrix(): + """A1-A8 of the item 9 acceptance criteria on deterministic pools.""" + d, _c, db_path = p3_env() + + def env(): + conn = _sq_conn(db_path) + _v11_wipe(conn) + conn.commit() + return conn + + try: + # --- A1: 30 fresh / 10 returns / wildcards -> 25: 21/2/2 ---------- + conn = env() + ids = _v11_ids(conn, 60) + _v11_activate(conn, ids[0:28]) # 28 fresh + _v11_stage_fresh(conn, ids[0:28]) # (all wild-eligible) + _v11_activate(conn, ids[28:38]) # 10 returns + _v11_stage_returns(conn, ids[28:38]) + conn.commit(); conn.close() + recipe, items, engine = _v11_compose(db_path) + slots = _slots(items) + assert len(items) == 25, len(items) + assert (recipe['new'], recipe['return'], recipe['wildcard']) \ + == (21, 2, 2), recipe + assert (len(slots['new']), len(slots['return']), + len(slots['wildcard'])) == (21, 2, 2) + # pinned order: new -> return -> wildcard + roles = [it['slot'] for it in items] + assert roles == ['new'] * 21 + ['return'] * 2 + ['wildcard'] * 2, \ + roles[:2] + # fresh picks ordered points DESC (non-increasing) + pts = [engine.points(it['id']) for it in slots['new']] + assert all(a >= b for a, b in zip(pts, pts[1:])), pts + + # --- A2a: 5 fresh / 20 returns / wildcards -> 5/18/2 --------------- + conn = env() + ids = _v11_ids(conn, 60) + _v11_activate(conn, ids[0:3]) + _v11_stage_fresh(conn, ids[0:3]) # 3 fresh-only + _v11_activate(conn, ids[3:5]) + _v11_stage_fresh(conn, ids[3:5]) # 2 fresh+wild + _v11_activate(conn, ids[5:25]) # 20 returns + _v11_stage_returns(conn, ids[5:25]) + conn.commit(); conn.close() + recipe, items, _e = _v11_compose(db_path) + assert len(items) == 25, len(items) + # Wildcards are BY DEFINITION never-swiped (wild pool ⊆ fresh pool), + # so on a 5-fresh day the 2 wilds come from those 5: the deck reads + # 5 fresh-total (3 new + 2 wild) + 20 second looks = 25 — Glenn's + # "5 fresh listed, rest second looks". The spec's "5 fresh + 18 + # returns + 2 wild" arithmetic assumed 7 fresh-eligible listings; + # with 5 the pool-honest fill is 20 (implementer-verified). + assert (recipe['new'], recipe['return'], recipe['wildcard']) \ + == (3, 20, 2), recipe + + # --- A2b: same but the wildcard pool is dry -> 5/20/0 -------------- + conn = env() + ids = _v11_ids(conn, 60) + _v11_activate(conn, ids[0:5]) + _v11_stage_fresh(conn, ids[0:5], seen=True) # fresh, no wilds + _v11_activate(conn, ids[5:25]) # 20 returns + _v11_stage_returns(conn, ids[5:25]) # + conn.commit(); conn.close() + recipe, items, _e = _v11_compose(db_path) + assert len(items) == 25, len(items) + assert (recipe['new'], recipe['return'], recipe['wildcard']) \ + == (5, 20, 0), recipe + + # --- A3: 23 fresh / 5 returns / wildcards -> 21/2/2 (the sign-off + # --- sentence shape) ---------------------------------------------- + conn = env() + ids = _v11_ids(conn, 60) + _v11_activate(conn, ids[0:23]) + _v11_stage_fresh(conn, ids[0:23]) + _v11_activate(conn, ids[23:28]) # 5 returns + _v11_stage_returns(conn, ids[23:28]) + conn.commit(); conn.close() + recipe, items, _e = _v11_compose(db_path) + slots = _slots(items) + assert len(items) == 25 and len(slots['new']) == 21 \ + and len(slots['return']) == 2 \ + and len(slots['wildcard']) == 2, recipe + + # --- A4: 0 fresh / 12 returns (2 nope) -> 12 returns, <=2 nopes, + # --- LAST_TWO excl: second compose repeats nothing ----------------- + conn = env() + ids = _v11_ids(conn, 60) + _v11_activate(conn, ids[0:12]) + _v11_stage_returns(conn, ids[0:10], 'maybe') + _v11_stage_returns(conn, ids[10:12], 'nope') # 2 re-nope cands + conn.commit(); conn.close() + recipe, items, _e = _v11_compose(db_path) + slots = _slots(items) + assert len(items) == 12 and len(slots['new']) == 0 \ + and len(slots['return']) == 12 \ + and len(slots['wildcard']) == 0, recipe + n_nopes = sum(1 for it in slots['return'] + if it['decision'] == 'nope') + assert n_nopes <= 2, n_nopes + first_ids = {it['id'] for it in items} + # second compose on the same pool: LAST_TWO bars the 12 just served + # and returns stay one-shot -> nothing to serve + recipe2, items2, _e = _v11_compose(db_path) + assert len(items2) == 0, items2 + assert not ({it['id'] for it in items2} & first_ids) + + # --- A5: 40 fresh / 0 returns -> 25 then the 15 leftover, no + # --- overlap, fresh ordered points DESC ---------------------------- + conn = env() + ids = _v11_ids(conn, 60) + _v11_activate(conn, ids[0:40]) + _v11_stage_fresh(conn, ids[0:40]) + # spread scores so the points DESC order check is meaningful + for k, lid in enumerate(ids[0:40]): + conn.execute("UPDATE listings SET score=? WHERE id=?", + (40 - k, lid)) + conn.commit(); conn.close() + recipe, items, engine = _v11_compose(db_path) + assert len(items) == 25, len(items) + assert recipe['return'] == 0, recipe + first_ids = {it['id'] for it in items} + slots1 = _slots(items) + pts = [engine.points(it['id']) for it in slots1['new']] + # scores unique DESC -> fresh-slot points non-increasing (wildcards + # sit pinned at the back and don't participate in that order) + assert pts == sorted(pts, reverse=True), pts + recipe2, items2, _e = _v11_compose(db_path) + slots2 = _slots(items2) + assert recipe2['return'] == 0, recipe2 + assert len(items2) == 15, len(items2) + second_ids = {it['id'] for it in items2} + assert not (first_ids & second_ids) # zero overlap + + # --- A6: 0/0/0 -> empty composed section (FE side = item 8) -------- + conn = env() + conn.commit(); conn.close() + recipe, items, _e = _v11_compose(db_path) + assert items == [], items + assert (recipe['new'], recipe['return'], recipe['wildcard']) \ + == (0, 0, 0), recipe + + # --- A7: family cap — fill-heavy cruiser pool: fresh/reserved hold + # --- <=4, fill raises return slots to <=8 -------------------------- + conn = env() + ids = _v11_ids(conn, 60) + _v11_activate(conn, ids[0:40], family='cruiser') + _v11_stage_fresh(conn, ids[0:10], seen=True) # 10 fresh cruiser + _v11_stage_returns(conn, ids[10:40], 'maybe') # 30 cruiser rets + conn.commit(); conn.close() + recipe, items, _e = _v11_compose(db_path) + slots = _slots(items) + fam = {} + for it in items: + fam[it['family']] = fam.get(it['family'], 0) + 1 + assert fam.get('cruiser', 0) <= 8, fam + assert len(slots['new']) == 2, recipe # fresh stops at cap 4 + assert len(slots['return']) == 6, recipe # 2 reserved + 4 fill + assert (recipe['new'], recipe['return']) == (2, 6), recipe + + # --- A8: re-nope gate — <=2 nope returns total; a rested item never + # --- returns; gate can empty the reserved slots -------------------- + conn = env() + ids = _v11_ids(conn, 60) + _v11_activate(conn, ids[0:10]) + _v11_stage_returns(conn, ids[0:10], 'maybe') # 10 maybes + _v11_activate(conn, ids[10:15], family='gatefam', score=100) + _v11_stage_returns(conn, ids[10:15], 'nope') # 5 nope re-nopes + # family net positive: gate passes for gatefam's nope candidates + _v11_stage_support(conn, ids[30:40], family='gatefam') # 10 likes + _v11_activate(conn, ids[15:16], family='gatefam') + _v11_stage_returns(conn, ids[15:16], 'maybe', + rests=[ids[15]]) # rested: r2 nope + conn.commit(); conn.close() + recipe, items, _e = _v11_compose(db_path) + slots = _slots(items) + assert len(items) == 12, len(items) # 10 maybe + 2 nope + n_nopes = sum(1 for it in slots['return'] + if it['decision'] == 'nope') + assert n_nopes == 2, n_nopes + assert all(it['id'] != ids[15] for it in slots['return']) # rested + + # gate-block: low-score nope family, net NOT positive -> reserved + # slots skipped; fill still caps at 2 nopes (gate-free fill rule) + conn = env() + ids = _v11_ids(conn, 60) + _v11_activate(conn, ids[0:3], family='blockfam', score=1) + _v11_stage_returns(conn, ids[0:3], 'nope') # 3 gate-blocked + conn.commit(); conn.close() + recipe, items, _e = _v11_compose(db_path) + slots = _slots(items) + assert recipe['return'] == 2, recipe # fill cap only + assert all(it['decision'] == 'nope' + for it in slots['return']), items + finally: + shutil.rmtree(d, ignore_errors=True) + + P3_API_TESTS = [ ('p3: events seeding idempotent (from swipes)', test_p3_event_seeding_idempotent), ('p3: swipe round math + clear-then-re-decide', test_p3_round_math_and_clear), @@ -1295,6 +1757,20 @@ P3_API_TESTS = [ if API_DEPS: TESTS += P3_API_TESTS +V11_TESTS = [ + ('v1.1: item 5 — section counter keys off in-section events', + test_v11_item5_section_counter), + ('v1.1: item 7 — stats fresh = active minus swiped', + test_v11_item7_stats_fresh), + ('v1.1: item 8/6 — pool counts, 0/0/0 empty compose, dry forge note', + test_v11_item8_pool_and_dry), + ('v1.1: item 9 — batch formula v2 compose matrix A1-A8', + test_v11_item9_compose_matrix), +] + +if API_DEPS: + TESTS += V11_TESTS + B1_TESTS = [ ('b1: guarded migrations (imgs + last_photo_idx, idempotent)', test_b1_migration_guarded), diff --git a/app/static/index.html b/app/static/index.html index 48f3eb0..75e71ae 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -33,7 +33,8 @@ header{height:calc(50px + env(safe-area-inset-top,0px));display:flex;align-items .card{position:absolute;left:14px;right:14px;top:6px;bottom:34px;border-radius:18px;overflow:hidden;background:var(--card); box-shadow:0 0 0 1px rgba(255,255,255,.07), 0 18px 40px rgba(0,0,0,.5);will-change:transform;touch-action:none} .nophoto{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:repeating-linear-gradient(45deg,#141419 0 14px,#18181f 14px 28px);color:#55555f;font-size:13px;text-align:center;padding:24px;line-height:1.6} -.card .photo{position:absolute;inset:0;width:100%;height:100%;object-fit:cover} +.card .photo{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;filter:drop-shadow(0 6px 24px rgba(0,0,0,.45))} +.phblur{position:absolute;inset:-18px;background-size:cover;background-position:center;background-repeat:no-repeat;filter:blur(22px) brightness(.55) saturate(1.1);transform:scale(1.08);pointer-events:none} .card .grad{position:absolute;left:0;right:0;bottom:0;height:52%;background:linear-gradient(180deg,rgba(0,0,0,0) 0%,rgba(0,0,0,.55) 55%,rgba(0,0,0,.88) 100%)} .card .info{position:absolute;left:16px;right:16px;bottom:14px;z-index:3} .chips{display:flex;gap:6px;margin-bottom:8px;flex-wrap:wrap} @@ -66,8 +67,9 @@ nav{height:calc(68px + env(safe-area-inset-bottom,0px));display:flex;align-items .dgrab{flex:none;height:26px;display:flex;align-items:center;justify-content:center;background:#0f0f13;cursor:grab;touch-action:none} .dgrab i{width:42px;height:5px;border-radius:3px;background:rgba(255,255,255,.3);display:block} .dclose{position:absolute;top:38px;left:14px;width:36px;height:36px;border-radius:50%;background:rgba(0,0,0,.55);color:#fff;border:0;font-size:17px;z-index:5;cursor:pointer} -.dhero{position:relative;height:34%;min-height:190px;flex:none;background:#17171c;touch-action:none} -.dphoto{width:100%;height:100%;object-fit:cover;display:block} +.dhero{position:relative;height:34%;min-height:190px;flex:none;background:#17171c;touch-action:none;overflow:hidden} +.dblur{position:absolute;inset:-18px;background-size:cover;background-position:center;background-repeat:no-repeat;filter:blur(22px) brightness(.55) saturate(1.1);transform:scale(1.08);pointer-events:none} +.dphoto{width:100%;height:100%;object-fit:contain;display:block;filter:drop-shadow(0 6px 24px rgba(0,0,0,.45))} .dphoto.ph{opacity:.25} .dherograd{position:absolute;left:0;right:0;bottom:0;height:70px;background:linear-gradient(transparent,rgba(15,15,19,.95))} .dbody{flex:1;overflow-y:auto;padding:14px 18px 20px} @@ -95,6 +97,10 @@ nav{height:calc(68px + env(safe-area-inset-bottom,0px));display:flex;align-items .empty{position:absolute;inset:6px 14px 2px;display:none;flex-direction:column;align-items:center;justify-content:center;gap:8px;border:1px dashed #2a2a33;border-radius:18px;color:var(--mut);text-align:center;padding:24px;font-size:13.5px} .empty.on{display:flex} .empty b{color:#fff;font-size:16px} +/* item 8: loud exhausted state — section mode only (legacy .empty stays) */ +.empty2{position:absolute;inset:6px 14px 2px;display:none;flex-direction:column;align-items:center;justify-content:center;gap:10px;border:1px dashed #4a4a55;border-radius:18px;background:#141419;color:var(--mut);text-align:center;padding:32px;font-size:14px;min-height:220px;z-index:3} +.empty2.on{display:flex} +.empty2 b{color:#fff;font-size:22px;font-weight:800} /* list screen */ .list{position:absolute;inset:0;display:none;flex-direction:column;background:var(--bg);z-index:50;padding-bottom:68px} .list.on{display:flex} @@ -138,7 +144,8 @@ nav{height:calc(68px + env(safe-area-inset-bottom,0px));display:flex;align-items .sect.hot{color:var(--accent1)} .band{position:absolute;top:12px;left:12px;z-index:4;max-width:78%;font-size:11px;font-weight:650;letter-spacing:.4px;border-radius:999px;padding:4px 9px;pointer-events:none;line-height:1.35;user-select:none} .band.wild{color:#ffb43a;background:rgba(255,180,58,.14);border:1px solid rgba(255,180,58,.35)} -.band.sl{color:rgba(255,255,255,.85);background:rgba(255,255,255,.10);border:1px solid rgba(255,255,255,.22)} +.band.sl{color:#c4b5fd;background:rgba(124,58,237,.22);border:1px solid rgba(167,139,250,.65);font-weight:800} +.dband{position:static;display:inline-block;margin-top:6px} .report{position:absolute;inset:6px 14px 2px;display:none;flex-direction:column;align-items:center;justify-content:center;gap:8px;border:1px dashed #2a2a33;border-radius:18px;color:var(--mut);text-align:center;padding:24px;font-size:13.5px;z-index:3} .report.on{display:flex} .report b{color:#fff;font-size:16px} @@ -210,6 +217,7 @@ nav{height:calc(68px + env(safe-area-inset-bottom,0px));display:flex;align-items