bridges-cli 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +24 -0
- package/README.md +25 -0
- package/biome.json +84 -0
- package/bun.lock +314 -0
- package/docs/readme-demo-1.png +0 -0
- package/package.json +37 -0
- package/src/Game.tsx +54 -0
- package/src/__tests__/Game.test.tsx +198 -0
- package/src/components/HashiGrid.tsx +110 -0
- package/src/components/HashiRow.tsx +94 -0
- package/src/components/Header.tsx +25 -0
- package/src/components/Messages.tsx +34 -0
- package/src/components/__tests__/HashiGrid.test.tsx +55 -0
- package/src/components/__tests__/HashiRow.test.tsx +155 -0
- package/src/components/__tests__/Messages.test.tsx +14 -0
- package/src/index.tsx +34 -0
- package/src/types.ts +11 -0
- package/src/utils/__tests__/parsePuzzle.test.ts +169 -0
- package/src/utils/parsePuzzle.ts +178 -0
- package/src/utils/samplePuzzles.ts +59 -0
- package/src/utils/usePuzzleInput.ts +32 -0
- package/tsconfig.json +27 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { setTimeout } from 'node:timers/promises'
|
|
2
|
+
import { render } from 'ink-testing-library'
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import Game from '../Game.tsx'
|
|
6
|
+
|
|
7
|
+
const TEST_PUZZLE = { encoding: '3x3:1a1.c.2a2' }
|
|
8
|
+
const TEST_PUZZLE_2 = { encoding: '3x3:3a3.c.1a1' }
|
|
9
|
+
|
|
10
|
+
describe('Game', () => {
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
Object.defineProperty(process.stdin, 'isTTY', {
|
|
13
|
+
get: () => true,
|
|
14
|
+
configurable: true,
|
|
15
|
+
})
|
|
16
|
+
vi.spyOn(process.stdin, 'isTTY', 'get').mockReturnValue(true)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
describe('--stdout flag', () => {
|
|
20
|
+
it('does not show instructions when stdout is true', () => {
|
|
21
|
+
const { lastFrame } = render(
|
|
22
|
+
<Game puzzles={[TEST_PUZZLE]} hasCustomPuzzle={false} stdout={true} />
|
|
23
|
+
)
|
|
24
|
+
expect(lastFrame()).not.toContain('Controls:')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('shows instructions when stdout is false', () => {
|
|
28
|
+
const { lastFrame } = render(
|
|
29
|
+
<Game puzzles={[TEST_PUZZLE]} hasCustomPuzzle={false} stdout={false} />
|
|
30
|
+
)
|
|
31
|
+
expect(lastFrame()).toContain('Controls:')
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe('game controls', () => {
|
|
36
|
+
it('navigates to next puzzle with n key when interactive', async () => {
|
|
37
|
+
const { stdin, lastFrame } = render(
|
|
38
|
+
<Game
|
|
39
|
+
puzzles={[TEST_PUZZLE, TEST_PUZZLE_2]}
|
|
40
|
+
hasCustomPuzzle={false}
|
|
41
|
+
stdout={false}
|
|
42
|
+
/>
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
expect(lastFrame()).toEqual(`Bridges: Puzzle #1
|
|
46
|
+
|
|
47
|
+
┌─────────────────┐
|
|
48
|
+
│ ╭───╮ ╭───╮ │
|
|
49
|
+
│ │ 1 │ │ 1 │ │
|
|
50
|
+
│ ╰───╯ ╰───╯ │
|
|
51
|
+
│ │
|
|
52
|
+
│ │
|
|
53
|
+
│ │
|
|
54
|
+
│ ╭───╮ ╭───╮ │
|
|
55
|
+
│ │ 2 │ │ 2 │ │
|
|
56
|
+
│ ╰───╯ ╰───╯ │
|
|
57
|
+
└─────────────────┘
|
|
58
|
+
|
|
59
|
+
Controls:
|
|
60
|
+
p: Previous puzzle
|
|
61
|
+
n: Next puzzle
|
|
62
|
+
s: Show solution
|
|
63
|
+
q: Quit`)
|
|
64
|
+
|
|
65
|
+
stdin.write('n')
|
|
66
|
+
await setTimeout(5)
|
|
67
|
+
expect(lastFrame()).toEqual(`Bridges: Puzzle #2
|
|
68
|
+
|
|
69
|
+
┌─────────────────┐
|
|
70
|
+
│ ╭───╮ ╭───╮ │
|
|
71
|
+
│ │ 3 │ │ 3 │ │
|
|
72
|
+
│ ╰───╯ ╰───╯ │
|
|
73
|
+
│ │
|
|
74
|
+
│ │
|
|
75
|
+
│ │
|
|
76
|
+
│ ╭───╮ ╭───╮ │
|
|
77
|
+
│ │ 1 │ │ 1 │ │
|
|
78
|
+
│ ╰───╯ ╰───╯ │
|
|
79
|
+
└─────────────────┘
|
|
80
|
+
|
|
81
|
+
Controls:
|
|
82
|
+
p: Previous puzzle
|
|
83
|
+
n: Next puzzle
|
|
84
|
+
s: Show solution
|
|
85
|
+
q: Quit`)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('navigates to previous puzzle with p key when interactive', async () => {
|
|
89
|
+
const { stdin, lastFrame } = render(
|
|
90
|
+
<Game
|
|
91
|
+
puzzles={[TEST_PUZZLE, TEST_PUZZLE_2]}
|
|
92
|
+
hasCustomPuzzle={false}
|
|
93
|
+
stdout={false}
|
|
94
|
+
/>
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
stdin.write('n')
|
|
98
|
+
await setTimeout(5)
|
|
99
|
+
expect(lastFrame()).toEqual(`Bridges: Puzzle #2
|
|
100
|
+
|
|
101
|
+
┌─────────────────┐
|
|
102
|
+
│ ╭───╮ ╭───╮ │
|
|
103
|
+
│ │ 3 │ │ 3 │ │
|
|
104
|
+
│ ╰───╯ ╰───╯ │
|
|
105
|
+
│ │
|
|
106
|
+
│ │
|
|
107
|
+
│ │
|
|
108
|
+
│ ╭───╮ ╭───╮ │
|
|
109
|
+
│ │ 1 │ │ 1 │ │
|
|
110
|
+
│ ╰───╯ ╰───╯ │
|
|
111
|
+
└─────────────────┘
|
|
112
|
+
|
|
113
|
+
Controls:
|
|
114
|
+
p: Previous puzzle
|
|
115
|
+
n: Next puzzle
|
|
116
|
+
s: Show solution
|
|
117
|
+
q: Quit`)
|
|
118
|
+
|
|
119
|
+
stdin.write('p')
|
|
120
|
+
await setTimeout(5)
|
|
121
|
+
expect(lastFrame()).toEqual(`Bridges: Puzzle #1
|
|
122
|
+
|
|
123
|
+
┌─────────────────┐
|
|
124
|
+
│ ╭───╮ ╭───╮ │
|
|
125
|
+
│ │ 1 │ │ 1 │ │
|
|
126
|
+
│ ╰───╯ ╰───╯ │
|
|
127
|
+
│ │
|
|
128
|
+
│ │
|
|
129
|
+
│ │
|
|
130
|
+
│ ╭───╮ ╭───╮ │
|
|
131
|
+
│ │ 2 │ │ 2 │ │
|
|
132
|
+
│ ╰───╯ ╰───╯ │
|
|
133
|
+
└─────────────────┘
|
|
134
|
+
|
|
135
|
+
Controls:
|
|
136
|
+
p: Previous puzzle
|
|
137
|
+
n: Next puzzle
|
|
138
|
+
s: Show solution
|
|
139
|
+
q: Quit`)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('does not navigate past last puzzle', async () => {
|
|
143
|
+
const { stdin, lastFrame } = render(
|
|
144
|
+
<Game puzzles={[TEST_PUZZLE]} hasCustomPuzzle={false} stdout={false} />
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
stdin.write('n')
|
|
148
|
+
await setTimeout(5)
|
|
149
|
+
expect(lastFrame()).toEqual(`Bridges: Puzzle #1
|
|
150
|
+
|
|
151
|
+
┌─────────────────┐
|
|
152
|
+
│ ╭───╮ ╭───╮ │
|
|
153
|
+
│ │ 1 │ │ 1 │ │
|
|
154
|
+
│ ╰───╯ ╰───╯ │
|
|
155
|
+
│ │
|
|
156
|
+
│ │
|
|
157
|
+
│ │
|
|
158
|
+
│ ╭───╮ ╭───╮ │
|
|
159
|
+
│ │ 2 │ │ 2 │ │
|
|
160
|
+
│ ╰───╯ ╰───╯ │
|
|
161
|
+
└─────────────────┘
|
|
162
|
+
|
|
163
|
+
Controls:
|
|
164
|
+
p: Previous puzzle
|
|
165
|
+
n: Next puzzle
|
|
166
|
+
s: Show solution
|
|
167
|
+
q: Quit`)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('does not navigate before first puzzle', async () => {
|
|
171
|
+
const { stdin, lastFrame } = render(
|
|
172
|
+
<Game puzzles={[TEST_PUZZLE]} hasCustomPuzzle={false} stdout={false} />
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
stdin.write('p')
|
|
176
|
+
await setTimeout(5)
|
|
177
|
+
expect(lastFrame()).toEqual(`Bridges: Puzzle #1
|
|
178
|
+
|
|
179
|
+
┌─────────────────┐
|
|
180
|
+
│ ╭───╮ ╭───╮ │
|
|
181
|
+
│ │ 1 │ │ 1 │ │
|
|
182
|
+
│ ╰───╯ ╰───╯ │
|
|
183
|
+
│ │
|
|
184
|
+
│ │
|
|
185
|
+
│ │
|
|
186
|
+
│ ╭───╮ ╭───╮ │
|
|
187
|
+
│ │ 2 │ │ 2 │ │
|
|
188
|
+
│ ╰───╯ ╰───╯ │
|
|
189
|
+
└─────────────────┘
|
|
190
|
+
|
|
191
|
+
Controls:
|
|
192
|
+
p: Previous puzzle
|
|
193
|
+
n: Next puzzle
|
|
194
|
+
s: Show solution
|
|
195
|
+
q: Quit`)
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
})
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { Box } from 'ink'
|
|
2
|
+
|
|
3
|
+
import type { HashiNodeData } from '../types.ts'
|
|
4
|
+
import HashiRow from './HashiRow.tsx'
|
|
5
|
+
import Header from './Header.tsx'
|
|
6
|
+
import Messages from './Messages.tsx'
|
|
7
|
+
|
|
8
|
+
export const ROW_HEIGHT = 3
|
|
9
|
+
export const NODE_WIDTH = 5
|
|
10
|
+
export const SPACE_BETWEEN = 0
|
|
11
|
+
export const OUTER_PADDING = 1
|
|
12
|
+
|
|
13
|
+
type HashiGridProps = {
|
|
14
|
+
/** The full data structure needed to render the grid. Height of the grid is determined
|
|
15
|
+
* by the number of rows here. */
|
|
16
|
+
rows: HashiNodeData[][]
|
|
17
|
+
/** Number of nodes in a row. */
|
|
18
|
+
numNodes: number
|
|
19
|
+
/** Show quit instructions */
|
|
20
|
+
showInstructions?: boolean
|
|
21
|
+
/** Current puzzle index for display */
|
|
22
|
+
puzzleIndex?: number
|
|
23
|
+
/** Current puzzle string for display */
|
|
24
|
+
puzzle?: string
|
|
25
|
+
/** Whether this is a custom puzzle */
|
|
26
|
+
isCustomPuzzle?: boolean
|
|
27
|
+
/** Whether this puzzle has a solution available */
|
|
28
|
+
hasSolution?: boolean
|
|
29
|
+
/** Whether to show the solution */
|
|
30
|
+
showSolution?: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Ensure the grid data is consistent with a valid Hashiwokakero puzzle.
|
|
35
|
+
*/
|
|
36
|
+
export function validateGrid({ rows, numNodes }: HashiGridProps): void {
|
|
37
|
+
if (!rows || rows.length === 0) {
|
|
38
|
+
throw new Error('HashiGrid: empty data supplied')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let rowCount = 0
|
|
42
|
+
for (const nodes of rows) {
|
|
43
|
+
const prefix = `HashiGrid row ${rowCount}: `
|
|
44
|
+
|
|
45
|
+
if (nodes.length !== numNodes) {
|
|
46
|
+
throw new Error(`${prefix}expected ${numNodes} nodes, got ${nodes.length}`)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
50
|
+
const node = nodes[i]
|
|
51
|
+
if (!node) {
|
|
52
|
+
throw new Error(`${prefix}node at position ${i} is undefined`)
|
|
53
|
+
}
|
|
54
|
+
if (
|
|
55
|
+
typeof node.value !== 'number' &&
|
|
56
|
+
node.value !== '-' &&
|
|
57
|
+
node.value !== '=' &&
|
|
58
|
+
node.value !== ' ' &&
|
|
59
|
+
node.value !== '#' &&
|
|
60
|
+
node.value !== '|'
|
|
61
|
+
) {
|
|
62
|
+
throw new Error(`${prefix}node at position ${i} has invalid value: ${node.value}`)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
rowCount++
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export default function HashiGrid({
|
|
70
|
+
rows,
|
|
71
|
+
numNodes,
|
|
72
|
+
showInstructions = false,
|
|
73
|
+
puzzleIndex = 0,
|
|
74
|
+
puzzle = '',
|
|
75
|
+
isCustomPuzzle = false,
|
|
76
|
+
hasSolution = false,
|
|
77
|
+
showSolution = false,
|
|
78
|
+
}: HashiGridProps) {
|
|
79
|
+
validateGrid({ rows, numNodes })
|
|
80
|
+
|
|
81
|
+
const height = rows.length * ROW_HEIGHT + 2
|
|
82
|
+
const borderWidth = 2
|
|
83
|
+
const outerPadding = 2 * OUTER_PADDING
|
|
84
|
+
const innerPadding = (numNodes - 1) * SPACE_BETWEEN
|
|
85
|
+
const nodesWidth = numNodes * NODE_WIDTH
|
|
86
|
+
const width = borderWidth + outerPadding + innerPadding + nodesWidth
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<Box flexDirection="column">
|
|
90
|
+
<Header
|
|
91
|
+
puzzleIndex={puzzleIndex}
|
|
92
|
+
puzzle={puzzle}
|
|
93
|
+
isCustomPuzzle={isCustomPuzzle}
|
|
94
|
+
showSolution={showSolution}
|
|
95
|
+
/>
|
|
96
|
+
<Box
|
|
97
|
+
borderStyle="single"
|
|
98
|
+
borderColor="white"
|
|
99
|
+
width={width}
|
|
100
|
+
height={height}
|
|
101
|
+
flexDirection="column"
|
|
102
|
+
>
|
|
103
|
+
{rows.map((nodes, i) => (
|
|
104
|
+
<HashiRow key={i} nodes={nodes} />
|
|
105
|
+
))}
|
|
106
|
+
</Box>
|
|
107
|
+
{showInstructions ? <Messages hasSolution={hasSolution} /> : null}
|
|
108
|
+
</Box>
|
|
109
|
+
)
|
|
110
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { Box, Text } from 'ink'
|
|
2
|
+
|
|
3
|
+
import type { HashiNodeData } from '../types.ts'
|
|
4
|
+
import { NODE_WIDTH, OUTER_PADDING, SPACE_BETWEEN } from './HashiGrid.tsx'
|
|
5
|
+
|
|
6
|
+
type HashiRowProps = {
|
|
7
|
+
nodes: HashiNodeData[]
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const TOP_ROW = 0
|
|
11
|
+
const MIDDLE_ROW = 1
|
|
12
|
+
const BOTTOM_ROW = 2
|
|
13
|
+
const ROW_HEIGHT = 3
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Build the HashiGrid node with its value and borders. Options:
|
|
17
|
+
* - node with a value (always 1 digit)
|
|
18
|
+
* - empty node - render just spaces
|
|
19
|
+
* - horizontal line - single and double
|
|
20
|
+
* - vertical line - single and double
|
|
21
|
+
*/
|
|
22
|
+
export function constructNode(node: HashiNodeData, line: 0 | 1 | 2): string {
|
|
23
|
+
// Horizontal line
|
|
24
|
+
if (node.value === '-') {
|
|
25
|
+
return line === MIDDLE_ROW ? '─────' : ' '.repeat(NODE_WIDTH)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Double horizontal line
|
|
29
|
+
if (node.value === '=') {
|
|
30
|
+
return line === MIDDLE_ROW ? '═════' : ' '.repeat(NODE_WIDTH)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Vertical line
|
|
34
|
+
if (node.value === '|') {
|
|
35
|
+
return ' │ '
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Double vertical line
|
|
39
|
+
if (node.value === '#') {
|
|
40
|
+
return ' ║ '
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Node with value to render
|
|
44
|
+
if (node.value !== ' ') {
|
|
45
|
+
if (line === TOP_ROW) {
|
|
46
|
+
const up = node.lineUp === 2 ? '╨' : node.lineUp === 1 ? '┴' : '─'
|
|
47
|
+
return `╭─${up}─╮`
|
|
48
|
+
} else if (line === MIDDLE_ROW) {
|
|
49
|
+
const left = node.lineLeft === 2 ? '╡' : node.lineLeft === 1 ? '┤' : '│'
|
|
50
|
+
const right = node.lineRight === 2 ? '╞' : node.lineRight === 1 ? '├' : '│'
|
|
51
|
+
return `${left} ${node.value} ${right}`
|
|
52
|
+
} else if (line === BOTTOM_ROW) {
|
|
53
|
+
const down = node.lineDown === 2 ? '╥' : node.lineDown === 1 ? '┬' : '─'
|
|
54
|
+
return `╰─${down}─╯`
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Empty node
|
|
59
|
+
return ' '.repeat(NODE_WIDTH)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export default function HashiRow({ nodes }: HashiRowProps) {
|
|
63
|
+
// Each row consists of multiple lines of terminal output
|
|
64
|
+
const lines: string[] = []
|
|
65
|
+
for (let line = 0; line < ROW_HEIGHT; line++) {
|
|
66
|
+
let rowStr = ' '.repeat(OUTER_PADDING)
|
|
67
|
+
|
|
68
|
+
// Render each node into the terminal line output
|
|
69
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
70
|
+
const node = nodes[i]
|
|
71
|
+
if (!node) {
|
|
72
|
+
rowStr += ' '.repeat(NODE_WIDTH)
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
rowStr += constructNode(node, line as 0 | 1 | 2)
|
|
77
|
+
|
|
78
|
+
// Add space between columns except the last
|
|
79
|
+
if (i < nodes.length - 1) {
|
|
80
|
+
rowStr += ' '.repeat(SPACE_BETWEEN)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
rowStr += ' '.repeat(OUTER_PADDING)
|
|
84
|
+
lines.push(rowStr)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<Box flexDirection="column">
|
|
89
|
+
{lines.map((line, i) => (
|
|
90
|
+
<Text key={i}>{line}</Text>
|
|
91
|
+
))}
|
|
92
|
+
</Box>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Box, Text } from 'ink'
|
|
2
|
+
|
|
3
|
+
type HeaderProps = {
|
|
4
|
+
puzzleIndex: number
|
|
5
|
+
puzzle: string
|
|
6
|
+
isCustomPuzzle?: boolean
|
|
7
|
+
showSolution?: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default function Header({
|
|
11
|
+
puzzleIndex,
|
|
12
|
+
puzzle,
|
|
13
|
+
isCustomPuzzle = false,
|
|
14
|
+
showSolution = false,
|
|
15
|
+
}: HeaderProps) {
|
|
16
|
+
const title = isCustomPuzzle
|
|
17
|
+
? `Bridges: Puzzle - ${puzzle}`
|
|
18
|
+
: `Bridges: Puzzle #${puzzleIndex + 1}${showSolution ? ' (Solution)' : ''}`
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<Box marginBottom={1}>
|
|
22
|
+
<Text bold>{title}</Text>
|
|
23
|
+
</Box>
|
|
24
|
+
)
|
|
25
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Box, Text } from 'ink'
|
|
2
|
+
|
|
3
|
+
type LegendItem = {
|
|
4
|
+
key: string
|
|
5
|
+
description: string
|
|
6
|
+
disabled?: boolean
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const LEGEND_ITEMS = (hasSolution: boolean): LegendItem[] => [
|
|
10
|
+
{ key: 'p', description: 'Previous puzzle' },
|
|
11
|
+
{ key: 'n', description: 'Next puzzle' },
|
|
12
|
+
{ key: 's', description: 'Show solution', disabled: !hasSolution },
|
|
13
|
+
{ key: 'q', description: 'Quit' },
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
type MessagesProps = {
|
|
17
|
+
hasSolution?: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default function Messages({ hasSolution = false }: MessagesProps) {
|
|
21
|
+
return (
|
|
22
|
+
<Box flexDirection="column" marginTop={1}>
|
|
23
|
+
<Text bold>Controls:</Text>
|
|
24
|
+
{LEGEND_ITEMS(hasSolution).map(item => (
|
|
25
|
+
<Box key={item.key}>
|
|
26
|
+
<Text bold color={item.disabled ? 'gray' : undefined}>
|
|
27
|
+
{item.key}
|
|
28
|
+
</Text>
|
|
29
|
+
<Text color={item.disabled ? 'gray' : undefined}>: {item.description}</Text>
|
|
30
|
+
</Box>
|
|
31
|
+
))}
|
|
32
|
+
</Box>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { render } from 'ink-testing-library'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import HashiGrid from '../HashiGrid.tsx'
|
|
5
|
+
|
|
6
|
+
describe('HashiGrid', () => {
|
|
7
|
+
it('renders multiple rows', () => {
|
|
8
|
+
const { lastFrame } = render(
|
|
9
|
+
<HashiGrid
|
|
10
|
+
numNodes={5}
|
|
11
|
+
rows={[
|
|
12
|
+
[{ value: 3 }, { value: ' ' }, { value: 2 }, { value: ' ' }, { value: ' ' }],
|
|
13
|
+
[{ value: ' ' }, { value: 1 }, { value: 3 }, { value: 2 }, { value: ' ' }],
|
|
14
|
+
[{ value: ' ' }, { value: ' ' }, { value: ' ' }, { value: 1 }, { value: 1 }],
|
|
15
|
+
[{ value: 4 }, { value: ' ' }, { value: ' ' }, { value: ' ' }, { value: 3 }],
|
|
16
|
+
]}
|
|
17
|
+
/>
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
expect(lastFrame()).toEqual(
|
|
21
|
+
`Bridges: Puzzle #1
|
|
22
|
+
|
|
23
|
+
┌───────────────────────────┐
|
|
24
|
+
│ ╭───╮ ╭───╮ │
|
|
25
|
+
│ │ 3 │ │ 2 │ │
|
|
26
|
+
│ ╰───╯ ╰───╯ │
|
|
27
|
+
│ ╭───╮╭───╮╭───╮ │
|
|
28
|
+
│ │ 1 ││ 3 ││ 2 │ │
|
|
29
|
+
│ ╰───╯╰───╯╰───╯ │
|
|
30
|
+
│ ╭───╮╭───╮ │
|
|
31
|
+
│ │ 1 ││ 1 │ │
|
|
32
|
+
│ ╰───╯╰───╯ │
|
|
33
|
+
│ ╭───╮ ╭───╮ │
|
|
34
|
+
│ │ 4 │ │ 3 │ │
|
|
35
|
+
│ ╰───╯ ╰───╯ │
|
|
36
|
+
└───────────────────────────┘`
|
|
37
|
+
)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe('grid data validation', () => {
|
|
41
|
+
it('throws if row length does not match numNodes', () => {
|
|
42
|
+
const { lastFrame } = render(
|
|
43
|
+
<HashiGrid numNodes={3} rows={[[{ value: 1 }, { value: ' ' }]]} />
|
|
44
|
+
)
|
|
45
|
+
expect(lastFrame()).toContain('expected 3 nodes, got 2')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('throws if node value is invalid', () => {
|
|
49
|
+
const { lastFrame } = render(
|
|
50
|
+
<HashiGrid numNodes={1} rows={[[{ value: 'invalid' } as never]]} />
|
|
51
|
+
)
|
|
52
|
+
expect(lastFrame()).toContain('invalid value')
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { render } from 'ink-testing-library'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import type { HashiNodeData } from '../../types.ts'
|
|
5
|
+
import HashiRow, { constructNode } from '../HashiRow.tsx'
|
|
6
|
+
|
|
7
|
+
describe('constructNode()', () => {
|
|
8
|
+
describe('empty node', () => {
|
|
9
|
+
it('renders space value with no lines', () => {
|
|
10
|
+
const node: HashiNodeData = { value: ' ' }
|
|
11
|
+
expect(constructNode(node, 0)).toEqual(' ')
|
|
12
|
+
expect(constructNode(node, 1)).toEqual(' ')
|
|
13
|
+
expect(constructNode(node, 2)).toEqual(' ')
|
|
14
|
+
})
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
describe('horizontal line node', () => {
|
|
18
|
+
it('renders a horizontal line in the middle', () => {
|
|
19
|
+
const node: HashiNodeData = { value: '-' }
|
|
20
|
+
expect(constructNode(node, 0)).toEqual(' ')
|
|
21
|
+
expect(constructNode(node, 1)).toEqual('─────')
|
|
22
|
+
expect(constructNode(node, 2)).toEqual(' ')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('renders a double horizontal line in the middle', () => {
|
|
26
|
+
const node: HashiNodeData = { value: '=' }
|
|
27
|
+
expect(constructNode(node, 0)).toEqual(' ')
|
|
28
|
+
expect(constructNode(node, 1)).toEqual('═════')
|
|
29
|
+
expect(constructNode(node, 2)).toEqual(' ')
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe('vertical line node', () => {
|
|
34
|
+
it('renders a vertical line in the center', () => {
|
|
35
|
+
const node: HashiNodeData = { value: '|' }
|
|
36
|
+
expect(constructNode(node, 0)).toEqual(' │ ')
|
|
37
|
+
expect(constructNode(node, 1)).toEqual(' │ ')
|
|
38
|
+
expect(constructNode(node, 2)).toEqual(' │ ')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('renders a double vertical line in the center', () => {
|
|
42
|
+
const node: HashiNodeData = { value: '#' }
|
|
43
|
+
expect(constructNode(node, 0)).toEqual(' ║ ')
|
|
44
|
+
expect(constructNode(node, 1)).toEqual(' ║ ')
|
|
45
|
+
expect(constructNode(node, 2)).toEqual(' ║ ')
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe('node with value', () => {
|
|
50
|
+
describe('TOP_ROW', () => {
|
|
51
|
+
it('renders top border', () => {
|
|
52
|
+
const node: HashiNodeData = { value: 5 }
|
|
53
|
+
expect(constructNode(node, 0)).toEqual('╭───╮')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('renders border with vertical line up', () => {
|
|
57
|
+
const node: HashiNodeData = { value: 5, lineUp: 1 }
|
|
58
|
+
expect(constructNode(node, 0)).toEqual('╭─┴─╮')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('renders border with double vertical line up', () => {
|
|
62
|
+
const node: HashiNodeData = { value: 5, lineUp: 2 }
|
|
63
|
+
expect(constructNode(node, 0)).toEqual('╭─╨─╮')
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
describe('MIDDLE_ROW', () => {
|
|
68
|
+
it('renders middle row - value with vertical borders', () => {
|
|
69
|
+
const node: HashiNodeData = { value: 5 }
|
|
70
|
+
expect(constructNode(node, 1)).toEqual('│ 5 │')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('renders value with horizontal line on left', () => {
|
|
74
|
+
const node: HashiNodeData = { value: 5, lineLeft: 1 }
|
|
75
|
+
expect(constructNode(node, 1)).toEqual('┤ 5 │')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('renders value with horizontal line on right', () => {
|
|
79
|
+
const node: HashiNodeData = { value: 5, lineRight: 1 }
|
|
80
|
+
expect(constructNode(node, 1)).toEqual('│ 5 ├')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('renders value with horizontal lines on both sides', () => {
|
|
84
|
+
const node: HashiNodeData = { value: 5, lineLeft: 1, lineRight: 1 }
|
|
85
|
+
expect(constructNode(node, 1)).toEqual('┤ 5 ├')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('renders value with double horizontal lines on both sides', () => {
|
|
89
|
+
const node: HashiNodeData = { value: 5, lineLeft: 2, lineRight: 2 }
|
|
90
|
+
expect(constructNode(node, 1)).toEqual('╡ 5 ╞')
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('BOTTOM_ROW', () => {
|
|
95
|
+
it('renders bottom border without lines', () => {
|
|
96
|
+
const node: HashiNodeData = { value: 5 }
|
|
97
|
+
expect(constructNode(node, 2)).toEqual('╰───╯')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('renders border with vertical line down', () => {
|
|
101
|
+
const node: HashiNodeData = { value: 5, lineDown: 1 }
|
|
102
|
+
expect(constructNode(node, 2)).toEqual('╰─┬─╯')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('renders border with double vertical line down', () => {
|
|
106
|
+
const node: HashiNodeData = { value: 5, lineDown: 2 }
|
|
107
|
+
expect(constructNode(node, 2)).toEqual('╰─╥─╯')
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
describe('HashiRow component', () => {
|
|
114
|
+
it('renders three nodes', () => {
|
|
115
|
+
const { lastFrame } = render(
|
|
116
|
+
<HashiRow nodes={[{ value: 1 }, { value: 2 }, { value: 3 }]} />
|
|
117
|
+
)
|
|
118
|
+
expect(lastFrame()).toEqual(
|
|
119
|
+
` ╭───╮╭───╮╭───╮
|
|
120
|
+
│ 1 ││ 2 ││ 3 │
|
|
121
|
+
╰───╯╰───╯╰───╯`
|
|
122
|
+
)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('renders nodes connected horizontally', () => {
|
|
126
|
+
const { lastFrame } = render(
|
|
127
|
+
<HashiRow
|
|
128
|
+
nodes={[{ value: 1, lineRight: 1 }, { value: '-' }, { value: 3, lineLeft: 1 }]}
|
|
129
|
+
/>
|
|
130
|
+
)
|
|
131
|
+
expect(lastFrame()).toEqual(` ╭───╮ ╭───╮
|
|
132
|
+
│ 1 ├─────┤ 3 │
|
|
133
|
+
╰───╯ ╰───╯`)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('renders a vertical node', () => {
|
|
137
|
+
const { lastFrame } = render(
|
|
138
|
+
<HashiRow nodes={[{ value: 1 }, { value: '|' }, { value: 3 }]} />
|
|
139
|
+
)
|
|
140
|
+
expect(lastFrame()).toEqual(` ╭───╮ │ ╭───╮
|
|
141
|
+
│ 1 │ │ │ 3 │
|
|
142
|
+
╰───╯ │ ╰───╯`)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('renders empty positions as spaces', () => {
|
|
146
|
+
const { lastFrame } = render(
|
|
147
|
+
<HashiRow nodes={[{ value: 1 }, { value: ' ' }, { value: 3 }]} />
|
|
148
|
+
)
|
|
149
|
+
expect(lastFrame()).toEqual(
|
|
150
|
+
` ╭───╮ ╭───╮
|
|
151
|
+
│ 1 │ │ 3 │
|
|
152
|
+
╰───╯ ╰───╯`
|
|
153
|
+
)
|
|
154
|
+
})
|
|
155
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { render } from 'ink-testing-library'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import Messages from '../Messages.tsx'
|
|
5
|
+
|
|
6
|
+
describe('Messages', () => {
|
|
7
|
+
it('renders the legend', () => {
|
|
8
|
+
const { lastFrame } = render(<Messages />)
|
|
9
|
+
expect(lastFrame()).toContain('Controls:')
|
|
10
|
+
expect(lastFrame()).toContain('p: Previous puzzle')
|
|
11
|
+
expect(lastFrame()).toContain('n: Next puzzle')
|
|
12
|
+
expect(lastFrame()).toContain('q: Quit')
|
|
13
|
+
})
|
|
14
|
+
})
|