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:
@@ -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
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 #}
|
||||||
|
|||||||
Reference in New Issue
Block a user