A.7.5 Game Kit carousel image-mode + universal stat-block top-left chip + EMANATION/REVERSAL --secUser convention — TDD. Mid-session 2026-05-25 PM (Sprint A.7.5 of [[project-image-based-deck-face-rendering]] — slotted between A.7 polish + tomorrow's A.8 room.html). Three threads bundled: (1) Game Kit _tarot_fan.html carousel modal gets the image-mode branch + per-card FLIP-to-back for non-polarized image-equipped decks (Minchiate today; brings the carousel into parity w. the other 5 image-mode surfaces shipped in A.3-A.7); (2) the A.3 Q3-spec top-left rank+suit chip lands across all 4 stat-block surfaces (my_sign main / _applet-my-sign / _sea_stage modal / new game_kit fan stage), retrofitting work that A.3 explicitly deferred per the "Lower-priority follow-ups" list in the project memory; (3) chip + EMANATION/REVERSAL label adopt --secUser as the new universal color convention so the title (--quaUser/--terUser per arcana) stays the focal text + the chip-and-label header recedes visually.
(1) _tarot_fan.html image-mode branch — server-side `{% if card.deck_variant.has_card_images %}` gate: image-mode renders `<img class="sig-stage-card-img">` + (for non-polarized decks) a sibling `<img class="sig-stage-card-back-img">` for the FLIP-to-back affordance; text-mode keeps the existing `.fan-card-corner --tl/--br` + `.fan-card-face` scaffold unchanged (Earthman + RWS today; will be removed once both decks get artwork — user's plan: scrape RWS art tonight + Earthman public-domain paintings to follow; "shabby cardstock" non-equippable Earthman variant retains text rendering as legacy preservation). New `.fan-card.fan-card--image` marker class added to the shared image-mode comma-list selector (`_card-deck.scss:705-765`) so the carousel cards pick up the contour-stroke + depth-shadow filter chain + `.is-flipped-to-back` toggle for free — single SCSS source of truth across all 5 image-mode surfaces. Also added `data-arcana-key="{{ card.arcana }}"` + `data-image-url="{{ card.image_url|default:'' }}"` data-attrs to every fan-card so `StageCard.fromDataset` + `_setImageMode` flow w. no extra plumbing.
(2) Game Kit carousel JS rewiring (`game-kit.js`): `_populateStage` now also calls `StageCard.populateStatExtras(stageBlock, card)` so the carousel stat block gets title + arcana + chip populated on every card focus (previously the stage block had only the keyword list; the call site simply wasn't wired). SPIN handler gates the 180° card rotation behind `!active.classList.contains('fan-card--image')` — for image-mode cards SPIN now just toggles `.is-reversed` on the stat block to swap EMANATION ↔ REVERSAL content w/o rotating the artwork (user-spec 2026-05-25 PM: "monodecks shouldn't have gravity and levity polarity"; image artwork is symmetric + shouldn't be inverted by a UI cycle). New `_flipToBack` helper mirrors the my_sign.html A.5-polish-2 FLIP-to-back animation (rotateY 0→90→0 over 500ms, `.is-flipped-to-back` toggle at 250ms midpoint, `data-flipping` cleared at 500ms); the existing `_flipActive` dispatches to it via `active.querySelector('.sig-stage-card-back-img')` presence check (the back-img element is only server-rendered for non-polarized image-equipped decks, so its presence is the gate). Polarized text-mode (Earthman) keeps the existing polarity-cycle FLIP. Per-card-change cleanup also clears `.is-flipped-to-back` on every card so a back-flipped card returns to front when it leaves focus (mirrors the SPIN reset semantics).
(3) Top-left rank+suit chip retrofit (4 stat-block surfaces): the A.3 Q3 spec called for a chip but explicitly deferred to "Lower-priority follow-ups" in the project memory; user pulled it in this sprint as part of the carousel rewrite. New `.stat-face-header` flex wrapper holds the chip + EMANATION/REVERSAL label inline (chip is 2 rows tall, label is 1 — flex `align-items: flex-start` keeps them "vaguely inline" per spec). Chip mirrors the existing `.fan-card-corner` pattern: vertically stacked rank + suit-icon, no chrome (initial draft had a bordered pill — corrected per user clarification 2026-05-25 PM "vertically stacked, --secUser, in the top-left corner"). All 4 stat-block templates (my_sign.html / _applet-my-sign.html / _sea_stage.html / game_kit.html's `#id_fan_stage_block`) get the new header wrapper around their existing `.stat-face-label`. Applet renders the chip server-side from `card.corner_rank` + `card.suit_icon`; the other 3 surfaces leave the chip elements empty + populated by `StageCard.populateStatExtras` on each card focus (the helper now also walks `.stat-chip-rank` + `.stat-chip-icon` w. the same find-all + textContent / className pattern it already uses for title + arcana). Chip color is --secUser by default; polarity-aware overrides for surfaces whose gravity bg flips to --secUser (sig-stat-block / sea-stat-block / fan-stage-block) flip the chip to --priUser for visibility — same logical inversion the keyword list rules already use.
(4) Trump fa-hand-dots fallback in `TarotCard.suit_icon` — was reading the per-card `icon` field then returning `''` for any major arcana w/o an explicit override. Earthman's seed migration 0007 set `icon="fa-hand-dots"` on trumps 2+ as the universal trump symbol, but trumps 0/1 + every Minchiate trump fell through to empty + rendered the chip as just a number/numeral w. no icon below. Promoted the fallback into the model property (per-card override still wins via the `self.icon` branch), so every trump everywhere — chip, text-mode corner, future surfaces — gets a hand-with-dots glyph for free. Updated `TarotCardSuitIconTest.test_major_without_icon_returns_empty` → `test_major_without_icon_defaults_to_hand_dots`.
(5) EMANATION/REVERSAL → --secUser (user-spec 2026-05-25 PM, mid-sprint): label color was --terUser (gold) across all 4 surfaces; flipped to --secUser everywhere so the label recedes against the title (gold/--quaUser per arcana stays the focal text). Default in the shared `stat-block-shared` mixin + applet bespoke `.stat-face-label` rule both updated. Per-polarity overrides: levity (bg --priUser) → label --secUser everywhere; gravity overrides preserved at --quiUser on the 3 surfaces whose gravity bg flips to --secUser (sig-stat-block / sea-stat-block / fan-stage-block — --secUser label would be invisible against --secUser bg, so --quiUser stays for contrast); applet gravity bg is --priUser (just full alpha vs. the default 0.8 — different from the other surfaces) so its gravity override removed entirely, label uses the shared --secUser default in both polarities. User-confirmed visually 2026-05-25 PM: applet EMANATION now in --secUser (`rgb(162, 170, 173)`) matching the chip color — chip + label read as a coordinated header pair rather than competing w. the title.
Tests: 1314/1314 IT+UT total green (76s; +8 new in this sprint — 4 chip-presence ITs across the 4 stat-block surfaces, 3 _tarot_fan image-mode-branch ITs covering image-equipped + text-mode + polarized-image-equipped permutations, 1 UT-rename for the trump fa-hand-dots default). Surfaces NOT covered by ITs: SCSS layout (visual-only — verified live via Claudezilla on /gameboard/game-kit/ Minchiate carousel, /billboard/my-sign/ stage card, /billboard/ applet preview); JS-side chip-fill via populateStatExtras (covered transitively by the populateStatExtras existing call sites — no new test for the chip-specific code path since the test surface for stage-card.js is currently Jasmine-only via FanStageSpec.js, deferred). No new FT runs per [[feedback-ft-run-discipline]] — all changes are template / SCSS / JS / model property; IT coverage is comprehensive for the server-rendered surfaces + the visual verify covered the JS-populated surfaces.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -906,6 +906,26 @@ class MySignViewTest(TestCase):
|
||||
self.user.refresh_from_db()
|
||||
self.assertIsNone(self.user.significator_id)
|
||||
|
||||
def test_stat_block_renders_rank_suit_chip_per_face(self):
|
||||
"""Sprint A.7.5 — `.stat-face-header` wraps the new top-left rank+suit
|
||||
chip inline w. the EMANATION/REVERSAL label per [[project-image-based-
|
||||
deck-face-rendering]]'s A.3 Q3 spec. Empty by default (JS-populated by
|
||||
stage-card.js populateStatExtras on focus); both upright + reversed
|
||||
faces carry their own chip slot so post-SPIN the chip stays visible."""
|
||||
import lxml.html
|
||||
response = self.client.get(reverse("billboard:my_sign"))
|
||||
parsed = lxml.html.fromstring(response.content)
|
||||
for face_cls in ("stat-face--upright", "stat-face--reversed"):
|
||||
face = parsed.cssselect(f".sig-stat-block .{face_cls}")
|
||||
self.assertEqual(len(face), 1, f"expected one {face_cls}")
|
||||
[header] = face[0].cssselect(".stat-face-header")
|
||||
[chip] = header.cssselect(".stat-face-chip")
|
||||
[_rank] = chip.cssselect(".stat-chip-rank")
|
||||
[_icon] = chip.cssselect("i.stat-chip-icon")
|
||||
# The label still lives inside the header (now flex-laid-out
|
||||
# inline w. the chip rather than as a bare child of the face).
|
||||
[_label] = header.cssselect(".stat-face-label")
|
||||
|
||||
def test_save_sign_get_redirects_back_to_picker(self):
|
||||
response = self.client.get(reverse("billboard:save_sign"))
|
||||
self.assertRedirects(response, reverse("billboard:my_sign"))
|
||||
@@ -1093,3 +1113,28 @@ class BillboardAppletMySignTest(TestCase):
|
||||
len(card_el.cssselect(".fan-card-corner")), 0,
|
||||
"Non-image deck keeps the text scaffold",
|
||||
)
|
||||
|
||||
def test_applet_stat_block_renders_server_side_chip(self):
|
||||
"""Sprint A.7.5 — applet is read-only so the rank+suit chip is server-
|
||||
rendered (not JS-populated as on stage / sea_stage / fan stage). Chip
|
||||
carries the card's corner_rank + suit_icon FA class inline w. the
|
||||
EMANATION label inside `.stat-face-header`."""
|
||||
from apps.epic.models import personal_sig_cards
|
||||
target = personal_sig_cards(self.user)[0]
|
||||
self.user.significator = target
|
||||
self.user.save(update_fields=["significator"])
|
||||
import lxml.html
|
||||
response = self.client.get("/billboard/")
|
||||
parsed = lxml.html.fromstring(response.content)
|
||||
[block] = parsed.cssselect(".my-sign-applet-stat-block")
|
||||
[header] = block.cssselect(".stat-face-header")
|
||||
[chip] = header.cssselect(".stat-face-chip")
|
||||
[rank] = chip.cssselect(".stat-chip-rank")
|
||||
# Court middle cards have single-letter corner ranks (M/J/Q/K) per
|
||||
# TarotCard.corner_rank — pin presence, not the exact value (which
|
||||
# depends on which middle court personal_sig_cards returns first).
|
||||
self.assertTrue(rank.text and rank.text.strip())
|
||||
# Middle court has a suit, so the suit-icon `<i>` is present + carries
|
||||
# the canonical FA class for the suit (fa-wand-sparkles for BRANDS etc).
|
||||
[icon] = chip.cssselect("i.stat-chip-icon")
|
||||
self.assertTrue(any(cls.startswith("fa-") for cls in (icon.get("class") or "").split()))
|
||||
|
||||
@@ -488,7 +488,13 @@ class TarotCard(models.Model):
|
||||
if self.icon:
|
||||
return self.icon
|
||||
if self.arcana == self.MAJOR:
|
||||
return ''
|
||||
# Sprint A.7.5 — trumps default to fa-hand-dots so the chip (and
|
||||
# any text-mode corner) always has a symbol below the rank. Per-
|
||||
# card overrides still win via the `self.icon` branch above (the
|
||||
# Earthman seed sets `icon="fa-hand-dots"` explicitly for trumps
|
||||
# 2+, which was the only place this fallback used to live; trumps
|
||||
# 0/1 + every Minchiate trump now pick it up for free).
|
||||
return 'fa-hand-dots'
|
||||
return {
|
||||
self.BRANDS: 'fa-wand-sparkles',
|
||||
self.CROWNS: 'fa-crown',
|
||||
|
||||
@@ -273,6 +273,12 @@ var StageCard = (function () {
|
||||
// For text-mode decks the same info is on the card face too — duplicates,
|
||||
// not regressive. For image-mode decks the stat block is the only home
|
||||
// for textual metadata. Caller-side opt-out by passing opts.skipExtras=true.
|
||||
//
|
||||
// Sprint A.7.5 — also fills the top-left rank+suit chip (`.stat-chip-rank`
|
||||
// + `.stat-chip-icon`) on each stat-face header. Chip data reuses the same
|
||||
// `corner_rank` + `suit_icon` fields populated on the card's corners; for
|
||||
// image-mode decks where the card has no corners, the chip is the only
|
||||
// place the rank+suit appears.
|
||||
function populateStatExtras(statBlock, card, opts) {
|
||||
if (!statBlock) return;
|
||||
opts = opts || {};
|
||||
@@ -287,6 +293,17 @@ var StageCard = (function () {
|
||||
statBlock.querySelectorAll('.stat-face-arcana').forEach(function (el) {
|
||||
el.textContent = arcana;
|
||||
});
|
||||
statBlock.querySelectorAll('.stat-chip-rank').forEach(function (el) {
|
||||
el.textContent = card.corner_rank || '';
|
||||
});
|
||||
statBlock.querySelectorAll('.stat-chip-icon').forEach(function (el) {
|
||||
if (card.suit_icon) {
|
||||
el.className = 'fa-solid ' + card.suit_icon + ' stat-chip-icon';
|
||||
el.style.display = '';
|
||||
} else {
|
||||
el.style.display = 'none';
|
||||
}
|
||||
});
|
||||
// Surface arcana on the stat-block parent so SCSS `[data-arcana-key]`
|
||||
// selectors can color-key the title (--quiUser for minor/middle,
|
||||
// --terUser for major) — mirrors the same hook used on the card.
|
||||
|
||||
@@ -55,8 +55,10 @@ class TarotCardSuitIconTest(SimpleTestCase):
|
||||
def test_major_with_icon_returns_icon(self):
|
||||
self.assertEqual(_card('MAJOR', 0, icon='fa-hat-cowboy-side').suit_icon, 'fa-hat-cowboy-side')
|
||||
|
||||
def test_major_without_icon_returns_empty(self):
|
||||
self.assertEqual(_card('MAJOR', 5).suit_icon, '')
|
||||
def test_major_without_icon_defaults_to_hand_dots(self):
|
||||
# Sprint A.7.5 — trump fallback for the stat-block chip + text-mode
|
||||
# corners. Per-card `icon` still wins (covered by the test above).
|
||||
self.assertEqual(_card('MAJOR', 5).suit_icon, 'fa-hand-dots')
|
||||
|
||||
def test_brands_returns_wand_sparkles(self):
|
||||
self.assertEqual(_card('MIDDLE', 11, 'BRANDS').suit_icon, 'fa-wand-sparkles')
|
||||
|
||||
@@ -120,6 +120,11 @@ var GameKit = (function () {
|
||||
uprightSel: '#id_fan_stat_upright',
|
||||
reversedSel: '#id_fan_stat_reversed',
|
||||
});
|
||||
// Sprint A.7.5 — fill the title + arcana + top-left rank+suit chip
|
||||
// fields per the new image-mode stat-block shape. The carousel stage
|
||||
// block previously had only the keyword list; for image-mode decks
|
||||
// the stat block is the only home for textual metadata.
|
||||
StageCard.populateStatExtras(stageBlock, card);
|
||||
_infoData = StageCard.buildInfoData(card);
|
||||
_infoIdx = 0;
|
||||
|
||||
@@ -127,6 +132,10 @@ var GameKit = (function () {
|
||||
// so a previously-reversed card returns to upright when it leaves focus.
|
||||
stageBlock.classList.remove('is-reversed');
|
||||
cards.forEach(function (c) { c.classList.remove('stage-card--reversed'); });
|
||||
// Sprint A.7.5 — also clear FLIP-to-back state on card change so a
|
||||
// back-flipped non-polarized card returns to its front when it leaves
|
||||
// focus (mirrors the SPIN reset semantics).
|
||||
cards.forEach(function (c) { c.classList.remove('is-flipped-to-back'); });
|
||||
_closeFyi();
|
||||
}
|
||||
|
||||
@@ -138,6 +147,16 @@ var GameKit = (function () {
|
||||
var active = cards[currentIndex];
|
||||
if (!active) return;
|
||||
if (active.dataset.flipping) return; // mid-flip
|
||||
|
||||
// Sprint A.7.5 — non-polarized image-equipped decks (Minchiate today)
|
||||
// render a `.sig-stage-card-back-img` child server-side per the my_sign
|
||||
// pattern. Presence of that child = FLIP toggles back-image instead of
|
||||
// polarity cycle (which has no meaning for non-polarized decks).
|
||||
if (active.querySelector('.sig-stage-card-back-img')) {
|
||||
_flipToBack(active);
|
||||
return;
|
||||
}
|
||||
|
||||
active.dataset.flipping = '1';
|
||||
|
||||
// Build the resting transform (carousel offset 0 + optional SPIN rotate(180))
|
||||
@@ -169,6 +188,25 @@ var GameKit = (function () {
|
||||
setTimeout(function () { delete active.dataset.flipping; }, 500);
|
||||
}
|
||||
|
||||
// Sprint A.7.5 — FLIP-to-back animation for non-polarized image-equipped
|
||||
// decks. Mirrors my_sign.html / _applet-my-sign.html FLIP-to-back: rotateY
|
||||
// 0→90→0 over 500ms, toggle `.is-flipped-to-back` at the halfway point,
|
||||
// clear `data-flipping` at end. SCSS hides/shows the front + back img via
|
||||
// the same `.is-flipped-to-back` class used by the my_sign surface.
|
||||
function _flipToBack(active) {
|
||||
active.dataset.flipping = '1';
|
||||
var spin = active.classList.contains('stage-card--reversed') ? ' rotate(180deg)' : '';
|
||||
var rest = 'translateX(0px) rotateY(0deg) scale(1)' + spin;
|
||||
var mid = 'translateX(0px) rotateY(0deg) scale(1)' + spin + ' rotateY(90deg)';
|
||||
active.animate([
|
||||
{ transform: rest },
|
||||
{ transform: mid, offset: 0.5 },
|
||||
{ transform: rest },
|
||||
], { duration: 500, easing: 'ease' });
|
||||
setTimeout(function () { active.classList.toggle('is-flipped-to-back'); }, 250);
|
||||
setTimeout(function () { delete active.dataset.flipping; }, 500);
|
||||
}
|
||||
|
||||
// ── FYI panel ─────────────────────────────────────────────────────────────
|
||||
|
||||
function _renderFyi() {
|
||||
@@ -351,6 +389,12 @@ var GameKit = (function () {
|
||||
stageBlock.classList.toggle('is-reversed');
|
||||
var active = cards[currentIndex];
|
||||
if (!active) return;
|
||||
// Sprint A.7.5 — image-mode cards don't rotate 180° on SPIN.
|
||||
// The artwork is the canonical card; spinning it would invert
|
||||
// the image visually w. no equivalent text-content swap (the
|
||||
// text scaffold isn't rendered in image-mode). Stat block still
|
||||
// flips via `.is-reversed` above to swap EMANATION ↔ REVERSAL.
|
||||
if (active.classList.contains('fan-card--image')) return;
|
||||
active.classList.toggle('stage-card--reversed');
|
||||
var t = cardTransform(0);
|
||||
var spin = active.classList.contains('stage-card--reversed') ? ' rotate(180deg)' : '';
|
||||
|
||||
@@ -770,6 +770,24 @@ class GameKitViewTest(TestCase):
|
||||
[inp] = self.parsed.cssselect("#id_game_kit_menu input[value='pronouns']")
|
||||
self.assertEqual(inp.get("type"), "checkbox")
|
||||
|
||||
def test_fan_stage_block_renders_rank_suit_chip_per_face(self):
|
||||
"""Sprint A.7.5 — `#id_fan_stage_block` (the carousel modal's stat
|
||||
block) gains the same `.stat-face-header` w. rank+suit chip + the
|
||||
`.stat-face-title` + `.stat-face-arcana` slots that the my_sign /
|
||||
sea_stage stat blocks have. Previously only the keyword list was
|
||||
present (text-mode decks carried text on the card face); for image-
|
||||
mode the stat block is the sole home for textual metadata."""
|
||||
for face_cls in ("stat-face--upright", "stat-face--reversed"):
|
||||
face = self.parsed.cssselect(f"#id_fan_stage_block .{face_cls}")
|
||||
self.assertEqual(len(face), 1, f"expected one {face_cls}")
|
||||
[header] = face[0].cssselect(".stat-face-header")
|
||||
[_chip] = header.cssselect(".stat-face-chip")
|
||||
[_rank] = header.cssselect(".stat-chip-rank")
|
||||
[_icon] = header.cssselect("i.stat-chip-icon")
|
||||
[_label] = header.cssselect(".stat-face-label")
|
||||
[_title] = face[0].cssselect(".stat-face-title")
|
||||
[_arcana] = face[0].cssselect(".stat-face-arcana")
|
||||
|
||||
|
||||
class ToggleGameKitSectionsViewTest(TestCase):
|
||||
def setUp(self):
|
||||
@@ -877,6 +895,75 @@ class TarotFanViewTest(TestCase):
|
||||
response = self.client.get(reverse("tarot_fan", kwargs={"deck_id": self.fiorentine.pk}))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_text_mode_deck_keeps_text_scaffold(self):
|
||||
"""Sprint A.7.5 — Earthman (has_card_images=False) carousel cards keep
|
||||
the existing `.fan-card-corner` + `.fan-card-face` text scaffold and
|
||||
lack the `.fan-card--image` marker. Pins the text-mode branch as the
|
||||
before-state so the image-mode branch below isn't a regression risk."""
|
||||
from apps.epic.models import TarotCard
|
||||
# Cap at 5 cards to keep the test focused — the deck has 106 cards.
|
||||
TarotCard.objects.filter(deck_variant=self.earthman).exclude(
|
||||
pk__in=TarotCard.objects.filter(deck_variant=self.earthman)[:5]
|
||||
).delete()
|
||||
response = self.client.get(reverse("tarot_fan", kwargs={"deck_id": self.earthman.pk}))
|
||||
parsed = lxml.html.fromstring(response.content)
|
||||
cards = parsed.cssselect(".fan-card")
|
||||
self.assertGreater(len(cards), 0)
|
||||
for card in cards:
|
||||
self.assertNotIn("fan-card--image", card.get("class", ""))
|
||||
self.assertEqual(len(card.cssselect("img.sig-stage-card-img")), 0)
|
||||
self.assertGreater(len(card.cssselect(".fan-card-corner")), 0)
|
||||
self.assertGreater(len(card.cssselect(".fan-card-face")), 0)
|
||||
|
||||
def test_image_mode_deck_renders_img_per_card_and_drops_text_scaffold(self):
|
||||
"""Sprint A.7.5 — Minchiate (has_card_images=True + non-polarized) cards
|
||||
carry `.fan-card--image` + an `<img.sig-stage-card-img>` per card +
|
||||
a `<img.sig-stage-card-back-img>` per card (since non-polarized; FLIP
|
||||
flips to back). Text scaffold (corners + face) absent server-side."""
|
||||
from apps.epic.models import DeckVariant
|
||||
minchiate = DeckVariant.objects.get(slug="minchiate-fiorentine-1860-1890")
|
||||
self.user.unlocked_decks.add(minchiate)
|
||||
response = self.client.get(reverse("tarot_fan", kwargs={"deck_id": minchiate.pk}))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
parsed = lxml.html.fromstring(response.content)
|
||||
cards = parsed.cssselect(".fan-card")
|
||||
# Spot-check the first card; deck has 97 cards.
|
||||
self.assertGreater(len(cards), 0)
|
||||
first = cards[0]
|
||||
self.assertIn("fan-card--image", first.get("class", ""))
|
||||
self.assertEqual(first.get("data-arcana-key"), "MAJOR") # Minchiate trump #0 = Il Matto
|
||||
[img] = first.cssselect("img.sig-stage-card-img")
|
||||
self.assertIn("minchiate-fiorentine-1860-1890", img.get("src", ""))
|
||||
[back_img] = first.cssselect("img.sig-stage-card-back-img")
|
||||
self.assertIn(
|
||||
"minchiate-fiorentine-1860-1890-back.png", back_img.get("src", "")
|
||||
)
|
||||
# Text scaffold absent across the WHOLE response — none of the cards
|
||||
# in image-mode should render corners/face.
|
||||
self.assertEqual(
|
||||
len(parsed.cssselect(".fan-card-corner")), 0,
|
||||
"image-mode cards must not render the text scaffold",
|
||||
)
|
||||
self.assertEqual(len(parsed.cssselect(".fan-card-face")), 0)
|
||||
|
||||
def test_image_mode_polarized_deck_omits_back_img(self):
|
||||
"""Polarized image-equipped deck (none today, but the gate is
|
||||
defensive): FLIP retains its polarity-cycle meaning and no back-img
|
||||
renders. Earthman flipped to has_card_images=True simulates the
|
||||
future state where Earthman art lands."""
|
||||
self.earthman.has_card_images = True
|
||||
self.earthman.save(update_fields=["has_card_images"])
|
||||
response = self.client.get(reverse("tarot_fan", kwargs={"deck_id": self.earthman.pk}))
|
||||
parsed = lxml.html.fromstring(response.content)
|
||||
cards = parsed.cssselect(".fan-card")
|
||||
self.assertGreater(len(cards), 0)
|
||||
for card in cards:
|
||||
self.assertIn("fan-card--image", card.get("class", ""))
|
||||
self.assertEqual(
|
||||
len(card.cssselect("img.sig-stage-card-back-img")), 0,
|
||||
"Polarized deck must not render the back-image element",
|
||||
)
|
||||
|
||||
|
||||
class MySeaViewTest(TestCase):
|
||||
"""Sprint 3 of the My Sea roadmap — standalone page is a shell only.
|
||||
@@ -905,6 +992,26 @@ class MySeaViewTest(TestCase):
|
||||
self.assertIn("page-gameboard", response.content.decode())
|
||||
self.assertIn("page-my-sea", response.content.decode())
|
||||
|
||||
def test_sea_stage_stat_block_renders_rank_suit_chip_per_face(self):
|
||||
"""Sprint A.7.5 — `_sea_stage.html` modal scaffold (included from
|
||||
my_sea-picker-phase + the gameroom sea overlay) carries the new
|
||||
`.stat-face-header` wrapper w. the rank+suit chip inline w. the
|
||||
EMANATION/REVERSAL label. Both upright + reversed faces have their
|
||||
own chip; stage-card.js populateStatExtras fills both identically
|
||||
on each card focus. Rendered standalone via render_to_string since
|
||||
the partial's parent views are phase-gated."""
|
||||
from django.template.loader import render_to_string
|
||||
html = render_to_string("apps/gameboard/_partials/_sea_stage.html")
|
||||
parsed = lxml.html.fromstring(html)
|
||||
for face_cls in ("stat-face--upright", "stat-face--reversed"):
|
||||
face = parsed.cssselect(f".sea-stat-block .{face_cls}")
|
||||
self.assertEqual(len(face), 1, f"expected one {face_cls}")
|
||||
[header] = face[0].cssselect(".stat-face-header")
|
||||
[chip] = header.cssselect(".stat-face-chip")
|
||||
[_rank] = chip.cssselect(".stat-chip-rank")
|
||||
[_icon] = chip.cssselect("i.stat-chip-icon")
|
||||
[_label] = header.cssselect(".stat-face-label")
|
||||
|
||||
|
||||
class MySeaDrawSeaLandingViewTest(TestCase):
|
||||
"""Sprint 5 iter 1 — view context for the DRAW SEA landing UX. Pins
|
||||
|
||||
Reference in New Issue
Block a user