@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,218 @@
1
+ // ---------------------------------------------------------------------------
2
+ // @typecad/expect — Configuration loader
3
+ //
4
+ // Reads the `cuttlefish.config.ts` file to extract both the base transpiler
5
+ // configuration and the optional `test` section for test-specific settings.
6
+ // ---------------------------------------------------------------------------
7
+ import path from 'node:path';
8
+ import fs from 'node:fs';
9
+ import ts from 'typescript';
10
+ // ---------------------------------------------------------------------------
11
+ // Defaults
12
+ // ---------------------------------------------------------------------------
13
+ const DEFAULT_TEST_CONFIG = {
14
+ include: ['tests/**/*.test.ts'],
15
+ port: '', // Must be provided by user or CLI flag
16
+ baudRate: 115200,
17
+ timeout: 30000,
18
+ serialOpenDelay: 500,
19
+ };
20
+ // ---------------------------------------------------------------------------
21
+ // Public API
22
+ // ---------------------------------------------------------------------------
23
+ /**
24
+ * Load configuration from `cuttlefish.config.ts` in the given project root.
25
+ *
26
+ * Parses the config file via the TypeScript AST (no dynamic import) to
27
+ * extract scalar properties — consistent with how the cuttlefish CLI does it.
28
+ *
29
+ * @param projectRoot Absolute path to the project root.
30
+ * @param overrides CLI flag overrides for test config.
31
+ */
32
+ export function loadConfig(projectRoot, overrides = {}) {
33
+ const configPath = findConfigFile(projectRoot);
34
+ if (!configPath) {
35
+ throw new Error(`No cuttlefish.config.ts found in ${projectRoot}. ` +
36
+ `Create one or specify --port and --board on the command line.`);
37
+ }
38
+ const raw = parseConfigAST(configPath);
39
+ // Merge test config: defaults < config file < CLI overrides
40
+ const testFromFile = raw.test ?? {};
41
+ const test = {
42
+ include: overrides.include ?? testFromFile.include ?? DEFAULT_TEST_CONFIG.include,
43
+ exclude: overrides.exclude ?? testFromFile.exclude,
44
+ port: overrides.port ?? testFromFile.port ?? DEFAULT_TEST_CONFIG.port,
45
+ baudRate: overrides.baudRate ?? testFromFile.baudRate ?? DEFAULT_TEST_CONFIG.baudRate,
46
+ timeout: overrides.timeout ?? testFromFile.timeout ?? DEFAULT_TEST_CONFIG.timeout,
47
+ serialOpenDelay: overrides.serialOpenDelay ?? testFromFile.serialOpenDelay ?? DEFAULT_TEST_CONFIG.serialOpenDelay,
48
+ resetAfterOpen: overrides.resetAfterOpen ?? testFromFile.resetAfterOpen ?? DEFAULT_TEST_CONFIG.resetAfterOpen,
49
+ buildTarget: overrides.buildTarget ?? testFromFile.buildTarget,
50
+ board: overrides.board ?? testFromFile.board,
51
+ verbose: overrides.verbose ?? testFromFile.verbose,
52
+ };
53
+ return {
54
+ test,
55
+ buildTarget: test.buildTarget ?? raw.frameworkData?.buildTarget ?? 'arduino:avr:uno',
56
+ board: test.board ?? raw.board ?? '@typecad/board-arduino-uno',
57
+ target: raw.target ?? 'avr',
58
+ projectRoot,
59
+ };
60
+ }
61
+ // ---------------------------------------------------------------------------
62
+ // Internal — Config file discovery
63
+ // ---------------------------------------------------------------------------
64
+ function findConfigFile(projectRoot) {
65
+ const candidates = [
66
+ path.join(projectRoot, 'cuttlefish.config.ts'),
67
+ path.join(projectRoot, 'cuttlefish.config.js'),
68
+ ];
69
+ return candidates.find(c => fs.existsSync(c));
70
+ }
71
+ export function parseConfigAST(configPath) {
72
+ const text = fs.readFileSync(configPath, 'utf8');
73
+ const sf = ts.createSourceFile(configPath, text, ts.ScriptTarget.Latest, true);
74
+ const result = {};
75
+ for (const stmt of sf.statements) {
76
+ // `const config: CuttlefishConfig = { ... };`
77
+ if (ts.isVariableStatement(stmt)) {
78
+ for (const decl of stmt.declarationList.declarations) {
79
+ if (decl.initializer && ts.isObjectLiteralExpression(decl.initializer)) {
80
+ extractConfigProperties(decl.initializer, result);
81
+ }
82
+ }
83
+ }
84
+ // `export default { ... };`
85
+ if (ts.isExportAssignment(stmt) && ts.isObjectLiteralExpression(stmt.expression)) {
86
+ extractConfigProperties(stmt.expression, result);
87
+ }
88
+ }
89
+ return result;
90
+ }
91
+ function extractConfigProperties(obj, out) {
92
+ for (const prop of obj.properties) {
93
+ if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name))
94
+ continue;
95
+ const name = prop.name.text;
96
+ switch (name) {
97
+ case 'target':
98
+ if (ts.isStringLiteral(prop.initializer))
99
+ out.target = prop.initializer.text;
100
+ break;
101
+ case 'board':
102
+ if (ts.isStringLiteral(prop.initializer))
103
+ out.board = prop.initializer.text;
104
+ break;
105
+ case 'frameworkData':
106
+ if (ts.isObjectLiteralExpression(prop.initializer)) {
107
+ out.frameworkData = {};
108
+ for (const fProp of prop.initializer.properties) {
109
+ if (ts.isPropertyAssignment(fProp) && ts.isIdentifier(fProp.name) && fProp.name.text === 'buildTarget') {
110
+ if (ts.isStringLiteral(fProp.initializer))
111
+ out.frameworkData.buildTarget = fProp.initializer.text;
112
+ }
113
+ }
114
+ }
115
+ break;
116
+ case 'framework':
117
+ if (ts.isStringLiteral(prop.initializer))
118
+ out.framework = prop.initializer.text;
119
+ break;
120
+ case 'test':
121
+ if (ts.isObjectLiteralExpression(prop.initializer)) {
122
+ out.test = extractTestConfig(prop.initializer);
123
+ }
124
+ break;
125
+ case 'output':
126
+ if (ts.isObjectLiteralExpression(prop.initializer)) {
127
+ out.output = extractOutputConfig(prop.initializer);
128
+ }
129
+ break;
130
+ case 'console':
131
+ if (ts.isObjectLiteralExpression(prop.initializer)) {
132
+ out.console = extractConsoleConfig(prop.initializer);
133
+ }
134
+ break;
135
+ }
136
+ }
137
+ }
138
+ function extractTestConfig(obj) {
139
+ const result = {};
140
+ for (const prop of obj.properties) {
141
+ if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name))
142
+ continue;
143
+ const name = prop.name.text;
144
+ switch (name) {
145
+ case 'port':
146
+ if (ts.isStringLiteral(prop.initializer))
147
+ result.port = prop.initializer.text;
148
+ break;
149
+ case 'baudRate':
150
+ if (ts.isNumericLiteral(prop.initializer))
151
+ result.baudRate = parseInt(prop.initializer.text, 10);
152
+ break;
153
+ case 'timeout':
154
+ if (ts.isNumericLiteral(prop.initializer))
155
+ result.timeout = parseInt(prop.initializer.text, 10);
156
+ break;
157
+ case 'serialOpenDelay':
158
+ if (ts.isNumericLiteral(prop.initializer))
159
+ result.serialOpenDelay = parseInt(prop.initializer.text, 10);
160
+ break;
161
+ case 'buildTarget':
162
+ if (ts.isStringLiteral(prop.initializer))
163
+ result.buildTarget = prop.initializer.text;
164
+ break;
165
+ case 'resetAfterOpen':
166
+ if (prop.initializer.kind === ts.SyntaxKind.TrueKeyword)
167
+ result.resetAfterOpen = true;
168
+ if (prop.initializer.kind === ts.SyntaxKind.FalseKeyword)
169
+ result.resetAfterOpen = false;
170
+ break;
171
+ case 'board':
172
+ if (ts.isStringLiteral(prop.initializer))
173
+ result.board = prop.initializer.text;
174
+ break;
175
+ case 'include':
176
+ if (ts.isArrayLiteralExpression(prop.initializer)) {
177
+ result.include = prop.initializer.elements
178
+ .filter(ts.isStringLiteral)
179
+ .map(el => el.text);
180
+ }
181
+ break;
182
+ case 'exclude':
183
+ if (ts.isArrayLiteralExpression(prop.initializer)) {
184
+ result.exclude = prop.initializer.elements
185
+ .filter(ts.isStringLiteral)
186
+ .map(el => el.text);
187
+ }
188
+ break;
189
+ }
190
+ }
191
+ return result;
192
+ }
193
+ function extractOutputConfig(obj) {
194
+ const result = {};
195
+ for (const prop of obj.properties) {
196
+ if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name))
197
+ continue;
198
+ const name = prop.name.text;
199
+ if (name === 'framework' && ts.isStringLiteral(prop.initializer))
200
+ result.framework = prop.initializer.text;
201
+ if (name === 'optimize' && ts.isStringLiteral(prop.initializer))
202
+ result.optimize = prop.initializer.text;
203
+ if (name === 'outDir' && ts.isStringLiteral(prop.initializer))
204
+ result.outDir = prop.initializer.text;
205
+ }
206
+ return result;
207
+ }
208
+ function extractConsoleConfig(obj) {
209
+ const result = {};
210
+ for (const prop of obj.properties) {
211
+ if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name))
212
+ continue;
213
+ if (prop.name.text === 'baudRate' && ts.isNumericLiteral(prop.initializer)) {
214
+ result.baudRate = parseInt(prop.initializer.text, 10);
215
+ }
216
+ }
217
+ return result;
218
+ }
@@ -0,0 +1,14 @@
1
+ import type { MatcherName } from './types.js';
2
+ /**
3
+ * Evaluate a single assertion on the host side.
4
+ *
5
+ * @param matcher The matcher name (e.g. `'toBe'`, `'toBeLessThan'`).
6
+ * @param expected The expected value(s) as a raw string from the serial protocol.
7
+ * @param actual The actual value as a raw string from the serial protocol.
8
+ * @returns `true` if the assertion passes.
9
+ */
10
+ export declare function evaluate(matcher: MatcherName, expected: string, actual: string): boolean;
11
+ /**
12
+ * Return a human-readable description of what the matcher checks.
13
+ */
14
+ export declare function describeExpected(matcher: MatcherName, expected: string): string;
@@ -0,0 +1,113 @@
1
+ // ---------------------------------------------------------------------------
2
+ // @typecad/expect — Assertion evaluator
3
+ //
4
+ // All assertion logic runs on the host (computer) side. The firmware only
5
+ // sends raw actual/expected values over serial — the host does the math.
6
+ // ---------------------------------------------------------------------------
7
+ // ---------------------------------------------------------------------------
8
+ // Public API
9
+ // ---------------------------------------------------------------------------
10
+ /**
11
+ * Evaluate a single assertion on the host side.
12
+ *
13
+ * @param matcher The matcher name (e.g. `'toBe'`, `'toBeLessThan'`).
14
+ * @param expected The expected value(s) as a raw string from the serial protocol.
15
+ * @param actual The actual value as a raw string from the serial protocol.
16
+ * @returns `true` if the assertion passes.
17
+ */
18
+ export function evaluate(matcher, expected, actual) {
19
+ switch (matcher) {
20
+ case 'toBe':
21
+ return evaluateToBe(expected, actual);
22
+ case 'toNotBe':
23
+ return !evaluateToBe(expected, actual);
24
+ case 'toBeGreaterThan':
25
+ return toNum(actual) > toNum(expected);
26
+ case 'toBeGreaterThanOrEqual':
27
+ return toNum(actual) >= toNum(expected);
28
+ case 'toBeLessThan':
29
+ return toNum(actual) < toNum(expected);
30
+ case 'toBeLessThanOrEqual':
31
+ return toNum(actual) <= toNum(expected);
32
+ case 'toBeCloseTo': {
33
+ // expected format: "value,precision" e.g. "3.14,2"
34
+ const parts = expected.split(',');
35
+ const expVal = toNum(parts[0] ?? '0');
36
+ const precision = parts.length > 1 ? parseInt(parts[1], 10) : 2;
37
+ const tolerance = Math.pow(10, -precision);
38
+ return Math.abs(toNum(actual) - expVal) < tolerance;
39
+ }
40
+ case 'toBeWithinRange': {
41
+ // expected format: "min,max"
42
+ const parts = expected.split(',');
43
+ const min = toNum(parts[0] ?? '0');
44
+ const max = toNum(parts[1] ?? '0');
45
+ const val = toNum(actual);
46
+ return val >= min && val <= max;
47
+ }
48
+ case 'toBeTruthy':
49
+ return toNum(actual) !== 0;
50
+ case 'toBeFalsy':
51
+ return toNum(actual) === 0;
52
+ case 'toContain':
53
+ return actual.includes(expected);
54
+ case 'toHaveLength':
55
+ return actual.length === parseInt(expected, 10);
56
+ default: {
57
+ const _exhaustive = matcher;
58
+ return false;
59
+ }
60
+ }
61
+ }
62
+ // ---------------------------------------------------------------------------
63
+ // Public — Human-readable descriptions
64
+ // ---------------------------------------------------------------------------
65
+ /**
66
+ * Return a human-readable description of what the matcher checks.
67
+ */
68
+ export function describeExpected(matcher, expected) {
69
+ switch (matcher) {
70
+ case 'toBe': return `to be ${expected}`;
71
+ case 'toNotBe': return `to not be ${expected}`;
72
+ case 'toBeGreaterThan': return `to be > ${expected}`;
73
+ case 'toBeGreaterThanOrEqual': return `to be >= ${expected}`;
74
+ case 'toBeLessThan': return `to be < ${expected}`;
75
+ case 'toBeLessThanOrEqual': return `to be <= ${expected}`;
76
+ case 'toBeCloseTo': {
77
+ const parts = expected.split(',');
78
+ const precision = parts[1] ?? '2';
79
+ return `to be close to ${parts[0]} (±${Math.pow(10, -parseInt(precision, 10))})`;
80
+ }
81
+ case 'toBeWithinRange': {
82
+ const parts = expected.split(',');
83
+ return `to be within ${parts[0]}–${parts[1]}`;
84
+ }
85
+ case 'toBeTruthy': return 'to be truthy (≠ 0)';
86
+ case 'toBeFalsy': return 'to be falsy (= 0)';
87
+ case 'toContain': return `to contain "${expected}"`;
88
+ case 'toHaveLength': return `to have length ${expected}`;
89
+ default: {
90
+ const _exhaustive = matcher;
91
+ return `${matcher}(${expected})`;
92
+ }
93
+ }
94
+ }
95
+ // ---------------------------------------------------------------------------
96
+ // Internal
97
+ // ---------------------------------------------------------------------------
98
+ function evaluateToBe(expected, actual) {
99
+ // Try numeric comparison first
100
+ // Use Number() instead of parseFloat() so hex literals like "0x10" are
101
+ // correctly parsed (parseFloat stops at the "x" and returns 0).
102
+ const numExp = Number(expected);
103
+ const numAct = Number(actual);
104
+ if (!isNaN(numExp) && !isNaN(numAct)) {
105
+ return numAct === numExp;
106
+ }
107
+ // Fall back to string comparison
108
+ return actual === expected;
109
+ }
110
+ function toNum(s) {
111
+ const n = Number(s);
112
+ return isNaN(n) ? 0 : n;
113
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Find all test files matching the include glob patterns.
3
+ *
4
+ * Supports patterns like `tests/**\/*.test.ts`, `src/**\/*.test.ts`, or exact
5
+ * file paths like `src/sketch.ts`. Only files ending in `.test.ts` are
6
+ * returned.
7
+ *
8
+ * @param projectRoot Absolute path to the project root.
9
+ * @param include Glob patterns to match.
10
+ * @returns Absolute paths to found test files, sorted alphabetically.
11
+ */
12
+ export declare function findTestFiles(projectRoot: string, include: string[]): string[];
@@ -0,0 +1,70 @@
1
+ // ---------------------------------------------------------------------------
2
+ // @typecad/expect — Test file finder
3
+ //
4
+ // Globs for test files matching the configured include patterns.
5
+ // ---------------------------------------------------------------------------
6
+ import path from 'node:path';
7
+ import fs from 'node:fs';
8
+ // ---------------------------------------------------------------------------
9
+ // Public API
10
+ // ---------------------------------------------------------------------------
11
+ /**
12
+ * Find all test files matching the include glob patterns.
13
+ *
14
+ * Supports patterns like `tests/**\/*.test.ts`, `src/**\/*.test.ts`, or exact
15
+ * file paths like `src/sketch.ts`. Only files ending in `.test.ts` are
16
+ * returned.
17
+ *
18
+ * @param projectRoot Absolute path to the project root.
19
+ * @param include Glob patterns to match.
20
+ * @returns Absolute paths to found test files, sorted alphabetically.
21
+ */
22
+ export function findTestFiles(projectRoot, include) {
23
+ const seen = new Set();
24
+ for (const pattern of include) {
25
+ for (const f of expandPattern(projectRoot, pattern)) {
26
+ if (f.endsWith('.test.ts') && !seen.has(f)) {
27
+ seen.add(f);
28
+ }
29
+ }
30
+ }
31
+ return [...seen].sort();
32
+ }
33
+ // ---------------------------------------------------------------------------
34
+ // Internal
35
+ // ---------------------------------------------------------------------------
36
+ function expandPattern(projectRoot, pattern) {
37
+ const normalized = pattern.replace(/\\/g, '/');
38
+ // Exact file path (no wildcards)
39
+ if (!normalized.includes('*')) {
40
+ const abs = path.resolve(projectRoot, normalized);
41
+ if (fs.existsSync(abs) && fs.statSync(abs).isFile())
42
+ return [abs];
43
+ return [];
44
+ }
45
+ // Split on the first ** or * to get directory prefix
46
+ const starIdx = normalized.indexOf('*');
47
+ const dirPart = normalized.slice(0, starIdx).replace(/\/$/, '');
48
+ const absDir = path.resolve(projectRoot, dirPart || '.');
49
+ if (!fs.existsSync(absDir) || !fs.statSync(absDir).isDirectory())
50
+ return [];
51
+ // Use recursive readdir, then match each relative path against the full pattern
52
+ const regex = globToRegex(normalized.slice(dirPart ? dirPart.length + 1 : 0));
53
+ const results = [];
54
+ for (const entry of fs.readdirSync(absDir, { recursive: true })) {
55
+ const rel = entry.toString().replace(/\\/g, '/');
56
+ if (regex.test(rel) && rel.endsWith('.test.ts')) {
57
+ const abs = path.resolve(absDir, rel);
58
+ if (fs.statSync(abs, { throwIfNoEntry: false })?.isFile()) {
59
+ results.push(abs);
60
+ }
61
+ }
62
+ }
63
+ return results;
64
+ }
65
+ function globToRegex(glob) {
66
+ const escaped = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&');
67
+ // **/ matches zero or more path segments, * matches within a single segment
68
+ const pattern = escaped.replace(/\?/g, '[^/]').replace(/\*\*\//g, '(.+/)?').replace(/\*/g, '[^/]*');
69
+ return new RegExp(`^${pattern}$`);
70
+ }
@@ -0,0 +1,5 @@
1
+ import type { DescribeResult } from './types.js';
2
+ /**
3
+ * Parse raw protocol lines into structured describe/test/assertion results.
4
+ */
5
+ export declare function parseProtocolLines(rawLines: string[]): DescribeResult[];
@@ -0,0 +1,196 @@
1
+ // ---------------------------------------------------------------------------
2
+ // @typecad/expect — Protocol parser
3
+ //
4
+ // Parses protocol lines from serial output into structured test results.
5
+ //
6
+ // Protocol format:
7
+ // [TC:SUITE_START]
8
+ // [TC:DESCRIBE:name]
9
+ // [TC:IT:name]
10
+ // [TC:EXPECT:matcher:expected:actual]
11
+ // [TC:SUITE_END]
12
+ // ---------------------------------------------------------------------------
13
+ import { evaluate } from './evaluator.js';
14
+ // ---------------------------------------------------------------------------
15
+ // Public API
16
+ // ---------------------------------------------------------------------------
17
+ /**
18
+ * Parse raw protocol lines into structured describe/test/assertion results.
19
+ */
20
+ export function parseProtocolLines(rawLines) {
21
+ const lines = rawLines.map(parseSingleLine).filter(Boolean);
22
+ return buildResults(lines);
23
+ }
24
+ // ---------------------------------------------------------------------------
25
+ // Internal — Line parsing
26
+ // ---------------------------------------------------------------------------
27
+ /**
28
+ * Parse a single protocol line into its tag and fields.
29
+ *
30
+ * Format: `[TC:TAG:field1:field2:...]`
31
+ *
32
+ * The last field of EXPECT lines may contain colons (e.g. string values),
33
+ * so we parse carefully.
34
+ */
35
+ function parseSingleLine(raw) {
36
+ // Strip brackets: "[TC:..." → "TC:..."
37
+ const inner = raw.replace(/^\[/, '').replace(/\]$/, '');
38
+ if (!inner.startsWith('TC:'))
39
+ return null;
40
+ // Split on first `:` after `TC` to get the tag
41
+ const withoutPrefix = inner.slice(3); // remove "TC:"
42
+ const colonIdx = withoutPrefix.indexOf(':');
43
+ let tag;
44
+ let rest;
45
+ if (colonIdx === -1) {
46
+ tag = withoutPrefix;
47
+ rest = '';
48
+ }
49
+ else {
50
+ tag = withoutPrefix.slice(0, colonIdx);
51
+ rest = withoutPrefix.slice(colonIdx + 1);
52
+ }
53
+ if (!isValidTag(tag))
54
+ return null;
55
+ const fields = rest.length > 0 ? splitFields(tag, rest) : [];
56
+ return { tag: tag, fields, raw };
57
+ }
58
+ function isValidTag(tag) {
59
+ return ['SUITE_START', 'DESCRIBE', 'IT', 'EXPECT', 'SUITE_END'].includes(tag);
60
+ }
61
+ /**
62
+ * Split field string into segments.
63
+ *
64
+ * For EXPECT lines, the format is `matcher:expected:actual` where the actual
65
+ * value is everything after the last colon-separated segment.
66
+ * For DESCRIBE/IT, there's only one field (the name).
67
+ */
68
+ function splitFields(tag, rest) {
69
+ if (tag === 'DESCRIBE' || tag === 'IT') {
70
+ return [rest]; // name may contain colons — treat as single field
71
+ }
72
+ if (tag === 'EXPECT') {
73
+ // Format: matcher:expected:actual
74
+ // matcher is always a single word, expected may contain commas (for range)
75
+ // actual is the last segment (everything after the last unescaped colon)
76
+ return splitExpectFields(rest);
77
+ }
78
+ return rest.split(':');
79
+ }
80
+ /**
81
+ * Split EXPECT fields: `toBe:0:11` → ['toBe', '0', '11']
82
+ *
83
+ * We know the structure: matcher is first, then expected value(s), then actual
84
+ * value as the final segment.
85
+ */
86
+ function splitExpectFields(rest) {
87
+ const parts = rest.split(':');
88
+ if (parts.length < 3)
89
+ return parts;
90
+ // First part is the matcher name
91
+ const matcher = parts[0];
92
+ // Last part is the actual value
93
+ const actual = parts[parts.length - 1];
94
+ // Everything in between is the expected value (may have been split on : if
95
+ // it contained colons, so rejoin)
96
+ const expected = parts.slice(1, -1).join(':');
97
+ return [matcher, expected, actual];
98
+ }
99
+ // ---------------------------------------------------------------------------
100
+ // Internal — Result building
101
+ // ---------------------------------------------------------------------------
102
+ function buildResults(lines) {
103
+ const describes = [];
104
+ let currentDescribe = null;
105
+ let currentTest = null;
106
+ for (const line of lines) {
107
+ switch (line.tag) {
108
+ case 'SUITE_START':
109
+ // No-op — we already know we're starting
110
+ break;
111
+ case 'DESCRIBE': {
112
+ // Close previous test + describe if open
113
+ if (currentTest && currentDescribe) {
114
+ finalizeTest(currentTest);
115
+ currentDescribe.tests.push(currentTest);
116
+ currentTest = null;
117
+ }
118
+ if (currentDescribe) {
119
+ finalizeDescribe(currentDescribe);
120
+ describes.push(currentDescribe);
121
+ }
122
+ currentDescribe = {
123
+ name: line.fields[0] ?? 'unnamed',
124
+ tests: [],
125
+ passed: true,
126
+ };
127
+ break;
128
+ }
129
+ case 'IT': {
130
+ // Close previous test if open
131
+ if (currentTest && currentDescribe) {
132
+ finalizeTest(currentTest);
133
+ currentDescribe.tests.push(currentTest);
134
+ }
135
+ currentTest = {
136
+ name: line.fields[0] ?? 'unnamed',
137
+ assertions: [],
138
+ passed: true,
139
+ durationMs: 0,
140
+ };
141
+ break;
142
+ }
143
+ case 'EXPECT': {
144
+ if (!currentTest) {
145
+ // EXPECT outside of IT — create an implicit test
146
+ currentTest = {
147
+ name: '(implicit)',
148
+ assertions: [],
149
+ passed: true,
150
+ durationMs: 0,
151
+ };
152
+ }
153
+ const [matcher, expected, actual] = line.fields;
154
+ if (matcher) {
155
+ const assertion = {
156
+ matcher: matcher,
157
+ expected: expected ?? '',
158
+ actual: actual ?? '',
159
+ passed: evaluate(matcher, expected ?? '', actual ?? ''),
160
+ };
161
+ currentTest.assertions.push(assertion);
162
+ }
163
+ break;
164
+ }
165
+ case 'SUITE_END': {
166
+ if (currentTest && currentDescribe) {
167
+ finalizeTest(currentTest);
168
+ currentDescribe.tests.push(currentTest);
169
+ currentTest = null;
170
+ }
171
+ if (currentDescribe) {
172
+ finalizeDescribe(currentDescribe);
173
+ describes.push(currentDescribe);
174
+ currentDescribe = null;
175
+ }
176
+ break;
177
+ }
178
+ }
179
+ }
180
+ // Handle unterminated groups
181
+ if (currentTest && currentDescribe) {
182
+ finalizeTest(currentTest);
183
+ currentDescribe.tests.push(currentTest);
184
+ }
185
+ if (currentDescribe) {
186
+ finalizeDescribe(currentDescribe);
187
+ describes.push(currentDescribe);
188
+ }
189
+ return describes;
190
+ }
191
+ function finalizeTest(test) {
192
+ test.passed = test.assertions.every(a => a.passed);
193
+ }
194
+ function finalizeDescribe(desc) {
195
+ desc.passed = desc.tests.every(t => t.passed);
196
+ }
@@ -0,0 +1,33 @@
1
+ export interface PreprocessorOptions {
2
+ /** Wrap string literals in Arduino F() macro to save SRAM on AVR. */
3
+ isAvr?: boolean;
4
+ }
5
+ /**
6
+ * Preprocess a test file's TypeScript source.
7
+ *
8
+ * 1. Strips `import { ... } from '@typecad/expect'`
9
+ * 2. Walks top-level expression-statements looking for `describe(...)...` chains
10
+ * 3. Replaces `done()` with the suite-end sentinel + idle loop
11
+ * 4. Hoists hardware expressions out of `expect()` into `const` declarations
12
+ * 5. Wraps everything with Serial.initialize + SUITE_START preamble
13
+ */
14
+ export declare function preprocess(source: string, fileName?: string, options?: PreprocessorOptions): string;
15
+ export declare class PreprocessorContext {
16
+ private lines;
17
+ private varCounter;
18
+ private fnCounter;
19
+ private preambleEmitted;
20
+ private readonly baudRate;
21
+ readonly isAvr: boolean;
22
+ constructor(isAvr: boolean);
23
+ /** Wrap a string literal in F() on AVR to keep it in flash. */
24
+ flash(s: string): string;
25
+ /** Emit a line of TypeScript output. */
26
+ emit(line: string): void;
27
+ /** Generate a unique temporary variable name. */
28
+ nextVar(): string;
29
+ /** Generate a unique extracted function name. */
30
+ nextFn(): string;
31
+ private emitPreamble;
32
+ build(): string;
33
+ }