scroll filter: Redact is a per-base MODIFIER, not a standalone tag — unchecking Frame/Fable hides that tag's struck rows too — TDD

The SCROLL (+ Billscroll) Frame/Fable/Redact filter treated redact as a 3rd
mutually-exclusive label, so a struck row showed whenever Redact was checked
regardless of its underlying tag. Per user-spec, Redact now layers on the base:
a struck row shows only when BOTH its base (Frame/Fable) AND Redact are checked.
So Fable+Redact (Frame off) hides every Frame log incl. redacted Carte Blanche
lines; Frame+Redact (Fable off) hides @disco's-character Fable logs even though
Redact is on.

- _scroll.html: each row now carries data-base (frame|fable, ALWAYS — the
  underlying tag) alongside data-label (collapses to redact when struck).
- room-scroll.js + billboard scroll.html applyFilter (mirrored): visible =
  base-checked AND (not struck OR redact-checked).

TDD: drama ITs +1 — data-base asserted on fable/frame/struck-fable/struck-frame
renders (struck keeps its base). Room scroll FT +2 — fable+redact/frame-off
hides all frame incl. struck; frame+redact/fable-off hides the struck fable
despite Redact on. Existing redact-hides-struck + billboard gear-flow FTs still
green (their struck rows are fable-based + never toggle Fable). 57 drama ITs + 7
room-scroll FTs + 3 billboard gear FTs green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-06-08 23:47:34 -04:00
parent 7ca9d4d7d9
commit 04ab673cea
5 changed files with 100 additions and 26 deletions

View File

@@ -294,6 +294,7 @@ class GameEventModelTest(TestCase):
top_capacitors=["Ardor"]) top_capacitors=["Ardor"])
html = self._render_scroll(ev) html = self._render_scroll(ev)
self.assertIn('data-label="fable"', html) self.assertIn('data-label="fable"', html)
self.assertIn('data-base="fable"', html)
# @handle is the bold username; "'s character" sits OUTSIDE the strong. # @handle is the bold username; "'s character" sits OUTSIDE the strong.
self.assertIn("<strong>@disco</strong>", html) self.assertIn("<strong>@disco</strong>", html)
self.assertIn('<span class="ev-char-stub">\'s character</span>', html) self.assertIn('<span class="ev-char-stub">\'s character</span>', html)
@@ -305,13 +306,30 @@ class GameEventModelTest(TestCase):
slot_number=1, token_type="carte", token_display="Carte Blanche") slot_number=1, token_type="carte", token_display="Carte Blanche")
html = self._render_scroll(ev) html = self._render_scroll(ev)
self.assertIn('data-label="frame"', html) self.assertIn('data-label="frame"', html)
self.assertIn('data-base="frame"', html)
self.assertIn("<strong>@disco</strong>", html) self.assertIn("<strong>@disco</strong>", html)
self.assertNotIn("'s character", html) self.assertNotIn("'s character", html)
def test_scroll_struck_fable_is_redact_not_fable(self): def test_scroll_struck_fable_is_redact_but_keeps_fable_base(self):
# struck → data-label collapses to "redact", but data-base stays "fable"
# so the filter can hide it when Fable is unchecked (Redact is a modifier
# on the base, not a standalone category).
ev = record(self.room, GameEvent.SIG_READY, actor=self.user, ev = record(self.room, GameEvent.SIG_READY, actor=self.user,
card_name="The Nomad", retracted=True) card_name="The Nomad", retracted=True)
self.assertIn('data-label="redact"', self._render_scroll(ev)) html = self._render_scroll(ev)
self.assertIn('data-label="redact"', html)
self.assertIn('data-base="fable"', html)
def test_scroll_struck_frame_is_redact_but_keeps_frame_base(self):
# A retracted deposit (a withdrawn Carte Blanche) is a struck FRAME row:
# data-label="redact", data-base="frame" — so unchecking Frame hides it
# even when Redact is checked.
ev = record(self.room, GameEvent.SLOT_FILLED, actor=self.user,
slot_number=1, token_type="carte",
token_display="Carte Blanche", retracted=True)
html = self._render_scroll(ev)
self.assertIn('data-label="redact"', html)
self.assertIn('data-base="frame"', html)
def test_sig_ready_prose_degrades_without_corner_rank(self): def test_sig_ready_prose_degrades_without_corner_rank(self):
# Old events recorded before this change have no corner_rank key # Old events recorded before this change have no corner_rank key

View File

@@ -42,8 +42,15 @@
if (!checked) return; if (!checked) return;
var scroll = document.getElementById('id_drama_scroll'); var scroll = document.getElementById('id_drama_scroll');
if (!scroll) return; if (!scroll) return;
scroll.querySelectorAll('.drama-event[data-label]').forEach(function (el) { // Redact is a MODIFIER on the base tag (Frame/Fable), not a standalone
el.style.display = checked.indexOf(el.dataset.label) !== -1 ? '' : 'none'; // category: a struck row shows only when BOTH its base AND Redact are
// checked. So unchecking Frame/Fable hides that tag's struck (redacted)
// rows too — you can't see a redacted Carte Blanche line with Frame off.
var redactOK = checked.indexOf('redact') !== -1;
scroll.querySelectorAll('.drama-event[data-base]').forEach(function (el) {
var baseOK = checked.indexOf(el.dataset.base) !== -1;
var struck = el.dataset.label === 'redact';
el.style.display = (baseOK && (!struck || redactOK)) ? '' : 'none';
}); });
} }
function applySavedFilter() { function applySavedFilter() {

View File

@@ -33,12 +33,21 @@ class RoomScrollOfEventsTest(FunctionalTest):
data={"token_type": "carte", "token_display": "Carte Blanche", data={"token_type": "carte", "token_display": "Carte Blanche",
"slot_number": 1, "renewal_days": 7}, "slot_number": 1, "renewal_days": 7},
) )
# A retracted SIG_READY (struck → data-label="redact") so the Frame / # A retracted SIG_READY (struck FABLE → data-base="fable",
# Redact filter has both kinds of row to toggle. # data-label="redact") so the filter has a struck row whose base is fable.
GameEvent.objects.create( GameEvent.objects.create(
room=self.room, actor=self.viewer, verb=GameEvent.SIG_READY, room=self.room, actor=self.viewer, verb=GameEvent.SIG_READY,
data={"retracted": True, "card_name": "The Nomad"}, data={"retracted": True, "card_name": "The Nomad"},
) )
# A retracted deposit (struck FRAME → data-base="frame",
# data-label="redact") — a withdrawn Carte Blanche, so the cross-gating
# tests have a struck row on EACH base to prove Redact gates per-base.
GameEvent.objects.create(
room=self.room, actor=self.viewer, verb=GameEvent.SLOT_FILLED,
data={"retracted": True, "token_type": "carte",
"token_display": "Carte Blanche", "slot_number": 2,
"renewal_days": 7},
)
self.room.table_status = Room.ROLE_SELECT self.room.table_status = Room.ROLE_SELECT
self.room.gate_status = Room.OPEN self.room.gate_status = Room.OPEN
self.room.save() self.room.save()
@@ -161,9 +170,7 @@ class RoomScrollOfEventsTest(FunctionalTest):
).get_attribute("class") or "", ).get_attribute("class") or "",
)) ))
def test_redact_filter_hides_struck_rows(self): def _scroll_to_feed_and_open_filter(self):
"""Unchecking Redact + OK in the scroll-view gear hides the struck
(retracted) rows while the framed ones stay."""
self._open() self._open()
self.browser.execute_script( self.browser.execute_script(
"document.querySelector('.room-aperture').scrollTop = 99999;") "document.querySelector('.room-aperture').scrollTop = 99999;")
@@ -171,24 +178,54 @@ class RoomScrollOfEventsTest(FunctionalTest):
self.wait_for(lambda: self.assertTrue( self.wait_for(lambda: self.assertTrue(
self.browser.find_element(By.ID, "id_scroll_filter_form").is_displayed())) self.browser.find_element(By.ID, "id_scroll_filter_form").is_displayed()))
redact_cb = self.browser.find_element( def _set_filter(self, frame, fable, redact):
By.CSS_SELECTOR, "#id_scroll_filter_form input[value='redact']") """Drive the 3 filter checkboxes to the wanted states + click OK."""
if redact_cb.is_selected(): sel = "#id_scroll_filter_form input[value='%s']"
self.browser.execute_script("arguments[0].click();", redact_cb) for value, want in (("frame", frame), ("fable", fable), ("redact", redact)):
cb = self.browser.find_element(By.CSS_SELECTOR, sel % value)
if cb.is_selected() != want:
self.browser.execute_script("arguments[0].click();", cb)
ok = self.browser.find_element( ok = self.browser.find_element(
By.CSS_SELECTOR, "#id_scroll_filter_form button[type='submit']") By.CSS_SELECTOR, "#id_scroll_filter_form button[type='submit']")
self.browser.execute_script("arguments[0].click();", ok) self.browser.execute_script("arguments[0].click();", ok)
self.wait_for(lambda: self.assertFalse( def _shown(self, base=None, label=None):
self.browser.find_element( sel = "#id_drama_scroll .drama-event"
By.CSS_SELECTOR, if base:
"#id_drama_scroll .drama-event[data-label='redact']", sel += f"[data-base='{base}']"
).is_displayed())) if label:
self.assertTrue( sel += f"[data-label='{label}']"
self.browser.find_element( return self.browser.find_element(By.CSS_SELECTOR, sel).is_displayed()
By.CSS_SELECTOR,
"#id_drama_scroll .drama-event[data-label='frame']", def test_redact_filter_hides_struck_rows(self):
).is_displayed()) """Unchecking Redact + OK in the scroll-view gear hides the struck
(retracted) rows while the framed (unstruck) ones stay."""
self._scroll_to_feed_and_open_filter()
self._set_filter(frame=True, fable=True, redact=False)
self.wait_for(lambda: self.assertFalse(self._shown(label="redact")))
self.assertTrue(self._shown(base="frame", label="frame"))
def test_fable_redact_on_frame_off_hides_all_frame_incl_struck(self):
"""User scenario A: Fable + Redact checked, Frame unchecked → NO frame
logs (Carte Blanche deposits) show, struck OR not — Redact is a modifier
on the base, so a redacted frame row stays hidden while Frame is off. The
struck FABLE row (Fable + Redact both on) stays visible."""
self._scroll_to_feed_and_open_filter()
self._set_filter(frame=False, fable=True, redact=True)
# Both the unstruck deposit AND the struck (withdrawn) deposit hide.
self.wait_for(lambda: self.assertFalse(self._shown(base="frame", label="frame")))
self.assertFalse(self._shown(base="frame", label="redact"))
# The struck fable row is still shown (its base + Redact are both checked).
self.assertTrue(self._shown(base="fable", label="redact"))
def test_frame_redact_on_fable_off_hides_fable_struck_despite_redact(self):
"""User scenario B: Frame + Redact checked, Fable unchecked → the struck
FABLE row (@disco's character …) hides EVEN THOUGH Redact is checked,
because its base (Fable) is off; the frame deposit stays."""
self._scroll_to_feed_and_open_filter()
self._set_filter(frame=True, fable=False, redact=True)
self.wait_for(lambda: self.assertFalse(self._shown(base="fable", label="redact")))
self.assertTrue(self._shown(base="frame", label="frame"))
# NOTE: the SCROLL applet's live WS refresh (record() → on_commit broadcast → # NOTE: the SCROLL applet's live WS refresh (record() → on_commit broadcast →
# RoomConsumer.scroll_update relay → room-scroll.js re-fetch + swap) has no FT. # RoomConsumer.scroll_update relay → room-scroll.js re-fetch + swap) has no FT.

View File

@@ -26,10 +26,17 @@
var ALL_LABELS = ['frame', 'fable', 'redact']; var ALL_LABELS = ['frame', 'fable', 'redact'];
function applyFilter(checked) { function applyFilter(checked) {
// Redact is a MODIFIER on the base tag (Frame/Fable), not a standalone
// category: a struck row shows only when BOTH its base AND Redact are
// checked — so unchecking Frame/Fable hides that tag's struck rows too.
// Mirrors room-scroll.js applyFilter (the two surfaces behave alike).
var redactOK = checked.indexOf('redact') !== -1;
document.querySelectorAll( document.querySelectorAll(
'#id_drama_scroll .drama-event[data-label]' '#id_drama_scroll .drama-event[data-base]'
).forEach(function (el) { ).forEach(function (el) {
el.style.display = checked.indexOf(el.dataset.label) !== -1 ? '' : 'none'; var baseOK = checked.indexOf(el.dataset.base) !== -1;
var struck = el.dataset.label === 'redact';
el.style.display = (baseOK && (!struck || redactOK)) ? '' : 'none';
}); });
} }

View File

@@ -1,7 +1,12 @@
{% load lyric_extras %} {% load lyric_extras %}
<section id="id_drama_scroll" class="drama-scroll" data-scroll-position="{{ scroll_position|default:0 }}"> <section id="id_drama_scroll" class="drama-scroll" data-scroll-position="{{ scroll_position|default:0 }}">
{% for event in events %} {% for event in events %}
<div class="drama-event {% if event.actor == viewer %}mine{% elif event.actor %}theirs{% else %}system{% endif %}" data-label="{% if event.struck %}redact{% elif event.is_fable %}fable{% else %}frame{% endif %}"> {# `data-base` is the underlying tag (frame|fable) REGARDLESS of struck; #}
{# `data-label` collapses to redact when struck. The filter treats Redact #}
{# as a MODIFIER on the base: a struck row shows only when BOTH its base #}
{# (Frame/Fable) AND Redact are checked, so unchecking a base hides that #}
{# tag's struck rows too. #}
<div class="drama-event {% if event.actor == viewer %}mine{% elif event.actor %}theirs{% else %}system{% endif %}" data-base="{% if event.is_fable %}fable{% else %}frame{% endif %}" data-label="{% if event.struck %}redact{% elif event.is_fable %}fable{% else %}frame{% endif %}">
<span class="drama-event-body{% if event.struck %} struck{% endif %}"> <span class="drama-event-body{% if event.struck %} struck{% endif %}">
{# Fable events (Significator / SkyDrive / Sea of Cards) read as #} {# Fable events (Significator / SkyDrive / Sea of Cards) read as #}
{# the actor's CHARACTER acting — "@handle's character" is a stub #} {# the actor's CHARACTER acting — "@handle's character" is a stub #}