my-sea spectate: live spread-sync + owner seat-ring push + visit caption fix — TDD

Three fixes to the my-sea spectator (bud-sea), all flowing over the existing
`mysea_<owner>` spectate consumer:

VISIT CAPTIONS (.sea-pos-label) — two bugs left every CROWN/COVER/… caption
blank on my_sea_visit:
- empty-hand: `label_by_position` was built from `latest_draw_slots`, which
  returns [] when the owner's hand is empty (only a significator placed) — so
  an owner mid-setup showed no captions, while her OWN my_sea (whose JS seeds
  labels from the POSITION_LABELS constant) showed them. Now the view pulls
  captions straight from POSITION_LABELS[spread], drawn-cards-independent.
- `--seciUser` typo (used once, never defined) → invalid colour dropped → the
  labels inherited the body colour, contrasting on some palettes but blending
  into the felt on others (read as "missing"). → `--secUser`.

SPREAD-SYNC — the owner's live draw pushed only the hand, not the spread, so a
post-DEL spread switch landed the new cards into the OLD spread's cells (the
asymmetry the user hit: owner on desire-obstacle-solution, visitor still laid
out as escape-velocity). The spread now rides each `sea_draw` broadcast;
`_applySpread` re-sets `data-spread` (CSS keys cell visibility off it),
re-captions from a server-sourced POSITION_LABELS json_script, + clears stale
fills before `_applyHand` repopulates against the right layout.

OWNER-SIDE LIVE SEAT PUSH — the owner's my_sea now subscribes to her own
spectate WS for `sea_seats`, so visitors arriving (deposit → 2C-6C) / leaving
(BYE) appear without a refresh, same broadcast the spectators get. The visit
page's inline `_renderSeats` is hoisted into my-sea-seats.js as the shared
`mySeaRenderSeats(seats, myToken)` (+ `mySeaConnectSeatRing`); each page passes
its own self-token (owner page passes '' — her 1C isn't --self server-side).

Coverage:
- ITs: MySeaVisitEmptyHandLabelsTest (captions present + rendered for an empty
  hand); MySeaLockHandViewTest broadcast test asserts the spread arg; spectate
  consumer test asserts the hand+spread relay (channels).
- Jasmine: 6 new MySeaSeatsSpec cases for mySeaRenderSeats (per-seat rebuild,
  --self by token, owner-page no-self, no-duplicate re-render, one-shot flare).
- Live-verified in Firefox: captions paint khaki on the brown palette; a
  desire-obstacle-solution sync flips data-spread + relabels Solution/Obstacle/
  Desire + hides leave/cover/lay.

[[feedback-jsonfield-exclude-sqlite-null]] not implicated; spread map is a plain
dict lookup. 304 gameboard ITs + Jasmine green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-30 00:35:18 -04:00
parent 877e0f544a
commit 9678d187b4
11 changed files with 339 additions and 48 deletions

View File

@@ -55,3 +55,72 @@ describe('my-sea-seats one-shot seated glow', function () {
jasmine.clock().uninstall();
});
});
// mySeaRenderSeats — the shared seat-ring re-render driven by a `sea_seats`
// broadcast. Used by BOTH the owner's my_sea AND the spectator's my_sea_visit
// (DRY, 2026-05-30); each passes its own `myToken` to mark its own chair.
describe('my-sea-seats mySeaRenderSeats shared ring re-render', function () {
var scene;
beforeEach(function () {
window.localStorage.clear();
scene = document.createElement('div');
scene.className = 'room-table-scene';
document.body.appendChild(scene);
});
afterEach(function () {
if (scene && scene.parentNode) scene.parentNode.removeChild(scene);
window.localStorage.clear();
});
var SEATS = [
{ n: 1, label: '1C', present: true, token: 'owner-9-3' },
{ n: 2, label: '2C', present: true, token: 'visit-42' },
{ n: 3, label: '3C', present: false, token: '' },
];
it('exposes mySeaRenderSeats globally', function () {
expect(typeof window.mySeaRenderSeats).toBe('function');
});
it('rebuilds one .table-seat per seat with the present/seated state', function () {
window.mySeaRenderSeats(SEATS, '');
var seats = scene.querySelectorAll('.table-seat');
expect(seats.length).toBe(3);
expect(seats[0].classList.contains('seated')).toBe(true);
expect(seats[2].classList.contains('seated')).toBe(false);
// Present seats get the check icon; absent seats the ban icon.
expect(seats[0].querySelector('.fa-circle-check')).not.toBeNull();
expect(seats[2].querySelector('.fa-ban')).not.toBeNull();
expect(seats[1].querySelector('.seat-position-label').textContent).toBe('2C');
});
it('marks only the seat matching myToken as --self', function () {
window.mySeaRenderSeats(SEATS, 'visit-42');
var self = scene.querySelectorAll('.table-seat--self');
expect(self.length).toBe(1);
expect(self[0].getAttribute('data-slot')).toBe('2');
});
it('marks no seat --self when myToken is empty (owner page)', function () {
window.mySeaRenderSeats(SEATS, '');
expect(scene.querySelectorAll('.table-seat--self').length).toBe(0);
});
it('clears prior seats before re-rendering (no duplicates)', function () {
window.mySeaRenderSeats(SEATS, '');
window.mySeaRenderSeats(SEATS, '');
expect(scene.querySelectorAll('.table-seat').length).toBe(3);
});
it('flares freshly-seated tokened seats once (localStorage-gated)', function () {
window.mySeaRenderSeats(SEATS, '');
var owner = scene.querySelector('.table-seat[data-slot="1"]');
expect(owner.classList.contains('seat-just-seated')).toBe(true);
// Second render for the same token does not replay the flare.
window.mySeaRenderSeats(SEATS, '');
owner = scene.querySelector('.table-seat[data-slot="1"]');
expect(owner.classList.contains('seat-just-seated')).toBe(false);
});
});