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.
@@ -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
+ })