• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

source-academy / py-slang / 29064527519

10 Jul 2026 02:23AM UTC coverage: 77.591% (-0.02%) from 77.614%
29064527519

Pull #255

github

web-flow
Merge 19e528ff4 into ebd09e224
Pull Request #255: Testing `sourceacademy-sicp` and py-slang Jest test suite

2702 of 3773 branches covered (71.61%)

Branch coverage included in aggregate %.

79 of 89 new or added lines in 1 file covered. (88.76%)

10 existing lines in 3 files now uncovered.

6259 of 7776 relevant lines covered (80.49%)

12294.74 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

77.18
/src/tests/utils.ts
1
import { execFileSync } from "node:child_process";
13✔
2
import path from "node:path";
13✔
3
import { ConductorError, ErrorType } from "@sourceacademy/conductor/common";
13✔
4
import { StmtNS } from "../ast-types";
5
import { Context } from "../engines/cse/context";
13✔
6
import { CSEResultPromise, evaluate, IOptions } from "../engines/cse/interpreter";
13✔
7
import { Stash, Value } from "../engines/cse/stash";
13✔
8
import { displayError } from "../engines/cse/streams";
13✔
9
import { PVMLCompiler } from "../engines/pvml/pvml-compiler";
13✔
10
import { PVMLInterpreter } from "../engines/pvml/pvml-interpreter";
13✔
11
import { RuntimeSourceError } from "../errors";
13✔
12
import { parse } from "../parser/parser-adapter";
13✔
13
import { Resolver } from "../resolver";
13✔
14
import { RunError } from "../runner";
13✔
15
import { runCodePvmlDetailed } from "../pvml-runner";
13✔
16
import math from "../stdlib/math";
13✔
17
import misc from "../stdlib/misc";
13✔
18
import { Group } from "../stdlib/utils";
19
import { PyComplexNumber, RecursivePartial, Result } from "../types";
13✔
20
import { FeatureNotSupportedError, makeValidatorsForChapter } from "../validator";
13✔
21
import { BreakContinueOutsideLoopError } from "../validator/types";
13✔
22
import Stmt = StmtNS.Stmt;
23

24
/**
25
 * Test-local replacement for the deleted pyRunner.runInContext.
26
 * Orchestrates: load groups → parse → resolve → evaluate → wrap result.
27
 */
28
async function runInContext(
29
  code: string,
30
  context: Context,
31
  options: RecursivePartial<IOptions> = {},
×
32
): Promise<Result> {
33
  // Load groups into context (builtins + preludes)
34
  if (!options.isPrelude && options.groups) {
1,750✔
35
    let prelude = "";
1,303✔
36
    for (const group of options.groups as Group[]) {
1,303✔
37
      for (const [name, value] of group.builtins) {
4,014✔
38
        context.nativeStorage.builtins.set(name, value);
106,025✔
39
      }
40
      prelude += group.prelude + "\n";
4,014✔
41
    }
42
    if (prelude.trim()) {
1,303✔
43
      await runInContext(prelude, context, { ...options, isPrelude: true, groups: [] });
447✔
44
    }
45
  }
46

47
  // Parse
48
  let pyAst: Stmt;
49
  try {
1,750✔
50
    const script = code + "\n";
1,750✔
51
    pyAst = parse(script);
1,750✔
52
    if (!options.isPrelude) {
1,750✔
53
      const resolver = new Resolver(
1,303✔
54
        script,
55
        pyAst,
56
        makeValidatorsForChapter(options.variant ?? 1),
1,303!
57
        (options.groups as Group[]) ?? [],
1,303!
58
        Object.keys(context.runtime.environments[0].head),
59
      );
60
      const errors = resolver.resolve(pyAst);
1,303✔
61
      if (errors.length > 0) throw errors[0];
1,303✔
62
    }
63
  } catch (error) {
64
    await displayError(context, error, ErrorType.EVALUATOR_SYNTAX);
2✔
65
    return CSEResultPromise(context, { type: "error", message: String(error) });
2✔
66
  }
67

68
  // Evaluate
69
  const result = await evaluate(code, pyAst, context, options);
1,748✔
70
  return CSEResultPromise(context, result);
1,748✔
71
}
72

73
// eslint-disable-next-line @typescript-eslint/no-explicit-any
74
type Class<T> = new (...args: any[]) => T;
75

76
export type TestOutputValue =
77
  | bigint
78
  | number
79
  | boolean
80
  | string
81
  | null
82
  | PyComplexNumber
83
  | TestOutputValue[];
84

85
export type TestErrorValue = Class<RuntimeSourceError> | Class<Error>;
86

87
export type TestExpectedValue = TestOutputValue | TestErrorValue;
88
/**
89
 * TestCases is a mapping from arguments to `describe` blocks, which map to an array of tuples of the form [code, expected, output], where:
90
 * - `code` is the code to be executed
91
 * - `expected` is the expected value of the expression, which can be a primitive value, null (for None), or an error class (for expected errors)
92
 * - `output` is the expected output to be printed to the console, or null if no output is expected
93
 */
94
export type TestCases = Record<string, [string, TestExpectedValue, string[] | null][]>;
95

96
export function toPythonAst(text: string): Stmt {
13✔
97
  const script = text + "\n";
99✔
98
  return parse(script);
99✔
99
}
100

101
export function toPythonAstAndResolve(text: string, variant: number): Stmt {
13✔
102
  const script = text + "\n";
99✔
103
  const ast = toPythonAst(text);
99✔
104
  const resolver = new Resolver(script, ast, makeValidatorsForChapter(variant), [misc, math]);
91✔
105
  const errors = resolver.resolve(ast);
91✔
106
  if (errors.length > 0) {
91✔
107
    throw errors[0];
65✔
108
  }
109
  return ast;
26✔
110
}
111

112
type InternalTestCase = {
113
  label: TestExpectedValue;
114
  code: string;
115
  expected: TestExpectedValue;
116
  output: string[] | null;
117
};
118

119
export const createInternalTestCases = (tests: TestCases[string]): InternalTestCase[] => {
13✔
120
  return tests.map(([code, expected, output]) => ({
2,127✔
121
    label:
122
      JSON.stringify(code) +
123
      " should " +
124
      (expected instanceof Function &&
4,803✔
125
      (expected.prototype instanceof RuntimeSourceError || expected.prototype instanceof Error)
126
        ? "throw " + expected.name
127
        : "return " + expected),
128
    code,
129
    expected,
130
    output,
131
  }));
132
};
133

134
type OutputType =
135
  | {
136
      type: "stdout";
137
      value: string;
138
    }
139
  | {
140
      type: "stderr";
141
      value: ConductorError;
142
    };
143
export const generateMockStreams = (context: Context, output: OutputType[]) => {
13✔
144
  const stdOutStream = new WritableStream<string>({
5,419✔
145
    write: (data: string) => {
146
      output.push({ type: "stdout", value: data });
156✔
147
    },
148
  });
149

150
  const stdErrStream = new WritableStream<ConductorError>({
5,419✔
151
    write: (data: ConductorError) => {
152
      output.push({ type: "stderr", value: data });
3,306✔
153
    },
154
  });
155

156
  const stdinStream = new ReadableStream<string>({
5,419✔
157
    start() {
158
      // No-op: we won't be pushing any data to stdin in our tests
159
    },
160
    pull() {
161
      // No-op
162
    },
163
    cancel() {
164
      // No-op
165
    },
166
  });
167
  context.streams = {
5,419✔
168
    initialised: true,
169
    stdout: {
170
      stream: stdOutStream,
171
      writer: stdOutStream.getWriter(),
172
    },
173
    stderr: {
174
      stream: stdErrStream,
175
      writer: stdErrStream.getWriter(),
176
    },
177
    stdin: {
178
      stream: stdinStream,
179
      reader: stdinStream.getReader(),
180
    },
181
  };
182
};
183

184
/**
185
 * Generates test cases for a given variant of the CSE evaluator based on the provided TestCases object.
186
 * @param testCases The test cases to generate, organized by function name and consisting of tuples of [code, expected, output].
187
 * @param variant The variant of the CSE evaluator to test (e.g., 1 for Python §1)
188
 * @param groups The groups to load into the context before running the test cases (e.g., [`linkedList`, `list`]).
189
 */
190
export const generateTestCases = (testCases: TestCases, variant: number, groups: Group[]) => {
13✔
191
  for (const [funcName, tests] of Object.entries(testCases)) {
33✔
192
    describe(funcName, () => {
154✔
193
      afterEach(() => {
154✔
194
        jest.restoreAllMocks(); // Automatically restores all spyOn mocks
1,303✔
195
      });
196
      test.each(createInternalTestCases(tests))(`$label`, async ({ code, expected, output }) => {
154✔
197
        const spy = jest.spyOn(Stash.prototype, "pop");
1,303✔
198
        const context = new Context();
1,303✔
199

200
        const outputLst: OutputType[] = [];
1,303✔
201
        generateMockStreams(context, outputLst);
1,303✔
202
        const result = await runInContext(code, context, { variant, groups });
1,303✔
203
        expect(result.status).toBe("finished");
1,303✔
204

205
        if (typeof expected === "function" && expected.prototype instanceof RuntimeSourceError) {
1,303✔
206
          expect(context.errors.length).toBeGreaterThan(0);
396✔
207
          expect(context.errors[0]).toHaveProperty("constructor", expected);
396✔
208
          return;
396✔
209
        }
210
        if (typeof expected === "function" && expected.prototype instanceof Error) {
907✔
211
          expect(result).toHaveProperty("value.message", expect.stringContaining(expected.name));
3✔
212
          return;
3✔
213
        }
214
        expect(outputLst.filter(item => item.type === "stderr")).toStrictEqual([]);
904✔
215

216
        expect(result.status).not.toHaveProperty("value.type", "error");
904✔
217
        if (output !== null) {
904✔
218
          expect(outputLst).toEqual(output.map(line => ({ type: "stdout", value: line + "\n" })));
156✔
219
        }
220

221
        const generateExpectedValueAssertion = (expected: TestOutputValue): Value => {
904✔
222
          if (expected === null) {
929✔
223
            return { type: "none" };
93✔
224
          }
225

226
          if (typeof expected === "bigint") {
836✔
227
            return { type: "bigint", value: expected };
222✔
228
          }
229

230
          if (typeof expected === "number") {
614✔
231
            if (isNaN(expected)) {
59!
232
              return { type: "number", value: NaN };
×
233
            }
234
            return { type: "number", value: expect.closeTo(expected) };
59✔
235
          }
236

237
          if (typeof expected === "boolean") {
555✔
238
            return { type: "bool", value: expected };
406✔
239
          }
240

241
          if (expected instanceof PyComplexNumber) {
149✔
242
            return {
94✔
243
              type: "complex",
244
              value: expect.objectContaining({
245
                real: isNaN(expected.real) ? NaN : expect.closeTo(expected.real),
94!
246
                imag: isNaN(expected.imag) ? NaN : expect.closeTo(expected.imag),
94✔
247
              }),
248
            };
249
          }
250

251
          if (Array.isArray(expected)) {
55✔
252
            return {
10✔
253
              type: "list",
254
              value: expected.map(generateExpectedValueAssertion),
255
            };
256
          }
257

258
          return { type: "string", value: expected };
45✔
259
        };
260
        expect(spy).toHaveLastReturnedWith(
904✔
261
          expect.objectContaining(generateExpectedValueAssertion(expected as TestOutputValue)),
262
        );
263
        return;
904✔
264
      });
265
    });
266
  }
267
};
268

269
// ---------------------------------------------------------------------------
270
// PVML test utilities
271
// ---------------------------------------------------------------------------
272

273
// eslint-disable-next-line @typescript-eslint/no-explicit-any
274
type ErrorClass = new (...args: any[]) => Error;
275

276
/**
277
 * Expected value for an PVML test case.
278
 * PVML's toJSValue returns JS primitives directly, so no bigint or PyComplexNumber.
279
 * `undefined` means the expression should evaluate to Python None / no return value.
280
 */
281
export type PVMLTestExpectedValue = number | boolean | string | null | undefined | ErrorClass;
282

283
/**
284
 * Same shape as TestCases but with PVML-compatible expected values.
285
 * Each tuple: [code, expected, output].
286
 *   - expected: a JS primitive, undefined, null, or an Error subclass (for expected throws)
287
 *   - output: expected print outputs, or null if none expected
288
 */
289
export type PVMLTestCases = Record<string, [string, PVMLTestExpectedValue, string[] | null][]>;
290

291
export const generatePVMLTestCases = (testCases: PVMLTestCases) => {
13✔
292
  for (const [sectionName, tests] of Object.entries(testCases)) {
5✔
293
    describe(sectionName, () => {
18✔
294
      test.each(
18✔
295
        tests.map(([code, expected, output]) => ({
55✔
296
          code,
297
          expected,
298
          output,
299
          label: typeof expected === "function" ? expected.name : JSON.stringify(expected),
55✔
300
        })),
301
      )("$code → $label", ({ code, expected, output }) => {
302
        const source = code.endsWith("\n") ? code : code + "\n";
55!
303
        if (typeof expected === "function") {
55✔
304
          expect(() => {
6✔
305
            const ast = parse(source);
6✔
306
            const program = PVMLCompiler.fromProgram(ast).compileProgram(ast);
6✔
307
            new PVMLInterpreter(program).execute();
6✔
308
          }).toThrow(expected);
309
          return;
6✔
310
        }
311

312
        const outputs: string[] = [];
49✔
313
        const ast = parse(source);
49✔
314
        const program = PVMLCompiler.fromProgram(ast).compileProgram(ast);
49✔
315
        const interpreter = new PVMLInterpreter(program, {
49✔
316
          sendOutput: msg => outputs.push(msg),
4✔
317
        });
318
        const result = PVMLInterpreter.toJSValue(interpreter.execute());
49✔
319

320
        if (expected === undefined) {
49✔
321
          expect(result).toBeUndefined();
2✔
322
        } else if (expected === null) {
47✔
323
          expect(result).toBeNull();
2✔
324
        } else if (typeof expected === "number" && !Number.isInteger(expected)) {
45!
325
          expect(result).toBeCloseTo(expected);
×
326
        } else {
327
          expect(result).toBe(expected);
45✔
328
        }
329

330
        if (output !== null) {
49✔
331
          expect(outputs).toEqual(output);
2✔
332
        }
333
      });
334
    });
335
  }
336
};
337

338
// ---------------------------------------------------------------------------
339
// Native Pynter (pvml/pynter) parity test utilities
340
//
341
// Reruns the same `TestCases` tables used by generateTestCases() (the CSE
342
// suite) against the PVML compiler + a native Pynter `runner` binary, to
343
// track how far the pvml/pynter pathway currently is from CSE parity.
344
//
345
// Opt-in: set PYNTER_RUNNER_PATH to a built `runner` binary
346
// (https://github.com/source-academy/pynter#build-locally) to enable these.
347
// Skipped entirely otherwise, since CI doesn't build the native binary.
348
// Failures are expected and informative here, not a sign of broken infra —
349
// see README.md's "Running the standalone CLI (repl)" section for the
350
// pathway's known, current limitations.
351
// ---------------------------------------------------------------------------
352

353
/** Converts a Pynter result (type name + raw value string) to a JS value comparable to `expected`. */
354
function pynterResultToComparable(result: { resultType: string; resultValue: string }): unknown {
355
  switch (result.resultType) {
×
356
    case "integer":
357
    case "float":
358
      return Number(result.resultValue);
×
359
    case "boolean":
360
      return result.resultValue === "true";
×
361
    case "string":
362
      return result.resultValue;
×
363
    case "null":
364
      return null;
×
365
    case "undefined":
366
      return undefined;
×
367
    default:
368
      // arrays, functions: not decoded from the native trailer today.
369
      return undefined;
×
370
  }
371
}
372

373
/** Converts a CSE `TestOutputValue` to a JS value comparable to pynterResultToComparable()'s output. */
374
function expectedToComparable(expected: TestOutputValue): unknown {
375
  if (typeof expected === "bigint") {
×
376
    // Pynter numbers are single-precision floats/32-bit ints, not arbitrary-precision.
377
    return Number(expected);
×
378
  }
379
  if (
×
380
    typeof expected === "number" ||
×
381
    typeof expected === "string" ||
382
    typeof expected === "boolean" ||
383
    expected === null
384
  ) {
385
    return expected;
×
386
  }
387
  // PyComplexNumber and arrays aren't representable by the native result trailer today.
388
  return undefined;
×
389
}
390

391
/**
392
 * Compares a Pynter float result against the CSE (float64) reference value.
393
 * Pynter's floats are IEEE-754 single-precision, so the reference value must
394
 * be rounded to float32 before comparing — otherwise every comparison is
395
 * inflated by the fp64→fp32 rounding a *correct* Pynter would also incur.
396
 * The tolerance is relative to that rounded value's magnitude (a fixed
397
 * absolute tolerance is meaningless once a value exceeds a few thousand, and
398
 * too generous once it's near zero), with a generous ULP budget on top to
399
 * absorb rounding compounded across chained float32 arithmetic (loops,
400
 * transcendental functions) rather than a single operation.
401
 */
402
const FLOAT32_EPSILON = Math.pow(2, -23);
13✔
403
const FLOAT32_ULP_BUDGET = 4096;
13✔
404

405
export function isCloseToFloat32(actual: unknown, wanted: number): boolean {
13✔
406
  if (typeof actual !== "number") return false;
×
407
  if (Number.isNaN(wanted)) return Number.isNaN(actual);
×
408
  const rounded = Math.fround(wanted);
×
409
  if (!Number.isFinite(rounded)) return actual === rounded;
×
410
  const tolerance = FLOAT32_EPSILON * FLOAT32_ULP_BUDGET * Math.max(Math.abs(rounded), 1);
×
411
  return Math.abs(actual - rounded) <= tolerance;
×
412
}
413

414
/** Matches a Python imaginary-number literal: 3j, .5j, 1.2j, 1.j, 1e3j, 1e-3j, 1J, etc. */
415
const COMPLEX_LITERAL_RE = /(?<![a-zA-Z_])(?:\d+\.\d*|\.\d+|\d+)(?:[eE][+-]?\d+)?[jJ]\b/;
13✔
416

417
/** Whether `expected` is (or, if an array, contains) a PyComplexNumber. */
418
function containsComplexNumber(expected: TestExpectedValue): boolean {
419
  if (expected instanceof PyComplexNumber) return true;
510!
420
  if (Array.isArray(expected)) return expected.some(containsComplexNumber);
510✔
421
  return false;
500✔
422
}
423

424
/**
425
 * Whether a test case involves complex numbers, which Pynter's VM doesn't support
426
 * at all (it mirrors Sinter: values are booleans, 32-bit ints, or single-precision
427
 * floats only) — py-slang's own PVML compiler rejects complex literals outright
428
 * (see PVMLCompiler.visitComplexExpr). Detected via the expected value's type
429
 * (recursing into arrays, e.g. a list of complex numbers) or a complex-literal
430
 * regex over the source, since a case can involve complex numbers as an
431
 * intermediate value without one being the final `expected` result (e.g. a
432
 * comparison, or an error case).
433
 */
434
function involvesComplexNumbers(code: string, expected: TestExpectedValue): boolean {
435
  return containsComplexNumber(expected) || COMPLEX_LITERAL_RE.test(code);
485✔
436
}
437

438
/**
439
 * Matches a call to parse()/tokenize(): these turn SICPy source text into a
440
 * data structure for py-slang's own metacircular-evaluator feature (chapter
441
 * 4's stdlib/parser.ts). That's not a Python 3 language or library feature —
442
 * it has no meaningful analogue once a program is compiled to bytecode and
443
 * run on a VM like Pynter, so it's out of scope for parity here regardless
444
 * of Pynter's own capabilities.
445
 */
446
const PARSE_FEATURE_CALL_RE = /\b(?:parse|tokenize)\s*\(/;
13✔
447

448
function involvesParseFeature(code: string): boolean {
449
  return PARSE_FEATURE_CALL_RE.test(code);
812✔
450
}
451

452
/** Reasons a test case is skipped for the native Pynter pathway, checked in order. */
453
const NATIVE_PYNTER_SKIP_REASONS: {
454
  matches: (code: string, expected: TestExpectedValue) => boolean;
455
  reason: string;
456
}[] = [
13✔
457
  { matches: involvesComplexNumbers, reason: "Pynter does not support complex numbers" },
458
  {
459
    matches: code => involvesParseFeature(code),
482✔
460
    reason: "parse()/tokenize() aren't part of Python 3",
461
  },
462
];
463

464
/**
465
 * Reruns `testCases` (as already used with generateTestCases() for the CSE
466
 * machine) through the PVML compiler + native Pynter, at the given chapter
467
 * `variant`. See the file-level comment above for gating/expectations.
468
 *
469
 * `groups` overrides the default VARIANT_GROUPS[variant] stdlib groups,
470
 * matching whatever non-default combination the sibling generateTestCases()
471
 * call for the same suite uses (e.g. stream tests run CSE at variant 2 with
472
 * extra `stream`/`pairmutator` groups layered on).
473
 */
474
export const generateNativePynterTestCases = (
13✔
475
  testCases: TestCases,
476
  variant: number,
477
  groups?: Group[],
478
) => {
479
  // Pynter's target is Python (SICPy) §3 specifically (see pynter/README.md) —
480
  // every native-Pynter test case must be posed as a §3 program, regardless of
481
  // which chapter the sibling generateTestCases() call for the same suite uses.
482
  if (variant !== 3) {
32!
483
    throw new Error(
×
484
      `generateNativePynterTestCases: Pynter only supports Python §3; got variant ${variant}.`,
485
    );
486
  }
487

488
  const pynterPath = process.env.PYNTER_RUNNER_PATH;
32✔
489
  const describeBlock = pynterPath ? describe : describe.skip;
32!
490

491
  for (const [funcName, tests] of Object.entries(testCases)) {
32✔
492
    describeBlock(`[pvml/pynter] ${funcName}`, () => {
137✔
493
      const runTestCase = async ({ code, expected, output }: InternalTestCase) => {
137✔
494
        let result;
495
        try {
×
496
          result = await runCodePvmlDetailed(code, variant, { pynterPath: pynterPath!, groups });
×
497
        } catch (e) {
498
          if (typeof expected === "function") {
×
499
            // CSE also expects a failure here; any RunError counts as agreement.
500
            expect(e).toBeInstanceOf(RunError);
×
501
            return;
×
502
          }
503
          throw e;
×
504
        }
505

506
        if (typeof expected === "function") {
×
507
          throw new Error(
×
508
            `Expected an error (${expected.name}), but pvml/pynter completed with ` +
509
              `result type "${result.resultType}": ${result.resultValue}`,
510
          );
511
        }
512

513
        if (output !== null) {
×
514
          expect(result.output).toBe(output.map(line => `${line}\n`).join(""));
×
515
        }
516

517
        const actual = pynterResultToComparable(result);
×
518
        const wanted = expectedToComparable(expected);
×
519
        if (result.resultType === "float" && typeof wanted === "number") {
×
520
          expect(isCloseToFloat32(actual, wanted)).toBe(true);
×
521
        } else {
522
          expect(actual).toEqual(wanted);
×
523
        }
524
      };
525

526
      const internalTestCases = createInternalTestCases(tests);
137✔
527
      const supported: InternalTestCase[] = [];
137✔
528
      const skipBuckets = new Map<string, InternalTestCase[]>();
137✔
529
      for (const testCase of internalTestCases) {
137✔
530
        const reason = NATIVE_PYNTER_SKIP_REASONS.find(({ matches }) =>
485✔
531
          matches(testCase.code, testCase.expected),
967✔
532
        )?.reason;
533
        if (reason === undefined) {
485✔
534
          supported.push(testCase);
401✔
535
        } else {
536
          (skipBuckets.get(reason) ?? skipBuckets.set(reason, []).get(reason)!).push(testCase);
84✔
537
        }
538
      }
539

540
      // test.each throws if given an empty array, rather than registering zero
541
      // tests, so only call it for groups that actually have cases in each bucket.
542
      if (supported.length > 0) {
137✔
543
        test.each(supported)(`$label`, runTestCase);
81✔
544
      }
545
      for (const [reason, cases] of skipBuckets) {
137✔
546
        test.skip.each(cases)(`$label (${reason})`, runTestCase);
57✔
547
      }
548
    });
549
  }
550
};
551

552
// ---------------------------------------------------------------------------
553
// CPython parity test utilities (issue #224)
554
//
555
// Reruns the same `TestCases` tables used by generateTestCases() (the CSE
556
// suite) against real CPython + the `sourceacademy-sicp` package
557
// (python/sicp/), so that a program's *entire result*, not just the library's
558
// own pytest suite, is checked against the actual reference implementation
559
// students eventually run their code on. One persistent `python3` subprocess
560
// per describe block (scripts/cpython_batch_runner.py), fed every case as a
561
// single JSON batch — the suite has thousands of cases, so a process spawned
562
// per case would dominate the runtime.
563
//
564
// Opt-in: set CPYTHON_PATH to a `python3` binary (python/sicp/ is added to
565
// its sys.path by the runner script itself — no install needed). Skipped
566
// entirely otherwise, since CI doesn't have Python available by default.
567
//
568
// Unlike native-Pynter parity, a fair number of cases here are *expected* to
569
// disagree with CPython by design, not because a pathway is behind on a
570
// shared spec: Source Academy Python's own pedagogical restrictions (chapter-
571
// gated syntax, `is` restricted to reference types, bool excluded from
572
// arithmetic) have no CPython equivalent to compare against. Those are
573
// filtered by CPYTHON_SKIP_REASONS the same way native-Pynter skips things
574
// Pynter's VM can't do — the two lists barely overlap, since they're skipping
575
// for opposite reasons (CPython is *more* permissive than this dialect,
576
// Pynter is *less*). As with native-Pynter, failures that make it through the
577
// skip list are informative, not necessarily a sign of broken infra — see
578
// README.md.
579
// ---------------------------------------------------------------------------
580

581
const CPYTHON_BATCH_RUNNER = path.join(__dirname, "..", "..", "scripts", "cpython_batch_runner.py");
13✔
582

583
/** A JSON-number-unsafe float value, round-tripped through the batch runner as a tagged string
584
 * (JSON has no Infinity/NaN). */
585
type CPythonFloat = number | "nan" | "inf" | "-inf";
586

587
type CPythonValue =
588
  | { type: "none" }
589
  | { type: "bool"; value: boolean }
590
  | { type: "int"; value: string }
591
  | { type: "float"; value: CPythonFloat }
592
  | { type: "complex"; value: { real: CPythonFloat; imag: CPythonFloat } }
593
  | { type: "str"; value: string }
594
  | { type: "list"; value: CPythonValue[] }
595
  | { type: "other"; repr: string };
596

597
interface CPythonCaseResult {
598
  id: string;
599
  output: string[];
600
  error: string | null;
601
  result: CPythonValue | null;
602
}
603

604
/** Runs `cases` through scripts/cpython_batch_runner.py in one subprocess, keyed by id. Throws if
605
 * the runner itself couldn't execute (missing python3, sicp/ not importable, ...) — an individual
606
 * case's own Python-level error is captured in its own result, not thrown here. */
607
function runCPythonBatch(
608
  pythonPath: string,
609
  cases: { id: string; code: string }[],
610
): Map<string, CPythonCaseResult> {
611
  const stdout = execFileSync(pythonPath, [CPYTHON_BATCH_RUNNER], {
74✔
612
    input: JSON.stringify(cases),
613
    encoding: "utf-8",
614
    maxBuffer: 64 * 1024 * 1024,
615
  });
616
  const results = JSON.parse(stdout) as CPythonCaseResult[];
74✔
617
  return new Map(results.map(r => [r.id, r]));
330✔
618
}
619

620
function floatToComparable(n: number): CPythonFloat {
621
  if (Number.isNaN(n)) return "nan";
15!
622
  if (n === Infinity) return "inf";
15!
623
  if (n === -Infinity) return "-inf";
15!
624
  return n;
15✔
625
}
626

627
/** Converts a CSE `TestOutputValue` to a value comparable with the batch runner's serialized
628
 * result shape (same tagged-union shape on both sides). Unlike native-Pynter's
629
 * expectedToComparable(), this needs no float32 rounding or complex-number exclusion — CPython has
630
 * real doubles and real complex numbers, matching the CSE machine's own value model closely.
631
 * `undefined` for a value the runner can't/doesn't decode (functions, etc.), same fallback
632
 * native-Pynter's version uses. */
633
function cpythonExpectedToComparable(expected: TestOutputValue): CPythonValue | undefined {
634
  if (typeof expected === "bigint") return { type: "int", value: expected.toString() };
298✔
635
  if (typeof expected === "number") return { type: "float", value: floatToComparable(expected) };
179✔
636
  if (typeof expected === "boolean") return { type: "bool", value: expected };
164✔
637
  if (typeof expected === "string") return { type: "str", value: expected };
35✔
638
  if (expected === null) return { type: "none" };
28✔
639
  if (expected instanceof PyComplexNumber) {
10!
NEW
640
    return {
×
641
      type: "complex",
642
      value: {
643
        real: floatToComparable(expected.real),
644
        imag: floatToComparable(expected.imag),
645
      },
646
    };
647
  }
648
  if (Array.isArray(expected)) {
10✔
649
    const values = expected.map(cpythonExpectedToComparable);
10✔
650
    if (values.some(v => v === undefined)) return undefined;
25!
651
    return { type: "list", value: values as CPythonValue[] };
10✔
652
  }
NEW
653
  return undefined;
×
654
}
655

656
/** Whether `expected` is a resolve-time chapter-gating restriction (a Python §<N> feature not
657
 * being available yet — lists, `is`, loops, lambda, ... at an earlier chapter): purely a Source
658
 * Academy Python pedagogical device with no CPython concept to compare against, since CPython has
659
 * no notion of "chapter" at all — every one of these is valid CPython syntax regardless of which
660
 * py-slang chapter validator is active. */
661
function isChapterGatingError(expected: TestExpectedValue): boolean {
662
  return (
331✔
663
    typeof expected === "function" &&
561✔
664
    (expected.prototype instanceof FeatureNotSupportedError ||
665
      expected === FeatureNotSupportedError ||
666
      expected.prototype instanceof BreakContinueOutsideLoopError ||
667
      expected === BreakContinueOutsideLoopError)
668
  );
669
}
670

671
/**
672
 * Whether `code` is a call whose entire argument list is `True`/`False` and `expected` is an
673
 * error: unlike CPython (where `bool` is an `int` subclass, so e.g. `abs(True) == 1`), this
674
 * dialect deliberately excludes `bool` from every arithmetic/numeric builtin and operator (see
675
 * `asIntIfBool`'s doc comment in operators.ts and the equivalent restriction across misc.ts/
676
 * math.ts) — a pedagogical rule with no CPython equivalent, not a bug. High-volume: nearly every
677
 * numeric builtin has one of these cases, so left unfiltered it would swamp genuinely interesting
678
 * failures.
679
 */
680
const BOOL_REJECTION_CALL_RE = /^\w+\((?:True|False)(?:,\s*(?:True|False))*\)$/;
13✔
681

682
function isBoolRejection(code: string, expected: TestExpectedValue): boolean {
683
  return typeof expected === "function" && BOOL_REJECTION_CALL_RE.test(code.trim());
339✔
684
}
685

686
/**
687
 * Matches a `while` loop whose condition is a bare non-comparison expression (a literal or an
688
 * arithmetic/variable expression, with no comparison/boolean operator) -- e.g. `while 1:`,
689
 * `while None:`, `while y + 1:`, but not `while i < 5:`. This dialect requires while-conditions to
690
 * be exactly `bool`, unlike CPython's normal truthy/falsy semantics -- a pedagogical restriction
691
 * with no CPython equivalent, like `isBoolRejection` above. Critical to skip, not just cosmetic:
692
 * some of these (`while 1:`, `while y + 1:`) are genuine infinite loops under real CPython, since
693
 * py-slang's error fires before the loop body ever runs but CPython has nothing to stop it -- left
694
 * unfiltered, this hangs the whole batch runner (see issue #224 test-mode hang investigation).
695
 */
696
const WHILE_CONDITION_RE = /\bwhile\s+([^:\n]+):/;
13✔
697
const COMPARISON_OR_BOOLEAN_OP_RE = /==|!=|<=|>=|<|>|\bin\b|\bis\b|\bnot\b|\band\b|\bor\b/;
13✔
698

699
function isWhileNonBoolCondition(code: string, expected: TestExpectedValue): boolean {
700
  if (typeof expected !== "function") return false;
335✔
701
  const match = code.match(WHILE_CONDITION_RE);
62✔
702
  return match !== null && !COMPARISON_OR_BOOLEAN_OP_RE.test(match[1]);
62✔
703
}
704

705
/** Reasons a test case is skipped for the CPython pathway, checked in order. */
706
const CPYTHON_SKIP_REASONS: {
707
  matches: (code: string, expected: TestExpectedValue) => boolean;
708
  reason: string;
709
}[] = [
13✔
710
  {
711
    matches: isBoolRejection,
712
    reason: "bool is an int subclass in CPython, unlike this dialect's arithmetic/numeric builtins",
713
  },
714
  {
715
    matches: isWhileNonBoolCondition,
716
    reason:
717
      "while-conditions must be exactly bool in this dialect, unlike CPython's truthy/falsy " +
718
      "semantics -- some of these are genuine infinite loops under CPython",
719
  },
720
  {
721
    matches: (_code, expected) => isChapterGatingError(expected),
331✔
722
    reason: "chapter-gating is a Source Academy Python concept CPython has no equivalent of",
723
  },
724
  {
725
    matches: code => involvesParseFeature(code),
330✔
726
    reason: "parse()/tokenize() aren't part of Python 3 (sicp.mce has no equivalent)",
727
  },
728
];
729

730
/**
731
 * Reruns `testCases` (as already used with generateTestCases() for the CSE machine) through real
732
 * CPython, at the given chapter `variant`. See the file-level comment above for gating and
733
 * expectations.
734
 *
735
 * `groups` overrides the default VARIANT_GROUPS[variant] stdlib groups, matching whatever
736
 * non-default combination the sibling generateTestCases() call for the same suite uses.
737
 */
738
export const generateCPythonTestCases = (
13✔
739
  testCases: TestCases,
740
  variant: number,
741
  groups?: Group[],
742
) => {
743
  void variant; // Not needed by the runner itself (sicp exposes the full superset of builtins
9✔
744
  // unconditionally); kept as a parameter so call sites read the same as generateTestCases()'s.
745
  void groups; // Same: sicp has no notion of "this group isn't loaded yet", so nothing to pass on.
9✔
746

747
  const pythonPath = process.env.CPYTHON_PATH;
9✔
748
  const describeBlock = pythonPath ? describe : describe.skip;
9!
749

750
  for (const [funcName, tests] of Object.entries(testCases)) {
9✔
751
    describeBlock(`[cpython] ${funcName}`, () => {
74✔
752
      const internalTestCases = createInternalTestCases(tests);
74✔
753
      const supported: InternalTestCase[] = [];
74✔
754
      const skipBuckets = new Map<string, InternalTestCase[]>();
74✔
755
      for (const testCase of internalTestCases) {
74✔
756
        const reason = CPYTHON_SKIP_REASONS.find(({ matches }) =>
339✔
757
          matches(testCase.code, testCase.expected),
1,335✔
758
        )?.reason;
759
        if (reason === undefined) {
339✔
760
          supported.push(testCase);
330✔
761
        } else {
762
          (skipBuckets.get(reason) ?? skipBuckets.set(reason, []).get(reason)!).push(testCase);
9✔
763
        }
764
      }
765

766
      let batchResults: Map<string, CPythonCaseResult> = new Map();
74✔
767
      if (pythonPath && supported.length > 0) {
74✔
768
        beforeAll(() => {
74✔
769
          batchResults = runCPythonBatch(
74✔
770
            pythonPath,
771
            supported.map((c, i) => ({ id: String(i), code: c.code })),
330✔
772
          );
773
        });
774
      }
775

776
      const runTestCase = (testCase: InternalTestCase, index: number) => {
74✔
777
        const { expected, output } = testCase;
330✔
778
        const result = batchResults.get(String(index));
330✔
779
        if (!result) throw new Error("CPython batch result missing for this case");
330!
780

781
        if (typeof expected === "function") {
330✔
782
          // CSE also expects a failure here; any CPython exception counts as agreement — exact
783
          // exception class/message parity isn't the point (py-slang's own error names, like
784
          // MissingRequiredPositionalError, don't exist in CPython).
785
          expect(result.error).not.toBeNull();
57✔
786
          return;
57✔
787
        }
788

789
        expect(result.error).toBeNull();
273✔
790
        if (output !== null) {
273✔
791
          expect(result.output).toEqual(output);
32✔
792
        }
793

794
        const wanted = cpythonExpectedToComparable(expected as TestOutputValue);
273✔
795
        if (wanted === undefined || result.result === undefined) return; // not decodable; skip value check
273!
796
        if (wanted.type === "float" && result.result?.type === "float") {
273✔
797
          const a = result.result.value;
15✔
798
          const w = wanted.value;
15✔
799
          if (typeof a === "number" && typeof w === "number") {
15✔
800
            expect(a).toBeCloseTo(w);
15✔
801
            return;
15✔
802
          }
803
        }
804
        if (wanted.type === "complex" && result.result?.type === "complex") {
258!
NEW
805
          const aReal = result.result.value.real;
×
NEW
806
          const aImag = result.result.value.imag;
×
NEW
807
          const wReal = wanted.value.real;
×
NEW
808
          const wImag = wanted.value.imag;
×
NEW
809
          if (
×
810
            typeof aReal === "number" &&
×
811
            typeof wReal === "number" &&
812
            typeof aImag === "number" &&
813
            typeof wImag === "number"
814
          ) {
NEW
815
            expect(aReal).toBeCloseTo(wReal);
×
NEW
816
            expect(aImag).toBeCloseTo(wImag);
×
NEW
817
            return;
×
818
          }
819
        }
820
        expect(result.result).toEqual(wanted);
258✔
821
      };
822

823
      if (supported.length > 0) {
74✔
824
        test.each(supported.map((c, i) => ({ ...c, __index: i })))(
330✔
825
          `$label`,
826
          ({ __index, ...testCase }) => runTestCase(testCase, __index),
330✔
827
        );
828
      }
829
      for (const [reason, cases] of skipBuckets) {
74✔
830
        test.skip.each(cases)(`$label (${reason})`, () => {});
6✔
831
      }
832
    });
833
  }
834
};
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc