Skip to content

Commit 98b65f3

Browse files
authored
Merge pull request #5 from heskew/claude/plan-next-tasks-axJvs
More tests and separate tests dir
2 parents 19d1d1f + 17c3f68 commit 98b65f3

21 files changed

Lines changed: 921 additions & 15 deletions

CLAUDE.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ src/
1818
app.tsx Root shell: navigation stack, key handling, screen routing, clearScreen on nav
1919
logger.ts snooplogg setup — writes to ~/.dug/debug.log when enabled
2020
relationships.ts Relationship inference from API metadata + naming conventions
21-
test-helpers.ts Shared test factories: makeSchema, makeAttr, mockFetch
2221
api/
2322
client.ts HarperClient — Operations API over HTTP, Basic Auth, caching, retries
2423
types.ts Zod schemas for all Harper API request/response shapes
@@ -39,6 +38,12 @@ src/
3938
json-tree.tsx Syntax-highlighted JSON renderer with annotations and line selection
4039
key-hints.tsx Bottom key hint bar
4140
query-builder.tsx Condition builder with attribute/comparator/value editing
41+
tests/
42+
test-helpers.ts Shared test factories: makeSchema, makeAttr, mockFetch
43+
api/ Tests for HarperClient and Zod schemas
44+
hooks/ Tests for navigation reducer, useApi, useTerminalSize
45+
screens/ Tests for all screen components
46+
components/ Tests for DataTable, JsonTree, KeyHints, Breadcrumb, QueryBuilder
4247
```
4348

4449
## Key Patterns
@@ -80,4 +85,4 @@ bun test # → 131 tests across 10 files
8085
## Known Issues / TODO
8186

8287
- Minor visual glitches may still occur in very small terminals or with rapid scrolling
83-
- Tests are colocated as `*.test.ts`/`*.test.tsx` siblings. Pure logic is tested directly; components use `ink-testing-library`. Several helpers (`truncate`, `formatCell`, `padCell`, `coerceValue`, `renderJson`, `isNetworkError`, `formatZodError`, navigation `reducer`) are exported for testability.
88+
- Tests live in `tests/` mirroring the `src/` directory structure. Pure logic is tested directly; components use `ink-testing-library`. Several helpers (`truncate`, `formatCell`, `padCell`, `coerceValue`, `renderJson`, `isNetworkError`, `formatZodError`, navigation `reducer`) are exported for testability.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, test, expect, beforeEach, afterEach, mock, jest } from 'bun:test';
2-
import { HarperClient, formatZodError, isNetworkError } from './client.js';
2+
import { HarperClient, formatZodError, isNetworkError } from '../../src/api/client.js';
33
import { mockFetch } from '../test-helpers.js';
44
import { z } from 'zod';
55

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
ComparatorSchema,
77
SortSpecSchema,
88
SystemInfoSchema,
9-
} from './types.js';
9+
} from '../../src/api/types.js';
1010

1111
describe('TableAttributeSchema', () => {
1212
test('basic valid attribute with defaults', () => {
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React from 'react';
22
import { describe, test, expect } from 'bun:test';
33
import { render } from 'ink-testing-library';
4-
import { Breadcrumb } from './breadcrumb.js';
4+
import { Breadcrumb } from '../../src/components/breadcrumb.js';
55

66
describe('Breadcrumb', () => {
77
test('single item renders without separator', () => {

src/components/data-table.helpers.test.ts renamed to tests/components/data-table.helpers.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, test, expect } from 'bun:test';
2-
import { truncate, formatCell, padCell } from './data-table.js';
2+
import { truncate, formatCell, padCell } from '../../src/components/data-table.js';
33

44
describe('truncate', () => {
55
test('string shorter than max returns as-is', () => {
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React from 'react';
22
import { describe, test, expect } from 'bun:test';
33
import { render } from 'ink-testing-library';
4-
import { DataTable } from './data-table.js';
4+
import { DataTable } from '../../src/components/data-table.js';
55

66
describe('DataTable component', () => {
77
test('empty data shows message', () => {
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, test, expect } from 'bun:test';
2-
import { renderJson, getLineKeyMap, type RenderedLine } from './json-tree.js';
2+
import { renderJson, getLineKeyMap, type RenderedLine } from '../../src/components/json-tree.js';
33

44
/** Helper to extract all text from a rendered line */
55
function lineText(line: RenderedLine): string {
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React from 'react';
22
import { describe, test, expect } from 'bun:test';
33
import { render } from 'ink-testing-library';
4-
import { KeyHints } from './key-hints.js';
4+
import { KeyHints } from '../../src/components/key-hints.js';
55

66
describe('KeyHints', () => {
77
test('single hint renders key and label', () => {
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, test, expect } from 'bun:test';
2-
import { coerceValue } from './query-builder.js';
2+
import { coerceValue } from '../../src/components/query-builder.js';
33

44
describe('coerceValue', () => {
55
test('"true" returns boolean true', () => {

tests/hooks/use-api.test.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import React from 'react';
2+
import { describe, test, expect, mock } from 'bun:test';
3+
import { render } from 'ink-testing-library';
4+
import { Text } from 'ink';
5+
import { useApi } from '../../src/hooks/use-api.js';
6+
7+
/** Tiny component that exposes useApi state as text for assertion */
8+
function TestHarness({ apiFn, immediate = false }: { apiFn: (...args: any[]) => Promise<any>; immediate?: boolean }) {
9+
const { data, loading, error, queryTime, execute, retry } = useApi(apiFn, immediate);
10+
// Stash execute/retry on a global so tests can call them
11+
(globalThis as any).__testExecute = execute;
12+
(globalThis as any).__testRetry = retry;
13+
return React.createElement(
14+
Text,
15+
null,
16+
JSON.stringify({ data, loading, error, queryTime: queryTime !== null ? 'set' : null }),
17+
);
18+
}
19+
20+
function parseState(frame: string | undefined) {
21+
return JSON.parse(frame ?? '{}');
22+
}
23+
24+
const tick = (ms = 50) => new Promise((r) => setTimeout(r, ms));
25+
26+
describe('useApi', () => {
27+
test('initial state is idle', () => {
28+
const apiFn = mock(() => Promise.resolve('ok'));
29+
const { lastFrame } = render(
30+
React.createElement(TestHarness, { apiFn }),
31+
);
32+
const state = parseState(lastFrame());
33+
expect(state.data).toBeNull();
34+
expect(state.loading).toBe(false);
35+
expect(state.error).toBeNull();
36+
});
37+
38+
test('immediate=true triggers execute on mount', async () => {
39+
const apiFn = mock(() => Promise.resolve({ hello: 'world' }));
40+
const { lastFrame } = render(
41+
React.createElement(TestHarness, { apiFn, immediate: true }),
42+
);
43+
await tick();
44+
const state = parseState(lastFrame());
45+
expect(state.data).toEqual({ hello: 'world' });
46+
expect(state.loading).toBe(false);
47+
expect(state.queryTime).toBe('set');
48+
expect(apiFn).toHaveBeenCalledTimes(1);
49+
});
50+
51+
test('execute resolves with data', async () => {
52+
const apiFn = mock(() => Promise.resolve('result'));
53+
const { lastFrame } = render(
54+
React.createElement(TestHarness, { apiFn }),
55+
);
56+
57+
(globalThis as any).__testExecute('arg1');
58+
await tick();
59+
60+
const state = parseState(lastFrame());
61+
expect(state.data).toBe('result');
62+
expect(state.loading).toBe(false);
63+
expect(state.error).toBeNull();
64+
});
65+
66+
test('execute sets error on rejection', async () => {
67+
const apiFn = mock(() => Promise.reject(new Error('boom')));
68+
const { lastFrame } = render(
69+
React.createElement(TestHarness, { apiFn }),
70+
);
71+
72+
(globalThis as any).__testExecute();
73+
await tick();
74+
75+
const state = parseState(lastFrame());
76+
expect(state.error).toBe('boom');
77+
expect(state.loading).toBe(false);
78+
expect(state.queryTime).toBe('set');
79+
});
80+
81+
test('retry re-executes with last args', async () => {
82+
let callCount = 0;
83+
const apiFn = mock((...args: any[]) => {
84+
callCount++;
85+
if (callCount === 1) return Promise.reject(new Error('fail'));
86+
return Promise.resolve(`ok-${args[0]}`);
87+
});
88+
const { lastFrame } = render(
89+
React.createElement(TestHarness, { apiFn }),
90+
);
91+
92+
// First call fails
93+
(globalThis as any).__testExecute('myarg');
94+
await tick();
95+
expect(parseState(lastFrame()).error).toBe('fail');
96+
97+
// Retry should reuse 'myarg'
98+
(globalThis as any).__testRetry();
99+
await tick();
100+
const state = parseState(lastFrame());
101+
expect(state.data).toBe('ok-myarg');
102+
expect(state.error).toBeNull();
103+
});
104+
105+
test('handles non-Error rejection', async () => {
106+
const apiFn = mock(() => Promise.reject('string error'));
107+
const { lastFrame } = render(
108+
React.createElement(TestHarness, { apiFn }),
109+
);
110+
111+
(globalThis as any).__testExecute();
112+
await tick();
113+
114+
const state = parseState(lastFrame());
115+
expect(state.error).toBe('string error');
116+
});
117+
});

0 commit comments

Comments
 (0)