Hold'em: every seat on the ring is the same seat, and the ring is measured before it is believed #122

Open
jheaps wants to merge 27 commits from polish/holdem-seats-one-size into master
5 changed files with 492 additions and 37 deletions
Showing only changes of commit e9ec4afafe - Show all commits
+10 -1
View File
@@ -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>
+69 -1
View File
@@ -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");
+245
View File
@@ -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/);
});
});
+35 -19
View File
@@ -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) {"]