Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 90 additions & 16 deletions src/projects/elemental-showdown/ElementalShowdown.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import { render, screen, waitFor } from "@testing-library/react";
import {
act,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ElementalShowdown } from "./ElementalShowdown";
import type { VoteReveal } from "./matchup-score";
import type { NextMatchup } from "./matchup-selection";
import { REVEAL_LINGER_MS } from "./Reveal";
import type { ShowdownElement } from "./schema";
import type { VoteCast } from "./showdown-schema";

Expand Down Expand Up @@ -33,17 +41,27 @@ const matchup = (
bottom: ShowdownElement,
): NextMatchup => ({ state: "matchup", top, bottom });

// Ten Votes with the crowd behind fire: the Voter crushed it too, as half of
// them did.
const reveal = (crowd: Partial<VoteReveal> = {}): VoteReveal => ({
state: "reveal",
counts: { "-2": 1, "-1": 0, "0": 1, "1": 3, "2": 5 },
vote: 2,
sameShare: 0.5,
headline: "with",
crowdMean: 1.4,
...crowd,
});

type CastVote = (options: { data: VoteCast }) => Promise<VoteReveal>;

function showdown({
shown = matchup(fire, water),
castVote = vi.fn<(options: { data: VoteCast }) => Promise<void>>(
async () => {},
),
castVote = vi.fn<CastVote>(async () => reveal()),
nextMatchup = async () => matchup(plant, fire),
}: {
shown?: NextMatchup;
castVote?: ReturnType<
typeof vi.fn<(options: { data: VoteCast }) => Promise<void>>
>;
castVote?: ReturnType<typeof vi.fn<CastVote>>;
nextMatchup?: () => Promise<NextMatchup>;
} = {}) {
render(
Expand All @@ -65,6 +83,7 @@ const cast = (value: number) => ({

afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});

describe("the Tug as a slider", () => {
Expand Down Expand Up @@ -137,23 +156,80 @@ describe("the Tug as a slider", () => {
});
});

describe("after a Vote", () => {
it("brings on the next Matchup", async () => {
describe("the reveal", () => {
it("keeps the crowd out of sight until the Vote is cast", () => {
showdown();

expect(screen.queryByText("▲ your vote")).toBeNull();
expect(screen.queryByText("the crowd")).toBeNull();
expect(screen.queryByText(/voted the same/)).toBeNull();
});

it("marks the Voter's own bar and says where the crowd stands", async () => {
const user = userEvent.setup();
showdown();

await user.click(pill());

await screen.findByText("plant");
const ownBar = await screen.findByText("▲ your vote");
expect(ownBar.parentElement).toHaveTextContent("🔥 crushes");
expect(screen.getByText("with the crowd")).toBeInTheDocument();
expect(screen.getByText("50% voted the same, of 10")).toBeInTheDocument();
expect(screen.getByText("the crowd")).toBeInTheDocument();
});

it("dresses a Matchup nobody has settled up as no crowd at all", async () => {
const user = userEvent.setup();
showdown({
castVote: vi.fn<CastVote>(async () =>
reveal({
counts: { "-2": 0, "-1": 1, "0": 0, "1": 1, "2": 1 },
vote: 2,
sameShare: 1 / 3,
headline: "early",
crowdMean: null,
}),
),
});

await user.click(pill());

await screen.findByText("early days, only 3 votes");
expect(screen.queryByText("the crowd")).toBeNull();
expect(screen.queryByText(/voted the same/)).toBeNull();
});
});

describe("after a Vote", () => {
it("brings on the next Matchup when the draining bar runs out", async () => {
// the clock, not userEvent: the wait the draining bar shows is a timer
vi.useFakeTimers();
showdown();
fireEvent.click(pill());
await act(async () => {});
expect(screen.getByText("with the crowd")).toBeInTheDocument();

await act(async () => void vi.advanceTimersByTime(REVEAL_LINGER_MS));

expect(screen.getByText("plant")).toBeInTheDocument();
expect(screen.queryByText("water")).toBeNull();
});

it("brings on the next Matchup at once when the Voter taps", async () => {
const user = userEvent.setup();
showdown();
await user.click(pill());
await screen.findByText("with the crowd");

fireEvent.pointerDown(tug());

await screen.findByText("plant");
});

it("casts once, however often the Tug is let go while the Vote is on its way", async () => {
const user = userEvent.setup();
const castVote = showdown({
castVote: vi.fn<(options: { data: VoteCast }) => Promise<void>>(
() => new Promise(() => {}),
),
castVote: vi.fn<CastVote>(() => new Promise(() => {})),
});

await user.click(pill());
Expand All @@ -166,9 +242,7 @@ describe("after a Vote", () => {
const failed = vi.spyOn(console, "error").mockImplementation(() => {});
const user = userEvent.setup();
showdown({
castVote: vi.fn<(options: { data: VoteCast }) => Promise<void>>(() =>
Promise.reject(new Error("offline")),
),
castVote: vi.fn<CastVote>(() => Promise.reject(new Error("offline"))),
});

await user.click(pill());
Expand Down
35 changes: 29 additions & 6 deletions src/projects/elemental-showdown/ElementalShowdown.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Link } from "@tanstack/react-router";
import { useRef, useState } from "react";
import { useCallback, useRef, useState } from "react";
import { INK, PAPER, textOn } from "./element-colour";
import type { VoteReveal } from "./matchup-score";
import type { NextMatchup } from "./matchup-selection";
import type { VoteCast, VoteValue } from "./showdown-schema";
import { Tug } from "./Tug";
Expand All @@ -13,14 +14,18 @@ export function ElementalShowdown({
nextMatchup,
}: {
shown: NextMatchup;
castVote: (options: { data: VoteCast }) => Promise<void>;
castVote: (options: { data: VoteCast }) => Promise<VoteReveal>;
nextMatchup: () => Promise<NextMatchup>;
}) {
const [matchup, setMatchup] = useState(shown);
const [reveal, setReveal] = useState<VoteReveal | null>(null);
const [votesCast, setVotesCast] = useState(0);
// A ref for the guard: a second Enter can arrive before a re-render would
// have told it the first Vote is already on its way.
const casting = useRef(false);
// Drawn while the Voter reads the reveal, so the next Matchup is there the
// moment they are done with this one.
const drawing = useRef<Promise<NextMatchup> | null>(null);

function cast(value: VoteValue) {
if (matchup.state !== "matchup" || casting.current) return;
Expand All @@ -29,10 +34,9 @@ export function ElementalShowdown({
castVote({
data: { topElementId: top.id, bottomElementId: bottom.id, value },
})
.then(nextMatchup)
.then((next) => {
setMatchup(next);
setVotesCast((count) => count + 1);
.then((crowd) => {
setReveal(crowd);
drawing.current = nextMatchup();
})
.catch((cause: unknown) => {
// Nothing was stored, so the Matchup stays up and the next drag is
Expand All @@ -44,6 +48,23 @@ export function ElementalShowdown({
});
}

// The reveal's own clock calls this, and so does a Voter who has read enough.
const advance = useCallback(() => {
const drawn = drawing.current;
if (!drawn) return;
drawing.current = null;
drawn
.then((next) => {
setMatchup(next);
setReveal(null);
setVotesCast((count) => count + 1);
})
.catch((cause: unknown) => {
// The reveal stays up rather than an empty screen taking its place.
console.error(new Error("drawing the next Matchup failed", { cause }));
});
}, []);

return (
<main
className="relative h-dvh select-none overflow-hidden font-showdown text-[15px] leading-[1.35]"
Expand All @@ -56,6 +77,8 @@ export function ElementalShowdown({
bottom={matchup.bottom}
isFirstMatchup={votesCast === 0}
onCast={cast}
reveal={reveal}
onAdvance={advance}
/>
) : (
<AllJudged matchupCount={matchup.matchupCount} />
Expand Down
Loading
Loading