Ask a card the two questions it is, not one out of fifty-two
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 was reading
down a column to find it. No arrangement 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. 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. Portrait content went 816 -> 372,
landscape 816 -> 322.
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 one
tap is what picking a card used to cost *plus* a scroll through thirteen
rows. Opening on a slot that already holds a card shows that card's suit
with it under the cursor, which is the case where the dealer came to
change something rather than add it.
Three things that had to move with it:
- the cursor is a rank now, 0-12, and changing suit deliberately leaves
it alone: the same rank in another suit is the card beside the one
you were on.
- the arrows read the column count off the layout instead of assuming
SUITS.length. The rank row is auto-fill — five across on a 360px
phone, seven at 460 — and the last time this number disagreed with
the grid it slid the cursor into a column nobody asked for.
- the suits are a radiogroup, not a tablist: the grid below is not
another panel of content, it is the same thirteen controls filtered,
and the unchosen red suits stay red, because reading four shapes to
find the hearts is the search this sheet exists to remove.
deck() still returns all fifty-two and the duplicate check still reads
all of it, so splitting the grid by suit cannot let a card be dealt
twice.
1276 tests pass; the layout sweep reports clean.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
This commit is contained in:
@@ -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", () => {
|
||||
test("the deck is the grid's order, written once", () => {
|
||||
describe("thirteen cards, one Tab stop", () => {
|
||||
test("the deck is written once, ace down to two", () => {
|
||||
const deck = CardPicker.deck();
|
||||
assert.equal(deck.length, 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.equal(deck[4].card, "KS");
|
||||
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", () => {
|
||||
// 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.
|
||||
withGrid(["8C", "7C!", "6C"], () => {
|
||||
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
|
||||
// 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
|
||||
// ace of hearts landed on the ace of diamonds, so the column silently became the one
|
||||
// to its right, and the next press of Down walked the king of diamonds.
|
||||
// the dealer sideways without being asked. Right from the ace over a dealt king
|
||||
// landed on the queen, so the column silently became the one to its right, and the
|
||||
// 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", () => {
|
||||
withGrid(["AS", "AH!", "AD", "AC", "KS", "KH", "KD", "KC"], () => {
|
||||
withGrid(["AS", "KS!", "QS", "JS", "TS", "9S", "8S", "7S"], () => {
|
||||
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(CardPicker.nextCursor(afterRight, 4), 5, "and down from it is the king of the same suit");
|
||||
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 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
|
||||
// suit, which is not what they asked and not where they would look.
|
||||
// Wrapping would answer "move me down a row" by moving the dealer to the other end
|
||||
// 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", () => {
|
||||
withGrid(["AS", "AH", "AD", "AC"], () => {
|
||||
withGrid(["AS", "KS", "QS", "JS"], () => {
|
||||
assert.equal(CardPicker.nextCursor(3, 1), 3);
|
||||
assert.equal(CardPicker.nextCursor(0, -1), 0);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
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
|
||||
// neighbours are still where they were a moment ago.
|
||||
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);
|
||||
assert.equal(CardPicker.cursor, 0);
|
||||
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", () => {
|
||||
withGrid(["2S!", "2H!", "2D!", "2C!"], nodes => {
|
||||
withGrid(["5S!", "4S!", "3S!", "2S!"], nodes => {
|
||||
CardPicker.aimCursor(3);
|
||||
assert.equal(CardPicker.cursor, 3);
|
||||
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
|
||||
// are given.
|
||||
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(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 at = picker.indexOf("onGridKey(event)");
|
||||
assert.ok(at >= 0, "the grid has no arrow keys");
|
||||
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, /"Home"/);
|
||||
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", () => {
|
||||
const picker = src("cardPicker");
|
||||
["open(state, hooks, seatIndex, cardIndex) {", "openBoard(state, hooks, cardIndex, limit = 5) {"]
|
||||
|
||||
Reference in New Issue
Block a user