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.
@@ -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 { NODE_WIDTH, OUTER_PADDING, SPACE_BETWEEN } from './HashiGrid.tsx'
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
- 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
- }
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
- // 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}─╯`
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
- let rowStr = ' '.repeat(OUTER_PADDING)
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
- rowStr += ' '.repeat(NODE_WIDTH)
50
+ rowItems.push(' '.repeat(NODE_WIDTH))
73
51
  continue
74
52
  }
75
53
 
76
- rowStr += constructNode(node, line as 0 | 1 | 2)
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
- rowStr += ' '.repeat(SPACE_BETWEEN)
67
+ rowItems.push(' '.repeat(SPACE_BETWEEN))
81
68
  }
82
69
  }
83
- rowStr += ' '.repeat(OUTER_PADDING)
84
- lines.push(rowStr)
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}${showSolution ? ' (Solution)' : ''}`
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
- 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
- ]
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
- {LEGEND_ITEMS(hasSolution).map(item => (
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}
@@ -19,6 +19,7 @@ describe('HashiGrid', () => {
19
19
 
20
20
  expect(lastFrame()).toEqual(
21
21
  `Bridges: Puzzle #1
22
+ • Type a number to select a node
22
23
 
23
24
  ┌───────────────────────────┐
24
25
  │ ╭───╮ ╭───╮ │
@@ -1,119 +1,12 @@
1
1
  import { render } from 'ink-testing-library'
2
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
- })
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
+ })