@typecad/expect 0.1.0-alpha.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,256 @@
1
+ // ---------------------------------------------------------------------------
2
+ // @typecad/expect — Test runner pipeline
3
+ //
4
+ // Orchestrates the full test cycle:
5
+ // discover → preprocess → transpile → compile → upload → serial → parse → evaluate → report
6
+ // ---------------------------------------------------------------------------
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { findTestFiles } from './finder.js';
10
+ import { preprocess } from './preprocessor.js';
11
+ import { transpileTestFile, compileSketch, uploadSketch } from './compiler.js';
12
+ import { readSerialOutput } from './serial.js';
13
+ import { parseProtocolLines } from './parser.js';
14
+ import { reportFileResult, reportSummary } from './reporter.js';
15
+ // ---------------------------------------------------------------------------
16
+ // ANSI codes (for inline progress messages)
17
+ // ---------------------------------------------------------------------------
18
+ const DIM = '\x1b[2m';
19
+ const RESET = '\x1b[0m';
20
+ const CYAN = '\x1b[36m';
21
+ const YELLOW = '\x1b[33m';
22
+ // ---------------------------------------------------------------------------
23
+ // Public API
24
+ // ---------------------------------------------------------------------------
25
+ /**
26
+ * Run all test files matching the configuration.
27
+ *
28
+ * @returns Exit code: 0 = all passed, 1 = failures, 2 = error.
29
+ */
30
+ export async function run(config) {
31
+ const startTime = Date.now();
32
+ // 1. Discover test files
33
+ const testFiles = findTestFiles(config.projectRoot, config.test.include);
34
+ if (testFiles.length === 0) {
35
+ console.log(`${YELLOW}No test files found matching: ${config.test.include.join(', ')}${RESET}`);
36
+ return 0;
37
+ }
38
+ console.log(`${DIM}Found ${testFiles.length} test file${testFiles.length !== 1 ? 's' : ''}${RESET}`);
39
+ console.log();
40
+ // 2. Process each file sequentially (one compile/upload cycle per file)
41
+ const fileResults = [];
42
+ for (const filePath of testFiles) {
43
+ const result = await processTestFile(filePath, config);
44
+ fileResults.push(result);
45
+ reportFileResult(result, { verbose: config.test.verbose });
46
+ }
47
+ // 3. Aggregate results
48
+ const runResult = aggregateResults(fileResults, Date.now() - startTime);
49
+ // 4. Final summary
50
+ reportSummary(runResult, {
51
+ board: config.board,
52
+ port: config.test.port,
53
+ });
54
+ return (runResult.totalFailed > 0 || runResult.totalErrors > 0) ? 1 : 0;
55
+ }
56
+ // ---------------------------------------------------------------------------
57
+ // Internal — Process a single test file through the full pipeline
58
+ // ---------------------------------------------------------------------------
59
+ async function processTestFile(filePath, config) {
60
+ const startTime = Date.now();
61
+ const relativePath = path.relative(config.projectRoot, filePath);
62
+ // Read source
63
+ let source;
64
+ try {
65
+ source = fs.readFileSync(filePath, 'utf8');
66
+ }
67
+ catch (e) {
68
+ return errorResult(filePath, `Failed to read file: ${e.message}`, startTime);
69
+ }
70
+ const skipReason = getSkipReason(source, relativePath, config);
71
+ if (skipReason) {
72
+ return skippedResult(relativePath, skipReason, startTime);
73
+ }
74
+ // Validate port only for files that will actually compile/upload. This lets
75
+ // target-incompatible files be skipped without requiring hardware to be
76
+ // connected.
77
+ if (!config.test.port) {
78
+ return errorResult(filePath, 'No serial port specified. Use --port <port> or set test.port in cuttlefish.config.ts', startTime);
79
+ }
80
+ console.log(`${CYAN}●${RESET} ${relativePath}`);
81
+ // Step 1: Preprocess
82
+ console.log(` ${DIM}preprocessing...${RESET}`);
83
+ let preprocessed;
84
+ try {
85
+ preprocessed = preprocess(source, path.basename(filePath), {
86
+ isAvr: config.target === 'avr' || config.target === 'megaavr',
87
+ });
88
+ }
89
+ catch (e) {
90
+ return errorResult(filePath, `Preprocessing failed: ${e.message}`, startTime);
91
+ }
92
+ // Step 2: Transpile to C++
93
+ console.log(` ${DIM}transpiling...${RESET}`);
94
+ const transpileResult = transpileTestFile(preprocessed, filePath, config.projectRoot, config.buildTarget);
95
+ if (!transpileResult.success) {
96
+ return errorResult(filePath, transpileResult.error ?? 'Transpilation failed', startTime);
97
+ }
98
+ // Step 3: Compile with arduino-cli
99
+ console.log(` ${DIM}compiling...${RESET}`);
100
+ const compileResult = compileSketch(transpileResult.sketchDir, config.buildTarget);
101
+ if (!compileResult.success) {
102
+ return errorResult(filePath, compileResult.error ?? 'Compilation failed', startTime);
103
+ }
104
+ // Step 4: Upload
105
+ console.log(` ${DIM}uploading to ${config.test.port}...${RESET}`);
106
+ const uploadResult = uploadSketch(transpileResult.sketchDir, config.buildTarget, config.test.port);
107
+ if (!uploadResult.success) {
108
+ return errorResult(filePath, uploadResult.error ?? 'Upload failed', startTime);
109
+ }
110
+ // Step 5: Read serial output
111
+ console.log(` ${DIM}reading serial output...${RESET}`);
112
+ const serialResult = await readSerialOutput(config.test.port, config.test.baudRate, config.test.timeout, config.test.serialOpenDelay, { resetAfterOpen: config.test.resetAfterOpen ?? config.target === 'esp32' });
113
+ if (serialResult.error && !serialResult.completed) {
114
+ return errorResult(filePath, serialResult.error, startTime);
115
+ }
116
+ // Step 6: Parse protocol lines
117
+ const describes = parseProtocolLines(serialResult.protocolLines);
118
+ const durationMs = Date.now() - startTime;
119
+ const passed = describes.every(d => d.passed);
120
+ return {
121
+ filePath: relativePath,
122
+ describes,
123
+ passed,
124
+ durationMs,
125
+ debugOutput: serialResult.debugLines,
126
+ };
127
+ }
128
+ // ---------------------------------------------------------------------------
129
+ // Internal — Helpers
130
+ // ---------------------------------------------------------------------------
131
+ function errorResult(filePath, error, startTime) {
132
+ return {
133
+ filePath,
134
+ describes: [],
135
+ passed: false,
136
+ durationMs: Date.now() - startTime,
137
+ debugOutput: [],
138
+ error,
139
+ };
140
+ }
141
+ function skippedResult(filePath, reason, startTime) {
142
+ return {
143
+ filePath,
144
+ describes: [],
145
+ passed: true,
146
+ durationMs: Date.now() - startTime,
147
+ debugOutput: [],
148
+ skipped: true,
149
+ skipReason: reason,
150
+ };
151
+ }
152
+ function aggregateResults(files, durationMs) {
153
+ let totalTests = 0;
154
+ let totalPassed = 0;
155
+ let totalFailed = 0;
156
+ let totalErrors = 0;
157
+ let totalSkipped = 0;
158
+ for (const file of files) {
159
+ if (file.skipped) {
160
+ totalSkipped++;
161
+ continue;
162
+ }
163
+ for (const desc of file.describes) {
164
+ for (const test of desc.tests) {
165
+ totalTests++;
166
+ if (test.passed)
167
+ totalPassed++;
168
+ else
169
+ totalFailed++;
170
+ }
171
+ }
172
+ if (file.error) {
173
+ totalErrors++;
174
+ }
175
+ }
176
+ return {
177
+ files,
178
+ totalTests,
179
+ totalPassed,
180
+ totalFailed,
181
+ totalErrors,
182
+ totalSkipped,
183
+ durationMs,
184
+ };
185
+ }
186
+ function getSkipReason(source, relativePath, config) {
187
+ const excludedByConfig = config.test.exclude?.find(pattern => matchesTestPattern(relativePath, pattern));
188
+ if (excludedByConfig) {
189
+ return `matched test.exclude pattern '${excludedByConfig}'`;
190
+ }
191
+ const skipTarget = findTargetDirective(source, 'typecad-skip-target');
192
+ if (skipTarget && targetListMatches(skipTarget.targets, config)) {
193
+ return skipTarget.reason ?? `skipped for target '${targetLabel(config)}'`;
194
+ }
195
+ const onlyTarget = findTargetDirective(source, 'typecad-only-target');
196
+ if (onlyTarget && !targetListMatches(onlyTarget.targets, config)) {
197
+ return onlyTarget.reason ?? `requires target '${onlyTarget.targets.join(', ')}'`;
198
+ }
199
+ return undefined;
200
+ }
201
+ function findTargetDirective(source, directive) {
202
+ const re = new RegExp(`@${directive}\\s+([^:\\r\\n]+)(?::\\s*(.*))?`, 'i');
203
+ const match = source.match(re);
204
+ if (!match)
205
+ return undefined;
206
+ const targets = match[1]
207
+ .split(/[,\s]+/)
208
+ .map(s => s.trim().toLowerCase())
209
+ .filter(Boolean);
210
+ if (targets.length === 0)
211
+ return undefined;
212
+ return { targets, reason: match[2]?.trim() || undefined };
213
+ }
214
+ function targetListMatches(targets, config) {
215
+ const tokens = new Set();
216
+ if (config.target)
217
+ tokens.add(config.target.toLowerCase());
218
+ if (config.buildTarget) {
219
+ const buildTarget = config.buildTarget.toLowerCase();
220
+ tokens.add(buildTarget);
221
+ const parts = buildTarget.split(':');
222
+ if (parts[0])
223
+ tokens.add(parts[0]);
224
+ if (parts[1])
225
+ tokens.add(parts[1]);
226
+ if (parts[2])
227
+ tokens.add(parts[2]);
228
+ }
229
+ if (config.board) {
230
+ const board = config.board.toLowerCase();
231
+ tokens.add(board);
232
+ const lastSegment = board.split('/').pop();
233
+ if (lastSegment)
234
+ tokens.add(lastSegment);
235
+ }
236
+ return targets.some(target => target === '*' || tokens.has(target.toLowerCase()));
237
+ }
238
+ function targetLabel(config) {
239
+ return config.buildTarget || config.target || config.board || 'unknown';
240
+ }
241
+ function matchesTestPattern(relativePath, pattern) {
242
+ const normalizedPath = relativePath.replace(/\\/g, '/');
243
+ const normalizedPattern = pattern.replace(/\\/g, '/');
244
+ if (!normalizedPattern.includes('*') && !normalizedPattern.includes('?')) {
245
+ return normalizedPath === normalizedPattern;
246
+ }
247
+ return globToRegex(normalizedPattern).test(normalizedPath);
248
+ }
249
+ function globToRegex(glob) {
250
+ const escaped = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&');
251
+ const pattern = escaped
252
+ .replace(/\?/g, '[^/]')
253
+ .replace(/\*\*\//g, '(.+/)?')
254
+ .replace(/\*/g, '[^/]*');
255
+ return new RegExp(`^${pattern}$`);
256
+ }
@@ -0,0 +1,28 @@
1
+ export interface SerialReadResult {
2
+ /** Lines starting with [TC: — the protocol data. */
3
+ protocolLines: string[];
4
+ /** All other serial output — debug prints, boot messages, etc. */
5
+ debugLines: string[];
6
+ /** Whether SUITE_END was received before timeout. */
7
+ completed: boolean;
8
+ /** If the read was cut short by an error. */
9
+ error?: string;
10
+ }
11
+ export interface SerialReadOptions {
12
+ /** Toggle ESP32-style DTR/RTS reset after the port is open and listeners are attached. */
13
+ resetAfterOpen?: boolean;
14
+ /** How long to hold reset active when resetAfterOpen is enabled. */
15
+ resetPulseMs?: number;
16
+ }
17
+ /**
18
+ * Read serial output from the device and separate protocol lines from debug.
19
+ *
20
+ * Uses the `serialport` npm package. The port is opened, data is accumulated
21
+ * line-by-line. Reading stops when a `[TC:SUITE_END]` line is received or
22
+ * the timeout expires.
23
+ *
24
+ * @param port Serial port path (e.g. `'COM3'`, `'/dev/ttyACM0'`).
25
+ * @param baudRate Baud rate — must match what the preprocessor emits.
26
+ * @param timeoutMs How long to wait for SUITE_END.
27
+ */
28
+ export declare function readSerialOutput(port: string, baudRate: number, timeoutMs: number, serialOpenDelay?: number, options?: SerialReadOptions): Promise<SerialReadResult>;
@@ -0,0 +1,129 @@
1
+ // ---------------------------------------------------------------------------
2
+ // @typecad/expect — Serial reader
3
+ //
4
+ // Opens a serial port after firmware upload, reads lines until [TC:SUITE_END]
5
+ // or timeout. Filters protocol lines from debug output.
6
+ // ---------------------------------------------------------------------------
7
+ import { PROTOCOL_PREFIX } from './types.js';
8
+ /**
9
+ * Read serial output from the device and separate protocol lines from debug.
10
+ *
11
+ * Uses the `serialport` npm package. The port is opened, data is accumulated
12
+ * line-by-line. Reading stops when a `[TC:SUITE_END]` line is received or
13
+ * the timeout expires.
14
+ *
15
+ * @param port Serial port path (e.g. `'COM3'`, `'/dev/ttyACM0'`).
16
+ * @param baudRate Baud rate — must match what the preprocessor emits.
17
+ * @param timeoutMs How long to wait for SUITE_END.
18
+ */
19
+ export async function readSerialOutput(port, baudRate, timeoutMs, serialOpenDelay = 500, options = {}) {
20
+ // Dynamic import — serialport is a native dependency
21
+ const { SerialPort } = await import('serialport');
22
+ const protocolLines = [];
23
+ const debugLines = [];
24
+ let completed = false;
25
+ let pending = '';
26
+ return new Promise((resolve) => {
27
+ let resolved = false;
28
+ const cleanup = (callback) => {
29
+ sp.removeAllListeners();
30
+ if (sp.isOpen) {
31
+ sp.close(() => callback());
32
+ }
33
+ else {
34
+ try {
35
+ sp.close();
36
+ }
37
+ catch {
38
+ // ignore close errors for unopened ports
39
+ }
40
+ callback();
41
+ }
42
+ };
43
+ const finish = (error) => {
44
+ if (resolved)
45
+ return;
46
+ resolved = true;
47
+ clearTimeout(timer);
48
+ cleanup(() => resolve({ protocolLines, debugLines, completed, error }));
49
+ };
50
+ const handleLine = (line) => {
51
+ const trimmed = line.trim();
52
+ if (trimmed.startsWith(PROTOCOL_PREFIX)) {
53
+ protocolLines.push(trimmed);
54
+ if (trimmed.includes('SUITE_END')) {
55
+ completed = true;
56
+ // Give a brief delay for any trailing output
57
+ setTimeout(() => finish(), 200);
58
+ }
59
+ }
60
+ else if (trimmed.length > 0) {
61
+ debugLines.push(trimmed);
62
+ }
63
+ };
64
+ const handleChunk = (chunk) => {
65
+ pending += chunk.toString();
66
+ while (true) {
67
+ const lf = pending.indexOf('\n');
68
+ const cr = pending.indexOf('\r');
69
+ const indexes = [lf, cr].filter(i => i >= 0);
70
+ if (indexes.length === 0)
71
+ break;
72
+ const next = Math.min(...indexes);
73
+ const delimiter = pending[next];
74
+ const line = pending.slice(0, next);
75
+ pending = pending.slice(next + 1);
76
+ // Treat CRLF as one newline.
77
+ if (delimiter === '\r' && pending.startsWith('\n')) {
78
+ pending = pending.slice(1);
79
+ }
80
+ handleLine(line);
81
+ }
82
+ };
83
+ const resetAfterOpen = options.resetAfterOpen ?? false;
84
+ const resetPulseMs = options.resetPulseMs ?? 100;
85
+ const resetBoard = () => {
86
+ if (!resetAfterOpen)
87
+ return;
88
+ // ESP32 DevKit boards use RTS for EN/reset and DTR for GPIO0/boot.
89
+ // Keep GPIO0 high (DTR false), pulse EN low (RTS true), then release.
90
+ sp.set({ dtr: false, rts: true }, (assertErr) => {
91
+ if (assertErr) {
92
+ finish(`Failed to assert serial reset on ${port}: ${assertErr.message}`);
93
+ return;
94
+ }
95
+ setTimeout(() => {
96
+ sp.set({ dtr: false, rts: false }, (releaseErr) => {
97
+ if (releaseErr) {
98
+ finish(`Failed to release serial reset on ${port}: ${releaseErr.message}`);
99
+ }
100
+ });
101
+ }, resetPulseMs);
102
+ });
103
+ };
104
+ // Open serial port
105
+ const sp = new SerialPort({ path: port, baudRate, autoOpen: false });
106
+ // Timeout guard
107
+ const timer = setTimeout(() => {
108
+ if (pending.trim().length > 0) {
109
+ handleLine(pending);
110
+ pending = '';
111
+ }
112
+ finish(completed ? undefined : `Timeout after ${timeoutMs}ms — no [TC:SUITE_END] received`);
113
+ }, timeoutMs);
114
+ sp.on('data', handleChunk);
115
+ sp.on('error', (err) => {
116
+ finish(`Serial error: ${err.message}`);
117
+ });
118
+ // Give the device time to reset after upload (Arduino resets on serial open)
119
+ setTimeout(() => {
120
+ sp.open((err) => {
121
+ if (err) {
122
+ finish(`Failed to open ${port}: ${err.message}`);
123
+ return;
124
+ }
125
+ resetBoard();
126
+ });
127
+ }, serialOpenDelay);
128
+ });
129
+ }
@@ -0,0 +1,121 @@
1
+ /** The prefix that every protocol line starts with. */
2
+ export declare const PROTOCOL_PREFIX = "[TC:";
3
+ export declare const PROTOCOL_SUFFIX = "]";
4
+ /**
5
+ * All protocol line types emitted by firmware.
6
+ */
7
+ export type ProtocolTag = 'SUITE_START' | 'DESCRIBE' | 'IT' | 'EXPECT' | 'SUITE_END';
8
+ /**
9
+ * A single parsed protocol line.
10
+ */
11
+ export interface ProtocolLine {
12
+ tag: ProtocolTag;
13
+ /** Payload segments (split on `:` after the tag). */
14
+ fields: string[];
15
+ /** The original raw line from serial. */
16
+ raw: string;
17
+ }
18
+ /**
19
+ * All supported matcher names.
20
+ */
21
+ export type MatcherName = 'toBe' | 'toBeGreaterThan' | 'toBeGreaterThanOrEqual' | 'toBeLessThan' | 'toBeLessThanOrEqual' | 'toBeCloseTo' | 'toBeWithinRange' | 'toBeTruthy' | 'toBeFalsy' | 'toNotBe' | 'toContain' | 'toHaveLength';
22
+ /**
23
+ * A single assertion result after host-side evaluation.
24
+ */
25
+ export interface AssertionResult {
26
+ matcher: MatcherName;
27
+ /** Expected value(s) as raw string from serial. */
28
+ expected: string;
29
+ /** Actual value as raw string from serial. */
30
+ actual: string;
31
+ /** Whether the assertion passed. */
32
+ passed: boolean;
33
+ }
34
+ /**
35
+ * A single `it(...)` test case result.
36
+ */
37
+ export interface TestResult {
38
+ name: string;
39
+ assertions: AssertionResult[];
40
+ /** Whether all assertions passed. */
41
+ passed: boolean;
42
+ /** Time between IT start and next IT / describe end (ms). */
43
+ durationMs: number;
44
+ }
45
+ /**
46
+ * A single `describe(...)` group result.
47
+ */
48
+ export interface DescribeResult {
49
+ name: string;
50
+ tests: TestResult[];
51
+ /** Whether all tests passed. */
52
+ passed: boolean;
53
+ }
54
+ /**
55
+ * Result of running one test file.
56
+ */
57
+ export interface FileResult {
58
+ filePath: string;
59
+ describes: DescribeResult[];
60
+ passed: boolean;
61
+ durationMs: number;
62
+ /** Non-test serial output (lines without [TC: prefix). */
63
+ debugOutput: string[];
64
+ /** True when the file was intentionally skipped before compile/upload. */
65
+ skipped?: boolean;
66
+ /** Human-readable skip reason. */
67
+ skipReason?: string;
68
+ /** If the file didn't compile or upload. */
69
+ error?: string;
70
+ }
71
+ /**
72
+ * Result of the entire test run.
73
+ */
74
+ export interface RunResult {
75
+ files: FileResult[];
76
+ totalTests: number;
77
+ totalPassed: number;
78
+ totalFailed: number;
79
+ /** Pipeline errors (compile/upload failures) — tracked separately from test failures. */
80
+ totalErrors: number;
81
+ /** Files intentionally skipped before compile/upload. */
82
+ totalSkipped: number;
83
+ durationMs: number;
84
+ }
85
+ /**
86
+ * Test-specific configuration — the `test` section of `cuttlefish.config.ts`.
87
+ */
88
+ export interface TestConfig {
89
+ /** Glob patterns for test files. Default: `['tests/**\/*.test.ts']`. */
90
+ include: string[];
91
+ /** Glob patterns for test files to skip after discovery. */
92
+ exclude?: string[];
93
+ /** Serial port (e.g. `'COM3'`, `'/dev/ttyACM0'`). */
94
+ port: string;
95
+ /** Serial baud rate. Default: `115200`. */
96
+ baudRate: number;
97
+ /** Timeout in ms waiting for SUITE_END. Default: `30000`. */
98
+ timeout: number;
99
+ /** Delay in ms before opening serial port (after board reset). Default: `500`. */
100
+ serialOpenDelay?: number;
101
+ /** Toggle ESP32-style DTR/RTS reset after opening the serial port. */
102
+ resetAfterOpen?: boolean;
103
+ /** Framework-specific build target override. */
104
+ buildTarget?: string;
105
+ /** Board package override. */
106
+ board?: string;
107
+ /** Show debug serial output and passing assertion details. */
108
+ verbose?: boolean;
109
+ }
110
+ /**
111
+ * Resolved configuration used by the runner.
112
+ */
113
+ export interface ResolvedConfig {
114
+ test: TestConfig;
115
+ /** From cuttlefish.config.ts root. */
116
+ buildTarget: string;
117
+ board: string;
118
+ target: string;
119
+ /** Absolute path to project root. */
120
+ projectRoot: string;
121
+ }
@@ -0,0 +1,12 @@
1
+ // ---------------------------------------------------------------------------
2
+ // @typecad/expect — Host-side types
3
+ //
4
+ // Internal type definitions used by the host runner (Node.js process) to
5
+ // represent parsed serial output, assertion results, and test reports.
6
+ // ---------------------------------------------------------------------------
7
+ // ---------------------------------------------------------------------------
8
+ // Serial protocol
9
+ // ---------------------------------------------------------------------------
10
+ /** The prefix that every protocol line starts with. */
11
+ export const PROTOCOL_PREFIX = '[TC:';
12
+ export const PROTOCOL_SUFFIX = ']';
@@ -0,0 +1,2 @@
1
+ export type { Suite, Expectation, StringExpectation } from './types.js';
2
+ export { describe, done } from './stubs.js';
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ // ---------------------------------------------------------------------------
2
+ // @typecad/expect — Barrel re-export
3
+ //
4
+ // Users import from this package:
5
+ // import { describe, done } from '@typecad/expect';
6
+ // ---------------------------------------------------------------------------
7
+ export { describe, done } from './stubs.js';
@@ -0,0 +1,17 @@
1
+ import type { Suite } from './types.js';
2
+ /**
3
+ * Open a describe group. Returns a `Suite` to chain `.it()` / `.expect()`.
4
+ *
5
+ * @example
6
+ * ```ts
7
+ * describe("A0 analog read")
8
+ * .it("reads zero").expect(A0.readAnalog()).toBe(0);
9
+ * ```
10
+ */
11
+ export declare function describe(_name: string): Suite;
12
+ /**
13
+ * Mark the end of test execution. Must be the last statement in every
14
+ * test file. Emits the `[TC:SUITE_END]` sentinel and enters an idle loop
15
+ * so the host runner knows the firmware is done.
16
+ */
17
+ export declare function done(): void;
package/dist/stubs.js ADDED
@@ -0,0 +1,26 @@
1
+ // ---------------------------------------------------------------------------
2
+ // @typecad/expect — Firmware stubs
3
+ //
4
+ // These provide types to the user's IDE. The test preprocessor rewrites every
5
+ // call into Serial protocol statements before the cuttlefish transpiler ever
6
+ // sees them, so the runtime bodies are no-ops (only reached if the preprocessor
7
+ // is skipped, e.g. type-checking in the IDE).
8
+ // ---------------------------------------------------------------------------
9
+ /**
10
+ * Open a describe group. Returns a `Suite` to chain `.it()` / `.expect()`.
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * describe("A0 analog read")
15
+ * .it("reads zero").expect(A0.readAnalog()).toBe(0);
16
+ * ```
17
+ */
18
+ export function describe(_name) {
19
+ return undefined;
20
+ }
21
+ /**
22
+ * Mark the end of test execution. Must be the last statement in every
23
+ * test file. Emits the `[TC:SUITE_END]` sentinel and enters an idle loop
24
+ * so the host runner knows the firmware is done.
25
+ */
26
+ export function done() { }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Numeric assertion — provides matchers that compare a numeric `actual` value
3
+ * against an `expected` value. Each matcher returns the parent `Suite` so
4
+ * the chain can continue with `.it()` or another `.expect()`.
5
+ */
6
+ export interface Expectation {
7
+ /** Exact equality: `actual === expected`. */
8
+ toBe(expected: number): Suite;
9
+ /** Strict greater-than: `actual > expected`. */
10
+ toBeGreaterThan(n: number): Suite;
11
+ /** Greater-than or equal: `actual >= expected`. */
12
+ toBeGreaterThanOrEqual(n: number): Suite;
13
+ /** Strict less-than: `actual < expected`. */
14
+ toBeLessThan(n: number): Suite;
15
+ /** Less-than or equal: `actual <= expected`. */
16
+ toBeLessThanOrEqual(n: number): Suite;
17
+ /**
18
+ * Approximate equality: `|actual − expected| < 10^(−precision)`.
19
+ * @param n The expected value.
20
+ * @param precision Number of decimal digits (default 2 → 0.01 tolerance).
21
+ */
22
+ toBeCloseTo(n: number, precision?: number): Suite;
23
+ /** Range check: `actual >= min && actual <= max`. */
24
+ toBeWithinRange(min: number, max: number): Suite;
25
+ /** Truthy: `actual !== 0`. */
26
+ toBeTruthy(): Suite;
27
+ /** Falsy: `actual === 0`. */
28
+ toBeFalsy(): Suite;
29
+ /** Not-equal: `actual !== expected`. */
30
+ toNotBe(expected: number): Suite;
31
+ }
32
+ /**
33
+ * String assertion — provides matchers that compare a string `actual` value.
34
+ */
35
+ export interface StringExpectation {
36
+ /** Exact string equality. */
37
+ toBe(expected: string): Suite;
38
+ /** Substring check. */
39
+ toContain(substring: string): Suite;
40
+ /** Length check. */
41
+ toHaveLength(n: number): Suite;
42
+ /** Not-equal. */
43
+ toNotBe(expected: string): Suite;
44
+ }
45
+ /**
46
+ * A test suite — the return value of `describe()`. Supports fluent chaining
47
+ * of `.it()` and `.expect()` calls.
48
+ *
49
+ * @example
50
+ * ```ts
51
+ * describe("A0 reads")
52
+ * .it("reads zero when grounded")
53
+ * .expect(A0.readAnalog()).toBe(0)
54
+ * .it("reads less than 100")
55
+ * .expect(A0.readAnalog()).toBeLessThan(100);
56
+ * ```
57
+ */
58
+ export interface Suite {
59
+ /** Start a new test case within the current describe group. */
60
+ it(name: string): Suite;
61
+ /** Assert a numeric value (or a function that returns one). */
62
+ expect(actual: number | (() => number)): Expectation;
63
+ /** Assert a string value (or a function that returns one). */
64
+ expectString(actual: string | (() => string)): StringExpectation;
65
+ }
package/dist/types.js ADDED
@@ -0,0 +1,8 @@
1
+ // ---------------------------------------------------------------------------
2
+ // @typecad/expect — Firmware-side types
3
+ //
4
+ // These interfaces define the fluent API that users write in test files.
5
+ // They exist purely for TypeScript IntelliSense — the preprocessor rewrites
6
+ // all calls to Serial protocol statements before transpilation.
7
+ // ---------------------------------------------------------------------------
8
+ export {};