Skip to content
Open
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: 106 additions & 0 deletions src/cli-options.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { describe, it } from "vitest";
import { knownStateNames, parseFlags } from "./cli-options.js";
import { testStates } from "./state/game-states.js";
import { expectEqual } from "./test-util.js";

/** meow always supplies these two, so every case starts from them. */
const baseFlags = { numBoards: 1, quit: false };

const optionsOf = (result: ReturnType<typeof parseFlags>) => {
if (!result.ok) {
throw new Error(`expected flags to parse, got: ${result.error}`);
}
return result.options;
};

describe("parseFlags", () => {
describe("test states", () => {
it("starts an ordinary game when --test is absent", () => {
expectEqual(optionsOf(parseFlags(baseFlags)).initialState, undefined);
});

it("loads a known test state", () => {
const { initialState } = optionsOf(
parseFlags({ ...baseFlags, test: "midgame" }),
);
expectEqual(initialState?.status, testStates.midgame.status);
expectEqual(
initialState?.gameBoards[0].solution,
testStates.midgame.gameBoards[0].solution,
);
});

it("marks the state for exit with --quit", () => {
const { initialState } = optionsOf(
parseFlags({ ...baseFlags, test: "win", quit: true }),
);
expectEqual(initialState?.exitPlease, true);
});

it("leaves the state running without --quit", () => {
const { initialState } = optionsOf(
parseFlags({ ...baseFlags, test: "win" }),
);
expectEqual(initialState?.exitPlease, false);
});

it("rejects an unknown test state, naming the valid ones", () => {
const result = parseFlags({ ...baseFlags, test: "bogus" });
expectEqual(result, {
ok: false,
error: `Unknown test state 'bogus'. Valid states are ${knownStateNames}`,
});
});

it("names every known state in that message", () => {
for (const name of Object.keys(testStates)) {
expectEqual(knownStateNames.split("|").includes(name), true);
}
});
});

describe("board count", () => {
it("passes an ordinary count through", () => {
expectEqual(
optionsOf(parseFlags({ ...baseFlags, numBoards: 3 })).numBoards,
3,
);
});

// meow hands back 0 for `--num-boards 0` and NaN for `--num-boards abc`.
// Neither should leave the game with no boards to render.
it("treats zero boards as one", () => {
expectEqual(
optionsOf(parseFlags({ ...baseFlags, numBoards: 0 })).numBoards,
1,
);
});

it("treats a non-numeric count as one board", () => {
expectEqual(
optionsOf(parseFlags({ ...baseFlags, numBoards: NaN })).numBoards,
1,
);
});

// Known bug — see docs/architecture-review.md finding 1.3. A negative
// count still reaches newGame and leaves it with no boards at all.
// Flip to `it` when fixed.
it.fails("rejects a negative board count", () => {
expectEqual(parseFlags({ ...baseFlags, numBoards: -3 }).ok, false);
});
});

describe("guess count", () => {
it("passes --num-guesses through", () => {
expectEqual(
optionsOf(parseFlags({ ...baseFlags, numGuesses: 10 })).numGuesses,
10,
);
});

it("leaves the guess count unset when absent", () => {
expectEqual(optionsOf(parseFlags(baseFlags)).numGuesses, undefined);
});
});
});
49 changes: 49 additions & 0 deletions src/cli-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { isKnownState, testStates } from "./state/game-states.js";
import { GameState } from "./types.js";

export const knownStateNames = Object.keys(testStates).join("|");

/** The flags meow hands back, narrowed to what the game reads. */
export type CliFlags = {
test?: string;
quit?: boolean;
numBoards: number;
numGuesses?: number;
};

/** The props cli.tsx passes to App. */
export type CliOptions = {
initialState?: GameState;
numBoards: number;
numGuesses?: number;
};

/** Either the options to start with, or the reason we can't start. */
export type ParseResult =
| { ok: true; options: CliOptions }
| { ok: false; error: string };

export function parseFlags(flags: CliFlags): ParseResult {
const { test, quit, numBoards, numGuesses } = flags;

if (test != undefined && !isKnownState(test)) {
return {
ok: false,
error: `Unknown test state '${test}'. Valid states are ${knownStateNames}`,
};
}

return {
ok: true,
options: {
initialState:
test == undefined
? undefined
: { ...testStates[test], exitPlease: quit },
// `||` rather than `??`: meow yields 0 for `--num-boards 0` and NaN for
// a non-numeric value, and neither should mean "no boards at all".
numBoards: numBoards || 1,
numGuesses,
},
};
}
32 changes: 6 additions & 26 deletions src/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,9 @@
import { render } from "ink";
import meow from "meow";
import React from "react";
import { isKnownState, testStates } from "./state/game-states.js";
import { knownStateNames, parseFlags } from "./cli-options.js";
import App from "./ui.js";

const knownStateNames = Object.keys(testStates).join("|");

const cli = meow(
`
Usage
Expand Down Expand Up @@ -42,32 +40,14 @@ const cli = meow(
},
);

function chooseState(
stateName: string | undefined,
exitPlease: boolean | undefined,
) {
if (stateName == undefined) {
return undefined;
}
if (!isKnownState(stateName)) {
console.log(
`Unknown test state '${stateName}'. Valid states are ${knownStateNames}`,
);
process.exit(1);
}

const state = testStates[stateName];
const parsed = parseFlags(cli.flags);

return { ...state, exitPlease };
if (!parsed.ok) {
console.log(parsed.error);
process.exit(1);
}

const _app = render(
<App
initialState={chooseState(cli.flags.test, cli.flags.quit)}
numBoards={cli.flags.numBoards}
numGuesses={cli.flags.numGuesses}
/>,
);
const _app = render(<App {...parsed.options} />);

// this was cauing a 'Warning: Detected unsettled top-level await' with exit code 13:
// await app.waitUntilExit();
92 changes: 92 additions & 0 deletions src/key-actions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, it } from "vitest";
import { keyToAction } from "./key-actions.js";
import { expectEqual } from "./test-util.js";

/** No modifier or special key pressed. */
const plain = {};

describe("keyToAction", () => {
describe("letters", () => {
it("types a lowercase letter as uppercase", () => {
expectEqual(keyToAction("a", plain), {
action: "input-letter",
letter: "A",
});
});

it("types an uppercase letter as-is", () => {
expectEqual(keyToAction("Z", plain), {
action: "input-letter",
letter: "Z",
});
});

it("accepts every letter of the alphabet, in either case", () => {
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
for (const letter of alphabet) {
expectEqual(keyToAction(letter, plain), {
action: "input-letter",
letter,
});
expectEqual(keyToAction(letter.toLowerCase(), plain), {
action: "input-letter",
letter,
});
}
});

it("ignores anything that is not a letter", () => {
for (const input of ["1", " ", "-", "?", "é", "🙂"]) {
expectEqual(keyToAction(input, plain), undefined);
}
});

it("ignores multi-character input, such as a paste", () => {
expectEqual(keyToAction("abc", plain), undefined);
expectEqual(keyToAction("", plain), undefined);
});
});

describe("editing keys", () => {
it("submits on return", () => {
expectEqual(keyToAction("", { return: true }), {
action: "submit-guess",
});
});

it("deletes on backspace", () => {
expectEqual(keyToAction("", { backspace: true }), {
action: "backspace",
});
});

it("deletes on delete", () => {
expectEqual(keyToAction("", { delete: true }), { action: "backspace" });
});
});

describe("ctrl chords", () => {
it("gives up on ctrl+Q", () => {
expectEqual(keyToAction("q", { ctrl: true }), { action: "give-up" });
});

it("does not also type a Q on ctrl+Q", () => {
// The handler used to run each check independently, so ctrl+Q both
// typed a letter and gave up. Only the give-up should survive.
const action = keyToAction("q", { ctrl: true });
expectEqual(action?.action, "give-up");
});

it("ignores every other ctrl chord", () => {
for (const input of ["a", "c", "n", "z"]) {
expectEqual(keyToAction(input, { ctrl: true }), undefined);
}
});
});

describe("keys the game leaves alone", () => {
it("ignores escape, which quits the app rather than moving the game", () => {
expectEqual(keyToAction("", { escape: true }), undefined);
});
});
});
40 changes: 40 additions & 0 deletions src/key-actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { GameAction } from "./types.js";

/**
* The parts of ink's Key that the game looks at. Declared structurally rather
* than importing ink's Key so tests can describe a keypress in a few fields.
*/
export type KeyPress = {
escape?: boolean;
return?: boolean;
backspace?: boolean;
delete?: boolean;
ctrl?: boolean;
};

/**
* What a keypress means to the game, or undefined for keys it ignores.
* Quitting the app is deliberately not here: it ends the process rather than
* moving the game along, so ui.tsx handles escape itself.
*/
export function keyToAction(
input: string,
key: KeyPress,
): GameAction | undefined {
if (key.ctrl) {
// A ctrl chord is never a letter to type. Checking this first is what
// stops ctrl+Q from both typing a 'Q' and giving up.
return input == "q" ? { action: "give-up" } : undefined;
}
if (key.return) {
return { action: "submit-guess" };
}
if (key.backspace || key.delete) {
return { action: "backspace" };
}
const letter = input.toUpperCase();
if (input.length == 1 && letter >= "A" && letter <= "Z") {
return { action: "input-letter", letter };
}
return undefined;
}
3 changes: 1 addition & 2 deletions src/state/reducer.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { describe, it } from "vitest";
import { KEY_NEW_GAME, KEY_QUIT, WORD_LEN } from "../constants.js";
import { expectEqual, seededRandom } from "../test-util.js";
import { GameAction } from "../ui.js";
import { GameState } from "../types.js";
import { GameAction, GameState } from "../types.js";
import { possibleSolutions } from "../words/possible-solutions.js";
import { newGame } from "./game-states.js";
import { reducer } from "./reducer.js";
Expand Down
3 changes: 1 addition & 2 deletions src/state/reducer.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { KEY_NEW_GAME, KEY_QUIT, WORD_LEN } from "../constants.js";
import { colorGuess, isValidWord, pickSolutions } from "../game-logic.js";
import { GameBoardState, GameState } from "../types.js";
import { GameAction } from "../ui.js";
import { GameAction, GameBoardState, GameState } from "../types.js";
import { newGame } from "./game-states.js";

const rowIsFull = (state: GameState & { status: "guessing" }) =>
Expand Down
6 changes: 6 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,9 @@ export type GameState = {
status: "loss";
}
);

export type GameAction =
| { action: "input-letter"; letter: string }
| { action: "submit-guess" }
| { action: "backspace" }
| { action: "give-up" };
Loading