fix(v1.1): item 5 — section counter keys off in-section events (2/2 -> 3/2 bug)
A card is decided only when a swipe event carries THIS section's id; the lifetime swipes.action a second look wears into its section no longer paints it as pre-decided. progress/undecided, report counts (latest in-section event per listing) and maybe_exhaust all use the new section_decided_ids() set; read path only, no writes. Every section_state item carries decided_this_section; the FE counts server truth unless a local intent is in flight, and sectDecided is clamped to sectTotal everywhere (commit + paintSect) so the pill can never read past N/N. Semantics note: completing a section now means striking EVERY item in it (returns re-struck on their second look) — the exhaust selfcheck was updated to strike all items, matching the deck's real flow. Undo-on-a- return residual (event stays after the DELETE) is the accepted Q5 clamp- only behavior, covered by a dedicated selfcheck case.
This commit is contained in:
@@ -451,11 +451,18 @@ 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['prefs'] = {'rubric_debug': get_pref(conn, 'rubric_debug', 'off')}
|
||||
data['can_forge'] = (mode != 'strict'
|
||||
or secmod.fresh_profile_exists(conn, sec))
|
||||
|
||||
@@ -425,16 +425,44 @@ 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 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 +486,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 +499,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'],
|
||||
|
||||
@@ -739,7 +739,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 +1281,87 @@ 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)
|
||||
|
||||
|
||||
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 +1378,14 @@ 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),
|
||||
]
|
||||
|
||||
if API_DEPS:
|
||||
TESTS += V11_TESTS
|
||||
|
||||
B1_TESTS = [
|
||||
('b1: guarded migrations (imgs + last_photo_idx, idempotent)',
|
||||
test_b1_migration_guarded),
|
||||
|
||||
@@ -686,7 +686,7 @@ function commit(el,dir){
|
||||
const act= dir==='maybe'?'maybe':dir;
|
||||
setDecision(t.d.id, act, (!legacyMode&§ion&&!section.exhausted)?section.section.id:null,
|
||||
nPhotos(t.d)>1?(t.cur||0):null); // B1: photo on screen at strike
|
||||
sectDecided++;
|
||||
sectDecided=Math.min(sectTotal, sectDecided+1); // clamp: never past the total
|
||||
paintSect();
|
||||
flyOut(el,dir);
|
||||
fillPile(); relayer();
|
||||
@@ -1175,7 +1175,7 @@ let sectDecided=0, sectTotal=0;
|
||||
function paintSect(){
|
||||
const t=$('sect');
|
||||
if(legacyMode||!section){ t.textContent=''; return; }
|
||||
t.textContent=`${sectDecided}/${sectTotal}`;
|
||||
t.textContent=`${Math.min(sectDecided,sectTotal)}/${sectTotal}`; // clamp: never paints past the total
|
||||
t.classList.toggle('hot',sectDecided>=sectTotal);
|
||||
}
|
||||
function paintMode(){
|
||||
@@ -1194,7 +1194,10 @@ function updateSectionUI(body){
|
||||
let decided=0;
|
||||
for(const it of (body.items||[])){
|
||||
const local=localDecisions.get(it.id);
|
||||
if(local===undefined? it.decision!=null : local!=null) decided++;
|
||||
// item 5: server truth = decided DURING this section (an in-section
|
||||
// event), never the lifetime decision a second look wears into it;
|
||||
// local intent wins while a swipe's POST is in flight
|
||||
if(local===undefined? !!it.decided_this_section : local!=null) decided++;
|
||||
}
|
||||
sectDecided=decided;
|
||||
paintSect();
|
||||
|
||||
Reference in New Issue
Block a user