Hold'em: every seat on the ring is the same seat, and the ring is measured before it is believed #122
@@ -760,7 +760,16 @@
|
|||||||
that filling it doesn't shove fifty-two cards down the screen under a thumb
|
that filling it doesn't shove fifty-two cards down the screen under a thumb
|
||||||
that is already reaching for one. *@
|
that is already reaching for one. *@
|
||||||
<p id="cardPickerNote" class="pickerNote" role="status"></p>
|
<p id="cardPickerNote" class="pickerNote" role="status"></p>
|
||||||
<div id="cardPickerGrid"></div>
|
@* The first of the two questions a card is. A radiogroup rather than a tablist:
|
||||||
|
the grid below is not a different panel of content, it is the same thirteen
|
||||||
|
controls filtered, and single-select filtering is what a radio group is for.
|
||||||
|
The suits are a fixed set written by paint(), not here, so that the one place
|
||||||
|
that knows what a suit looks like stays CardPicker.SUITS. *@
|
||||||
|
<div id="cardPickerSuits" role="radiogroup" aria-label="Suit"></div>
|
||||||
|
@* Which suit's thirteen these are, said once for a screen reader rather than
|
||||||
|
left to be inferred from the chosen radio two elements up. *@
|
||||||
|
<span id="cardPickerGridLabel" class="visuallyHidden"></span>
|
||||||
|
<div id="cardPickerGrid" role="group" aria-labelledby="cardPickerGridLabel"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2664,9 +2664,70 @@ input[type="checkbox"] {
|
|||||||
color: #e07b7b;
|
color: #e07b7b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The first of the two questions. Four, side by side, in the same four places every
|
||||||
|
time the sheet opens — a row a thumb can learn, rather than a control to read. */
|
||||||
|
#cardPickerSuits {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: var(--sp-2);
|
||||||
|
margin-bottom: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pickerSuitTab {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 48px;
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text-bright);
|
||||||
|
font-family: inherit;
|
||||||
|
/* Larger than any type on the sheet. The glyph is the whole of the control, and at
|
||||||
|
body size four of them across a phone read as decoration on a row of blank
|
||||||
|
buttons rather than as the thing being chosen. */
|
||||||
|
font-size: var(--fs-xl);
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s ease, border-color 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Red whether or not it is the chosen one. Drawing the three unchosen suits in one grey
|
||||||
|
made a dealer reaching for the hearts read four glyphs to find them, which is the
|
||||||
|
search this sheet exists to remove — and it is the one thing about a suit that can be
|
||||||
|
seen before the shape is. The same red the slots above already use. */
|
||||||
|
.pickerSuitTab.cardRed {
|
||||||
|
color: #e07b7b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The chosen suit is drawn as the cards below it are — white face, dark ink — because
|
||||||
|
it is a statement about them: these thirteen are that suit. Anything quieter and the
|
||||||
|
row reads as four buttons with one of them slightly darker. */
|
||||||
|
.pickerSuitTab.chosen {
|
||||||
|
border-color: #d5d5d5;
|
||||||
|
background: #fdfdfd;
|
||||||
|
color: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pickerSuitTab.cardRed.chosen {
|
||||||
|
color: #b32020;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.pickerSuitTab:hover:not(.chosen) {
|
||||||
|
border-color: var(--line-strong);
|
||||||
|
color: var(--text-bright);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* As many ranks across as the row holds, rather than a fixed count that is either too
|
||||||
|
many for a 360px phone or too few for one held sideways. 3.25rem is the floor a card
|
||||||
|
is allowed to reach: 52px, comfortably over the 44 a thumb needs, which comes out at
|
||||||
|
five across in portrait and nine or more in landscape. The arrows read the column
|
||||||
|
count back off this rule rather than assuming one — see CardPicker.gridStride. */
|
||||||
#cardPickerGrid {
|
#cardPickerGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(auto-fill, minmax(3.25rem, 1fr));
|
||||||
gap: var(--sp-1);
|
gap: var(--sp-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6050,6 +6111,13 @@ body.hasActionBar #toastHost {
|
|||||||
column-gap: var(--sp-3);
|
column-gap: var(--sp-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The card picker is six pixels over on the shortest phone in the list — 322px of
|
||||||
|
content in the 317 that 88vh comes to at 360. The same seven points the seat menu
|
||||||
|
takes buy 14, which is the whole of it. */
|
||||||
|
#cardPickerSheet {
|
||||||
|
max-height: 92vh;
|
||||||
|
}
|
||||||
|
|
||||||
/* The move picker is a list of chairs and has no hand tier at all, so there is
|
/* The move picker is a list of chairs and has no hand tier at all, so there is
|
||||||
nothing to put in the right-hand column and a column reserved for nothing would
|
nothing to put in the right-hand column and a column reserved for nothing would
|
||||||
narrow the names for no reason. One column, and the chairs spread across it. */
|
narrow the names for no reason. One column, and the chairs spread across it. */
|
||||||
|
|||||||
@@ -2,23 +2,51 @@
|
|||||||
//
|
//
|
||||||
// Typing "AH" into a two-character box is fine at a desk and miserable at a table:
|
// Typing "AH" into a two-character box is fine at a desk and miserable at a table:
|
||||||
// it needs a keyboard, it accepts nonsense like "ZZ", and it happily lets the same
|
// it needs a keyboard, it accepts nonsense like "ZZ", and it happily lets the same
|
||||||
// card land in two players' hands. The picker is one tap per card, greys out cards
|
// card land in two players' hands. The picker greys out cards already dealt somewhere
|
||||||
// already dealt somewhere else at the table, and stores exactly the same two-character
|
// else at the table and stores exactly the same two-character string the rest of the
|
||||||
// string the rest of the tool has always read.
|
// tool has always read.
|
||||||
|
//
|
||||||
|
// It used to put all fifty-two on the screen at once, four suits across and thirteen
|
||||||
|
// ranks down, and that grid is 646px tall. Measured with the board tracked and seven
|
||||||
|
// cards out:
|
||||||
|
//
|
||||||
|
// 640x360 816px of content in a 317px sheet — 40 of the 52 below the fold
|
||||||
|
// 667x375 816 in 330 — 40 below
|
||||||
|
// 844x390 774 in 343 — 36 below
|
||||||
|
// 375x667 816 in 587 — 20 below
|
||||||
|
// 1280x900 774 in 775 — none
|
||||||
|
//
|
||||||
|
// So on every phone the tool is actually used on, entering the flop began with a
|
||||||
|
// scroll, and a dealer looking for the nine of diamonds was reading down a column.
|
||||||
|
// Fifty-two 44px targets do not fit a phone in any arrangement: thirteen across is
|
||||||
|
// 572px of grid before gaps, which is wider than any phone in portrait.
|
||||||
|
//
|
||||||
|
// A card is not one choice out of fifty-two. It is two: a suit and a rank. So the sheet
|
||||||
|
// asks them in that order — four suits on a row, then the thirteen ranks of the one
|
||||||
|
// chosen — which is seventeen targets instead of fifty-two, all of them on the screen
|
||||||
|
// at once on every phone, and none of them smaller than they were. The suit stays put
|
||||||
|
// between picks, so a second card of the same suit is still a single tap.
|
||||||
const CardPicker = {
|
const CardPicker = {
|
||||||
RANKS: ["A", "K", "Q", "J", "T", "9", "8", "7", "6", "5", "4", "3", "2"],
|
RANKS: ["A", "K", "Q", "J", "T", "9", "8", "7", "6", "5", "4", "3", "2"],
|
||||||
SUITS: [
|
SUITS: [
|
||||||
{ code: "S", symbol: "♠", red: false },
|
{ code: "S", symbol: "♠", name: "Spades", red: false },
|
||||||
{ code: "H", symbol: "♥", red: true },
|
{ code: "H", symbol: "♥", name: "Hearts", red: true },
|
||||||
{ code: "D", symbol: "♦", red: true },
|
{ code: "D", symbol: "♦", name: "Diamonds", red: true },
|
||||||
{ code: "C", symbol: "♣", red: false },
|
{ code: "C", symbol: "♣", name: "Clubs", red: false },
|
||||||
],
|
],
|
||||||
|
|
||||||
// Which card in the grid the arrows are holding, as a flat index into
|
// Which card in the grid the arrows are holding, as an index into the thirteen
|
||||||
// RANKS x SUITS. Kept across a redraw so the grid doesn't send the dealer back to
|
// ranks on show. Kept across a redraw so the grid doesn't send the dealer back to
|
||||||
// the ace of spades every time they pick something.
|
// the ace every time they pick something — and kept across a change of suit, which
|
||||||
|
// is the same rank in a different suit rather than a new place to be.
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
|
|
||||||
|
// Which suit's thirteen are on the grid. Deliberately not reset when the sheet
|
||||||
|
// opens: a dealer entering a flush board, or two suited hole cards, picks the suit
|
||||||
|
// once. Where the slot being filled already holds a card, that card's suit wins —
|
||||||
|
// the sheet should open showing what it is about to replace.
|
||||||
|
suit: "S",
|
||||||
|
|
||||||
state: null,
|
state: null,
|
||||||
hooks: null,
|
hooks: null,
|
||||||
// Which set of slots is being filled: a seat's two hole cards, or the five
|
// Which set of slots is being filled: a seat's two hole cards, or the five
|
||||||
@@ -36,6 +64,7 @@ const CardPicker = {
|
|||||||
this.seatIndex = seatIndex;
|
this.seatIndex = seatIndex;
|
||||||
this.cardIndex = cardIndex;
|
this.cardIndex = cardIndex;
|
||||||
this.cursor = 0;
|
this.cursor = 0;
|
||||||
|
this.aimSuit();
|
||||||
this.render();
|
this.render();
|
||||||
document.getElementById("cardPickerOverlay").hidden = false;
|
document.getElementById("cardPickerOverlay").hidden = false;
|
||||||
},
|
},
|
||||||
@@ -51,6 +80,7 @@ const CardPicker = {
|
|||||||
this.boardLimit = Math.max(1, Math.min(5, limit));
|
this.boardLimit = Math.max(1, Math.min(5, limit));
|
||||||
this.cardIndex = Math.min(cardIndex, this.boardLimit - 1);
|
this.cardIndex = Math.min(cardIndex, this.boardLimit - 1);
|
||||||
this.cursor = 0;
|
this.cursor = 0;
|
||||||
|
this.aimSuit();
|
||||||
this.render();
|
this.render();
|
||||||
document.getElementById("cardPickerOverlay").hidden = false;
|
document.getElementById("cardPickerOverlay").hidden = false;
|
||||||
},
|
},
|
||||||
@@ -145,10 +175,10 @@ const CardPicker = {
|
|||||||
return ["Flop 1", "Flop 2", "Flop 3", "Turn", "River"][slot] || `Card ${slot + 1}`;
|
return ["Flop 1", "Flop 2", "Flop 3", "Turn", "River"][slot] || `Card ${slot + 1}`;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Every card in the deck, in the order the grid draws them: four suits across,
|
// Every card in the deck, ace down to two, each rank's four suits together. The
|
||||||
// thirteen ranks down. The arrow keys and the roving tabindex both work in this
|
// grid shows one suit's worth of this at a time — see gridCards — and the duplicate
|
||||||
// order, so it is written once rather than rebuilt by two nested loops that have to
|
// check reads the whole of it, so the order is written once here rather than
|
||||||
// agree.
|
// rebuilt by two nested loops that have to agree.
|
||||||
deck() {
|
deck() {
|
||||||
const cards = [];
|
const cards = [];
|
||||||
this.RANKS.forEach(rank => {
|
this.RANKS.forEach(rank => {
|
||||||
@@ -157,6 +187,64 @@ const CardPicker = {
|
|||||||
return cards;
|
return cards;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
suitOf(code) {
|
||||||
|
return this.SUITS.find(suit => suit.code === code) || this.SUITS[0];
|
||||||
|
},
|
||||||
|
|
||||||
|
// The thirteen on the grid: one suit's worth of deck(), in deck order, so the ranks
|
||||||
|
// read A down to 2 exactly as they always have.
|
||||||
|
gridCards() {
|
||||||
|
return this.deck().filter(entry => entry.suit.code === this.suit);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Open on the card that is already there. A slot holding the nine of diamonds
|
||||||
|
// should show the diamonds with the nine under the cursor — the dealer came here to
|
||||||
|
// change that card, and hunting for where it lives is work the sheet can do. An
|
||||||
|
// empty slot changes nothing, which is what keeps the suit across a flush board.
|
||||||
|
aimSuit() {
|
||||||
|
const current = (this.slotValues()[this.cardIndex] || "").toUpperCase();
|
||||||
|
if (!/^[AKQJT2-9][SHDC]$/.test(current)) return;
|
||||||
|
this.suit = current.slice(1);
|
||||||
|
const rank = this.RANKS.indexOf(current.slice(0, 1));
|
||||||
|
if (rank >= 0) this.cursor = rank;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Changing suit is not moving: the cursor is a rank, and the same rank in another
|
||||||
|
// suit is the card directly beside the one you were on. Coming back with the cursor
|
||||||
|
// reset to the ace would undo the only thing the dealer had told the sheet.
|
||||||
|
setSuit(code, { focus = false } = {}) {
|
||||||
|
if (this.suit === code) return;
|
||||||
|
this.suit = code;
|
||||||
|
this.render();
|
||||||
|
if (focus) {
|
||||||
|
const chosen = document.querySelector(`#cardPickerSuits [data-suit="${code}"]`);
|
||||||
|
if (chosen) chosen.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Arrow keys along the suits, the way the APG's radio group asks for: one Tab stop
|
||||||
|
// for the set, arrows to move within it, and moving is choosing.
|
||||||
|
onSuitKey(event) {
|
||||||
|
const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[event.key];
|
||||||
|
if (step === undefined) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const at = this.SUITS.findIndex(suit => suit.code === this.suit);
|
||||||
|
const to = (at + step + this.SUITS.length) % this.SUITS.length;
|
||||||
|
this.setSuit(this.SUITS[to].code, { focus: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
// How far Down and Up move, asked of the grid rather than assumed. The row is as
|
||||||
|
// many ranks as the width holds — five on a 360px phone, nine on a phone held
|
||||||
|
// sideways — so a constant here would be a lie on most screens, and the last time
|
||||||
|
// this number disagreed with the layout the cursor slid into a column nobody asked
|
||||||
|
// for. Outside a browser there is nothing to measure and nothing to move.
|
||||||
|
gridStride() {
|
||||||
|
const grid = typeof document !== "undefined" && document.getElementById("cardPickerGrid");
|
||||||
|
if (!grid || typeof getComputedStyle !== "function") return this.RANKS.length;
|
||||||
|
const columns = getComputedStyle(grid).gridTemplateColumns.split(/\s+/).filter(Boolean).length;
|
||||||
|
return columns > 0 ? columns : this.RANKS.length;
|
||||||
|
},
|
||||||
|
|
||||||
// Where the arrows can go: one card, in the direction asked for, and nowhere if
|
// Where the arrows can go: one card, in the direction asked for, and nowhere if
|
||||||
// that runs off the end of the grid. Wrapping would put the dealer on a different
|
// that runs off the end of the grid. Wrapping would put the dealer on a different
|
||||||
// rank without them asking to move rank.
|
// rank without them asking to move rank.
|
||||||
@@ -197,7 +285,7 @@ const CardPicker = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
onGridKey(event) {
|
onGridKey(event) {
|
||||||
const stride = this.SUITS.length;
|
const stride = this.gridStride();
|
||||||
const step = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: stride, ArrowUp: -stride }[event.key];
|
const step = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: stride, ArrowUp: -stride }[event.key];
|
||||||
if (step !== undefined) {
|
if (step !== undefined) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -266,9 +354,38 @@ const CardPicker = {
|
|||||||
slots.appendChild(btn);
|
slots.appendChild(btn);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The first question. Four of them, always in the same four places, so the one
|
||||||
|
// the dealer wants is somewhere their thumb already knows.
|
||||||
|
const suits = document.getElementById("cardPickerSuits");
|
||||||
|
suits.innerHTML = "";
|
||||||
|
this.SUITS.forEach(suit => {
|
||||||
|
const chosen = suit.code === this.suit;
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.type = "button";
|
||||||
|
btn.className = "pickerSuitTab";
|
||||||
|
btn.classList.toggle("cardRed", suit.red);
|
||||||
|
btn.classList.toggle("chosen", chosen);
|
||||||
|
btn.textContent = suit.symbol;
|
||||||
|
btn.setAttribute("role", "radio");
|
||||||
|
btn.setAttribute("aria-checked", chosen ? "true" : "false");
|
||||||
|
// The glyph is the whole of the control on the eye, and "♦, radio button"
|
||||||
|
// is not a suit out loud. The word is.
|
||||||
|
btn.setAttribute("aria-label", suit.name);
|
||||||
|
btn.dataset.suit = suit.code;
|
||||||
|
// One Tab stop for the four, like the grid below it has one for thirteen.
|
||||||
|
btn.tabIndex = chosen ? 0 : -1;
|
||||||
|
btn.dataset.focusKey = `suit-${suit.code}`;
|
||||||
|
btn.addEventListener("click", () => this.setSuit(suit.code, { focus: true }));
|
||||||
|
btn.addEventListener("keydown", event => this.onSuitKey(event));
|
||||||
|
suits.appendChild(btn);
|
||||||
|
});
|
||||||
|
|
||||||
|
const gridLabel = document.getElementById("cardPickerGridLabel");
|
||||||
|
if (gridLabel) gridLabel.textContent = `${this.suitOf(this.suit).name} — pick a rank`;
|
||||||
|
|
||||||
const grid = document.getElementById("cardPickerGrid");
|
const grid = document.getElementById("cardPickerGrid");
|
||||||
grid.innerHTML = "";
|
grid.innerHTML = "";
|
||||||
this.deck().forEach(({ rank, suit, card }) => {
|
this.gridCards().forEach(({ rank, suit, card }) => {
|
||||||
const takenBy = used.get(card);
|
const takenBy = used.get(card);
|
||||||
|
|
||||||
const btn = document.createElement("button");
|
const btn = document.createElement("button");
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
// Fifty-two cards on a phone.
|
||||||
|
//
|
||||||
|
// The picker put the whole deck on the screen at once — four suits across, thirteen
|
||||||
|
// ranks down — and that grid is 646px tall. Measured with the board tracked and seven
|
||||||
|
// cards already out:
|
||||||
|
//
|
||||||
|
// 640x360 816px of content in a 317px sheet, 40 of the 52 below the fold
|
||||||
|
// 667x375 816 in 330, 40 below
|
||||||
|
// 844x390 774 in 343, 36 below
|
||||||
|
// 375x667 816 in 587, 20 below
|
||||||
|
// 1280x900 774 in 775, none
|
||||||
|
//
|
||||||
|
// So on every phone the tool is actually used on, entering the flop began with a
|
||||||
|
// scroll, and a dealer looking for the nine of diamonds read down a column to find it.
|
||||||
|
// There is no arrangement that fixes that: fifty-two targets at the 44px this sweep
|
||||||
|
// holds everything else to need 572px of width before gaps if they are thirteen across,
|
||||||
|
// which is wider than any phone in portrait, and thirteen rows deep if they are four.
|
||||||
|
//
|
||||||
|
// A card is not one choice out of fifty-two. It is two — a suit and a rank — and that
|
||||||
|
// is how a dealer says it out loud. So the sheet asks them in that order: four suits on
|
||||||
|
// a row, then the thirteen ranks of the one chosen. Seventeen targets instead of
|
||||||
|
// fifty-two, none of them smaller than they were, and measured afterwards at eight
|
||||||
|
// viewports from 360x640 to 1280x900: nothing below the fold, nothing scrolling, every
|
||||||
|
// button still at least 44x44 and still hit-testing as itself.
|
||||||
|
//
|
||||||
|
// The second tap is not a second cost in the usual case. The suit stays where it was
|
||||||
|
// put, so a second card of the same suit is one tap — and picking one card used to be
|
||||||
|
// one tap plus a scroll through thirteen rows.
|
||||||
|
const test = require("node:test");
|
||||||
|
const { describe } = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const { CardPicker } = 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 SRC = fs.readFileSync(path.join(ROOT, "wwwroot", "js", "casino", "holdem", "cardPicker.js"), "utf8");
|
||||||
|
|
||||||
|
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));
|
||||||
|
};
|
||||||
|
|
||||||
|
const methodBody = name => {
|
||||||
|
const at = SRC.indexOf(`\n ${name}`);
|
||||||
|
assert.ok(at >= 0, `cardPicker has no ${name}`);
|
||||||
|
return SRC.slice(at, SRC.indexOf("\n },", at));
|
||||||
|
};
|
||||||
|
|
||||||
|
// The picker reaches for the global document in two places. Neither is exercised here —
|
||||||
|
// these tests ask what it decides, not what it draws — but aimSuit reads the slot it is
|
||||||
|
// about to fill, so it needs the state and nothing else.
|
||||||
|
function aimedAt(board, cardIndex, { suit = "S", cursor = 0 } = {}) {
|
||||||
|
CardPicker.state = { board: board.slice(), seats: [] };
|
||||||
|
CardPicker.target = "board";
|
||||||
|
CardPicker.boardLimit = board.length;
|
||||||
|
CardPicker.cardIndex = cardIndex;
|
||||||
|
CardPicker.suit = suit;
|
||||||
|
CardPicker.cursor = cursor;
|
||||||
|
CardPicker.aimSuit();
|
||||||
|
return { suit: CardPicker.suit, cursor: CardPicker.cursor };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("the deck is still the deck", () => {
|
||||||
|
test("all fifty-two of it, ace down to two", () => {
|
||||||
|
const deck = CardPicker.deck();
|
||||||
|
assert.equal(deck.length, 52);
|
||||||
|
assert.equal(new Set(deck.map(entry => entry.card)).size, 52);
|
||||||
|
assert.equal(deck[0].card, "AS");
|
||||||
|
assert.equal(deck[51].card, "2C");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Which is what the duplicate check reads, and why splitting the *grid* by suit
|
||||||
|
// cannot let the same card be dealt twice.
|
||||||
|
test("and a card already out is still a card already out, in any suit", () => {
|
||||||
|
CardPicker.state = {
|
||||||
|
seats: [{ name: "Mike", cards: ["AS", "KH"] }],
|
||||||
|
board: ["7D", "", "", "", ""],
|
||||||
|
};
|
||||||
|
CardPicker.target = "board";
|
||||||
|
CardPicker.boardLimit = 5;
|
||||||
|
CardPicker.cardIndex = 1;
|
||||||
|
const used = CardPicker.cardsInUse();
|
||||||
|
assert.equal(used.get("AS"), "Mike");
|
||||||
|
assert.equal(used.get("KH"), "Mike");
|
||||||
|
assert.equal(used.get("7D"), "the board");
|
||||||
|
assert.equal(used.size, 3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("one suit at a time", () => {
|
||||||
|
test("the grid is thirteen ranks of the chosen suit, in deck order", () => {
|
||||||
|
CardPicker.suit = "D";
|
||||||
|
const cards = CardPicker.gridCards();
|
||||||
|
assert.equal(cards.length, 13);
|
||||||
|
assert.deepEqual(cards.map(entry => entry.card).slice(0, 3), ["AD", "KD", "QD"]);
|
||||||
|
assert.equal(cards[12].card, "2D");
|
||||||
|
assert.ok(cards.every(entry => entry.suit.code === "D"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every suit gets the same thirteen", () => {
|
||||||
|
CardPicker.SUITS.forEach(suit => {
|
||||||
|
CardPicker.suit = suit.code;
|
||||||
|
assert.deepEqual(CardPicker.gridCards().map(entry => entry.rank), CardPicker.RANKS);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The glyph is the whole of the control on the eye, and "♦, radio button" is not a
|
||||||
|
// suit out loud.
|
||||||
|
test("and a suit has a name to be called by", () => {
|
||||||
|
assert.deepEqual(CardPicker.SUITS.map(suit => suit.name),
|
||||||
|
["Spades", "Hearts", "Diamonds", "Clubs"]);
|
||||||
|
assert.match(methodBody("paint()"), /aria-label", suit\.name/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unknown suit code falls back rather than emptying the grid", () => {
|
||||||
|
assert.equal(CardPicker.suitOf("Z").code, "S");
|
||||||
|
assert.equal(CardPicker.suitOf("H").symbol, "♥");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("where the sheet opens", () => {
|
||||||
|
// The dealer came here to change that card. Hunting for where it lives is work the
|
||||||
|
// sheet can do.
|
||||||
|
test("on the suit of the card already in the slot, with it under the cursor", () => {
|
||||||
|
assert.deepEqual(aimedAt(["7H", "2D", "TS", "", ""], 0), { suit: "H", cursor: 7 });
|
||||||
|
assert.deepEqual(aimedAt(["7H", "2D", "TS", "", ""], 1), { suit: "D", cursor: 12 });
|
||||||
|
assert.deepEqual(aimedAt(["7H", "2D", "TS", "", ""], 2), { suit: "S", cursor: 4 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Which is what makes a flush board, or two suited hole cards, one tap each after
|
||||||
|
// the first.
|
||||||
|
test("and an empty slot leaves the suit where the dealer put it", () => {
|
||||||
|
assert.deepEqual(aimedAt(["7H", "", "", "", ""], 1, { suit: "H", cursor: 7 }),
|
||||||
|
{ suit: "H", cursor: 7 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("junk in a slot is left alone rather than read as a suit", () => {
|
||||||
|
assert.deepEqual(aimedAt(["ZZ", "", "", "", ""], 0, { suit: "C", cursor: 3 }),
|
||||||
|
{ suit: "C", cursor: 3 });
|
||||||
|
assert.deepEqual(aimedAt(["", "", "", "", ""], 0, { suit: "C", cursor: 3 }),
|
||||||
|
{ suit: "C", cursor: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("both ways in ask the question before they draw", () => {
|
||||||
|
["open(state, hooks, seatIndex, cardIndex) {", "openBoard(state, hooks, cardIndex, limit = 5) {"]
|
||||||
|
.forEach(signature => {
|
||||||
|
const at = SRC.indexOf(signature);
|
||||||
|
assert.ok(at >= 0, `there is no \`${signature}\``);
|
||||||
|
assert.match(SRC.slice(at, SRC.indexOf("this.render();", at)), /this\.aimSuit\(\);/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("changing suit is not moving", () => {
|
||||||
|
// The cursor is a rank. The same rank in another suit is the card beside the one you
|
||||||
|
// were on, so coming back to the ace would undo the only thing the dealer had said.
|
||||||
|
test("the rank under the cursor survives the change", () => {
|
||||||
|
const body = methodBody("setSuit(code, { focus = false } = {}) {");
|
||||||
|
assert.ok(!/cursor/.test(body), "setSuit still has an opinion about where the cursor is");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("and asking for the suit already showing redraws nothing", () => {
|
||||||
|
const body = methodBody("setSuit(code, { focus = false } = {}) {");
|
||||||
|
assert.match(body, /if \(this\.suit === code\) return;/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The APG's radio group: one Tab stop for the set, arrows to move within it, and
|
||||||
|
// moving is choosing. Wrapping is right here and wrong in the grid below — four
|
||||||
|
// suits are a ring with no ends, thirteen ranks have two.
|
||||||
|
test("the arrows run round the four suits", () => {
|
||||||
|
const body = methodBody("onSuitKey(event)");
|
||||||
|
assert.match(body, /ArrowRight: 1.*ArrowDown: 1.*ArrowLeft: -1.*ArrowUp: -1/s);
|
||||||
|
assert.match(body, /% this\.SUITS\.length/);
|
||||||
|
assert.match(body, /event\.preventDefault\(\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("what the markup and the stylesheet have to agree about", () => {
|
||||||
|
// Not a tablist: the grid below is not another panel of content, it is the same
|
||||||
|
// thirteen controls filtered, and single-select filtering is what a radio group is.
|
||||||
|
test("the suits are a radio group with a name", () => {
|
||||||
|
const at = PAGE.indexOf('id="cardPickerSuits"');
|
||||||
|
assert.ok(at >= 0, "there is no suit row");
|
||||||
|
const tag = PAGE.slice(PAGE.lastIndexOf("<", at), PAGE.indexOf(">", at));
|
||||||
|
assert.match(tag, /role="radiogroup"/);
|
||||||
|
assert.match(tag, /aria-label="Suit"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("each suit says whether it is the chosen one, and only one is a Tab stop", () => {
|
||||||
|
const body = methodBody("paint()");
|
||||||
|
assert.match(body, /setAttribute\("role", "radio"\)/);
|
||||||
|
assert.match(body, /aria-checked", chosen \? "true" : "false"/);
|
||||||
|
assert.match(body, /tabIndex = chosen \? 0 : -1/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("and the grid says which suit's thirteen these are", () => {
|
||||||
|
const at = PAGE.indexOf('id="cardPickerGrid"');
|
||||||
|
assert.ok(at >= 0);
|
||||||
|
assert.match(PAGE.slice(at, PAGE.indexOf(">", at)), /aria-labelledby="cardPickerGridLabel"/);
|
||||||
|
assert.match(methodBody("paint()"), /gridLabel\.textContent = `\$\{this\.suitOf\(this\.suit\)\.name\}/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A row of four, in the same four places every time, so the one the dealer wants is
|
||||||
|
// somewhere their thumb already knows.
|
||||||
|
test("the suit row is four fixed columns, not as many as fit", () => {
|
||||||
|
assert.match(ruleFor("#cardPickerSuits"), /grid-template-columns:\s*repeat\(4, minmax\(0, 1fr\)\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a suit is a target before it is a glyph", () => {
|
||||||
|
assert.match(ruleFor(".pickerSuitTab"), /min-height:\s*48px/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drawing the three unchosen suits in one grey made a dealer reaching for the hearts
|
||||||
|
// read four shapes to find them, which is the search this sheet exists to remove.
|
||||||
|
test("red is red whether or not it is the one chosen", () => {
|
||||||
|
assert.match(ruleFor(".pickerSuitTab.cardRed"), /color:\s*#e07b7b/);
|
||||||
|
assert.match(ruleFor(".pickerSuitTab.chosen"), /background:\s*#fdfdfd/);
|
||||||
|
assert.match(ruleFor(".pickerSuitTab.cardRed.chosen"), /color:\s*#b32020/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Five across on a 360px phone, seven at 460, more on a phone held sideways.
|
||||||
|
test("the ranks take as many columns as the row holds", () => {
|
||||||
|
assert.match(ruleFor("#cardPickerGrid"),
|
||||||
|
/grid-template-columns:\s*repeat\(auto-fill, minmax\(3\.25rem, 1fr\)\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Which is why the arrows cannot assume a number any more.
|
||||||
|
test("so the arrows read the column count back off the layout", () => {
|
||||||
|
assert.match(methodBody("onGridKey(event)"), /const stride = this\.gridStride\(\)/);
|
||||||
|
assert.match(methodBody("gridStride()"), /getComputedStyle\(grid\)\.gridTemplateColumns/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("what was measured is written beside the code", () => {
|
||||||
|
const note = SRC.slice(0, SRC.indexOf("const CardPicker = {"));
|
||||||
|
assert.match(note, /646px/);
|
||||||
|
assert.match(note, /40 of the 52/);
|
||||||
|
assert.match(note, /572px/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -258,12 +258,11 @@ describe("a redraw that does not drop the dealer", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("fifty-two cards, one Tab stop", () => {
|
describe("thirteen cards, one Tab stop", () => {
|
||||||
test("the deck is the grid's order, written once", () => {
|
test("the deck is written once, ace down to two", () => {
|
||||||
const deck = CardPicker.deck();
|
const deck = CardPicker.deck();
|
||||||
assert.equal(deck.length, 52);
|
assert.equal(deck.length, 52);
|
||||||
assert.equal(new Set(deck.map(c => c.card)).size, 52);
|
assert.equal(new Set(deck.map(c => c.card)).size, 52);
|
||||||
// Four suits across, thirteen ranks down — which is what the arrow keys step by.
|
|
||||||
assert.deepEqual(deck.slice(0, 4).map(c => c.card), ["AS", "AH", "AD", "AC"]);
|
assert.deepEqual(deck.slice(0, 4).map(c => c.card), ["AS", "AH", "AD", "AC"]);
|
||||||
assert.equal(deck[4].card, "KS");
|
assert.equal(deck[4].card, "KS");
|
||||||
assert.equal(deck[51].card, "2C");
|
assert.equal(deck[51].card, "2C");
|
||||||
@@ -285,7 +284,7 @@ describe("fifty-two cards, one Tab stop", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
test("a step is one card, whether or not that card is already out", () => {
|
test("a step is one card, whether or not that card is already out", () => {
|
||||||
// 7C is dealt. Stepping down the clubs from the eight lands on it and says so,
|
// The seven is dealt. Stepping along from the eight lands on it and says so,
|
||||||
// rather than quietly arriving at the six.
|
// rather than quietly arriving at the six.
|
||||||
withGrid(["8C", "7C!", "6C"], () => {
|
withGrid(["8C", "7C!", "6C"], () => {
|
||||||
assert.equal(CardPicker.nextCursor(0, 1), 1);
|
assert.equal(CardPicker.nextCursor(0, 1), 1);
|
||||||
@@ -294,28 +293,29 @@ describe("fifty-two cards, one Tab stop", () => {
|
|||||||
|
|
||||||
// The whole reason the skipping had to go. Hunting for the next card that could take
|
// The whole reason the skipping had to go. Hunting for the next card that could take
|
||||||
// focus did the exact thing the no-wrap rule below was written to prevent: it moved
|
// focus did the exact thing the no-wrap rule below was written to prevent: it moved
|
||||||
// the dealer sideways without being asked. Right from the ace of spades over a dealt
|
// the dealer sideways without being asked. Right from the ace over a dealt king
|
||||||
// ace of hearts landed on the ace of diamonds, so the column silently became the one
|
// landed on the queen, so the column silently became the one to its right, and the
|
||||||
// to its right, and the next press of Down walked the king of diamonds.
|
// next press of Down walked a rank nobody had asked for.
|
||||||
test("and it never slides the dealer into a column they didn't ask for", () => {
|
test("and it never slides the dealer into a column they didn't ask for", () => {
|
||||||
withGrid(["AS", "AH!", "AD", "AC", "KS", "KH", "KD", "KC"], () => {
|
withGrid(["AS", "KS!", "QS", "JS", "TS", "9S", "8S", "7S"], () => {
|
||||||
const afterRight = CardPicker.nextCursor(0, 1);
|
const afterRight = CardPicker.nextCursor(0, 1);
|
||||||
assert.equal(afterRight, 1, "a dealt ace of hearts is still the cell to the right of the ace of spades");
|
assert.equal(afterRight, 1, "a dealt king is still the cell to the right of the ace");
|
||||||
assert.equal(CardPicker.nextCursor(afterRight, 4), 5, "and down from it is the king of the same suit");
|
assert.equal(CardPicker.nextCursor(afterRight, 4), 5,
|
||||||
|
"and down a row of four from it is the nine, four ranks along");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wrapping would answer "move me down one rank" by moving the dealer to a different
|
// Wrapping would answer "move me down a row" by moving the dealer to the other end
|
||||||
// suit, which is not what they asked and not where they would look.
|
// of the grid, which is not what they asked and not where they would look.
|
||||||
test("the edge of the grid is the edge, not a way round to the other side", () => {
|
test("the edge of the grid is the edge, not a way round to the other side", () => {
|
||||||
withGrid(["AS", "AH", "AD", "AC"], () => {
|
withGrid(["AS", "KS", "QS", "JS"], () => {
|
||||||
assert.equal(CardPicker.nextCursor(3, 1), 3);
|
assert.equal(CardPicker.nextCursor(3, 1), 3);
|
||||||
assert.equal(CardPicker.nextCursor(0, -1), 0);
|
assert.equal(CardPicker.nextCursor(0, -1), 0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("only one card is in the Tab order at a time", () => {
|
test("only one card is in the Tab order at a time", () => {
|
||||||
withGrid(["AS", "AH", "AD", "AC"], nodes => {
|
withGrid(["AS", "KS", "QS", "JS"], nodes => {
|
||||||
CardPicker.aimCursor(2);
|
CardPicker.aimCursor(2);
|
||||||
assert.deepEqual(nodes.map(n => n.tabIndex), [-1, -1, 0, -1]);
|
assert.deepEqual(nodes.map(n => n.tabIndex), [-1, -1, 0, -1]);
|
||||||
});
|
});
|
||||||
@@ -325,7 +325,7 @@ describe("fifty-two cards, one Tab stop", () => {
|
|||||||
// become a dealt card. It stays there, because that cell is still a cell and its
|
// become a dealt card. It stays there, because that cell is still a cell and its
|
||||||
// neighbours are still where they were a moment ago.
|
// neighbours are still where they were a moment ago.
|
||||||
test("a cursor left on a dealt card stays on it", () => {
|
test("a cursor left on a dealt card stays on it", () => {
|
||||||
withGrid(["AS!", "AH", "AD", "AC"], nodes => {
|
withGrid(["AS!", "KS", "QS", "JS"], nodes => {
|
||||||
CardPicker.aimCursor(0);
|
CardPicker.aimCursor(0);
|
||||||
assert.equal(CardPicker.cursor, 0);
|
assert.equal(CardPicker.cursor, 0);
|
||||||
assert.deepEqual(nodes.map(n => n.tabIndex), [0, -1, -1, -1]);
|
assert.deepEqual(nodes.map(n => n.tabIndex), [0, -1, -1, -1]);
|
||||||
@@ -333,7 +333,7 @@ describe("fifty-two cards, one Tab stop", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("and a grid with nothing left in it still has somewhere to put the cursor", () => {
|
test("and a grid with nothing left in it still has somewhere to put the cursor", () => {
|
||||||
withGrid(["2S!", "2H!", "2D!", "2C!"], nodes => {
|
withGrid(["5S!", "4S!", "3S!", "2S!"], nodes => {
|
||||||
CardPicker.aimCursor(3);
|
CardPicker.aimCursor(3);
|
||||||
assert.equal(CardPicker.cursor, 3);
|
assert.equal(CardPicker.cursor, 3);
|
||||||
assert.deepEqual(nodes.map(n => n.tabIndex), [-1, -1, -1, 0]);
|
assert.deepEqual(nodes.map(n => n.tabIndex), [-1, -1, -1, 0]);
|
||||||
@@ -344,23 +344,39 @@ describe("fifty-two cards, one Tab stop", () => {
|
|||||||
// Home and End lean on: they ask for one card past either end and take what they
|
// Home and End lean on: they ask for one card past either end and take what they
|
||||||
// are given.
|
// are given.
|
||||||
test("Home and End ask past the ends and land on them", () => {
|
test("Home and End ask past the ends and land on them", () => {
|
||||||
withGrid(["AS", "AH", "AD", "AC"], () => {
|
withGrid(["AS", "KS", "QS", "JS"], () => {
|
||||||
assert.equal(CardPicker.nextCursor(-1, 1), 0);
|
assert.equal(CardPicker.nextCursor(-1, 1), 0);
|
||||||
assert.equal(CardPicker.nextCursor(4, -1), 3);
|
assert.equal(CardPicker.nextCursor(4, -1), 3);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the arrows step by a row of suits, not by a guess", () => {
|
// It used to step by `SUITS.length`, because the grid was four suits wide at every
|
||||||
|
// size. The grid is thirteen ranks of one suit now and takes as many columns as the
|
||||||
|
// width holds — five on a 360px phone, seven at 460 — so a constant would be wrong
|
||||||
|
// on most screens, which is the thing that slid the cursor into the wrong column
|
||||||
|
// the last time these two disagreed. It asks the layout instead.
|
||||||
|
test("the arrows step by the row the grid actually has, not by a guess", () => {
|
||||||
const picker = src("cardPicker");
|
const picker = src("cardPicker");
|
||||||
const at = picker.indexOf("onGridKey(event)");
|
const at = picker.indexOf("onGridKey(event)");
|
||||||
assert.ok(at >= 0, "the grid has no arrow keys");
|
assert.ok(at >= 0, "the grid has no arrow keys");
|
||||||
const body = picker.slice(at, picker.indexOf("\n },", at));
|
const body = picker.slice(at, picker.indexOf("\n },", at));
|
||||||
assert.match(body, /const stride = this\.SUITS\.length/);
|
assert.match(body, /const stride = this\.gridStride\(\)/);
|
||||||
assert.match(body, /ArrowRight: 1.*ArrowLeft: -1.*ArrowDown: stride.*ArrowUp: -stride/s);
|
assert.match(body, /ArrowRight: 1.*ArrowLeft: -1.*ArrowDown: stride.*ArrowUp: -stride/s);
|
||||||
assert.match(body, /"Home"/);
|
assert.match(body, /"Home"/);
|
||||||
assert.match(body, /"End"/);
|
assert.match(body, /"End"/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("and the row it actually has is read off the grid", () => {
|
||||||
|
const picker = src("cardPicker");
|
||||||
|
const at = picker.indexOf("gridStride() {");
|
||||||
|
assert.ok(at >= 0, "there is no gridStride");
|
||||||
|
const body = picker.slice(at, picker.indexOf("\n },", at));
|
||||||
|
assert.match(body, /getComputedStyle\(grid\)\.gridTemplateColumns/);
|
||||||
|
// Outside a browser there is nothing to measure, and thirteen ranks in one row
|
||||||
|
// is the shape that makes Up and Down do nothing rather than something wrong.
|
||||||
|
assert.match(body, /return this\.RANKS\.length/);
|
||||||
|
});
|
||||||
|
|
||||||
test("a fresh sheet starts at the top of the deck", () => {
|
test("a fresh sheet starts at the top of the deck", () => {
|
||||||
const picker = src("cardPicker");
|
const picker = src("cardPicker");
|
||||||
["open(state, hooks, seatIndex, cardIndex) {", "openBoard(state, hooks, cardIndex, limit = 5) {"]
|
["open(state, hooks, seatIndex, cardIndex) {", "openBoard(state, hooks, cardIndex, limit = 5) {"]
|
||||||
|
|||||||
Reference in New Issue
Block a user