bridges-cli 0.0.1 → 0.1.0
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 +4 -5
- package/README.md +13 -7
- package/package.json +3 -3
- package/src/Game.tsx +141 -11
- package/src/__tests__/Game.test.tsx +482 -24
- package/src/components/HashiGrid.tsx +30 -44
- package/src/components/HashiRow.tsx +54 -66
- package/src/components/Header.tsx +48 -2
- package/src/components/Messages.tsx +15 -8
- package/src/components/__tests__/HashiGrid.test.tsx +1 -0
- package/src/components/__tests__/HashiRow.test.tsx +100 -112
- package/src/components/__tests__/Header.test.tsx +156 -0
- package/src/components/__tests__/Messages.test.tsx +46 -1
- package/src/demo.tsx +20 -0
- package/src/index.tsx +25 -7
- package/src/types.ts +34 -0
- package/src/utils/__tests__/bridges.test.tsx +137 -0
- package/src/utils/__tests__/findNodeInDirection.test.ts +75 -0
- package/src/utils/__tests__/{parsePuzzle.test.ts → puzzle-encoding.test.ts} +1 -1
- package/src/utils/bridges.ts +171 -0
- package/src/utils/puzzle-encoding.ts +286 -0
- package/src/utils/usePuzzleInput.ts +385 -13
- package/src/utils/parsePuzzle.ts +0 -178
- package/src/utils/samplePuzzles.ts +0 -59
|
@@ -1,94 +1,82 @@
|
|
|
1
1
|
import { Box, Text } from 'ink'
|
|
2
|
+
import type React from 'react'
|
|
2
3
|
|
|
3
|
-
import type { HashiNodeData } from '../types.ts'
|
|
4
|
-
import {
|
|
4
|
+
import type { HashiNodeData, SelectionState } from '../types.ts'
|
|
5
|
+
import {
|
|
6
|
+
constructNode,
|
|
7
|
+
getDisplayMode,
|
|
8
|
+
NODE_WIDTH,
|
|
9
|
+
OUTER_PADDING,
|
|
10
|
+
ROW_HEIGHT,
|
|
11
|
+
SPACE_BETWEEN,
|
|
12
|
+
} from '../utils/bridges.ts'
|
|
5
13
|
|
|
6
14
|
type HashiRowProps = {
|
|
7
15
|
nodes: HashiNodeData[]
|
|
16
|
+
highlightedNode?: number
|
|
17
|
+
rowIndex: number
|
|
18
|
+
selectionState?: SelectionState
|
|
8
19
|
}
|
|
9
20
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
}
|
|
21
|
+
export default function HashiRow({
|
|
22
|
+
nodes,
|
|
23
|
+
highlightedNode,
|
|
24
|
+
rowIndex,
|
|
25
|
+
selectionState,
|
|
26
|
+
}: HashiRowProps) {
|
|
27
|
+
// Each row consists of multiple lines of terminal output
|
|
28
|
+
const lines: React.ReactNode[] = []
|
|
42
29
|
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
} else if (line === BOTTOM_ROW) {
|
|
53
|
-
const down = node.lineDown === 2 ? '╥' : node.lineDown === 1 ? '┬' : '─'
|
|
54
|
-
return `╰─${down}─╯`
|
|
30
|
+
// Build a map of col index to disambiguation label for this row
|
|
31
|
+
const disambiguationMap: Record<number, string> = {}
|
|
32
|
+
if (selectionState?.mode === 'disambiguation' && selectionState.matchingNodes) {
|
|
33
|
+
for (let i = 0; i < selectionState.matchingNodes.length; i++) {
|
|
34
|
+
const match = selectionState.matchingNodes[i]
|
|
35
|
+
const label = selectionState.disambiguationLabels[i]
|
|
36
|
+
if (match && match.row === rowIndex) {
|
|
37
|
+
disambiguationMap[match.col] = label ?? ''
|
|
38
|
+
}
|
|
55
39
|
}
|
|
56
40
|
}
|
|
57
41
|
|
|
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
42
|
for (let line = 0; line < ROW_HEIGHT; line++) {
|
|
66
|
-
|
|
43
|
+
const rowItems: React.ReactNode[] = []
|
|
67
44
|
|
|
68
45
|
// Render each node into the terminal line output
|
|
69
46
|
for (let i = 0; i < nodes.length; i++) {
|
|
70
47
|
const node = nodes[i]
|
|
48
|
+
|
|
71
49
|
if (!node) {
|
|
72
|
-
|
|
50
|
+
rowItems.push(' '.repeat(NODE_WIDTH))
|
|
73
51
|
continue
|
|
74
52
|
}
|
|
75
53
|
|
|
76
|
-
|
|
54
|
+
const displayMode = getDisplayMode(
|
|
55
|
+
node,
|
|
56
|
+
highlightedNode,
|
|
57
|
+
rowIndex,
|
|
58
|
+
i,
|
|
59
|
+
selectionState?.selectedNode ?? null,
|
|
60
|
+
selectionState?.mode
|
|
61
|
+
)
|
|
62
|
+
const label = disambiguationMap[i]
|
|
63
|
+
rowItems.push(constructNode(node, line as 0 | 1 | 2, displayMode, label))
|
|
77
64
|
|
|
78
65
|
// Add space between columns except the last
|
|
79
66
|
if (i < nodes.length - 1) {
|
|
80
|
-
|
|
67
|
+
rowItems.push(' '.repeat(SPACE_BETWEEN))
|
|
81
68
|
}
|
|
82
69
|
}
|
|
83
|
-
|
|
84
|
-
|
|
70
|
+
|
|
71
|
+
const rowStr = (
|
|
72
|
+
<>
|
|
73
|
+
{' '.repeat(OUTER_PADDING)}
|
|
74
|
+
{rowItems}
|
|
75
|
+
{' '.repeat(OUTER_PADDING)}
|
|
76
|
+
</>
|
|
77
|
+
)
|
|
78
|
+
lines.push(<Text key={line}>{rowStr}</Text>)
|
|
85
79
|
}
|
|
86
80
|
|
|
87
|
-
return
|
|
88
|
-
<Box flexDirection="column">
|
|
89
|
-
{lines.map((line, i) => (
|
|
90
|
-
<Text key={i}>{line}</Text>
|
|
91
|
-
))}
|
|
92
|
-
</Box>
|
|
93
|
-
)
|
|
81
|
+
return <Box flexDirection="column">{lines}</Box>
|
|
94
82
|
}
|
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
import { Box, Text } from 'ink'
|
|
2
2
|
|
|
3
|
+
import type { SelectionState } from '../types.ts'
|
|
4
|
+
|
|
3
5
|
type HeaderProps = {
|
|
4
6
|
puzzleIndex: number
|
|
5
7
|
puzzle: string
|
|
6
8
|
isCustomPuzzle?: boolean
|
|
7
9
|
showSolution?: boolean
|
|
10
|
+
selectionState?: SelectionState
|
|
11
|
+
minNumber?: number
|
|
12
|
+
maxNumber?: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const directionNames: Record<string, string> = {
|
|
16
|
+
h: 'left',
|
|
17
|
+
j: 'down',
|
|
18
|
+
k: 'up',
|
|
19
|
+
l: 'right',
|
|
8
20
|
}
|
|
9
21
|
|
|
10
22
|
export default function Header({
|
|
@@ -12,14 +24,48 @@ export default function Header({
|
|
|
12
24
|
puzzle,
|
|
13
25
|
isCustomPuzzle = false,
|
|
14
26
|
showSolution = false,
|
|
27
|
+
selectionState,
|
|
28
|
+
minNumber,
|
|
29
|
+
maxNumber,
|
|
15
30
|
}: HeaderProps) {
|
|
16
31
|
const title = isCustomPuzzle
|
|
17
32
|
? `Bridges: Puzzle - ${puzzle}`
|
|
18
|
-
: `Bridges: Puzzle #${puzzleIndex + 1}
|
|
33
|
+
: `Bridges: Puzzle #${puzzleIndex + 1}`
|
|
34
|
+
|
|
35
|
+
let statusText = ''
|
|
36
|
+
if (selectionState) {
|
|
37
|
+
const { mode, selectedNumber, direction, bridgeErased, isDoubleBridge } = selectionState
|
|
38
|
+
if (mode === 'selecting-node' && selectedNumber !== null) {
|
|
39
|
+
statusText = 'Select direction with h/j/k/l (H/J/K/L = double line)'
|
|
40
|
+
} else if (mode === 'disambiguation' && selectedNumber !== null) {
|
|
41
|
+
statusText = 'Press label shown to select that node'
|
|
42
|
+
} else if (mode === 'selected' && selectedNumber !== null && direction) {
|
|
43
|
+
if (bridgeErased) {
|
|
44
|
+
statusText = 'Erased bridge'
|
|
45
|
+
} else {
|
|
46
|
+
const lineType = direction === 'h' || direction === 'l' ? 'horizontal' : 'vertical'
|
|
47
|
+
const bridgeType = isDoubleBridge ? 'double ' : ''
|
|
48
|
+
statusText = `Drew ${bridgeType}${lineType} bridge`
|
|
49
|
+
}
|
|
50
|
+
} else if (mode === 'invalid' && selectedNumber !== null && direction) {
|
|
51
|
+
statusText = `Cannot draw bridge ${directionNames[direction]} from node`
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const idleMessage = showSolution
|
|
56
|
+
? 'Viewing solution (press s to return to puzzle)'
|
|
57
|
+
: minNumber !== undefined && maxNumber !== undefined
|
|
58
|
+
? `Type a number [${minNumber}-${maxNumber}] to select a node`
|
|
59
|
+
: 'Type a number to select a node'
|
|
19
60
|
|
|
20
61
|
return (
|
|
21
|
-
<Box marginBottom={1}>
|
|
62
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
22
63
|
<Text bold>{title}</Text>
|
|
64
|
+
{statusText ? (
|
|
65
|
+
<Text dimColor>• {statusText}</Text>
|
|
66
|
+
) : (
|
|
67
|
+
<Text dimColor>• {idleMessage}</Text>
|
|
68
|
+
)}
|
|
23
69
|
</Box>
|
|
24
70
|
)
|
|
25
71
|
}
|
|
@@ -1,27 +1,34 @@
|
|
|
1
1
|
import { Box, Text } from 'ink'
|
|
2
2
|
|
|
3
|
+
import type { SelectionState } from '../types.ts'
|
|
4
|
+
|
|
3
5
|
type LegendItem = {
|
|
4
6
|
key: string
|
|
5
7
|
description: string
|
|
6
8
|
disabled?: boolean
|
|
7
9
|
}
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
export function legendItems(hasSolution: boolean, isSelecting: boolean): LegendItem[] {
|
|
12
|
+
return [
|
|
13
|
+
{ key: 'p', description: 'Previous puzzle', disabled: isSelecting },
|
|
14
|
+
{ key: 'n', description: 'Next puzzle', disabled: isSelecting },
|
|
15
|
+
{ key: 's', description: 'Show solution', disabled: isSelecting || !hasSolution },
|
|
16
|
+
{ key: 'q', description: 'Quit' },
|
|
17
|
+
]
|
|
18
|
+
}
|
|
15
19
|
|
|
16
20
|
type MessagesProps = {
|
|
17
21
|
hasSolution?: boolean
|
|
22
|
+
selectionState?: SelectionState
|
|
18
23
|
}
|
|
19
24
|
|
|
20
|
-
export default function Messages({ hasSolution = false }: MessagesProps) {
|
|
25
|
+
export default function Messages({ hasSolution = false, selectionState }: MessagesProps) {
|
|
26
|
+
const isSelecting = selectionState !== undefined && selectionState.mode !== 'idle'
|
|
27
|
+
|
|
21
28
|
return (
|
|
22
29
|
<Box flexDirection="column" marginTop={1}>
|
|
23
30
|
<Text bold>Controls:</Text>
|
|
24
|
-
{
|
|
31
|
+
{legendItems(hasSolution, isSelecting).map(item => (
|
|
25
32
|
<Box key={item.key}>
|
|
26
33
|
<Text bold color={item.disabled ? 'gray' : undefined}>
|
|
27
34
|
{item.key}
|
|
@@ -1,119 +1,12 @@
|
|
|
1
1
|
import { render } from 'ink-testing-library'
|
|
2
2
|
import { describe, expect, it } from 'vitest'
|
|
3
|
-
|
|
4
|
-
import type {
|
|
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
|
-
})
|
|
3
|
+
import HashiRow from '../../components/HashiRow.tsx'
|
|
4
|
+
import type { SelectionState } from '../../types.ts'
|
|
112
5
|
|
|
113
6
|
describe('HashiRow component', () => {
|
|
114
7
|
it('renders three nodes', () => {
|
|
115
8
|
const { lastFrame } = render(
|
|
116
|
-
<HashiRow nodes={[{ value: 1 }, { value: 2 }, { value: 3 }]} />
|
|
9
|
+
<HashiRow nodes={[{ value: 1 }, { value: 2 }, { value: 3 }]} rowIndex={0} />
|
|
117
10
|
)
|
|
118
11
|
expect(lastFrame()).toEqual(
|
|
119
12
|
` ╭───╮╭───╮╭───╮
|
|
@@ -126,6 +19,7 @@ describe('HashiRow component', () => {
|
|
|
126
19
|
const { lastFrame } = render(
|
|
127
20
|
<HashiRow
|
|
128
21
|
nodes={[{ value: 1, lineRight: 1 }, { value: '-' }, { value: 3, lineLeft: 1 }]}
|
|
22
|
+
rowIndex={0}
|
|
129
23
|
/>
|
|
130
24
|
)
|
|
131
25
|
expect(lastFrame()).toEqual(` ╭───╮ ╭───╮
|
|
@@ -135,7 +29,7 @@ describe('HashiRow component', () => {
|
|
|
135
29
|
|
|
136
30
|
it('renders a vertical node', () => {
|
|
137
31
|
const { lastFrame } = render(
|
|
138
|
-
<HashiRow nodes={[{ value: 1 }, { value: '|' }, { value: 3 }]} />
|
|
32
|
+
<HashiRow nodes={[{ value: 1 }, { value: '|' }, { value: 3 }]} rowIndex={0} />
|
|
139
33
|
)
|
|
140
34
|
expect(lastFrame()).toEqual(` ╭───╮ │ ╭───╮
|
|
141
35
|
│ 1 │ │ │ 3 │
|
|
@@ -144,7 +38,7 @@ describe('HashiRow component', () => {
|
|
|
144
38
|
|
|
145
39
|
it('renders empty positions as spaces', () => {
|
|
146
40
|
const { lastFrame } = render(
|
|
147
|
-
<HashiRow nodes={[{ value: 1 }, { value: ' ' }, { value: 3 }]} />
|
|
41
|
+
<HashiRow nodes={[{ value: 1 }, { value: ' ' }, { value: 3 }]} rowIndex={0} />
|
|
148
42
|
)
|
|
149
43
|
expect(lastFrame()).toEqual(
|
|
150
44
|
` ╭───╮ ╭───╮
|
|
@@ -152,4 +46,98 @@ describe('HashiRow component', () => {
|
|
|
152
46
|
╰───╯ ╰───╯`
|
|
153
47
|
)
|
|
154
48
|
})
|
|
49
|
+
|
|
50
|
+
describe('highlighted nodes', () => {
|
|
51
|
+
it('renders highlighted node with bold when value matches', () => {
|
|
52
|
+
const { lastFrame } = render(
|
|
53
|
+
<HashiRow nodes={[{ value: 1 }]} rowIndex={0} highlightedNode={1} />
|
|
54
|
+
)
|
|
55
|
+
expect(lastFrame()).toEqual(
|
|
56
|
+
` \x1b[1m╭───╮\x1b[22m
|
|
57
|
+
\x1b[1m│ 1 │\x1b[22m
|
|
58
|
+
\x1b[1m╰───╯\x1b[22m`
|
|
59
|
+
)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('renders dimmed node when value does not match', () => {
|
|
63
|
+
const { lastFrame } = render(
|
|
64
|
+
<HashiRow nodes={[{ value: 1 }]} rowIndex={0} highlightedNode={2} />
|
|
65
|
+
)
|
|
66
|
+
expect(lastFrame()).toEqual(
|
|
67
|
+
` \x1b[2m╭───╮\x1b[22m
|
|
68
|
+
\x1b[2m│ 1 │\x1b[22m
|
|
69
|
+
\x1b[2m╰───╯\x1b[22m`
|
|
70
|
+
)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('renders multiple nodes with one highlighted and others dimmed', () => {
|
|
74
|
+
const { lastFrame } = render(
|
|
75
|
+
<HashiRow
|
|
76
|
+
nodes={[{ value: 1 }, { value: 2 }, { value: 3 }]}
|
|
77
|
+
rowIndex={0}
|
|
78
|
+
highlightedNode={2}
|
|
79
|
+
/>
|
|
80
|
+
)
|
|
81
|
+
expect(lastFrame()).toEqual(
|
|
82
|
+
` \x1b[2m╭───╮\x1b[22m\x1b[1m╭───╮\x1b[22m\x1b[2m╭───╮\x1b[22m
|
|
83
|
+
\x1b[2m│ 1 │\x1b[22m\x1b[1m│ 2 │\x1b[22m\x1b[2m│ 3 │\x1b[22m
|
|
84
|
+
\x1b[2m╰───╯\x1b[22m\x1b[1m╰───╯\x1b[22m\x1b[2m╰───╯\x1b[22m`
|
|
85
|
+
)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('renders normal when highlightedNode is undefined', () => {
|
|
89
|
+
const { lastFrame } = render(<HashiRow nodes={[{ value: 1 }]} rowIndex={0} />)
|
|
90
|
+
expect(lastFrame()).toEqual(
|
|
91
|
+
` ╭───╮
|
|
92
|
+
│ 1 │
|
|
93
|
+
╰───╯`
|
|
94
|
+
)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('highlights only specific node in selecting-node mode', () => {
|
|
98
|
+
const selectionState: SelectionState = {
|
|
99
|
+
mode: 'selecting-node',
|
|
100
|
+
selectedNumber: 1,
|
|
101
|
+
direction: null,
|
|
102
|
+
matchingNodes: [{ row: 0, col: 1 }],
|
|
103
|
+
disambiguationLabels: [],
|
|
104
|
+
selectedNode: { row: 0, col: 1 },
|
|
105
|
+
}
|
|
106
|
+
const { lastFrame } = render(
|
|
107
|
+
<HashiRow
|
|
108
|
+
nodes={[{ value: 1 }, { value: 1 }, { value: 1 }]}
|
|
109
|
+
rowIndex={0}
|
|
110
|
+
selectionState={selectionState}
|
|
111
|
+
/>
|
|
112
|
+
)
|
|
113
|
+
expect(lastFrame()).toEqual(
|
|
114
|
+
` \x1b[2m╭───╮\x1b[22m\x1b[1m╭───╮\x1b[22m\x1b[2m╭───╮\x1b[22m
|
|
115
|
+
\x1b[2m│ 1 │\x1b[22m\x1b[1m│ 1 │\x1b[22m\x1b[2m│ 1 │\x1b[22m
|
|
116
|
+
\x1b[2m╰───╯\x1b[22m\x1b[1m╰───╯\x1b[22m\x1b[2m╰───╯\x1b[22m`
|
|
117
|
+
)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('dims non-selected nodes in invalid mode but keeps selected highlighted', () => {
|
|
121
|
+
const selectionState: SelectionState = {
|
|
122
|
+
mode: 'invalid',
|
|
123
|
+
selectedNumber: 1,
|
|
124
|
+
direction: 'h',
|
|
125
|
+
matchingNodes: [{ row: 0, col: 1 }],
|
|
126
|
+
disambiguationLabels: [],
|
|
127
|
+
selectedNode: { row: 0, col: 1 },
|
|
128
|
+
}
|
|
129
|
+
const { lastFrame } = render(
|
|
130
|
+
<HashiRow
|
|
131
|
+
nodes={[{ value: 1 }, { value: 1 }, { value: 1 }]}
|
|
132
|
+
rowIndex={0}
|
|
133
|
+
selectionState={selectionState}
|
|
134
|
+
/>
|
|
135
|
+
)
|
|
136
|
+
expect(lastFrame()).toEqual(
|
|
137
|
+
` \x1b[2m╭───╮\x1b[22m\x1b[1m╭───╮\x1b[22m\x1b[2m╭───╮\x1b[22m
|
|
138
|
+
\x1b[2m│ 1 │\x1b[22m\x1b[1m│ 1 │\x1b[22m\x1b[2m│ 1 │\x1b[22m
|
|
139
|
+
\x1b[2m╰───╯\x1b[22m\x1b[1m╰───╯\x1b[22m\x1b[2m╰───╯\x1b[22m`
|
|
140
|
+
)
|
|
141
|
+
})
|
|
142
|
+
})
|
|
155
143
|
})
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { render } from 'ink-testing-library'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import type { SelectionState } from '../../types.ts'
|
|
4
|
+
import Header from '../Header.tsx'
|
|
5
|
+
|
|
6
|
+
describe('Header', () => {
|
|
7
|
+
it('shows idle message when no selection state and no number range', () => {
|
|
8
|
+
const { lastFrame } = render(<Header puzzleIndex={0} puzzle="5x5:1a1" />)
|
|
9
|
+
expect(lastFrame()).toContain('Type a number to select a node')
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('shows dynamic number range when provided', () => {
|
|
13
|
+
const { lastFrame } = render(
|
|
14
|
+
<Header puzzleIndex={0} puzzle="5x5:1a1" minNumber={2} maxNumber={7} />
|
|
15
|
+
)
|
|
16
|
+
expect(lastFrame()).toContain('Type a number [2-7] to select a node')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
describe('selection state messages', () => {
|
|
20
|
+
it('shows "select direction" message after we disambiguate which node', () => {
|
|
21
|
+
const selectionState: SelectionState = {
|
|
22
|
+
mode: 'selecting-node',
|
|
23
|
+
selectedNumber: 1,
|
|
24
|
+
direction: null,
|
|
25
|
+
matchingNodes: [
|
|
26
|
+
{ row: 0, col: 0 },
|
|
27
|
+
{ row: 1, col: 1 },
|
|
28
|
+
],
|
|
29
|
+
disambiguationLabels: [],
|
|
30
|
+
selectedNode: { row: 0, col: 0 },
|
|
31
|
+
}
|
|
32
|
+
const { lastFrame } = render(
|
|
33
|
+
<Header puzzleIndex={0} puzzle="5x5:1a1" selectionState={selectionState} />
|
|
34
|
+
)
|
|
35
|
+
expect(lastFrame()).toContain(`Select direction with`)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('shows disambiguation message without direction', () => {
|
|
39
|
+
const selectionState: SelectionState = {
|
|
40
|
+
mode: 'disambiguation',
|
|
41
|
+
selectedNumber: 1,
|
|
42
|
+
direction: null,
|
|
43
|
+
matchingNodes: [
|
|
44
|
+
{ row: 0, col: 0 },
|
|
45
|
+
{ row: 1, col: 2 },
|
|
46
|
+
],
|
|
47
|
+
disambiguationLabels: ['a', 'b'],
|
|
48
|
+
selectedNode: null,
|
|
49
|
+
}
|
|
50
|
+
const { lastFrame } = render(
|
|
51
|
+
<Header puzzleIndex={0} puzzle="5x5:1a1" selectionState={selectionState} />
|
|
52
|
+
)
|
|
53
|
+
expect(lastFrame()).toContain('Press label shown to select that node')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('shows disambiguation message with direction', () => {
|
|
57
|
+
const selectionState: SelectionState = {
|
|
58
|
+
mode: 'disambiguation',
|
|
59
|
+
selectedNumber: 1,
|
|
60
|
+
direction: 'h',
|
|
61
|
+
matchingNodes: [
|
|
62
|
+
{ row: 0, col: 0 },
|
|
63
|
+
{ row: 1, col: 2 },
|
|
64
|
+
],
|
|
65
|
+
disambiguationLabels: ['a', 'b'],
|
|
66
|
+
selectedNode: null,
|
|
67
|
+
}
|
|
68
|
+
const { lastFrame } = render(
|
|
69
|
+
<Header puzzleIndex={0} puzzle="5x5:1a1" selectionState={selectionState} />
|
|
70
|
+
)
|
|
71
|
+
expect(lastFrame()).toContain('Press label shown to select that node')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('shows selected message with direction (horizontal)', () => {
|
|
75
|
+
const selectionState: SelectionState = {
|
|
76
|
+
mode: 'selected',
|
|
77
|
+
selectedNumber: 1,
|
|
78
|
+
direction: 'l',
|
|
79
|
+
matchingNodes: [{ row: 0, col: 0 }],
|
|
80
|
+
disambiguationLabels: [],
|
|
81
|
+
selectedNode: { row: 0, col: 0 },
|
|
82
|
+
}
|
|
83
|
+
const { lastFrame } = render(
|
|
84
|
+
<Header puzzleIndex={0} puzzle="5x5:1a1" selectionState={selectionState} />
|
|
85
|
+
)
|
|
86
|
+
expect(lastFrame()).toContain('Drew horizontal bridge')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('shows selected message with direction (vertical)', () => {
|
|
90
|
+
const selectionState: SelectionState = {
|
|
91
|
+
mode: 'selected',
|
|
92
|
+
selectedNumber: 1,
|
|
93
|
+
direction: 'k',
|
|
94
|
+
matchingNodes: [{ row: 0, col: 0 }],
|
|
95
|
+
disambiguationLabels: [],
|
|
96
|
+
selectedNode: { row: 0, col: 0 },
|
|
97
|
+
}
|
|
98
|
+
const { lastFrame } = render(
|
|
99
|
+
<Header puzzleIndex={0} puzzle="5x5:1a1" selectionState={selectionState} />
|
|
100
|
+
)
|
|
101
|
+
expect(lastFrame()).toContain('Drew vertical bridge')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('shows erased bridge message when bridgeErased is true', () => {
|
|
105
|
+
const selectionState: SelectionState = {
|
|
106
|
+
mode: 'selected',
|
|
107
|
+
selectedNumber: 1,
|
|
108
|
+
direction: 'l',
|
|
109
|
+
matchingNodes: [{ row: 0, col: 0 }],
|
|
110
|
+
disambiguationLabels: [],
|
|
111
|
+
selectedNode: { row: 0, col: 0 },
|
|
112
|
+
bridgeErased: true,
|
|
113
|
+
}
|
|
114
|
+
const { lastFrame } = render(
|
|
115
|
+
<Header puzzleIndex={0} puzzle="5x5:1a1" selectionState={selectionState} />
|
|
116
|
+
)
|
|
117
|
+
expect(lastFrame()).toContain('Erased bridge')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('shows invalid message when no node in direction', () => {
|
|
121
|
+
const selectionState: SelectionState = {
|
|
122
|
+
mode: 'invalid',
|
|
123
|
+
selectedNumber: 1,
|
|
124
|
+
direction: 'h',
|
|
125
|
+
matchingNodes: [{ row: 0, col: 0 }],
|
|
126
|
+
disambiguationLabels: [],
|
|
127
|
+
selectedNode: { row: 0, col: 0 },
|
|
128
|
+
}
|
|
129
|
+
const { lastFrame } = render(
|
|
130
|
+
<Header puzzleIndex={0} puzzle="5x5:1a1" selectionState={selectionState} />
|
|
131
|
+
)
|
|
132
|
+
expect(lastFrame()).toContain('Cannot draw bridge left from node')
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
describe('puzzle display', () => {
|
|
137
|
+
it('shows puzzle number for indexed puzzles', () => {
|
|
138
|
+
const { lastFrame } = render(<Header puzzleIndex={2} puzzle="5x5:1a1" />)
|
|
139
|
+
expect(lastFrame()).toContain('Bridges: Puzzle #3')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('shows solution suffix when showSolution is true', () => {
|
|
143
|
+
const { lastFrame } = render(
|
|
144
|
+
<Header puzzleIndex={0} puzzle="5x5:1a1" showSolution={true} />
|
|
145
|
+
)
|
|
146
|
+
expect(lastFrame()).toContain('Viewing solution (press s to return to puzzle)')
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('shows custom puzzle label for custom puzzles', () => {
|
|
150
|
+
const { lastFrame } = render(
|
|
151
|
+
<Header puzzleIndex={0} puzzle="5x5:1a1" isCustomPuzzle={true} />
|
|
152
|
+
)
|
|
153
|
+
expect(lastFrame()).toContain('Bridges: Puzzle - 5x5:1a1')
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
})
|