Nine chairs, and until this no two of them were the same size. Measured at 1280x900 with five players seated: 64px for Bob, 70 for Christopher because his name wrapped, 71 for Alexandra because she had chips out, 89 for "Mike from work" who had both. The ragged edge was the smaller half of it. The larger half is that the sizes moved. A puck grew 17px the moment its player put money in and shrank again when the street closed, so the felt reflowed twice a hand under the thumb of someone trying to tap it — and because the ring is sized against the tallest puck on it, the whole table could resize with it. Three rows are held open instead: the name, the amount, and the stack. The name gets one line whatever it says, the amount pill keeps its row when it is empty rather than collapsing, and each row is a token rather than an em so an amount stepping its type down to fit shortens its digits and not its row. What the puck says changes all night; how big it is does not. It costs nothing. measuredSeatBox already took the tallest puck on the ring, on purpose, so the room was reserved at every seat and the short ones simply weren't drawn into it. What pays for the third row is the second line of name going away: an earlier round measured uniform-with-two-lines at 88px and rightly refused it. Uniform-with-one is 71. A name with no word to drop steps its type down instead of ellipsising. fitSeatNames turns "Mike from work" into "Mike", which is what the table calls him, and could do nothing at all with "Christopher" — so that one was cut to "Christo…" on a felt that may also have a Christine at it. The amounts on the same puck have never taken that trade; this is fitAmount's rule applied to the other thing a dealer reads across a table. One step, floored at 9px, then the ellipsis. The float that kept the button seat's name off the dealer chip goes with the second line — three rules of it, indenting the first line so a long name could drop below the chip onto a second. There is no second line to drop to, and left in place it drew "Christopher" as an empty label. The padding every crowded ring already used is the rule at all sizes now, widened from 22px to 25 so it actually covers the chip and the gap it is inset by. Then the part that was quietly wrong. renderSeats builds nine empty pucks and fills them afterwards, so the layout search ran against blank boxes and cached the answer under a key naming a table it had never measured. On a 390px phone that is a ring sized for empty seats — and the comment where it was cached said "let the resize that follows do this properly". Nothing resizes a phone. Turning the phone sideways and back was the only thing that ever fixed it, and it fixed it into a different layout: same viewport, same table, 90px pucks on load and 72px after a rotate. So the finished pucks are measured against the box the search settled on, and if they disagree the search is asked again with the real thing in front of it. Once, and the second answer is cached. Fresh load and rotate-and-back now agree exactly. That also lets the cache key shrink to almost nothing. It used to name every player's name, the dealer's seat, whether anybody had cards and whether anybody had chips out — four things, three of which flip during an ordinary hand, each flip a ten-candidate re-search. None of them can change a puck's height any more. Measured over three calls in a row: no re-search, and not one of the nine seats moved a pixel. The crowded ring gives up the 1.4 leading on its money rows to pay for the row it now holds open — 10px of figures is 12px of ink and the rest was blank. Without it the Galaxy S8 twelve-handed, where the pucks are already at the 44px floor, put neighbouring seats' blind badges down onto each other's "ALL IN". Hanging the badges further out was tried first and is the wrong direction: the badge landing on a stack figure belongs to the seat next door, so every pixel further out is a pixel further onto a neighbour. 992 unit tests. 320 layout audits across 13 viewports with a mouse and a thumb: one failure, the chip behind seat 11 that master already carries. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
531 lines
26 KiB
JavaScript
531 lines
26 KiB
JavaScript
// A phone held sideways, and the three pieces of arithmetic that make the table fit on
|
|
// one.
|
|
//
|
|
// The layout audit is what proved this was broken and what proves it is fixed — it
|
|
// measures the drawn page, which is the only place a layout is really true. What is
|
|
// here is the part underneath that can be wrong on its own: which shape the bar is in,
|
|
// how much of the bottom that leaves spoken for, how a name is shortened when it will
|
|
// not fit, and how the ring is pulled back in when a puck lands past the rail.
|
|
const test = require("node:test");
|
|
const { describe } = require("node:test");
|
|
const assert = require("node:assert");
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const { ActionBar, Render } = require("./load");
|
|
|
|
const CSS = fs.readFileSync(
|
|
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("which shape the bar is in", () => {
|
|
// The layout is chosen by a media query — orientation, height and width together.
|
|
// The one thing that must not happen is a second copy of that query in JavaScript,
|
|
// because the first thing anybody changes is the breakpoint, in one of the two
|
|
// places. So the stylesheet declares which layout it picked and actionBar.js reads
|
|
// the answer back.
|
|
const withComputed = (value, run) => {
|
|
const host = { id: "actionBar" };
|
|
global.getComputedStyle = el => ({
|
|
getPropertyValue: prop => (el === host && prop === "--actionBarLayout" ? value : ""),
|
|
});
|
|
try { return run(host); } finally { delete global.getComputedStyle; }
|
|
};
|
|
|
|
test("the stylesheet says, and this reads", () => {
|
|
assert.equal(withComputed(" rail ", host => ActionBar.isRail(host)), true);
|
|
assert.equal(withComputed(" bar ", host => ActionBar.isRail(host)), false);
|
|
});
|
|
|
|
test("anything it does not recognise is the bar it has always been", () => {
|
|
assert.equal(withComputed("", host => ActionBar.isRail(host)), false);
|
|
assert.equal(withComputed("sidebar", host => ActionBar.isRail(host)), false);
|
|
});
|
|
|
|
test("and with no browser at all, likewise", () => {
|
|
assert.equal(ActionBar.isRail(null), false);
|
|
assert.equal(ActionBar.isRail({ id: "actionBar" }), false,
|
|
"no getComputedStyle to ask — this runs in the unit suite too");
|
|
});
|
|
|
|
test("the stylesheet declares both halves of it", () => {
|
|
// If either of these goes, isRail() quietly answers "bar" forever and the felt
|
|
// is measured against a bar that is not underneath it.
|
|
assert.match(CSS, /#actionBar\s*\{[^}]*--actionBarLayout:\s*bar/,
|
|
"the default, on the base rule");
|
|
assert.match(CSS, /--actionBarLayout:\s*rail/,
|
|
"and the rail, inside the landscape query");
|
|
});
|
|
|
|
test("the rail is scoped to a phone, not to any short window", () => {
|
|
// Above 900px the sidebar is already a right-hand column and a rail would land
|
|
// on top of it.
|
|
const query = CSS.match(/@media \(orientation: landscape\)[^{]*\{[\s\S]{0,400}?--actionBarLayout: rail/);
|
|
assert.ok(query, "the rail lives in a landscape media query");
|
|
assert.match(query[0], /max-width:\s*900px/);
|
|
assert.match(query[0], /max-height:\s*560px/);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("the bar lines up with the table", () => {
|
|
// On a desktop the bar was centred on the window and the table on its own column,
|
|
// and the sidebar takes 320px out of the right of that — so the two things a dealer
|
|
// is looking at were 172px apart, at 960px wide and at 1600px and everywhere in
|
|
// between, because the offset is half the sidebar and the sidebar is a constant.
|
|
//
|
|
// The rule that fixes it is four numbers that have to keep agreeing with four other
|
|
// numbers, none of them anywhere near each other in the file. That is what this
|
|
// block is for: the arithmetic is checked on the running page by measurement (bar
|
|
// centre against table-column centre, which came out 0px off at every width), and
|
|
// the numbers it depends on are checked here so they cannot drift apart afterwards.
|
|
const rule = CSS.match(/@media \(min-width: 901px\) \{\s*#actionBar \{([^}]*)\}/);
|
|
|
|
test("there is a rule, and it is scoped above the one-column breakpoint", () => {
|
|
// Below 901px the sidebar is not a column at all — it is stacked under the table
|
|
// — so there is nothing to lean away from and the bar is centred as it was.
|
|
assert.ok(rule, "no min-width: 901px rule for #actionBar");
|
|
assert.match(CSS, /@media \(max-width: 900px\)[\s\S]{0,200}#holdemLayout \{\s*grid-template-columns: 1fr;/,
|
|
"the one-column layout should start exactly where this rule stops");
|
|
});
|
|
|
|
test("it leans away from the sidebar by the sidebar's own width", () => {
|
|
assert.match(rule[1], /left:\s*var\(--sp-6\)/);
|
|
assert.match(rule[1], /right:\s*calc\(var\(--sidebarW\)\s*\+\s*var\(--sp-5\)\s*\+\s*var\(--sp-6\)\)/);
|
|
});
|
|
|
|
test("and the sidebar's width is one number, not two", () => {
|
|
// The grid draws the column and the bar leans away from it. Written out twice,
|
|
// the day somebody widens the sidebar is the day the bar goes crooked and no
|
|
// test says so.
|
|
assert.match(CSS, /--sidebarW:\s*\d+px/, "the token should be declared");
|
|
assert.match(CSS, /grid-template-columns:\s*1fr var\(--sidebarW\)/,
|
|
"the grid should draw the column from the token");
|
|
});
|
|
|
|
test("no 100vw anywhere in it", () => {
|
|
// The bar's own centring comment warns about this: on a page with a scrollbar
|
|
// 100vw is wider than the viewport, and the overflow drags the table sideways.
|
|
// The offsets are exact without it — see the derivation in the stylesheet.
|
|
assert.equal(/100vw/.test(rule[1]), false, rule[1]);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("the button seat's name", () => {
|
|
// The dealer button's chip sits in the puck's top-right corner, and the name has to
|
|
// keep out from under it. `padding-right` takes the chip's width off the name's line,
|
|
// which is the whole of the answer now that there is only ever one line.
|
|
//
|
|
// A float used to do this on the roomier rings — three rules of it, indenting the
|
|
// first line so a name too long to fit beside the chip could drop below it onto a
|
|
// second. That only worked because there was a second line. There isn't: see the row
|
|
// heights in holdem.css, and the fitSeatNames tests below for what happens instead to
|
|
// a name that does not fit.
|
|
const base = CSS.match(/\n\.seat\.seatHasButton \.seatPuckName \{([^}]*)\}/);
|
|
|
|
test("the chip's width comes off the name, at every size", () => {
|
|
assert.ok(base, "the base rule should reserve the chip's width");
|
|
assert.match(base[1], /padding-right:\s*25px/);
|
|
});
|
|
|
|
test("and it is as wide as the chip it stands in for", () => {
|
|
const chip = CSS.match(/\n\.dealerChip \{[^}]*?(?<![\w-])width:\s*(\d+)px/);
|
|
const inset = CSS.match(/\n\.dealerChip \{[^}]*?(?<![\w-])right:\s*(\d+)px/);
|
|
const pad = base[1].match(/padding-right:\s*(\d+)px/);
|
|
assert.ok(chip && inset && pad);
|
|
assert.equal(Number(pad[1]), Number(chip[1]) + Number(inset[1]),
|
|
"the padding should cover the chip and the gap it is inset by");
|
|
});
|
|
|
|
test("the float that used to do this is gone, not merely unused", () => {
|
|
// Left in place with a one-line clamp it would clip the name to nothing: the
|
|
// word cannot fit beside the float, so it moves to a second line, and the second
|
|
// line is what the clamp hides. Measured — "Christopher" drew as an empty label.
|
|
assert.equal(/\.seatPuckName::before/.test(CSS), false,
|
|
"a float and one line of name cannot both be true");
|
|
});
|
|
|
|
test("and no rule anywhere gives a puck's name a second line", () => {
|
|
// This is what every puck being the same height rests on. A name is a label, and
|
|
// the ring is sized against the tallest puck on it, so one name wrapping is one
|
|
// seat setting how far apart all of them have to sit.
|
|
const clamps = CSS.match(/(?<![\w-])line-clamp:\s*(\d+)/g) || [];
|
|
assert.ok(clamps.length > 0, "the name should still be clamped to something");
|
|
clamps.forEach(clamp => assert.match(clamp, /:\s*1$/, clamp));
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("how much of the bottom is spoken for", () => {
|
|
// feltRoomFor asks one question — what is under the felt — and gets two different
|
|
// answers depending on the layout. Under the bar it is the bar. Under the rail it is
|
|
// the table hint and the two links, because the bar is off to the side.
|
|
const fakeSection = (wrapBottom, sectionBottom) => {
|
|
const wrap = {
|
|
getBoundingClientRect: () => ({ bottom: wrapBottom }),
|
|
parentElement: { getBoundingClientRect: () => ({ bottom: sectionBottom }) },
|
|
};
|
|
global.document = { getElementById: id => (id === "pokerTableWrap" ? wrap : null) };
|
|
};
|
|
|
|
test.afterEach(() => { delete global.document; });
|
|
|
|
test("it is the gap under the felt, links and all", () => {
|
|
fakeSection(300, 342);
|
|
assert.equal(ActionBar.roomBelowFelt(), 42);
|
|
});
|
|
|
|
test("a difference, not a height — so it does not chase the felt it is sizing", () => {
|
|
// The wrap's own height comes from --feltRoomPx, which is what this feeds. Read
|
|
// as "how tall is everything under the felt" off the section's height it would
|
|
// move every time the felt did, and settle nowhere.
|
|
fakeSection(300, 342);
|
|
const first = ActionBar.roomBelowFelt();
|
|
fakeSection(500, 542); // the felt grew by 200; what is under it did not
|
|
assert.equal(ActionBar.roomBelowFelt(), first);
|
|
});
|
|
|
|
test("nothing under the felt is nothing to reserve", () => {
|
|
fakeSection(300, 300);
|
|
assert.equal(ActionBar.roomBelowFelt(), 0);
|
|
});
|
|
|
|
test("and a section that measures shorter than its own child is not negative room", () => {
|
|
fakeSection(300, 288);
|
|
assert.equal(ActionBar.roomBelowFelt(), 0);
|
|
});
|
|
|
|
test("with no table on the page it asks for nothing", () => {
|
|
global.document = { getElementById: () => null };
|
|
assert.equal(ActionBar.roomBelowFelt(), 0);
|
|
});
|
|
|
|
test("the felt gets the height the bar used to take", () => {
|
|
// The whole point, in one line. At 640x360 the bar was 205px of a 360px window
|
|
// and the felt got 85. Beside the table instead, the same window gives the felt
|
|
// everything but the header and the links.
|
|
const asABar = ActionBar.feltRoomFor(360, 205, 70);
|
|
const asARail = ActionBar.feltRoomFor(360, 30, 70);
|
|
assert.ok(asARail > asABar * 2, `${asABar}px became ${asARail}px`);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("a name that will not fit", () => {
|
|
// Shortened at a word, not chopped in the middle of one. A puck is 34px of text on
|
|
// a nine-handed phone, and what the ellipsis produced for the four names on the test
|
|
// table was "Christo…", "Mike / from…", "Alexan…". Only one of those is throwing
|
|
// away something the dealer does not need: he is Mike, and the rest of it is how
|
|
// this table tells him from the other Mike.
|
|
//
|
|
// The label element is faked here rather than measured. What is under test is the
|
|
// ladder — which words go, in what order, and when it stops — and the browser's part
|
|
// is one boolean per pass.
|
|
const label = (text, fits) => {
|
|
const classes = new Set();
|
|
const el = {
|
|
textContent: text,
|
|
title: "",
|
|
// The step of type a name takes when it has no word left to drop, held as
|
|
// the same set of strings the real element's classList is.
|
|
classList: {
|
|
add: name => classes.add(name),
|
|
remove: name => classes.delete(name),
|
|
contains: name => classes.has(name),
|
|
},
|
|
// The browser's answer, standing in for a real one: this name fits if
|
|
// `fits` says the version currently written to it does.
|
|
get scrollWidth() { return fits(this.textContent) ? 10 : 100; },
|
|
get clientWidth() { return 10; },
|
|
get scrollHeight() { return 10; },
|
|
get clientHeight() { return 10; },
|
|
querySelector: () => el,
|
|
};
|
|
return el;
|
|
};
|
|
|
|
const layer = (labels) => ({
|
|
children: labels,
|
|
childElementCount: labels.length,
|
|
});
|
|
|
|
const seatsNamed = (...names) => {
|
|
Render.state = { seats: names.map(name => ({ name })) };
|
|
};
|
|
|
|
test.afterEach(() => { Render.state = null; });
|
|
|
|
test("trailing words go until it fits", () => {
|
|
const el = label("Christopher John Robinson", text => text === "Christopher John");
|
|
seatsNamed("Christopher John Robinson");
|
|
Render.fitSeatNames(layer([el]));
|
|
assert.equal(el.textContent, "Christopher John");
|
|
});
|
|
|
|
test("and it will go all the way to the first word", () => {
|
|
const el = label("Christopher John Robinson", text => text === "Christopher");
|
|
seatsNamed("Christopher John Robinson");
|
|
Render.fitSeatNames(layer([el]));
|
|
assert.equal(el.textContent, "Christopher");
|
|
});
|
|
|
|
test("a name never ends on a word a name cannot end on", () => {
|
|
// Dropping "work" off "Mike from work" leaves "Mike from", which is not a
|
|
// shorter name — it is a name that looks cut off, and it reads worse than the
|
|
// ellipsis this was meant to replace. He is Mike.
|
|
const el = label("Mike from work", () => false);
|
|
seatsNamed("Mike from work");
|
|
Render.fitSeatNames(layer([el]));
|
|
assert.equal(el.textContent, "Mike");
|
|
});
|
|
|
|
test("and it takes a run of them in one go", () => {
|
|
const el = label("Al with the hat", () => false);
|
|
seatsNamed("Al with the hat");
|
|
Render.fitSeatNames(layer([el]));
|
|
assert.equal(el.textContent, "Al");
|
|
});
|
|
|
|
test("but a joining word inside a name is nobody's business", () => {
|
|
// The list is closed and small on purpose: it is not trying to understand names,
|
|
// only the handful of English joining words people label each other with at a
|
|
// home game. Anything it doesn't know stays, which is the safe way to be wrong —
|
|
// a name that keeps a word it could have dropped is merely long.
|
|
const el = label("Katarzyna Nowak Kowalska", text => text === "Katarzyna Nowak");
|
|
seatsNamed("Katarzyna Nowak Kowalska");
|
|
Render.fitSeatNames(layer([el]));
|
|
assert.equal(el.textContent, "Katarzyna Nowak");
|
|
});
|
|
|
|
test("dropping never goes below the first word", () => {
|
|
// Every word after the first being a joining word is not a real name, but it is
|
|
// typeable, and it must not come out empty.
|
|
assert.equal(Render.dropAWord(["Mike", "from", "of", "the"], 4), 1);
|
|
assert.equal(Render.dropAWord(["from", "the"], 2), 1);
|
|
assert.equal(Render.dropAWord(["Mike"], 1), 1, "one word has nothing to drop");
|
|
});
|
|
|
|
test("a single word steps its type down rather than being cut", () => {
|
|
// There is no honest way to shorten "Christopher" at a word, and "Christo…" is a
|
|
// name only until a Christine sits down. The amounts on the same puck have never
|
|
// taken that trade — fitAmount shrinks a tournament stack rather than rounding it
|
|
// — and a misread name is the same kind of failure as a misread stack.
|
|
const el = label("Christopher", () => false);
|
|
seatsNamed("Christopher");
|
|
Render.fitSeatNames(layer([el]));
|
|
assert.equal(el.textContent, "Christopher", "never cut mid-word here");
|
|
assert.ok(el.classList.contains("puckNameTight"), "and never left overflowing either");
|
|
});
|
|
|
|
test("a name that fits is not touched", () => {
|
|
const el = label("Mike from work", () => true);
|
|
seatsNamed("Mike from work");
|
|
Render.fitSeatNames(layer([el]));
|
|
assert.equal(el.textContent, "Mike from work");
|
|
assert.ok(!el.classList.contains("puckNameTight"), "nothing to shrink for");
|
|
});
|
|
|
|
test("a name shortened until it fits is not also shrunk", () => {
|
|
// The step of type is the last resort, not a second helping. "Mike" fits, so it
|
|
// is read at the size everything else on the felt is.
|
|
const el = label("Mike from work", text => text === "Mike");
|
|
seatsNamed("Mike from work");
|
|
Render.fitSeatNames(layer([el]));
|
|
assert.equal(el.textContent, "Mike");
|
|
assert.ok(!el.classList.contains("puckNameTight"));
|
|
});
|
|
|
|
test("the step comes off again when the puck it was for is gone", () => {
|
|
// Otherwise it is a ratchet: a name that had to shrink on a nine-handed phone
|
|
// would still be shrunk on the same table six-handed, or on a desktop, because
|
|
// nothing ever measured it at the size it was supposed to be read at.
|
|
const el = label("Christopher", () => false);
|
|
seatsNamed("Christopher");
|
|
Render.fitSeatNames(layer([el]));
|
|
assert.ok(el.classList.contains("puckNameTight"));
|
|
|
|
const roomy = label("Christopher", () => true);
|
|
roomy.classList.add("puckNameTight");
|
|
Render.fitSeatNames(layer([roomy]));
|
|
assert.ok(!roomy.classList.contains("puckNameTight"));
|
|
});
|
|
|
|
test("the whole name is on the tooltip whether it was shortened or not", () => {
|
|
// A label clamped to two lines was already hiding some of it before any of this.
|
|
const shortened = label("Mike from work", text => text === "Mike");
|
|
const untouched = label("Bob", () => true);
|
|
seatsNamed("Mike from work", "Bob");
|
|
Render.fitSeatNames(layer([shortened, untouched]));
|
|
assert.equal(shortened.title, "Mike from work");
|
|
assert.equal(untouched.title, "Bob");
|
|
});
|
|
|
|
test("every seat is written to before any of them is measured", () => {
|
|
// Three passes over all the seats, not a loop per seat: each pass writes to
|
|
// every puck and then reads back from every puck, so the browser lays the felt
|
|
// out three times whatever the table's size. Interleaved, nine seats would cost
|
|
// nine layouts, and this runs on every render.
|
|
const reads = [];
|
|
const watched = (text, fits) => {
|
|
const el = label(text, fits);
|
|
Object.defineProperty(el, "scrollWidth", {
|
|
get() { reads.push(this.textContent); return fits(this.textContent) ? 10 : 100; },
|
|
});
|
|
return el;
|
|
};
|
|
const a = watched("Christopher John Robinson", t => t === "Christopher");
|
|
const b = watched("Katarzyna Nowak Kowalska", t => t === "Katarzyna");
|
|
seatsNamed("Christopher John Robinson", "Katarzyna Nowak Kowalska");
|
|
Render.fitSeatNames(layer([a, b]));
|
|
|
|
// Both names are read at each length before either is written down again.
|
|
assert.deepEqual(reads.slice(0, 2),
|
|
["Christopher John Robinson", "Katarzyna Nowak Kowalska"]);
|
|
assert.deepEqual(reads.slice(2, 4), ["Christopher John", "Katarzyna Nowak"]);
|
|
assert.equal(a.textContent, "Christopher");
|
|
assert.equal(b.textContent, "Katarzyna");
|
|
});
|
|
|
|
test("an empty chair has no name to shorten", () => {
|
|
const el = label("", () => false);
|
|
seatsNamed("");
|
|
assert.doesNotThrow(() => Render.fitSeatNames(layer([el])));
|
|
});
|
|
|
|
test("and with no layer there is nothing to do", () => {
|
|
assert.doesNotThrow(() => Render.fitSeatNames(null));
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("pulling the ring back onto the table", () => {
|
|
// seatRingRadii works out the widest ring whose outermost puck still finishes inside
|
|
// the rail, and it is right about that — but it is right about a puck it measured
|
|
// before the ring moved, before the width search settled, and before a name was
|
|
// shortened or wrapped to a second line. Each of those changes a puck's height by up
|
|
// to 15px after the arithmetic is done.
|
|
//
|
|
// Measured on the running page, four of the thirteen viewports finished with the top
|
|
// seat 5-6px past the rail. Not a wrong formula: a stale input to a right one. So
|
|
// the ring is settled the way the audit judges it, by looking at where the pucks
|
|
// actually landed.
|
|
//
|
|
// The felt is 400x400 at (100, 100) throughout. Seats are placed by writing a
|
|
// percentage into style.left/top, which this reads back to work out where the puck
|
|
// would be — the same relationship the browser has, without a browser.
|
|
const felt = { left: 100, top: 100, right: 500, bottom: 500, width: 400, height: 400 };
|
|
|
|
const seat = (puckW, puckH, box) => ({
|
|
style: { left: "", top: "" },
|
|
classList: { toggle() {} },
|
|
getBoundingClientRect() {
|
|
// Whatever percentage was last written, resolved against the ring box.
|
|
const cx = box.left + (parseFloat(this.style.left) / 100) * box.width;
|
|
const cy = box.top + (parseFloat(this.style.top) / 100) * box.height;
|
|
return {
|
|
left: cx - puckW / 2, right: cx + puckW / 2,
|
|
top: cy - puckH / 2, bottom: cy + puckH / 2,
|
|
width: puckW, height: puckH,
|
|
};
|
|
},
|
|
});
|
|
|
|
// The ring box the percentages resolve against, deliberately the same as the felt so
|
|
// the arithmetic in the test stays readable.
|
|
const ringBox = { ...felt };
|
|
|
|
const setUp = (count, puckW, puckH) => {
|
|
const seats = Array.from({ length: count }, () => seat(puckW, puckH, ringBox));
|
|
Render.state = { seats: seats.map(() => ({ name: "" })), seatCount: count };
|
|
global.document = { getElementById: id => (id === "pokerTable"
|
|
? { getBoundingClientRect: () => felt }
|
|
: { getBoundingClientRect: () => ringBox }) };
|
|
// ringBox() reads #seatsLayer; the fake above hands the same box back for it.
|
|
return { children: seats, childElementCount: seats.length };
|
|
};
|
|
|
|
const place = (layer, radii) => {
|
|
for (let i = 0; i < layer.childElementCount; i++) {
|
|
Render.placeSeat(layer.children[i], i, 1, radii);
|
|
}
|
|
};
|
|
|
|
const worstOverhang = layer => {
|
|
let over = 0;
|
|
for (let i = 0; i < layer.childElementCount; i++) {
|
|
const b = layer.children[i].getBoundingClientRect();
|
|
over = Math.max(over, felt.left - b.left, b.right - felt.right,
|
|
felt.top - b.top, b.bottom - felt.bottom);
|
|
}
|
|
return over;
|
|
};
|
|
|
|
test.afterEach(() => { Render.state = null; delete global.document; });
|
|
|
|
test("a ring already inside the rail is left exactly where it is", () => {
|
|
const layer = setUp(6, 60, 50);
|
|
const radii = { rx: 30, ry: 30 };
|
|
place(layer, radii);
|
|
assert.deepEqual(Render.settleSeatsOnTable(layer, 1, radii), radii);
|
|
});
|
|
|
|
test("a puck past the rail brings the whole ring in", () => {
|
|
// 44% of 400 puts a seat's centre 176px from the middle; half a 60px puck is
|
|
// another 30, against a felt whose half-height is 200. Six pixels over, at the
|
|
// top and the bottom and both sides — which is the size the running page was
|
|
// actually wrong by.
|
|
const layer = setUp(4, 60, 60);
|
|
const radii = { rx: 44, ry: 44 };
|
|
place(layer, radii);
|
|
assert.equal(Math.round(worstOverhang(layer)), 6, "the fault is there to fix");
|
|
|
|
const settled = Render.settleSeatsOnTable(layer, 1, radii);
|
|
assert.ok(settled.ry < radii.ry, "the ring came in");
|
|
assert.ok(worstOverhang(layer) <= Render.SEAT_SETTLE_SLACK_PX,
|
|
`still ${worstOverhang(layer).toFixed(1)}px over`);
|
|
});
|
|
|
|
test("it comes in by what it was over, not by a step it guessed", () => {
|
|
const layer = setUp(4, 60, 60);
|
|
const radii = { rx: 44, ry: 44 };
|
|
place(layer, radii);
|
|
const over = worstOverhang(layer);
|
|
const settled = Render.settleSeatsOnTable(layer, 1, radii);
|
|
// Six pixels of a 400px felt is 1.5% of the ring, so 44 becomes 42.5 — the
|
|
// measurement, not a step size that would have to be tuned per screen.
|
|
assert.equal(settled.ry.toFixed(2), (44 - (over / felt.height) * 100).toFixed(2));
|
|
assert.equal(settled.ry, 42.5);
|
|
});
|
|
|
|
test("and it stops at the floor rather than collapsing into the pot", () => {
|
|
// A puck taller than the felt has an overhang no ring can fix. Pulling in
|
|
// forever would put every seat on top of the pot badge, which is a worse table
|
|
// than one with a puck touching the rail.
|
|
const layer = setUp(4, 60, 600);
|
|
const radii = { rx: 40, ry: 40 };
|
|
place(layer, radii);
|
|
const settled = Render.settleSeatsOnTable(layer, 1, radii);
|
|
assert.equal(settled.ry, Render.RING_MIN_R);
|
|
assert.ok(settled.ry > 0);
|
|
});
|
|
|
|
test("a table with nothing laid out yet is handed back untouched", () => {
|
|
Render.state = { seats: [], seatCount: 0 };
|
|
global.document = { getElementById: () => null };
|
|
const radii = { rx: 44, ry: 42 };
|
|
assert.deepEqual(Render.settleSeatsOnTable({ children: [], childElementCount: 0 }, 1, radii), radii);
|
|
});
|
|
|
|
test("it is bounded — this runs after every tap a dealer makes", () => {
|
|
assert.ok(Render.SEAT_SETTLE_PASSES <= 2,
|
|
"each pass is a forced layout of the whole felt");
|
|
assert.ok(Render.SEAT_NAME_PASSES <= 3);
|
|
});
|
|
});
|