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

source-academy / py-slang / 28942828497

08 Jul 2026 12:32PM UTC coverage: 77.536% (+0.1%) from 77.401%
28942828497

Pull #231

github

web-flow
Merge 19fcf4443 into f8b9b8450
Pull Request #231: Revamping equality and identity

2618 of 3656 branches covered (71.61%)

Branch coverage included in aggregate %.

14 of 16 new or added lines in 5 files covered. (87.5%)

57 existing lines in 2 files now uncovered.

6135 of 7633 relevant lines covered (80.37%)

14074.3 hits per line

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

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

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

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

65
  // Evaluate
66
  const result = await evaluate(code, pyAst, context, options);
1,758✔
67
  return CSEResultPromise(context, result);
1,758✔
68
}
69

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

73
export type TestOutputValue =
74
  | bigint
75
  | number
76
  | boolean
77
  | string
78
  | null
79
  | PyComplexNumber
80
  | TestOutputValue[];
81

82
export type TestErrorValue = Class<RuntimeSourceError> | Class<Error>;
83

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

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

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

109
type InternalTestCase = {
110
  label: TestExpectedValue;
111
  code: string;
112
  expected: TestExpectedValue;
113
  output: string[] | null;
114
};
115

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

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

147
  const stdErrStream = new WritableStream<ConductorError>({
5,127✔
148
    write: (data: ConductorError) => {
149
      output.push({ type: "stderr", value: data });
3,346✔
150
    },
151
  });
152

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

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

197
        const outputLst: OutputType[] = [];
1,304✔
198
        generateMockStreams(context, outputLst);
1,304✔
199
        const result = await runInContext(code, context, { variant, groups });
1,304✔
200
        expect(result.status).toBe("finished");
1,304✔
201

202
        if (typeof expected === "function" && expected.prototype instanceof RuntimeSourceError) {
1,304✔
203
          expect(context.errors.length).toBeGreaterThan(0);
422✔
204
          expect(context.errors[0]).toHaveProperty("constructor", expected);
422✔
205
          return;
422✔
206
        }
207
        if (typeof expected === "function" && expected.prototype instanceof Error) {
882✔
208
          expect(result).toHaveProperty("value.message", expect.stringContaining(expected.name));
3✔
209
          return;
3✔
210
        }
211
        expect(outputLst.filter(item => item.type === "stderr")).toStrictEqual([]);
879✔
212

213
        expect(result.status).not.toHaveProperty("value.type", "error");
879✔
214
        if (output !== null) {
879✔
215
          expect(outputLst).toEqual(output.map(line => ({ type: "stdout", value: line + "\n" })));
154✔
216
        }
217

218
        const generateExpectedValueAssertion = (expected: TestOutputValue): Value => {
879✔
219
          if (expected === null) {
930✔
220
            return { type: "none" };
91✔
221
          }
222

223
          if (typeof expected === "bigint") {
839✔
224
            return { type: "bigint", value: expected };
236✔
225
          }
226

227
          if (typeof expected === "number") {
603✔
228
            if (isNaN(expected)) {
58!
229
              return { type: "number", value: NaN };
×
230
            }
231
            return { type: "number", value: expect.closeTo(expected) };
58✔
232
          }
233

234
          if (typeof expected === "boolean") {
545✔
235
            return { type: "bool", value: expected };
385✔
236
          }
237

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

248
          if (Array.isArray(expected)) {
66✔
249
            return {
19✔
250
              type: "list",
251
              value: expected.map(generateExpectedValueAssertion),
252
            };
253
          }
254

255
          return { type: "string", value: expected };
47✔
256
        };
257
        expect(spy).toHaveLastReturnedWith(
879✔
258
          expect.objectContaining(generateExpectedValueAssertion(expected as TestOutputValue)),
259
        );
260
        return;
879✔
261
      });
262
    });
263
  }
264
};
265

266
// ---------------------------------------------------------------------------
267
// PVML test utilities
268
// ---------------------------------------------------------------------------
269

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

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

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

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

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

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

327
        if (output !== null) {
49✔
328
          expect(outputs).toEqual(output);
2✔
329
        }
330
      });
331
    });
332
  }
333
};
334

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

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

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

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

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

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

414
/** Whether `expected` is (or, if an array, contains) a PyComplexNumber. */
415
function containsComplexNumber(expected: TestExpectedValue): boolean {
416
  if (expected instanceof PyComplexNumber) return true;
545!
417
  if (Array.isArray(expected)) return expected.some(containsComplexNumber);
545✔
418
  return false;
526✔
419
}
420

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

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

445
function involvesParseFeature(code: string): boolean {
446
  return PARSE_FEATURE_CALL_RE.test(code);
490✔
447
}
448

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

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

485
  const pynterPath = process.env.PYNTER_RUNNER_PATH;
32✔
486
  const describeBlock = pynterPath ? describe : describe.skip;
32!
487

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

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

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

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

523
      const internalTestCases = createInternalTestCases(tests);
137✔
524
      const supported: InternalTestCase[] = [];
137✔
525
      const skipBuckets = new Map<string, InternalTestCase[]>();
137✔
526
      for (const testCase of internalTestCases) {
137✔
527
        const reason = NATIVE_PYNTER_SKIP_REASONS.find(({ matches }) =>
494✔
528
          matches(testCase.code, testCase.expected),
984✔
529
        )?.reason;
530
        if (reason === undefined) {
494✔
531
          supported.push(testCase);
409✔
532
        } else {
533
          (skipBuckets.get(reason) ?? skipBuckets.set(reason, []).get(reason)!).push(testCase);
85✔
534
        }
535
      }
536

537
      // test.each throws if given an empty array, rather than registering zero
538
      // tests, so only call it for groups that actually have cases in each bucket.
539
      if (supported.length > 0) {
137✔
540
        test.each(supported)(`$label`, runTestCase);
81✔
541
      }
542
      for (const [reason, cases] of skipBuckets) {
137✔
543
        test.skip.each(cases)(`$label (${reason})`, runTestCase);
58✔
544
      }
545
    });
546
  }
547
};
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