Hold'em: every seat on the ring is the same seat, and the ring is measured before it is believed #122
@@ -413,15 +413,25 @@
|
||||
<span>Name</span>
|
||||
<input type="text" class="seatNameInput" placeholder="Seat" maxlength="24" />
|
||||
</label>
|
||||
<div class="seatCardToggles">
|
||||
<label class="inHandToggle" title="This seat is playing the current hand">
|
||||
<input type="checkbox" class="inHandCheckbox" />
|
||||
<span>In hand</span>
|
||||
</label>
|
||||
<label class="foldToggle" title="Folded">
|
||||
<input type="checkbox" class="foldCheckbox" />
|
||||
<span>Fold</span>
|
||||
</label>
|
||||
@* One question with three answers, not two boxes with four corners.
|
||||
|
||||
A seat is sitting out, in the hand, or folded — and "folded" only means
|
||||
anything while the seat is in. So the two checkboxes here could be ticked
|
||||
into a state that means nothing, and the code that stopped them doing it
|
||||
`disabled` the Fold box whenever In hand was clear. Measured: that left the
|
||||
label at the same opacity and the same colour with a `title` on it, so a
|
||||
dealer pressing Fold on a seat that was sitting out got no movement and no
|
||||
reason — the exact thing round 16 went through the tool to remove, hiding
|
||||
from the rule that finds it because the title sat on the label rather than
|
||||
on the control.
|
||||
|
||||
Three named states cannot contradict each other and none of them is ever
|
||||
unavailable, so there is nothing left to dim. The radiogroup is the same
|
||||
shape the card picker's suits use. *@
|
||||
<div class="seatState" role="radiogroup" aria-label="This seat">
|
||||
<button type="button" class="seatStateOption" data-seat-state="out" role="radio" aria-checked="false">Sitting out</button>
|
||||
<button type="button" class="seatStateOption" data-seat-state="in" role="radio" aria-checked="false">In the hand</button>
|
||||
<button type="button" class="seatStateOption" data-seat-state="folded" role="radio" aria-checked="false">Folded</button>
|
||||
</div>
|
||||
<label class="fieldRow stackRow">
|
||||
<span>Stack</span>
|
||||
|
||||
@@ -1865,19 +1865,53 @@ button,
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.seatCardToggles {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
font-size: var(--fs-base);
|
||||
color: #aaa;
|
||||
/* Sitting out · In the hand · Folded. Three equal columns rather than three widths,
|
||||
because the answer moves between them as the hand goes on and a segment that resizes
|
||||
under the thumb reads as the control flinching. */
|
||||
.seatState {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--sp-0);
|
||||
padding: var(--sp-0);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--surface-1);
|
||||
}
|
||||
|
||||
.inHandToggle,
|
||||
.foldToggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
.seatStateOption {
|
||||
min-height: 36px;
|
||||
padding: var(--sp-1) var(--sp-1);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
font-family: inherit;
|
||||
font-size: var(--fs-base);
|
||||
line-height: 1.2;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
|
||||
.seatStateOption[aria-checked="true"] {
|
||||
background: var(--surface-3);
|
||||
border-color: var(--line);
|
||||
color: var(--text-bright);
|
||||
}
|
||||
|
||||
/* The one state that is a thing having gone out of the hand rather than a thing being
|
||||
in it, in the red the seat menu's own Fold already wears. Only when it is the answer:
|
||||
a red segment sitting unpressed beside two grey ones reads as a warning about the
|
||||
seat rather than as a choice about it. */
|
||||
.seatStateOption[data-seat-state="folded"][aria-checked="true"] {
|
||||
background: linear-gradient(180deg, #3d1f1f, #2c1515);
|
||||
border-color: #5b2626;
|
||||
color: #ffd6d6;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.seatStateOption:hover[aria-checked="false"] {
|
||||
color: var(--text-bright);
|
||||
}
|
||||
}
|
||||
|
||||
.seatNameInput {
|
||||
@@ -5320,8 +5354,109 @@ body.hasActionBar #toastHost {
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
/* ---- Seat editor as a bottom sheet ---- */
|
||||
|
||||
/* The sidebar's own controls never got the touch pass the seat form did — these
|
||||
are settings a dealer changes at the table, on the same phone. Written against
|
||||
the panel rather than each field, so a setting added later inherits it. */
|
||||
.panelBody input:not([type="checkbox"]):not([type="color"]),
|
||||
.subPanelBody input:not([type="checkbox"]):not([type="color"]),
|
||||
.panelBody select {
|
||||
font-size: 16px;
|
||||
min-height: 44px;
|
||||
padding: var(--sp-2) var(--sp-2);
|
||||
}
|
||||
|
||||
.panelBody .holdemBtnSmall,
|
||||
.handHistoryActions .holdemBtn,
|
||||
/* The card picker's Clear and Done sat at 40px: the touch rule was scoped to
|
||||
.panelBody and the picker is a sheet, so it never reached them. Clear wipes a
|
||||
player's cards, which is not a thing to make a dealer aim at. */
|
||||
.cardPickerHeader .holdemBtn,
|
||||
/* Undo was 30px, and it is the control a dealer reaches for having just recorded
|
||||
the wrong thing with five people waiting — the worst place to be fourteen pixels
|
||||
short of a fingertip. Growing the box costs bar height, which comes out of the
|
||||
felt, but measured at 390x844 the felt is 366px against a 220px floor, so there
|
||||
is room for it. A hit-area pad was tried first and does not work here: the pill
|
||||
clips its overflow to keep a long label on one line, which clips the pad too. */
|
||||
.actionUndo {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* Destructive and tiny is the worst combination: these delete a chip colour or a
|
||||
custom field, and were 28px wide. */
|
||||
.removeChipColorBtn,
|
||||
.removeFieldBtn {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* flex-shrink would take a stated width straight back off again in a row this
|
||||
narrow, which is why it stayed 32px wide with the height applied. */
|
||||
.chipColorInput {
|
||||
flex: none;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
/* A bare checkbox can't grow into a target, but the row it sits in can — the
|
||||
label is what a tap actually lands on. */
|
||||
.panelBody .fieldRow {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* An underlined word is a 14px-tall target; give it room without making it look
|
||||
like a button. */
|
||||
.linkBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
padding: 0 var(--sp-2);
|
||||
}
|
||||
|
||||
/* The dealer chip's 44px tap pad used to be redeclared here, centred on the disc.
|
||||
On a 72x46 puck that square started at the puck's own middle, and the seat that
|
||||
had the button could not be tapped where a thumb lands. It is gone; see
|
||||
.dealerChip near the top of this file for why nothing needs to replace it. */
|
||||
|
||||
.toastAction {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.confirmActions {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.confirmActions .holdemBtn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* A card the width of a phone is a card a thumb can hit without aiming. Nothing to
|
||||
do with the sheet below — it moved here when the seat rules moved out from
|
||||
around it. */
|
||||
.pickerCard {
|
||||
min-height: 46px;
|
||||
font-size: var(--fs-lg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Seat editor as a bottom sheet ----
|
||||
|
||||
Lifted out of the block above so its own question can be asked, which is not the one
|
||||
that block asks. The phone block's comment has always said why the sheet exists —
|
||||
"the seat editor lives far enough down the page that tapping a seat looks like it did
|
||||
nothing" — and that is true of every width where #holdemLayout is one column, which
|
||||
is 900px, not 700. The two tiers in between had the fault the sheet was written to
|
||||
fix. Measured, tapping a seat:
|
||||
|
||||
844x390 the editor drew in the flow at y=369 in a 390px window — 21px of 401
|
||||
on screen, 5% of it, at scrollY 0 with nothing scrolling it into view.
|
||||
The seat lit up green and the form it opened was under the bottom edge.
|
||||
768x1024 31% of it visible, the rest below the fold, and again no scroll.
|
||||
|
||||
So the query is the layout's own breakpoint. Past 900px the sidebar is a column
|
||||
beside the table and the editor is at the top of it, already in sight. */
|
||||
@media (max-width: 900px) {
|
||||
/* What the sheet is in front of.
|
||||
Lighter than the 0.72 the other six overlays use, on purpose: those replace the
|
||||
table, and this one is a form *about* something on it. The chair being edited
|
||||
@@ -5482,29 +5617,11 @@ body.hasActionBar #toastHost {
|
||||
min-height: 62px;
|
||||
}
|
||||
|
||||
/* Thirteen rows of cards need to stay reachable without the sheet running off
|
||||
the bottom of a phone. */
|
||||
.pickerCard {
|
||||
min-height: 46px;
|
||||
font-size: var(--fs-lg);
|
||||
}
|
||||
|
||||
.seatEditorForm .seatCardToggles {
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.seatEditorForm .inHandToggle,
|
||||
.seatEditorForm .foldToggle {
|
||||
.seatEditorForm .seatStateOption {
|
||||
min-height: 44px;
|
||||
font-size: var(--fs-md);
|
||||
}
|
||||
|
||||
.seatEditorForm .inHandToggle input,
|
||||
.seatEditorForm .foldToggle input {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.seatEditorForm .amountOverrideRow,
|
||||
.seatEditorForm .seatTotal {
|
||||
font-size: var(--fs-md);
|
||||
@@ -5516,82 +5633,6 @@ body.hasActionBar #toastHost {
|
||||
min-height: 44px;
|
||||
margin-top: var(--sp-2);
|
||||
}
|
||||
|
||||
/* The sidebar's own controls never got the touch pass the seat form did — these
|
||||
are settings a dealer changes at the table, on the same phone. Written against
|
||||
the panel rather than each field, so a setting added later inherits it. */
|
||||
.panelBody input:not([type="checkbox"]):not([type="color"]),
|
||||
.subPanelBody input:not([type="checkbox"]):not([type="color"]),
|
||||
.panelBody select {
|
||||
font-size: 16px;
|
||||
min-height: 44px;
|
||||
padding: var(--sp-2) var(--sp-2);
|
||||
}
|
||||
|
||||
.panelBody .holdemBtnSmall,
|
||||
.handHistoryActions .holdemBtn,
|
||||
/* The card picker's Clear and Done sat at 40px: the touch rule was scoped to
|
||||
.panelBody and the picker is a sheet, so it never reached them. Clear wipes a
|
||||
player's cards, which is not a thing to make a dealer aim at. */
|
||||
.cardPickerHeader .holdemBtn,
|
||||
/* Undo was 30px, and it is the control a dealer reaches for having just recorded
|
||||
the wrong thing with five people waiting — the worst place to be fourteen pixels
|
||||
short of a fingertip. Growing the box costs bar height, which comes out of the
|
||||
felt, but measured at 390x844 the felt is 366px against a 220px floor, so there
|
||||
is room for it. A hit-area pad was tried first and does not work here: the pill
|
||||
clips its overflow to keep a long label on one line, which clips the pad too. */
|
||||
.actionUndo {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* Destructive and tiny is the worst combination: these delete a chip colour or a
|
||||
custom field, and were 28px wide. */
|
||||
.removeChipColorBtn,
|
||||
.removeFieldBtn {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* flex-shrink would take a stated width straight back off again in a row this
|
||||
narrow, which is why it stayed 32px wide with the height applied. */
|
||||
.chipColorInput {
|
||||
flex: none;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
/* A bare checkbox can't grow into a target, but the row it sits in can — the
|
||||
label is what a tap actually lands on. */
|
||||
.panelBody .fieldRow {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* An underlined word is a 14px-tall target; give it room without making it look
|
||||
like a button. */
|
||||
.linkBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
padding: 0 var(--sp-2);
|
||||
}
|
||||
|
||||
/* The dealer chip's 44px tap pad used to be redeclared here, centred on the disc.
|
||||
On a 72x46 puck that square started at the puck's own middle, and the seat that
|
||||
had the button could not be tapped where a thumb lands. It is gone; see
|
||||
.dealerChip near the top of this file for why nothing needs to replace it. */
|
||||
|
||||
.toastAction {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.confirmActions {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.confirmActions .holdemBtn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* The seat sheet, for anyone who asked not to be moved.
|
||||
@@ -6336,6 +6377,14 @@ h2[data-modal-focus]:focus-visible {
|
||||
min-height: 44px;
|
||||
padding: var(--sp-2) var(--sp-2);
|
||||
}
|
||||
|
||||
/* Sized by the pointer rather than by the width, because a wide screen can still be
|
||||
a touch screen. The sheet block sizes these too, and has to: it is the width that
|
||||
decides whether the seat form is a sheet with a phone's room in it. This is the
|
||||
floor for everywhere else. */
|
||||
.seatStateOption {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
.toastClose::after {
|
||||
|
||||
@@ -2439,6 +2439,65 @@ const Render = {
|
||||
this.renderPanelGlances();
|
||||
},
|
||||
|
||||
// The three things a seat can be, in the order a hand goes through them. `folded`
|
||||
// is only meaningful while `inHand` is set, which is why these are one choice and
|
||||
// not two: the pair (out, folded) has no meaning, and the old two-checkbox control
|
||||
// could be asked for it.
|
||||
SEAT_STATES: [
|
||||
{ key: "out", label: "Sitting out", inHand: false, folded: false },
|
||||
{ key: "in", label: "In the hand", inHand: true, folded: false },
|
||||
{ key: "folded", label: "Folded", inHand: true, folded: true },
|
||||
],
|
||||
|
||||
seatStateOf(player) {
|
||||
if (!player.inHand) return "out";
|
||||
return player.folded ? "folded" : "in";
|
||||
},
|
||||
|
||||
// Draw the current answer onto the three buttons. Called on every paint and again
|
||||
// whenever something other than a press changes the seat — typing a name into an
|
||||
// empty chair seats somebody, and the control has to say so.
|
||||
paintSeatState(form, player) {
|
||||
const current = this.seatStateOf(player);
|
||||
form.querySelectorAll(".seatStateOption").forEach(btn => {
|
||||
const chosen = btn.dataset.seatState === current;
|
||||
btn.setAttribute("aria-checked", chosen ? "true" : "false");
|
||||
// One Tab stop for the three, the way the APG asks: Tab reaches the group,
|
||||
// the arrows move inside it.
|
||||
btn.tabIndex = chosen ? 0 : -1;
|
||||
});
|
||||
},
|
||||
|
||||
wireSeatState(form, player, index) {
|
||||
const options = [...form.querySelectorAll(".seatStateOption")];
|
||||
const choose = (key, { focus = false } = {}) => {
|
||||
const state = this.SEAT_STATES.find(entry => entry.key === key);
|
||||
if (!state) return;
|
||||
player.inHand = state.inHand;
|
||||
player.folded = state.folded;
|
||||
this.paintSeatState(form, player);
|
||||
if (focus) {
|
||||
const chosen = options.find(btn => btn.dataset.seatState === key);
|
||||
if (chosen) chosen.focus({ preventScroll: true });
|
||||
}
|
||||
this.commitSeatChange(form, player, index);
|
||||
};
|
||||
|
||||
options.forEach(btn => {
|
||||
btn.addEventListener("click", () => choose(btn.dataset.seatState));
|
||||
btn.addEventListener("keydown", event => {
|
||||
const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[event.key];
|
||||
if (step === undefined) return;
|
||||
event.preventDefault();
|
||||
const at = this.SEAT_STATES.findIndex(entry => entry.key === this.seatStateOf(player));
|
||||
const to = (at + step + this.SEAT_STATES.length) % this.SEAT_STATES.length;
|
||||
choose(this.SEAT_STATES[to].key, { focus: true });
|
||||
});
|
||||
});
|
||||
|
||||
this.paintSeatState(form, player);
|
||||
},
|
||||
|
||||
wireSeatFields(form, player, index) {
|
||||
const nameInput = form.querySelector(".seatNameInput");
|
||||
nameInput.value = player.name;
|
||||
@@ -2457,32 +2516,13 @@ const Render = {
|
||||
if (wasEmpty && player.name.trim()) {
|
||||
player.inHand = true;
|
||||
player.folded = false;
|
||||
const inHandBox = form.querySelector(".inHandCheckbox");
|
||||
if (inHandBox) inHandBox.checked = true;
|
||||
const foldBox = form.querySelector(".foldCheckbox");
|
||||
if (foldBox) { foldBox.checked = false; foldBox.disabled = false; }
|
||||
this.paintSeatState(form, player);
|
||||
}
|
||||
document.getElementById("seatEditorTitle").textContent = this.puckLabel(player, index);
|
||||
this.commitSeatChange(form, player, index);
|
||||
});
|
||||
|
||||
const inHandCheckbox = form.querySelector(".inHandCheckbox");
|
||||
inHandCheckbox.checked = player.inHand;
|
||||
inHandCheckbox.addEventListener("change", () => {
|
||||
player.inHand = inHandCheckbox.checked;
|
||||
if (!player.inHand) player.folded = false;
|
||||
foldCheckboxRef.checked = player.folded;
|
||||
foldCheckboxRef.disabled = !player.inHand;
|
||||
this.commitSeatChange(form, player, index);
|
||||
});
|
||||
|
||||
const foldCheckboxRef = form.querySelector(".foldCheckbox");
|
||||
foldCheckboxRef.checked = player.folded;
|
||||
foldCheckboxRef.disabled = !player.inHand;
|
||||
foldCheckboxRef.addEventListener("change", () => {
|
||||
player.folded = foldCheckboxRef.checked;
|
||||
this.commitSeatChange(form, player, index);
|
||||
});
|
||||
this.wireSeatState(form, player, index);
|
||||
|
||||
const slots = [form.querySelector(".card1Slot"), form.querySelector(".card2Slot")];
|
||||
slots.forEach((slot, cardIdx) => {
|
||||
|
||||
@@ -41,9 +41,9 @@ describe("what the sheet puts in front of a dealer at rest", () => {
|
||||
// <details> blocks are stripped whole, so what is left is the flat part.
|
||||
const alwaysVisible = () => seatForm().replace(/<details[\s\S]*?<\/details>/g, "");
|
||||
|
||||
test("the flat part is the name, the two status boxes and the stack, and nothing else", () => {
|
||||
test("the flat part is the name, what the seat is doing and the stack, and nothing else", () => {
|
||||
const flat = alwaysVisible();
|
||||
["seatNameInput", "inHandCheckbox", "foldCheckbox", "stackInput", "seatBuyIns"]
|
||||
["seatNameInput", "seatState", "seatStateOption", "stackInput", "seatBuyIns"]
|
||||
.forEach(cls => assert.ok(flat.includes(cls),
|
||||
`${cls} is what a dealer opens a seat for — it cannot be behind a disclosure`));
|
||||
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
// What a seat is doing, asked once.
|
||||
//
|
||||
// The seat editor had two checkboxes, "In hand" and "Fold". A seat is really in one of
|
||||
// three states — sitting out, in the hand, folded — and "folded" means nothing while
|
||||
// the seat is out, so two boxes offered a fourth corner that has no meaning. The code
|
||||
// that stopped a dealer reaching it `disabled` the Fold box whenever In hand was clear.
|
||||
//
|
||||
// Measured: that left the label at opacity 1 and colour rgb(170,170,170) — identical to
|
||||
// a live one — with a `title` of "Folded" on it. So pressing Fold on a seat that was
|
||||
// sitting out gave no movement and no reason, which is the exact failure round 16 went
|
||||
// through the whole tool to remove. It hid from the sweep rule that finds those because
|
||||
// the title sat on the <label> rather than on the control the rule inspects.
|
||||
//
|
||||
// Three named states cannot contradict each other and none of them is ever unavailable,
|
||||
// so there is nothing left to dim.
|
||||
//
|
||||
// The second half of this round is where that form appears at all. The phone block's
|
||||
// own comment has always said why the editor becomes a bottom sheet — "the seat editor
|
||||
// lives far enough down the page that tapping a seat looks like it did nothing" — but
|
||||
// the sheet was scoped to 700px while #holdemLayout goes to one column at 900. The two
|
||||
// tiers in between had precisely the fault the sheet was written to fix. Measured,
|
||||
// tapping a seat:
|
||||
//
|
||||
// 844x390 the editor drew in the flow at y=369 in a 390px window: 21px of 401 on
|
||||
// screen, 5% of it, at scrollY 0 with nothing scrolling it into view.
|
||||
// 768x1024 31% visible, the rest below the fold, and again no scroll.
|
||||
//
|
||||
// Afterwards every viewport from 360x640 to 900x500 shows 97-99% of the sheet, and
|
||||
// above 900 the editor is a static block at the top of a sidebar that is beside the
|
||||
// table rather than below it — 100% visible there too.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { Render, makeTable } = require("./load");
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
|
||||
const PAGE = fs.readFileSync(path.join(ROOT, "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
|
||||
const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
||||
|
||||
const seatForm = () => {
|
||||
const at = PAGE.indexOf('<template id="seatEditorTemplate">');
|
||||
assert.ok(at >= 0, "there is no seat editor template");
|
||||
return PAGE.slice(at, PAGE.indexOf("</template>", at));
|
||||
};
|
||||
|
||||
const ruleFor = selector => {
|
||||
const at = CSS.indexOf(`\n${selector} {`);
|
||||
assert.ok(at >= 0, `there is no \`${selector}\` rule`);
|
||||
return CSS.slice(at, CSS.indexOf("}", at));
|
||||
};
|
||||
|
||||
// Everything inside every block with this query, braces balanced and joined. Every one
|
||||
// of them: this stylesheet opens `@media (hover: none)` twice, and a helper that stops
|
||||
// at the first has twice caught a test asserting about the wrong half of the file.
|
||||
function blockAt(query) {
|
||||
const found = [];
|
||||
for (let at = CSS.indexOf(query); at >= 0; at = CSS.indexOf(query, at + 1)) {
|
||||
let depth = 0;
|
||||
for (let i = at + query.length - 1; i < CSS.length; i++) {
|
||||
if (CSS[i] === "{") depth++;
|
||||
else if (CSS[i] === "}" && --depth === 0) { found.push(CSS.slice(at, i + 1)); break; }
|
||||
}
|
||||
}
|
||||
assert.ok(found.length, `there is no \`${query}\` block`);
|
||||
return found.join("\n");
|
||||
}
|
||||
|
||||
// The three buttons, and enough of a form to hand them back.
|
||||
function fakeForm() {
|
||||
const options = Render.SEAT_STATES.map(state => ({
|
||||
dataset: { seatState: state.key },
|
||||
attrs: {},
|
||||
tabIndex: 0,
|
||||
handlers: {},
|
||||
setAttribute(name, value) { this.attrs[name] = value; },
|
||||
getAttribute(name) { return this.attrs[name]; },
|
||||
addEventListener(type, fn) { this.handlers[type] = fn; },
|
||||
focus() { options.focused = this.dataset.seatState; },
|
||||
}));
|
||||
return {
|
||||
options,
|
||||
querySelectorAll: selector => {
|
||||
assert.equal(selector, ".seatStateOption");
|
||||
return options;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const checked = form => form.options.filter(o => o.getAttribute("aria-checked") === "true")
|
||||
.map(o => o.dataset.seatState);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("three states, not two booleans", () => {
|
||||
test("the markup asks one question with three answers", () => {
|
||||
const form = seatForm();
|
||||
const at = form.indexOf('class="seatState"');
|
||||
assert.ok(at >= 0, "the seat form has no state control");
|
||||
const group = form.slice(form.lastIndexOf("<div", at), form.indexOf("</div>", at));
|
||||
assert.match(group, /role="radiogroup"/);
|
||||
assert.match(group, /aria-label="This seat"/);
|
||||
["out", "in", "folded"].forEach(key => {
|
||||
assert.match(group, new RegExp(`data-seat-state="${key}"`), `there is no "${key}" answer`);
|
||||
});
|
||||
assert.equal((group.match(/role="radio"/g) || []).length, 3);
|
||||
});
|
||||
|
||||
test("and the two checkboxes are gone from the page and the stylesheet", () => {
|
||||
["inHandCheckbox", "foldCheckbox", "inHandToggle", "foldToggle", "seatCardToggles"]
|
||||
.forEach(name => {
|
||||
assert.ok(!PAGE.includes(name), `${name} is still in the markup`);
|
||||
assert.ok(!CSS.includes(name), `${name} is still in the stylesheet`);
|
||||
});
|
||||
});
|
||||
|
||||
// The pair (out, folded) is the one the old control could be asked for.
|
||||
test("every state names both halves, and none of them is the meaningless pair", () => {
|
||||
assert.deepEqual(Render.SEAT_STATES.map(s => s.key), ["out", "in", "folded"]);
|
||||
Render.SEAT_STATES.forEach(state => {
|
||||
assert.equal(typeof state.inHand, "boolean");
|
||||
assert.equal(typeof state.folded, "boolean");
|
||||
assert.ok(!(state.folded && !state.inHand),
|
||||
`"${state.key}" is folded and not in the hand, which means nothing`);
|
||||
});
|
||||
});
|
||||
|
||||
test("and a seat reads back as the state it was put in", () => {
|
||||
Render.SEAT_STATES.forEach(state => {
|
||||
assert.equal(Render.seatStateOf({ inHand: state.inHand, folded: state.folded }), state.key);
|
||||
});
|
||||
// Including a seat saved before any of this, where folded was left set on a
|
||||
// chair that had sat out.
|
||||
assert.equal(Render.seatStateOf({ inHand: false, folded: true }), "out");
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the control draws", () => {
|
||||
test("exactly one answer is marked, and it is the seat's", () => {
|
||||
const form = fakeForm();
|
||||
Render.paintSeatState(form, { inHand: true, folded: true });
|
||||
assert.deepEqual(checked(form), ["folded"]);
|
||||
Render.paintSeatState(form, { inHand: false, folded: false });
|
||||
assert.deepEqual(checked(form), ["out"]);
|
||||
Render.paintSeatState(form, { inHand: true, folded: false });
|
||||
assert.deepEqual(checked(form), ["in"]);
|
||||
});
|
||||
|
||||
// One stop for the group, arrows inside it — the APG's radio group, and the same
|
||||
// shape the card picker's suits use.
|
||||
test("and exactly one of the three is a Tab stop", () => {
|
||||
const form = fakeForm();
|
||||
Render.paintSeatState(form, { inHand: true, folded: false });
|
||||
assert.deepEqual(form.options.map(o => o.tabIndex), [-1, 0, -1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what pressing one does", () => {
|
||||
const wired = player => {
|
||||
const form = fakeForm();
|
||||
const commits = [];
|
||||
const realCommit = Render.commitSeatChange;
|
||||
Render.commitSeatChange = () => commits.push(true);
|
||||
try {
|
||||
Render.wireSeatState(form, player, 0);
|
||||
} finally {
|
||||
Render.commitSeatChange = realCommit;
|
||||
}
|
||||
return { form, commits, press: key => {
|
||||
const btn = form.options.find(o => o.dataset.seatState === key);
|
||||
const again = Render.commitSeatChange;
|
||||
Render.commitSeatChange = () => commits.push(true);
|
||||
try { btn.handlers.click(); } finally { Render.commitSeatChange = again; }
|
||||
} };
|
||||
};
|
||||
|
||||
test("it writes both halves of the seat, not one", () => {
|
||||
const player = { inHand: true, folded: false };
|
||||
const { press } = wired(player);
|
||||
press("folded");
|
||||
assert.deepEqual({ inHand: player.inHand, folded: player.folded }, { inHand: true, folded: true });
|
||||
press("out");
|
||||
assert.deepEqual({ inHand: player.inHand, folded: player.folded }, { inHand: false, folded: false });
|
||||
press("in");
|
||||
assert.deepEqual({ inHand: player.inHand, folded: player.folded }, { inHand: true, folded: false });
|
||||
});
|
||||
|
||||
test("and says so, so the table redraws", () => {
|
||||
const player = { inHand: true, folded: false };
|
||||
const { commits, press } = wired(player);
|
||||
const before = commits.length;
|
||||
press("out");
|
||||
assert.ok(commits.length > before, "nothing was committed, so the felt still shows the old state");
|
||||
});
|
||||
|
||||
// Typing a name into an empty chair seats somebody. The control has to say so —
|
||||
// it is the only thing on the form that shows it.
|
||||
test("naming an empty chair repaints the control rather than leaving it stale", () => {
|
||||
const src = fs.readFileSync(
|
||||
path.join(ROOT, "wwwroot", "js", "casino", "holdem", "render.js"), "utf8");
|
||||
const at = src.indexOf("const wasEmpty = !player.name.trim()");
|
||||
assert.ok(at >= 0);
|
||||
assert.match(src.slice(at, at + 400), /this\.paintSeatState\(form, player\);/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("how it is drawn", () => {
|
||||
// The answer moves between the three as the hand goes on, and a segment that
|
||||
// resizes under the thumb reads as the control flinching.
|
||||
test("three equal columns, whatever the words in them", () => {
|
||||
assert.match(ruleFor(".seatState"), /grid-template-columns:\s*repeat\(3, minmax\(0, 1fr\)\)/);
|
||||
});
|
||||
|
||||
test("the chosen one is the one that looks pressed", () => {
|
||||
assert.match(ruleFor('.seatStateOption[aria-checked="true"]'), /background:\s*var\(--surface-3\)/);
|
||||
assert.match(ruleFor('.seatStateOption[aria-checked="true"]'), /color:\s*var\(--text-bright\)/);
|
||||
});
|
||||
|
||||
// The same red the seat menu's own Fold wears, and only when it is the answer: a red
|
||||
// segment sitting unpressed beside two grey ones reads as a warning about the seat.
|
||||
test("folded is red, and only once it is chosen", () => {
|
||||
const rule = ruleFor('.seatStateOption[data-seat-state="folded"][aria-checked="true"]');
|
||||
assert.match(rule, /#3d1f1f/);
|
||||
assert.ok(!/\n\.seatStateOption\[data-seat-state="folded"\] \{/.test(CSS),
|
||||
"an unchosen Folded segment is painted red");
|
||||
});
|
||||
|
||||
test("and a thumb gets its 44px wherever there is no pointer", () => {
|
||||
assert.match(blockAt("@media (hover: none) {"), /\.seatStateOption \{\s*min-height: 44px;/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("where the form appears at all", () => {
|
||||
// The sheet exists because a stacked sidebar puts the editor far below the felt.
|
||||
// That is true at every width where the layout is one column.
|
||||
test("the sheet is scoped to the breakpoint that stacks the layout", () => {
|
||||
const oneColumn = blockAt("@media (max-width: 900px) {");
|
||||
assert.match(oneColumn, /#holdemLayout \{\s*grid-template-columns: 1fr;/,
|
||||
"900px is no longer where the sidebar stops being a column beside the table");
|
||||
const sheet = CSS.slice(CSS.indexOf("---- Seat editor as a bottom sheet ----"));
|
||||
const query = sheet.slice(sheet.indexOf("@media"), sheet.indexOf("{", sheet.indexOf("@media")) + 1);
|
||||
assert.equal(query.trim(), "@media (max-width: 900px) {");
|
||||
});
|
||||
|
||||
test("and it is a sheet, fixed to the bottom, not a block in the flow", () => {
|
||||
const sheet = CSS.slice(CSS.indexOf("---- Seat editor as a bottom sheet ----"));
|
||||
const at = sheet.indexOf(" #seatEditorPanel {");
|
||||
assert.ok(at >= 0, "the sheet block no longer positions the panel");
|
||||
const rule = sheet.slice(at, sheet.indexOf("}", at));
|
||||
assert.match(rule, /position:\s*fixed/);
|
||||
assert.match(rule, /bottom:\s*0/);
|
||||
assert.match(rule, /max-height:\s*74vh/);
|
||||
});
|
||||
|
||||
test("what was measured is written beside the query", () => {
|
||||
const note = CSS.slice(CSS.indexOf("---- Seat editor as a bottom sheet ----"),
|
||||
CSS.indexOf("@media (max-width: 900px) {", CSS.indexOf("---- Seat editor as a bottom sheet ----")));
|
||||
assert.match(note, /844x390/);
|
||||
assert.match(note, /768x1024/);
|
||||
assert.match(note, /5% of it/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user