@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,115 @@
1
+ // ---------------------------------------------------------------------------
2
+ // @typecad/expect — AST Preprocessor
3
+ //
4
+ // Transforms fluent test syntax into flat Serial.print/println protocol
5
+ // calls that the cuttlefish transpiler handles correctly.
6
+ //
7
+ // INPUT (user test file):
8
+ // describe("A0 analog read")
9
+ // .it("reads zero").expect(A0.readAnalog()).toBe(0);
10
+ // done();
11
+ //
12
+ // OUTPUT (preprocessed — fed to transpiler):
13
+ // Serial.begin(115200);
14
+ // Serial.println("[TC:SUITE_START]");
15
+ // Serial.println("[TC:DESCRIBE:A0 analog read]");
16
+ // Serial.println("[TC:IT:reads zero]");
17
+ // const __tc_v1: number = A0.readAnalog();
18
+ // Serial.print("[TC:EXPECT:toBe:0:");
19
+ // Serial.print(__tc_v1);
20
+ // Serial.println("]");
21
+ // Serial.println("[TC:SUITE_END]");
22
+ // while (true) { delay(1000); }
23
+ // ---------------------------------------------------------------------------
24
+ import ts from 'typescript';
25
+ import { isDescribeChain, collectChainSegments } from './chain-collector.js';
26
+ import { emitSegments } from './protocol-emitter.js';
27
+ /**
28
+ * Preprocess a test file's TypeScript source.
29
+ *
30
+ * 1. Strips `import { ... } from '@typecad/expect'`
31
+ * 2. Walks top-level expression-statements looking for `describe(...)...` chains
32
+ * 3. Replaces `done()` with the suite-end sentinel + idle loop
33
+ * 4. Hoists hardware expressions out of `expect()` into `const` declarations
34
+ * 5. Wraps everything with Serial.initialize + SUITE_START preamble
35
+ */
36
+ export function preprocess(source, fileName = 'test.ts', options) {
37
+ const sf = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
38
+ const ctx = new PreprocessorContext(options?.isAvr ?? false);
39
+ for (const stmt of sf.statements) {
40
+ if (ts.isImportDeclaration(stmt)) {
41
+ const moduleSpecifier = stmt.moduleSpecifier.text;
42
+ if (moduleSpecifier === '@typecad/expect')
43
+ continue; // strip
44
+ ctx.emit(stmt.getText(sf));
45
+ }
46
+ else if (ts.isExpressionStatement(stmt)) {
47
+ processExpressionStatement(stmt, sf, ctx);
48
+ }
49
+ else {
50
+ ctx.emit(stmt.getText(sf));
51
+ }
52
+ }
53
+ return ctx.build();
54
+ }
55
+ // ---------------------------------------------------------------------------
56
+ // PreprocessorContext — shared state threaded through sub-modules
57
+ // ---------------------------------------------------------------------------
58
+ export class PreprocessorContext {
59
+ constructor(isAvr) {
60
+ this.lines = [];
61
+ this.varCounter = 0;
62
+ this.fnCounter = 0;
63
+ this.preambleEmitted = false;
64
+ this.baudRate = 115200;
65
+ this.isAvr = isAvr;
66
+ }
67
+ /** Wrap a string literal in F() on AVR to keep it in flash. */
68
+ flash(s) {
69
+ return this.isAvr ? `F("${s}")` : `"${s}"`;
70
+ }
71
+ /** Emit a line of TypeScript output. */
72
+ emit(line) {
73
+ if (!this.preambleEmitted)
74
+ this.emitPreamble();
75
+ this.lines.push(line);
76
+ }
77
+ /** Generate a unique temporary variable name. */
78
+ nextVar() {
79
+ return `__tc_v${++this.varCounter}`;
80
+ }
81
+ /** Generate a unique extracted function name. */
82
+ nextFn() {
83
+ return `__tc_fn${++this.fnCounter}`;
84
+ }
85
+ emitPreamble() {
86
+ this.preambleEmitted = true;
87
+ this.lines.push(`Serial.begin(${this.baudRate});`);
88
+ this.lines.push(`Serial.println(${this.flash('[TC:SUITE_START]')});`);
89
+ }
90
+ build() {
91
+ return this.lines.join('\n') + '\n';
92
+ }
93
+ }
94
+ // ---------------------------------------------------------------------------
95
+ // Statement processing
96
+ // ---------------------------------------------------------------------------
97
+ function processExpressionStatement(stmt, sf, ctx) {
98
+ const expr = stmt.expression;
99
+ if (isDoneCall(expr)) {
100
+ ctx.emit(`Serial.println(${ctx.flash('[TC:SUITE_END]')});`);
101
+ ctx.emit(`while (true) { delay(1000); }`);
102
+ return;
103
+ }
104
+ if (isDescribeChain(expr)) {
105
+ const segments = collectChainSegments(expr, sf, ctx);
106
+ emitSegments(segments, ctx);
107
+ return;
108
+ }
109
+ ctx.emit(stmt.getText(sf));
110
+ }
111
+ function isDoneCall(expr) {
112
+ return (ts.isCallExpression(expr) &&
113
+ ts.isIdentifier(expr.expression) &&
114
+ expr.expression.text === 'done');
115
+ }
@@ -0,0 +1,29 @@
1
+ import type { PreprocessorContext } from './preprocessor.js';
2
+ import type { ChainSegment } from './chain-collector.js';
3
+ /**
4
+ * Emit all Serial protocol lines for a collected chain.
5
+ */
6
+ export declare function emitSegments(segments: ChainSegment[], ctx: PreprocessorContext): void;
7
+ /**
8
+ * Emit the Serial.print sequence for one assertion.
9
+ *
10
+ * Numeric: Serial.print("[TC:EXPECT:matcher:expected:");
11
+ * Serial.print(actual);
12
+ * Serial.println("]");
13
+ *
14
+ * String: Serial.print("[TC:EXPECT:matcher:");
15
+ * Serial.print("expected");
16
+ * Serial.print(":");
17
+ * Serial.print(actual);
18
+ * Serial.println("]");
19
+ */
20
+ export declare function emitExpectProtocol(actualVar: string, matcher: string, matcherArgs: string[], ctx: PreprocessorContext, isString?: boolean): void;
21
+ /**
22
+ * Is the expression "simple" enough to inline without hoisting?
23
+ * Simple: identifiers, literals. Complex: anything with method calls.
24
+ */
25
+ export declare function isSimpleExpression(expr: string): boolean;
26
+ /** Heuristic: does this expression produce a string? */
27
+ export declare function isStringExpression(expr: string): boolean;
28
+ /** Escape characters that could break the protocol line format. */
29
+ export declare function escapeProtocol(s: string): string;
@@ -0,0 +1,107 @@
1
+ // ---------------------------------------------------------------------------
2
+ // @typecad/expect — Protocol Emitter
3
+ //
4
+ // Converts collected chain segments into Serial.print/println protocol lines.
5
+ // All logic for the [TC:DESCRIBE:...], [TC:IT:...], [TC:EXPECT:...] wire
6
+ // format lives here.
7
+ // ---------------------------------------------------------------------------
8
+ // ---------------------------------------------------------------------------
9
+ // Segment emission
10
+ // ---------------------------------------------------------------------------
11
+ /**
12
+ * Emit all Serial protocol lines for a collected chain.
13
+ */
14
+ export function emitSegments(segments, ctx) {
15
+ // First pass: emit any extracted function definitions before the protocol
16
+ for (const seg of segments) {
17
+ if (seg.kind === 'expect' && seg.extractedFn) {
18
+ ctx.emit(seg.extractedFn);
19
+ }
20
+ }
21
+ // Second pass: emit protocol lines
22
+ for (const seg of segments) {
23
+ switch (seg.kind) {
24
+ case 'describe':
25
+ ctx.emit(`Serial.println(${ctx.flash(`[TC:DESCRIBE:${escapeProtocol(seg.name ?? '')}]`)});`);
26
+ break;
27
+ case 'it':
28
+ ctx.emit(`Serial.println(${ctx.flash(`[TC:IT:${escapeProtocol(seg.name ?? '')}]`)});`);
29
+ break;
30
+ case 'expect': {
31
+ if (!seg.matcher)
32
+ break; // expect() without matcher — skip
33
+ const actualExpr = seg.actualExpr ?? '0';
34
+ const isString = seg.isStringExpect || seg.matcher === 'toContain' || seg.matcher === 'toHaveLength'
35
+ || (seg.matcher === 'toBe' && isStringExpression(actualExpr));
36
+ const typeAnnotation = isString ? 'string' : 'number';
37
+ if (isSimpleExpression(actualExpr)) {
38
+ emitExpectProtocol(actualExpr, seg.matcher, seg.matcherArgs ?? [], ctx, isString);
39
+ }
40
+ else {
41
+ // Hoist: const __tc_v1: number = A0.readAnalog();
42
+ const tmpVar = ctx.nextVar();
43
+ ctx.emit(`const ${tmpVar}: ${typeAnnotation} = ${actualExpr};`);
44
+ emitExpectProtocol(tmpVar, seg.matcher, seg.matcherArgs ?? [], ctx, isString);
45
+ }
46
+ break;
47
+ }
48
+ }
49
+ }
50
+ }
51
+ // ---------------------------------------------------------------------------
52
+ // Protocol line builder
53
+ // ---------------------------------------------------------------------------
54
+ /**
55
+ * Emit the Serial.print sequence for one assertion.
56
+ *
57
+ * Numeric: Serial.print("[TC:EXPECT:matcher:expected:");
58
+ * Serial.print(actual);
59
+ * Serial.println("]");
60
+ *
61
+ * String: Serial.print("[TC:EXPECT:matcher:");
62
+ * Serial.print("expected");
63
+ * Serial.print(":");
64
+ * Serial.print(actual);
65
+ * Serial.println("]");
66
+ */
67
+ export function emitExpectProtocol(actualVar, matcher, matcherArgs, ctx, isString = false) {
68
+ if (isString && matcher === 'toBe') {
69
+ const rawExpected = (matcherArgs[0] ?? '').replace(/^["']|["']$/g, '');
70
+ ctx.emit(`Serial.print(${ctx.flash(`[TC:EXPECT:${matcher}:`)});`);
71
+ ctx.emit(`Serial.print("${escapeProtocol(rawExpected)}");`);
72
+ ctx.emit(`Serial.print(${ctx.flash(':')});`);
73
+ ctx.emit(`Serial.print(${actualVar});`);
74
+ ctx.emit(`Serial.println(${ctx.flash(']')});`);
75
+ return;
76
+ }
77
+ const expectedPart = matcherArgs.join(',');
78
+ ctx.emit(`Serial.print(${ctx.flash(`[TC:EXPECT:${matcher}:${expectedPart}:`)});`);
79
+ ctx.emit(`Serial.print(${actualVar});`);
80
+ ctx.emit(`Serial.println(${ctx.flash(']')});`);
81
+ }
82
+ // ---------------------------------------------------------------------------
83
+ // Expression classification helpers
84
+ // ---------------------------------------------------------------------------
85
+ /**
86
+ * Is the expression "simple" enough to inline without hoisting?
87
+ * Simple: identifiers, literals. Complex: anything with method calls.
88
+ */
89
+ export function isSimpleExpression(expr) {
90
+ return !/\.\w+\s*\(/.test(expr);
91
+ }
92
+ /** Heuristic: does this expression produce a string? */
93
+ export function isStringExpression(expr) {
94
+ if (/^["']/.test(expr))
95
+ return true;
96
+ if (/\.readString|\.readLine/.test(expr))
97
+ return true;
98
+ return false;
99
+ }
100
+ /** Escape characters that could break the protocol line format. */
101
+ export function escapeProtocol(s) {
102
+ return s
103
+ .replace(/\\/g, '\\\\')
104
+ .replace(/"/g, '\\"')
105
+ .replace(/\n/g, '\\n')
106
+ .replace(/\r/g, '\\r');
107
+ }
@@ -0,0 +1,11 @@
1
+ import type { RunResult, FileResult } from './types.js';
2
+ /**
3
+ * Print a single test file's results immediately.
4
+ */
5
+ export declare function reportFileResult(file: FileResult, options?: {
6
+ verbose?: boolean;
7
+ }): void;
8
+ export declare function reportSummary(result: RunResult, options?: {
9
+ board?: string;
10
+ port?: string;
11
+ }): void;
@@ -0,0 +1,200 @@
1
+ // ---------------------------------------------------------------------------
2
+ // @typecad/expect — Vitest-style console reporter
3
+ //
4
+ // Renders test results to the console with ANSI colors, mimicking vitest's
5
+ // output format.
6
+ //
7
+ // Example output:
8
+ //
9
+ // cuttlefish-test v0.1.0
10
+ //
11
+ // ✓ A0 analog read (2 tests)
12
+ // ✓ reads zero when grounded 2ms
13
+ // ✓ reads less than 100 1ms
14
+ //
15
+ // ✗ Temperature sensor (1 of 2 failing)
16
+ // ✓ reads above freezing 3ms
17
+ // ✗ reads room temperature 2ms
18
+ //
19
+ // expect(actual).toBeWithinRange(20, 25)
20
+ // Actual: 31
21
+ // Expected: 20–25
22
+ //
23
+ // Tests 3 passed | 1 failed (4)
24
+ // Board Arduino Uno @ COM3
25
+ // Time 8.42s
26
+ // ---------------------------------------------------------------------------
27
+ import { describeExpected } from './evaluator.js';
28
+ // ---------------------------------------------------------------------------
29
+ // ANSI color codes
30
+ // ---------------------------------------------------------------------------
31
+ const RESET = '\x1b[0m';
32
+ const BOLD = '\x1b[1m';
33
+ const DIM = '\x1b[2m';
34
+ const RED = '\x1b[31m';
35
+ const GREEN = '\x1b[32m';
36
+ const YELLOW = '\x1b[33m';
37
+ const CYAN = '\x1b[36m';
38
+ const WHITE = '\x1b[37m';
39
+ const RED_BG = '\x1b[41m';
40
+ const GREEN_BG = '\x1b[42m';
41
+ const PASS_ICON = `${GREEN}✓${RESET}`;
42
+ const FAIL_ICON = `${RED}✗${RESET}`;
43
+ const SKIP_ICON = `${YELLOW}↓${RESET}`;
44
+ // ---------------------------------------------------------------------------
45
+ // Public API
46
+ // ---------------------------------------------------------------------------
47
+ /**
48
+ * Print a single test file's results immediately.
49
+ */
50
+ export function reportFileResult(file, options = {}) {
51
+ const verbose = options.verbose ?? false;
52
+ reportFile(file, verbose);
53
+ const failures = collectFailuresFromFile(file);
54
+ if (failures.length > 0) {
55
+ console.log(`${BOLD}${RED} FAILURES${RESET}`);
56
+ console.log();
57
+ for (const f of failures) {
58
+ reportFailure(f);
59
+ }
60
+ }
61
+ console.log();
62
+ }
63
+ export function reportSummary(result, options = {}) {
64
+ const { board, port } = options;
65
+ reportSummarySection(result, board, port);
66
+ }
67
+ // ---------------------------------------------------------------------------
68
+ // Internal — File reporting
69
+ // ---------------------------------------------------------------------------
70
+ function reportFile(file, verbose) {
71
+ if (file.skipped) {
72
+ console.log(` ${SKIP_ICON} ${BOLD}${file.filePath}${RESET} ${YELLOW}(skipped)${RESET}`);
73
+ if (verbose && file.skipReason) {
74
+ console.log(` ${DIM}${file.skipReason}${RESET}`);
75
+ }
76
+ console.log();
77
+ return;
78
+ }
79
+ if (file.error) {
80
+ console.log(` ${FAIL_ICON} ${BOLD}${file.filePath}${RESET} ${RED}(error)${RESET}`);
81
+ console.log(` ${DIM}${file.error}${RESET}`);
82
+ console.log();
83
+ return;
84
+ }
85
+ for (const desc of file.describes) {
86
+ reportDescribe(desc, verbose);
87
+ }
88
+ // Debug output
89
+ if (verbose && file.debugOutput.length > 0) {
90
+ console.log(` ${DIM}── serial debug ──${RESET}`);
91
+ for (const line of file.debugOutput) {
92
+ console.log(` ${DIM}${line}${RESET}`);
93
+ }
94
+ console.log();
95
+ }
96
+ }
97
+ function reportDescribe(desc, verbose) {
98
+ const totalTests = desc.tests.length;
99
+ const passedTests = desc.tests.filter(t => t.passed).length;
100
+ const failedTests = totalTests - passedTests;
101
+ const icon = desc.passed ? PASS_ICON : FAIL_ICON;
102
+ const countText = desc.passed
103
+ ? `${DIM}(${totalTests} test${totalTests !== 1 ? 's' : ''})${RESET}`
104
+ : `${RED}(${failedTests} of ${totalTests} failing)${RESET}`;
105
+ console.log(` ${icon} ${BOLD}${desc.name}${RESET} ${countText}`);
106
+ for (const test of desc.tests) {
107
+ reportTest(test, verbose);
108
+ }
109
+ console.log();
110
+ }
111
+ function reportTest(test, verbose) {
112
+ const icon = test.passed ? PASS_ICON : FAIL_ICON;
113
+ const duration = test.durationMs > 0 ? `${DIM}${test.durationMs}ms${RESET}` : '';
114
+ console.log(` ${icon} ${test.name} ${duration}`);
115
+ // In verbose mode, show passing assertion details too
116
+ if (verbose && test.passed) {
117
+ for (const a of test.assertions) {
118
+ console.log(` ${DIM}${a.matcher}(${a.expected}) → ${a.actual} ✓${RESET}`);
119
+ }
120
+ }
121
+ }
122
+ function collectFailuresFromFile(file) {
123
+ const failures = [];
124
+ if (file.error) {
125
+ return failures;
126
+ }
127
+ for (const desc of file.describes) {
128
+ for (const test of desc.tests) {
129
+ for (const assertion of test.assertions) {
130
+ if (!assertion.passed) {
131
+ failures.push({
132
+ describeName: desc.name,
133
+ testName: test.name,
134
+ assertion,
135
+ });
136
+ }
137
+ }
138
+ }
139
+ }
140
+ return failures;
141
+ }
142
+ function reportFailure(f) {
143
+ const a = f.assertion;
144
+ console.log(` ${RED}${BOLD}${f.describeName} > ${f.testName}${RESET}`);
145
+ console.log();
146
+ console.log(` ${DIM}expect(actual).${a.matcher}(${a.expected})${RESET}`);
147
+ console.log(` ${RED}Actual: ${BOLD}${a.actual}${RESET}`);
148
+ console.log(` ${GREEN}Expected: ${BOLD}${describeExpected(a.matcher, a.expected)}${RESET}`);
149
+ console.log();
150
+ }
151
+ // ---------------------------------------------------------------------------
152
+ // Internal — Summary
153
+ // ---------------------------------------------------------------------------
154
+ function reportSummarySection(result, board, port) {
155
+ // Tests line
156
+ const passedText = result.totalPassed > 0
157
+ ? `${GREEN}${BOLD}${result.totalPassed} passed${RESET}`
158
+ : '';
159
+ const failedText = result.totalFailed > 0
160
+ ? `${RED}${BOLD}${result.totalFailed} failed${RESET}`
161
+ : '';
162
+ const skippedText = result.totalSkipped > 0
163
+ ? `${YELLOW}${BOLD}${result.totalSkipped} skipped${RESET}`
164
+ : '';
165
+ const parts = [passedText, failedText, skippedText].filter(Boolean).join(`${DIM} | ${RESET}`);
166
+ const total = result.totalTests + result.totalSkipped;
167
+ console.log(` ${BOLD}Tests${RESET} ${parts} ${DIM}(${total})${RESET}`);
168
+ if (result.totalSkipped > 0) {
169
+ const skippedFiles = result.files.filter(file => file.skipped).length;
170
+ const totalFiles = result.files.length;
171
+ console.log(` ${BOLD}Test Files${RESET} ${YELLOW}${BOLD}${skippedFiles} skipped${RESET} ${DIM}(${totalFiles})${RESET}`);
172
+ }
173
+ // Errors line (compile/upload failures)
174
+ if (result.totalErrors > 0) {
175
+ console.log(` ${BOLD}Errors${RESET} ${RED}${BOLD}${result.totalErrors} file${result.totalErrors !== 1 ? 's' : ''} failed to build${RESET}`);
176
+ }
177
+ // Board line
178
+ if (board || port) {
179
+ const boardPart = board ?? 'unknown';
180
+ const portPart = port ? ` @ ${port}` : '';
181
+ console.log(` ${BOLD}Board${RESET} ${boardPart}${portPart}`);
182
+ }
183
+ // Time line
184
+ const seconds = (result.durationMs / 1000).toFixed(2);
185
+ console.log(` ${BOLD}Time${RESET} ${seconds}s`);
186
+ // Final status bar
187
+ if (result.totalFailed > 0 || result.totalErrors > 0) {
188
+ console.log();
189
+ const failParts = [];
190
+ if (result.totalFailed > 0)
191
+ failParts.push(`${result.totalFailed} test${result.totalFailed !== 1 ? 's' : ''} failed`);
192
+ if (result.totalErrors > 0)
193
+ failParts.push(`${result.totalErrors} build error${result.totalErrors !== 1 ? 's' : ''}`);
194
+ console.log(`${RED_BG}${WHITE}${BOLD} FAIL ${RESET} ${RED}${failParts.join(', ')}${RESET}`);
195
+ }
196
+ else {
197
+ console.log();
198
+ console.log(`${GREEN_BG}${WHITE}${BOLD} PASS ${RESET} ${GREEN}All tests passed${RESET}`);
199
+ }
200
+ }
@@ -0,0 +1,7 @@
1
+ import type { ResolvedConfig } from './types.js';
2
+ /**
3
+ * Run all test files matching the configuration.
4
+ *
5
+ * @returns Exit code: 0 = all passed, 1 = failures, 2 = error.
6
+ */
7
+ export declare function run(config: ResolvedConfig): Promise<number>;