Introduction

Tests are split into language and environment. That is, the language being tested, and the environment running the test code.

Curriculum Helpers

RandomMocker

Mocks Math.random for testing purposes. Each time mock() is called the pseudo-random number generator is reset to its initial state, so that the same sequence of random numbers is generated each time. restore() restores the native Math.random function.

const randomMocker = new RandomMocker();
randomMocker.mock();
Math.random(); // first call is always 0.2523451747838408
Math.random(); // second call is always 0.08812504541128874
randomMocker.mock();
Math.random(); // generator is reset, so we get 0.2523451747838408 again
randomMocker.restore();
Math.random(); // back to native Math.random

concatRegex

Combines one or more regular expressions into one.

const regex1 = /a\s/;
const regex2 = /b/;
concatRegex(regex1, regex2).source === "a\\sb";

functionRegex

Given a function name and, optionally, a list of parameters, returns a regex that can be used to match that function declaration in a code block.

let regex = functionRegex("foo", ["bar", "baz"]);
regex.test("function foo(bar, baz){}"); // true
regex.test("function foo(bar, baz, qux){}"); // false
regex.test("foo = (bar, baz) => {}"); // true

Options

  • capture: boolean - If true, the regex will capture the function definition, including it's body, otherwise not. Defaults to false.
  • includeBody: boolean - If true, the regex will include the function body in the match. Otherwise it will stop at the first bracket. Defaults to true.
let regEx = functionRegex("foo", ["bar", "baz"], { capture: true });
let combinedRegEx = concatRegex(/var x = "y"; /, regEx);

let match = `var x = "y";
function foo(bar, baz){}`.match(regex);
match[1]; // "function foo(bar, baz){}"
// i.e. only the function definition is captured
let regEx = functionRegex("foo", ["bar", "baz"], { includeBody: false });

let match = `function foo(bar, baz){console.log('ignored')}`.match(regex);
match[1]; // "function foo(bar, baz){"

NOTE: capture does not work properly with arrow functions. It will capture text after the function body, too.

let regEx = functionRegex("myFunc", ["arg1"], { capture: true });

let match =
  "myFunc = arg1 => arg1; console.log();\n // captured, unfortunately".match(
    regEx,
  );
match[1]; // "myFunc = arg1 => arg1; console.log();\n // captured, unfortunately"

prepTestComponent

Renders a React component into a DOM element and returns a Promise containing the DOM element. The arguments are, respectively, the component to render and an (optional) object containing the props to pass to the component.

import { SomeComponent } from "./SomeComponent";
const element = await prepTestComponent(SomeComponent, {
  someProp: "someValue",
});
element.querySelector("h1").textContent === "Some Value";

permutateRegex

Permutates regular expressions or source strings, to create regex matching elements in any order.

const source1 = "a";
const regex1 = /b/;
const source2 = "c";

permutateRegex([source1, regex1, source2]).source ===
  new RegExp(
    /(?:a\s*\|\|\s*b\s*\|\|\s*c|b\s*\|\|\s*a\s*\|\|\s*c|c\s*\|\|\s*a\s*\|\|\s*b|a\s*\|\|\s*c\s*\|\|\s*b|b\s*\|\|\s*c\s*\|\|\s*a|c\s*\|\|\s*b\s*\|\|\s*a)/,
  ).source;

Inputs can have capturing groups, but both groups and backreferrences need to be named. In the resulting regex they will be renamed to avoid duplicated names, and to allow backreferrences to refer to correct group.

const regex = permutateRegex(["a", /(?<ref>'|"|`)b\k<ref>/], {
  elementsSeparator: String.raw`\s*===\s*`,
});

regex.source ===
  new RegExp(
    /(?:a\s*===\s*(?<ref_0>'|"|`)b\k<ref_0>|(?<ref_1>'|"|`)b\k<ref_1>\s*===\s*a)/,
  ).source;

regex.test('a === "b"'); // true
regex.test("'b' === a"); // true
regex.test("a === `b`"); // true
regex.test(`a === 'b"`); // false

Options

  • capture: boolean - Whole regex is wrapped in regex group. If capture is true the group will be capturing, otherwise it will be non-capturing. Defaults to false.
  • elementsSeparator: string - Separates permutated elements within single permutation. Defaults to \s*\|\|\s*.
  • permutationsSeparator: string - Separates permutations. Defaults to |.
permutateRegex(["a", /b/, "c"], { capture: true }).source ===
  new RegExp(
    /(a\s*\|\|\s*b\s*\|\|\s*c|b\s*\|\|\s*a\s*\|\|\s*c|c\s*\|\|\s*a\s*\|\|\s*b|a\s*\|\|\s*c\s*\|\|\s*b|b\s*\|\|\s*c\s*\|\|\s*a|c\s*\|\|\s*b\s*\|\|\s*a)/,
  ).source;
permutateRegex(["a", /b/, "c"], { elementsSeparator: "," }).source ===
  new RegExp(/(?:a,b,c|b,a,c|c,a,b|a,c,b|b,c,a|c,b,a)/).source;
permutateRegex(["a", /b/, "c"], { permutationsSeparator: "&" }).source ===
  new RegExp(
    /(?:a\s*\|\|\s*b\s*\|\|\s*c&b\s*\|\|\s*a\s*\|\|\s*c&c\s*\|\|\s*a\s*\|\|\s*b&a\s*\|\|\s*c\s*\|\|\s*b&b\s*\|\|\s*c\s*\|\|\s*a&c\s*\|\|\s*b\s*\|\|\s*a)/,
  ).source;

CSS

Browser

CSS Code Tested

:root {
  --building-color1: #aa80ff;
  --building-color2: #66cc99;
  --building-color3: #cc6699;
  --building-color4: #538cc6;
  --window-color1: #bb99ff;
  --window-color2: #8cd9b3;
  --window-color3: #d98cb3;
  --window-color4: #8cb3d9;
}
* {
  box-sizing: border-box;
}
body {
  height: 100vh;
  margin: 0;
  overflow: hidden;
}
.background-buildings,
.foreground-buildings {
  width: 100%;
  height: 100%;
  display: flex;
  align-items: flex-end;
  justify-content: space-evenly;
  position: absolute;
  top: 0;
}
.building-wrap {
  display: flex;
  flex-direction: column;
  align-items: center;
}
.window-wrap {
  display: flex;
  align-items: center;
  justify-content: space-evenly;
}
.sky {
  background: radial-gradient(
    closest-corner circle at 15% 15%,
    #ffcf33,
    #ffcf33 20%,
    #ffff66 21%,
    #bbeeff 100%
  );
}
/* BACKGROUND BUILDINGS - "bb" stands for "background building" */
.bb1 {
  width: 10%;
  height: 70%;
}
.bb1a {
  width: 70%;
}
.bb1b {
  width: 80%;
}
.bb1c {
  width: 90%;
}
.bb1d {
  width: 100%;
  height: 70%;
  background: linear-gradient(var(--building-color1) 50%, var(--window-color1));
}
.bb1-window {
  height: 10%;
  background: linear-gradient(var(--building-color1), var(--window-color1));
}
.bb2 {
  width: 10%;
  height: 50%;
}
.bb2a {
  border-bottom: 5vh solid var(--building-color2);
  border-left: 5vw solid transparent;
  border-right: 5vw solid transparent;
}
.bb2b {
  width: 100%;
  height: 100%;
  background: repeating-linear-gradient(
    var(--building-color2),
    var(--building-color2) 6%,
    var(--window-color2) 6%,
    var(--window-color2) 9%
  );
}
.bb3 {
  width: 10%;
  height: 55%;
  background: repeating-linear-gradient(
    90deg,
    var(--building-color3),
    var(--building-color3),
    var(--window-color3) 15%
  );
}
.bb4 {
  width: 11%;
  height: 58%;
}
.bb4a {
  width: 3%;
  height: 10%;
  background-color: var(--building-color4);
}
.bb4b {
  width: 80%;
  height: 5%;
  background-color: var(--building-color4);
}
.bb4c {
  width: 100%;
  height: 85%;
  background-color: var(--building-color4);
}
.bb4-window {
  width: 18%;
  height: 90%;
  background-color: var(--window-color4);
}
/* FOREGROUND BUILDINGS - "fb" stands for "foreground building" */
.fb1 {
  width: 10%;
  height: 60%;
}
.fb1a {
  border-bottom: 7vh solid var(--building-color4);
  border-left: 2vw solid transparent;
  border-right: 2vw solid transparent;
}
.fb1b {
  width: 60%;
  height: 10%;
  background-color: var(--building-color4);
}
.fb1c {
  width: 100%;
  height: 80%;
  background: repeating-linear-gradient(
      90deg,
      var(--building-color4),
      var(--building-color4) 10%,
      transparent 10%,
      transparent 15%
    ),
    repeating-linear-gradient(
      var(--building-color4),
      var(--building-color4) 10%,
      var(--window-color4) 10%,
      var(--window-color4) 90%
    );
}
.fb2 {
  width: 10%;
  height: 40%;
}
.fb2a {
  width: 100%;
  border-bottom: 10vh solid var(--building-color3);
  border-left: 1vw solid transparent;
  border-right: 1vw solid transparent;
}
.fb2b {
  width: 100%;
  height: 75%;
  background-color: var(--building-color3);
}
.fb2-window {
  width: 22%;
  height: 100%;
  background-color: var(--window-color3);
}
.fb3 {
  width: 10%;
  height: 35%;
}
.fb3a {
  width: 80%;
  height: 15%;
  background-color: var(--building-color1);
}
.fb3b {
  width: 100%;
  height: 35%;
  background-color: var(--building-color1);
}
.fb3-window {
  width: 25%;
  height: 80%;
  background-color: var(--window-color1);
}
.fb4 {
  width: 8%;
  height: 45%;
  position: relative;
  left: 10%;
}
.fb4a {
  border-top: 5vh solid transparent;
  border-left: 8vw solid var(--building-color1);
}
.fb4b {
  width: 100%;
  height: 89%;
  background-color: var(--building-color1);
  display: flex;
  flex-wrap: wrap;
}
.fb4-window {
  width: 30%;
  height: 10%;
  border-radius: 50%;
  background-color: var(--window-color1);
  margin: 10%;
}
.fb5 {
  width: 10%;
  height: 33%;
  position: relative;
  right: 10%;
  background: repeating-linear-gradient(
      var(--building-color2),
      var(--building-color2) 5%,
      transparent 5%,
      transparent 10%
    ),
    repeating-linear-gradient(
      90deg,
      var(--building-color2),
      var(--building-color2) 12%,
      var(--window-color2) 12%,
      var(--window-color2) 44%
    );
}
.fb6 {
  width: 9%;
  height: 38%;
  background: repeating-linear-gradient(
      90deg,
      var(--building-color3),
      var(--building-color3) 10%,
      transparent 10%,
      transparent 30%
    ),
    repeating-linear-gradient(
      var(--building-color3),
      var(--building-color3) 10%,
      var(--window-color3) 10%,
      var(--window-color3) 30%
    );
}
@media (max-width: 1000px) {
  .sky {
    background: radial-gradient(
      closest-corner circle at 15% 15%,
      #ffcf33,
      #ffcf33 20%,
      #ffff66 21%,
      #bbeeff 100%
    );
  }
}
const tester = new CSSHelp(document);
describe("getStyle", () => {
  it("should return an ExtendedCSSStyleDeclartion object of length 1", () => {
    expect(tester.getStyle("*")?.length).toEqual(1);
  });
  it("should return a non-empty ExtendedCSSStyleDeclaration object", () => {
    expect(tester.getStyle(".bb1")).toBeTruthy();
  });
  it("should return a whitespaceless string", () => {
    expect(tester.getStyle(".bb1d")?.getPropVal("background", true)).toEqual(
      "linear-gradient(var(--building-color1)50%,var(--window-color1))",
    );
  });
});

describe("getStyleAny", () => {
  it("should return an ExtendedCSSStyleDeclartion object of length 1", () => {
    expect(tester.getStyleAny([".earth", ".sky"])?.length).toEqual(1);
  });
  it("should return null", () => {
    expect(tester.getStyleAny([".sun", ".earth", ".moon"])).toBeNull();
  });
});

describe("isPropertyUsed", () => {
  it("should return true on existing properties", () => {
    expect(tester.isPropertyUsed("height")).toBeTruthy();
  });
  it("should return true on existing custom properties", () => {
    expect(tester.isPropertyUsed("--building-color1")).toBeTruthy();
  });
});

describe("isDeclaredAfter", () => {
  it("should return true if existing style is declared after another", () => {
    expect(tester.getStyleRule(".bb1a")?.isDeclaredAfter(".bb1")).toBeTruthy();
  });
});

describe("getPropertyValue", () => {
  it("should return custom property value needing trim", () => {
    expect(
      tester.getStyle(":root")?.getPropertyValue("--building-color1")?.trim(),
    ).toEqual("#aa80ff");
  });
  it("should return value to existing property", () => {
    expect(
      tester.getStyle(".bb4a")?.getPropertyValue("background-color"),
    ).toBeTruthy();
  });
  it("should return property value without evaluating result", () => {
    expect(
      tester.getStyle(".bb4a")?.getPropertyValue("background-color"),
    ).toEqual("var(--building-color4)");
  });
});

describe("getCSSRules", () => {
  it("should return a CSSRules array of length 1", () => {
    expect(tester.getCSSRules("media")?.length).toEqual(1);
  });
});

describe("getRuleListsWithinMedia", () => {
  it("should return a CSSMediaRule array with a selectable CSSStyleRule", () => {
    expect(
      t
        .getRuleListsWithinMedia("(max-width: 1000px)")
        .find((x) => x.selectorText === ".sky"),
    ).toBeTruthy();
  });
  it("should return CSSStyleDeclaration property with complex value", () => {
    // NOTE: JSDOM causes value to have tabbed characters, DOM has single-line values.
    expect(
      t
        .getRuleListsWithinMedia("(max-width: 1000px)")
        .find((x) => x.selectorText === ".sky")?.style?.background,
    ).toEqual(
      `radial-gradient(
    closest-corner circle at 15% 15%,
    #ffcf33,
    #ffcf33 20%,
    #ffff66 21%,
    #bbeeff 100%
  )`,
    );
  });
});

describe("selectorsFromSelector", () => {
  it("should return an empty array", () => {
    setupDocument();
    expect(tester.selectorsFromSelector(".void")).toEqual([]);
  });
  it("should return an array with 9 members", () => {
    setupDocument();
    expect(tester.selectorsFromSelector("a")).toEqual([
      "a",
      "label > a",
      "label a",
      "form > label > a",
      "form label a",
      "body > form > label > a",
      "body form label a",
      "html > body > form > label > a",
      "html body form label a",
    ]);
  });
});

Python

Regex-based helpers

getDef

 const __helpers = {
   python: {
     getDef: (code, functionName) => {
       const regex = new RegExp(
         `^(?<function_indentation> *?)def +${functionName} *\\((?<function_parameters>[^\\)]*)\\)\\s*:\\n(?<function_body>.*?)(?=\\n\\k<function_indentation>[\\w#]|$)`,
         "ms",
       );
 
       const matchedCode = regex.exec(code);
       if (matchedCode) {
         const { function_parameters, function_body, function_indentation } =
           matchedCode.groups;
 
         const functionIndentationSansNewLine = function_indentation.replace(
           /\n+/,
           "",
         );
         return {
           def: matchedCode[0],
           function_parameters,
           function_body,
           function_indentation: functionIndentationSansNewLine.length,
         };
       }
 
       return null;
     },
     getBlock: (code, blockPattern) => {
       const escapedBlockPattern =
         blockPattern instanceof RegExp
           ? blockPattern.source
           : blockPattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
 
       const regex = new RegExp(
         `^(?<block_indentation> *?)(?<block_condition>${escapedBlockPattern})\\s*:\\n(?<block_body>.*?)(?=\\n\\k<block_indentation>[\\w#]|$)`,
         "ms",
       );
 
       const matchedCode = regex.exec(code);
       if (matchedCode) {
         /* eslint-disable camelcase */
         const { block_body, block_indentation, block_condition } =
           matchedCode.groups;
 
         const blockIndentationSansNewLine = block_indentation.replace(
           /\n+/g,
           "",
         );
         return {
           block_body,
           block_condition,
           block_indentation: blockIndentationSansNewLine.length,
         };
         /* eslint-enable camelcase */
       }
 
       return null;
     },
     removeComments: (code) => {
       return code.replace(/\/\/.*|\/\*[\s\S]*?\*\/|(#.*$)/gm, "");
     },
   },
 };
const code = `
a = 1
b = 2

def add(x, y):
    result = x + y
    print(f"{x} + {y} = {result}")
    return result

`;

{
  const add = __helpers.python.getDef(code, "add");
  const { function_body, function_indentation, def, function_parameters } =
    add;
  console.assert(function_indentation === 0);
  console.assert(function_parameters === "x, y");
  console.assert(function_body.match(/result\s*=\s*x\s*\+\s*y/));
  console.log(add);
}

getBlock

 const __helpers = {
   python: {
     getDef: (code, functionName) => {
       const regex = new RegExp(
         `^(?<function_indentation> *?)def +${functionName} *\\((?<function_parameters>[^\\)]*)\\)\\s*:\\n(?<function_body>.*?)(?=\\n\\k<function_indentation>[\\w#]|$)`,
         "ms",
       );
 
       const matchedCode = regex.exec(code);
       if (matchedCode) {
         const { function_parameters, function_body, function_indentation } =
           matchedCode.groups;
 
         const functionIndentationSansNewLine = function_indentation.replace(
           /\n+/,
           "",
         );
         return {
           def: matchedCode[0],
           function_parameters,
           function_body,
           function_indentation: functionIndentationSansNewLine.length,
         };
       }
 
       return null;
     },
     getBlock: (code, blockPattern) => {
       const escapedBlockPattern =
         blockPattern instanceof RegExp
           ? blockPattern.source
           : blockPattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
 
       const regex = new RegExp(
         `^(?<block_indentation> *?)(?<block_condition>${escapedBlockPattern})\\s*:\\n(?<block_body>.*?)(?=\\n\\k<block_indentation>[\\w#]|$)`,
         "ms",
       );
 
       const matchedCode = regex.exec(code);
       if (matchedCode) {
         /* eslint-disable camelcase */
         const { block_body, block_indentation, block_condition } =
           matchedCode.groups;
 
         const blockIndentationSansNewLine = block_indentation.replace(
           /\n+/g,
           "",
         );
         return {
           block_body,
           block_condition,
           block_indentation: blockIndentationSansNewLine.length,
         };
         /* eslint-enable camelcase */
       }
 
       return null;
     },
     removeComments: (code) => {
       return code.replace(/\/\/.*|\/\*[\s\S]*?\*\/|(#.*$)/gm, "");
     },
   },
 };
const code = `
a = 1
b = 2

def add_or_subtract(a, b, add=True):
    if add:
      return a + b
    else:
      return a - b

`;

{
  const equivalentPatterns = [
    "if add",
    /(if|elif) add/,
  ];
  for (const pattern of equivalentPatterns) {
    const ifBlock = __helpers.python.getBlock(code, pattern);
    const { block_body, block_indentation, block_condition } = ifBlock;
    console.assert(block_indentation === 4);
    console.assert(block_condition === "if add");
    console.assert(block_body.match(/return a \+ b/));
    console.log(ifBlock);
  }
}

removeComments

 const __helpers = {
   python: {
     getDef: (code, functionName) => {
       const regex = new RegExp(
         `^(?<function_indentation> *?)def +${functionName} *\\((?<function_parameters>[^\\)]*)\\)\\s*:\\n(?<function_body>.*?)(?=\\n\\k<function_indentation>[\\w#]|$)`,
         "ms",
       );
 
       const matchedCode = regex.exec(code);
       if (matchedCode) {
         const { function_parameters, function_body, function_indentation } =
           matchedCode.groups;
 
         const functionIndentationSansNewLine = function_indentation.replace(
           /\n+/,
           "",
         );
         return {
           def: matchedCode[0],
           function_parameters,
           function_body,
           function_indentation: functionIndentationSansNewLine.length,
         };
       }
 
       return null;
     },
     getBlock: (code, blockPattern) => {
       const escapedBlockPattern =
         blockPattern instanceof RegExp
           ? blockPattern.source
           : blockPattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
 
       const regex = new RegExp(
         `^(?<block_indentation> *?)(?<block_condition>${escapedBlockPattern})\\s*:\\n(?<block_body>.*?)(?=\\n\\k<block_indentation>[\\w#]|$)`,
         "ms",
       );
 
       const matchedCode = regex.exec(code);
       if (matchedCode) {
         /* eslint-disable camelcase */
         const { block_body, block_indentation, block_condition } =
           matchedCode.groups;
 
         const blockIndentationSansNewLine = block_indentation.replace(
           /\n+/g,
           "",
         );
         return {
           block_body,
           block_condition,
           block_indentation: blockIndentationSansNewLine.length,
         };
         /* eslint-enable camelcase */
       }
 
       return null;
     },
     removeComments: (code) => {
       return code.replace(/\/\/.*|\/\*[\s\S]*?\*\/|(#.*$)/gm, "");
     },
   },
 };
// Note: Comment identifiers are escaped for docs markdown parser
const code = `
a = 1
\# comment
def b(d, e):
    a = 2
    \# comment
    return a #comment
`;
{
  const commentlessCode = __helpers.python.removeComments(code);
  console.assert(commentlessCode === `\na = 1\n\ndef b(d, e):\n    a = 2\n    \n    return a \n`);
  console.log(commentlessCode);
}

__pyodide.runPython

Running the code of a singular function to get the output:

 const __helpers = {
   python: {
     getDef: (code, functionName) => {
       const regex = new RegExp(
         `^(?<function_indentation> *?)def +${functionName} *\\((?<function_parameters>[^\\)]*)\\)\\s*:\\n(?<function_body>.*?)(?=\\n\\k<function_indentation>[\\w#]|$)`,
         "ms",
       );
 
       const matchedCode = regex.exec(code);
       if (matchedCode) {
         const { function_parameters, function_body, function_indentation } =
           matchedCode.groups;
 
         const functionIndentationSansNewLine = function_indentation.replace(
           /\n+/,
           "",
         );
         return {
           def: matchedCode[0],
           function_parameters,
           function_body,
           function_indentation: functionIndentationSansNewLine.length,
         };
       }
 
       return null;
     },
     getBlock: (code, blockPattern) => {
       const escapedBlockPattern =
         blockPattern instanceof RegExp
           ? blockPattern.source
           : blockPattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
 
       const regex = new RegExp(
         `^(?<block_indentation> *?)(?<block_condition>${escapedBlockPattern})\\s*:\\n(?<block_body>.*?)(?=\\n\\k<block_indentation>[\\w#]|$)`,
         "ms",
       );
 
       const matchedCode = regex.exec(code);
       if (matchedCode) {
         /* eslint-disable camelcase */
         const { block_body, block_indentation, block_condition } =
           matchedCode.groups;
 
         const blockIndentationSansNewLine = block_indentation.replace(
           /\n+/g,
           "",
         );
         return {
           block_body,
           block_condition,
           block_indentation: blockIndentationSansNewLine.length,
         };
         /* eslint-enable camelcase */
       }
 
       return null;
     },
     removeComments: (code) => {
       return code.replace(/\/\/.*|\/\*[\s\S]*?\*\/|(#.*$)/gm, "");
     },
   },
 };
 const __pyodide = {
   runPython: (code) => {
     return code;
   },
 };
const code = `
a = 1
b = 2

def add(x, y):
  result = x + y
  print(f"{x} + {y} = {result}")
  return result

`;

{
  const add = __helpers.python.getDef(code, "add");
  const { function_body, function_indentation, def, function_parameters } =
    add;
  const c = `
a = 100
b = 200

def add(${function_parameters}):
${' '.repeat(function_indentation)}assert ${function_parameters.split(',')[0]} == 100
${function_body}

assert add(a, b) == 300
`;
  const out = __pyodide.runPython(c); // If this does not throw, code is correct
  console.log(add);
}

AST-based helpers

In contrast to the regex-based helpers, these helpers need to be run in Python, not JavaScript

Formatting output

str returns a string that would parse to the same AST as the node. For example:

function_str = """
def foo():
  # will not be in the output
  x = 1

"""
output_str = """
def foo():
    x = 1"""
str(Node(function_str)) == output_str # True

The output and source string compile to the same AST, but the output is indented with 4 spaces. Comments and trailing whitespace are removed.

Finding nodes

find_ functions search the current scope and return one of the following:

  • A single Node object if there can be only one match. E.g. find_function
  • A list of Node objects if there can be multiple matches. E.g.: find_ifs

find_function

Node('def foo():\n  x = "1"').find_function("foo").has_variable("x") # True

find_functions

code_str = """
class Spam(ABC):
  @property
  @abstractmethod
  def foo(self):
    return self.x

  @foo.setter
  @abstractmethod
  def foo(self, new_x):
    self.x = new_x
"""
explorer = Node(code_str)
len(explorer.find_class("Spam").find_functions("foo")) # 2
explorer.find_class("Spam").find_functions("foo")[0].is_equivalent("@property\n@abstractmethod\ndef foo(self):\n  return self.x") # True
explorer.find_class("Spam").find_functions("foo")[1].is_equivalent("@foo.setter\n@abstractmethod\ndef foo(self, new_x):\n  self.x = new_x") # True

find_async_function

Node('async def foo():\n  await bar()').find_async_function("foo").is_equivalent("async def foo():\n  await bar()") # True

find_awaits

code_str = """
async def foo(spam):
  if spam:
    await spam()
  await bar()
  await func()
"""
explorer = Node(code_str)
explorer.find_async_function("foo").find_awaits()[0].is_equivalent("await bar()") # True
explorer.find_async_function("foo").find_awaits()[1].is_equivalent("await func()") # True
explorer.find_async_function("foo").find_ifs()[0].find_awaits()[0].is_equivalent("await spam()") # True

find_variable

Node("y = 2\nx = 1").find_variable("x").is_equivalent("x = 1")
Node("a: int = 1").find_variable("a").is_equivalent("a: int = 1")
Node("self.spam = spam").find_variable("self.spam").is_equivalent("self.spam = spam")

find_variables

Returns a list of all variable assignments with the given name (unlike find_variable which returns only the first match).

code_str = """
x: int = 0
a.b = 0
x = 5
a.b = 2
x = 10
"""
node = Node(code_str)
len(node.find_variables("x")) # 3
node.find_variables("x")[0].is_equivalent("x: int = 0") # True
node.find_variables("x")[1].is_equivalent("x = 5") # True
node.find_variables("x")[2].is_equivalent("x = 10") # True
len(node.find_variables("a.b")) # 2
node.find_variables("a.b")[0].is_equivalent("a.b = 0") # True
node.find_variables("a.b")[1].is_equivalent("a.b = 2") # True

find_aug_variable

Node("x += 1").find_aug_variable("x").is_equivalent("x += 1")
Node("x -= 1").find_aug_variable("x").is_equivalent("x -= 1")

When the variable is out of scope, find_variable returns an None node (i.e. Node() or Node(None)):

Node('def foo():\n  x = "1"').find_variable("x") == Node() # True

find_body

func_str = """
def foo():
    x = 1"""
Node(func_str).find_function("foo").find_body().is_equivalent("x = 1") # True

find_return

code_str = """
def foo():
  if x == 1:
    return False
  return True
"""
Node(code_str).find_function("foo").find_return().is_equivalent("return True") # True
Node(code_str).find_function("foo").find_ifs()[0].find_return().is_equivalent("return False") # True

find_calls

 code_str = """
print(1)
print(2)
foo("spam")
obj.foo("spam")
obj.bar.foo("spam")
"""
explorer = Node(code_str)
len(explorer.find_calls("print")) # 2
explorer.find_calls("print")[0].is_equivalent("print(1)")
explorer.find_calls("print")[1].is_equivalent("print(2)")
len(explorer.find_calls("foo")) # 3
explorer.find_calls("foo")[0].is_equivalent("foo('spam')")
explorer.find_calls("foo")[1].is_equivalent("obj.foo('spam')")
explorer.find_calls("foo")[2].is_equivalent("obj.bar.foo('spam')")

find_call_args

explorer = Node("print(1, 2)")
len(explorer.find_calls("print")[0].find_call_args()) # 2
explorer.find_calls("print")[0].find_call_args()[0].is_equivalent("1")
explorer.find_calls("print")[0].find_call_args()[1].is_equivalent("2")

find_class

class_str = """
class Foo:
  def __init__(self):
    pass
"""
Node(class_str).find_class("Foo").has_function("__init__") # True

find_ifs

if_str = """
if x == 1:
  x += 1
elif x == 2:
  pass
else:
  return

if True:
  pass
"""

Node(if_str).find_ifs()[0].is_equivalent("if x == 1:\n  x += 1\nelif x == 2:\n  pass\nelse:\n  return")
Node(if_str).find_ifs()[1].is_equivalent("if True:\n  pass")

find_if

if_str = """
if x == 1:
  x += 1
elif x == 2:
  pass
else:
  return

if True:
  pass
"""

Node(if_str).find_if("x == 1").is_equivalent("if x == 1:\n  x += 1\nelif x == 2:\n  pass\nelse:\n  return")
Node(if_str).find_if("True").is_equivalent("if True:\n  pass")

find_whiles

while_str = """
while True:
  x += 1
else:
  return

while False:
  pass
"""
explorer = Node(while_str)
explorer.find_whiles()[0].is_equivalent("while True:\n  x += 1\nelse:\n  return") # True
explorer.find_whiles()[1].is_equivalent("while False:\n  pass") # True

find_while

while_str = """
while True:
  x += 1
else:
  return

while False:
  pass
"""
explorer = Node(while_str)
explorer.find_while("True").is_equivalent("while True:\n  x += 1\nelse:\n  return") # True
explorer.find_while("False").is_equivalent("while False:\n  pass") # True

find_conditions

if_str = """
if x > 0:
  x = 1
elif x < 0:
  x = -1
else:
  return x
"""
explorer1 = Node(if_str)
len(explorer1.find_ifs()[0].find_conditions()) # 3
explorer1.find_ifs()[0].find_conditions()[0].is_equivalent("x > 0") # True
explorer1.find_ifs()[0].find_conditions()[1].is_equivalent("x < 0") # True
explorer1.find_ifs()[0].find_conditions()[2] == Node() # True
Node("x = 1").find_conditions() # []

while_str = """
while True:
  x += 1
else:
  return

while False:
  pass
"""
explorer2 = Node(while_str)
explorer2.find_whiles()[0].find_conditions()[0].is_equivalent("True") # True
explorer2.find_whiles()[0].find_conditions()[1] == Node() # True
explorer2.find_whiles()[1].find_conditions()[0].is_equivalent("False") # True

find_for_loops

for_str = """
dict = {'a': 1, 'b': 2, 'c': 3}
for x, y in enumerate(dict):
    print(x, y)
else:
    pass

for i in range(4):
    pass
"""
explorer = Node(for_str)
explorer.find_for_loops()[0].is_equivalent("for x, y in enumerate(dict):\n  print(x, y)\nelse:\n  pass") # True
explorer.find_for_loops()[1].is_equivalent("for i in range(4):\n  pass") # True

find_for

for_str = """
dict = {'a': 1, 'b': 2, 'c': 3}
for x, y in enumerate(dict):
    print(x, y)
else:
    pass

for i in range(4):
    pass
"""
explorer = Node(for_str)
explorer.find_for("(x, y)", "enumerate(dict)").is_equivalent("for x, y in enumerate(dict):\n  print(x, y)\nelse:\n  pass") # True
explorer.find_for("i", "range(4)").is_equivalent("for i in range(4):\n  pass") # True

find_for_vars

for_str = """
dict = {'a': 1, 'b': 2, 'c': 3}
for x, y in enumerate(dict):
    print(x, y)
else:
    pass

for i in range(4):
    pass
"""
explorer = Node(for_str)
explorer.find_for_loops()[0].find_for_vars().is_equivalent("(x, y)") # True
explorer.find_for_loops()[1].find_for_vars().is_equivalent("i") # True

find_for_iter

for_str = """
dict = {'a': 1, 'b': 2, 'c': 3}
for x, y in enumerate(dict):
    print(x, y)
else:
    pass

for i in range(4):
    pass
"""
explorer = Node(for_str)
explorer.find_for_loops()[0].find_for_iter().is_equivalent("enumerate(dict)") # True
explorer.find_for_loops()[1].find_for_iter().is_equivalent("range(4)") # True

find_bodies

if_str = """
if True:
  x = 1
elif False:
  x = 2
"""
explorer1 = Node(if_str)
explorer1.find_ifs()[0].find_bodies()[0].is_equivalent("x = 1") # True
explorer1.find_ifs()[0].find_bodies()[1].is_equivalent("x = 2") # True

while_str = """
while True:
  x += 1
else:
  x = 0

while False:
  pass
"""
explorer2 = Node(while_str)
explorer2.find_whiles()[0].find_bodies()[0].is_equivalent("x += 1") # True
explorer2.find_whiles()[0].find_bodies()[1].is_equivalent("x = 0") # True
explorer2.find_whiles()[1].find_bodies()[0].is_equivalent("pass") # True

for_str = """
dict = {'a': 1, 'b': 2, 'c': 3}
for x, y in enumerate(dict):
    print(x, y)
else:
    print(x)

for i in range(4):
    pass
"""
explorer3 = Node(for_str)
explorer3.find_for_loops()[0].find_bodies()[0].is_equivalent("print(x, y)") # True
explorer3.find_for_loops()[0].find_bodies()[1].is_equivalent("print(x)") # True
explorer3.find_for_loops()[1].find_bodies()[0].is_equivalent("pass") # True

find_imports

code_str = """
import ast, sys
from math import factorial as f
"""

explorer = Node(code_str)
len(explorer.find_imports()) # 2
explorer.find_imports()[0].is_equivalent("import ast, sys")
explorer.find_imports()[1].is_equivalent("from math import factorial as f")

find_comps

Returns a list of list comprehensions, set comprehensions, dictionary comprehensions and generator expressions nodes not assigned to a variable or part of other statements.

code_str = """
[i**2 for i in lst]
(i for i in lst)
{i * j for i in spam for j in lst}
{k: v for k,v in dict}
comp = [i for i in lst]
"""
explorer = Node(code_str)
len(explorer.find_comps()) # 4
explorer.find_comps()[0].is_equivalent("[i**2 for i in lst]")
explorer.find_comps()[1].is_equivalent("(i for i in lst)")
explorer.find_comps()[2].is_equivalent("{i * j for i in spam for j in lst}")
explorer.find_comps()[3].is_equivalent("{k: v for k,v in dict}")

find_comp_iters

Returns a list of comprehension/generator expression iterables. It can be chained to find_variable, find_return, find_call_args()[n].

code_str = """
x = [i**2 for i in lst]

def foo(spam):
  return [i * j for i in spam for j in lst]
"""
explorer = Node(code_str)
len(explorer.find_variable("x").find_comp_iters()) # 1
explorer.find_variable("x").find_comp_iters()[0].is_equivalent("lst")

len(explorer.find_function("foo").find_return().find_comp_iters()) # 2
explorer.find_function("foo").find_return().find_comp_iters()[0].is_equivalent("spam")
explorer.find_function("foo").find_return().find_comp_iters()[1].is_equivalent("lst")

find_comp_targets

Returns a list of comprhension/generator expression targets (i.e. the iteration variables).

code_str = """
x = [i**2 for i in lst]

def foo(spam):
  return [i * j for i in spam for j in lst]
"""
explorer = Node(code_str)
len(explorer.find_variable("x").find_comp_targets()) # 1
explorer.find_variable("x").find_comp_targets()[0].is_equivalent("i")

len(explorer.find_function("foo").find_return().find_comp_targets()) # 2
explorer.find_function("foo").find_return().find_comp_targets()[0].is_equivalent("i")
explorer.find_function("foo").find_return().find_comp_targets()[1].is_equivalent("j")

find_comp_key

Returns the dictionary comprehension key.

code_str = """
x = {k: v for k,v in dict}

def foo(spam):
  return {k: v for k in spam for v in lst}
"""
explorer = Node(code_str)
explorer.find_variable("x").find_comp_key().is_equivalent("k")

explorer.find_function("foo").find_return().find_comp_key().is_equivalent("k")

find_comp_expr

Returns the expression evaluated at each iteration of comprehensions/generator expressions. It includes the if/else portion. In case only the if is present, use find_comp_ifs.

code_str = """
x = [i**2 if i else -1 for i in lst]

def foo(spam):
  return [i * j for i in spam for j in lst]
"""
explorer = Node(code_str)
explorer.find_variable("x").find_comp_expr().is_equivalent("i**2 if i else -1")

explorer.find_function("foo").find_return().find_comp_expr().is_equivalent("i * j")

find_comp_ifs

Returns a list of comprehension/generator expression if conditions. The if/else construct instead is part of the expression and is found with find_comp_expr.

code_str = """
x = [i**2 if i else -1 for i in lst]

def foo(spam):
  return [i * j for i in spam if i > 0 for j in lst if j != 6]
"""
explorer = Node(code_str)
len(explorer.find_variable("x").find_comp_ifs()) # 0

len(explorer.find_function("foo").find_return().find_comp_ifs()) # 2
explorer.find_function("foo").find_return().find_comp_ifs()[0].is_equivalent("i > 0")
explorer.find_function("foo").find_return().find_comp_ifs()[1].is_equivalent("j != 6")

find_trys

Returns a list of all try statements.

code_str = """
try:
    x = 1 / 0
except ZeroDivisionError:
    print("division by zero")
else:
    print("no error")
finally:
    print("cleanup")

try:
    y = int("abc")
except:
    pass
"""
node = Node(code_str)
len(node.find_trys()) # 2
node.find_trys()[0].is_equivalent("try:\n  x = 1 / 0\nexcept ZeroDivisionError:\n  print('division by zero')\nelse:\n  print('no error')\nfinally:\n  print('cleanup')") # True
node.find_trys()[1].is_equivalent("try:\n  y = int('abc')\nexcept:\n  pass") # True

find_excepts

Returns a list of all except handlers in a try statement.

code_str = """
try:
    x = 1 / 0
except ZeroDivisionError:
    print("division by zero")
except ValueError as e:
    print(f"value error: {e}")
except:
    print("other error")
"""
node = Node(code_str)
try_stmt = node.find_trys()[0]
len(try_stmt.find_excepts()) # 3

is_equivalent() cannot be used on the items found by find_excepts() because a standalone except raises a SyntaxError.

find_except

Returns a specific except handler matching the exception type and optional variable name.

code_str = """
try:
    x = 1 / 0
except ZeroDivisionError:
    print("division by zero")
except ValueError as e:
    print(f"value error: {e}")
except:
    print("other error")
"""
node = Node(code_str)
try_stmt = node.find_trys()[0]
try_stmt.find_except("ZeroDivisionError").find_body().is_equivalent("print('division by zero')") # True
try_stmt.find_except("ValueError", "e").find_body().is_equivalent("print(f'value error: {e}')") # True
try_stmt.find_except().find_body().is_equivalent("print('other error')") # True (bare except)

has_except

Returns True if a try statement has an except handler for the given exception type and variable name.

code_str = """
try:
    x = 1 / 0
except ZeroDivisionError:
    print("division by zero")
except ValueError as e:
    print(f"value error: {e}")
"""
node = Node(code_str)
try_stmt = node.find_trys()[0]
try_stmt.has_except("ZeroDivisionError") # True
try_stmt.has_except("ValueError", "e") # True
try_stmt.has_except("ValueError") # False

find_try_else

Returns the else block of a try statement.

code_str = """
try:
    x = 1
except ValueError:
    print("error")
else:
    print("success")
    x = 2
"""
node = Node(code_str)
try_stmt = node.find_trys()[0]
try_stmt.find_try_else().is_equivalent("print('success')\nx = 2") # True

find_finally

Returns the finally block of a try statement.

code_str = """
try:
    x = 1
except ValueError:
    print("error")
finally:
    print("cleanup")
    x = None
"""
node = Node(code_str)
try_stmt = node.find_trys()[0]
try_stmt.find_finally().is_equivalent("print('cleanup')\nx = None") # True

find_matches

Returns a list of match statements.

code_str = """
match x:
  case 0:
    pass
  case _:
    pass

match y:
  case 1:
    pass
"""
node = Node(code_str)
len(node.find_matches()) # 2
node.find_matches()[0].is_equivalent("match x:\n  case 0:\n    pass\n  case _:\n    pass") # True

find_match_subject

Returns the subject of a match statement.

code_str = """
match x:
  case 0:
    pass
"""
node = Node(code_str)
node.find_matches()[0].find_match_subject().is_equivalent("x") # True

find_match_cases

Returns a list of case blocks in a match statement.

code_str = """
match x:
  case 0:
    print(0)
    print('spam')
  case _:
    pass
"""
node = Node(code_str)
len(node.find_matches()[0].find_match_cases()) # 2
node.find_matches()[0].find_match_cases()[0].find_body().is_equivalent("print(0)\nprint('spam')") # True
node.find_matches()[0].find_match_cases()[1].find_body().is_equivalent("pass") # True

is_equivalent() cannot be used on the items found by find_match_cases() because a standalone case raises a SyntaxError.

find_case_pattern

Returns the pattern of a case block.

code_str = """
match x:
  case 0:
    pass
  case [a, b]:
    pass
  case _:
    pass
"""
node = Node(code_str)
node.find_matches()[0].find_match_cases()[0].find_case_pattern().is_equivalent("0") # True
node.find_matches()[0].find_match_cases()[1].find_case_pattern().is_equivalent("[a, b]") # True
node.find_matches()[0].find_match_cases()[2].find_case_pattern().is_equivalent("_") # True

find_case_guard

Returns the guard condition of a case block.

code_str = """
match x:
  case 0 if y > 0:
    pass
  case [a, b] if y == -1:
    pass
  case _:
    pass
"""
node = Node(code_str)
node.find_matches()[0].find_match_cases()[0].find_case_guard().is_equivalent("y > 0") # True
node.find_matches()[0].find_match_cases()[1].find_case_guard().is_equivalent("y == -1") # True
node.find_matches()[0].find_match_cases()[2].find_case_guard().is_empty() # True (no guard)

Getting values

get_ functions return the value of the node, not the node itself.

get_variable

Node("x = 1").get_variable("x") # 1

Checking for existence

has_ functions return a boolean indicating whether the node exists.

has_variable

Node("x = 1").has_variable("x") # True

has_function

Node("def foo():\n  pass").has_function("foo") # True

has_stmt

Returns a boolean indicating if the specified statement is found.

Node("name = input('hi')\nself.matrix[1][5] = 3").has_stmt("self.matrix[1][5] = 3") # True

has_args

Node("def foo(*, a, b, c=0):\n  pass").find_function("foo").has_args("*, a, b, c=0") # True

has_pass

Node("def foo():\n  pass").find_function("foo").has_pass() # True
Node("if x==1:\n  x+=1\nelse:  pass").find_ifs()[0].find_bodies()[1].has_pass() # True

has_return

Returns a boolean indicating if the function returns the specified expression/object.

code_str = """
def foo():
  if x == 1:
    return False
  return True
"""
explorer = Node(code_str)
explorer.find_function("foo").has_return("True") # True
explorer.find_function("foo").find_ifs()[0].has_return("False") # True

has_returns

Returns a boolean indicating if the function has the specified return annotation.

Node("def foo() -> int:\n  return 0").find_function("foo").has_returns("int") # True
Node("def foo() -> 'spam':\n  pass").find_function("foo").has_returns("spam") # True

has_decorators

code_str = """
class A:
  @property
  @staticmethod
  def foo():
    pass
"""
Node(code_str).find_class("A").find_function("foo").has_decorators("property") # True
Node(code_str).find_class("A").find_function("foo").has_decorators("property", "staticmethod") # True
Node(code_str).find_class("A").find_function("foo").has_decorators("staticmethod", "property") # False, order does matter

has_call

code_str = """
print(math.sqrt(25))
if True:
  spam()
"""

explorer = Node(code_str)
explorer.has_call("print(math.sqrt(25))")
explorer.find_ifs()[0].find_bodies()[0].has_call("spam()")

block_has_call

Checks if a function/method call exists within a specific function (specified as the second argument) or the entire code (if the second argument is not provided).

code_str = """
srt = sorted([5, 1, 9])

def foo(lst):
  return sorted(lst)

def spam(lst):
  return lst.sort()

def eggs(dictionary):
  if True:
    k = dictionary.get(key)
"""
node = Node(code_str)

node.block_has_call("sorted", "foo") # True
node.block_has_call("sorted") # True
node.block_has_call("sort", "spam") # True
node.block_has_call("get", "eggs") # True
node.block_has_call("get") # True
node.block_has_call("split") # False

has_import

code_str = """
import ast, sys
from math import factorial as f
"""

explorer = Node(code_str)
explorer.has_import("import ast, sys")
explorer.has_import("from math import factorial as f")

has_class

code_str = """
class spam:
  pass
"""

Node(code_str).has_class("spam")

Misc

is_equivalent

This is a somewhat loose check. The AST of the target string and the AST of the node do not have to be identical, but the code must be equivalent.

Node("x = 1").is_equivalent("x = 1") # True
Node("\nx = 1").is_equivalent("x =    1") # True
Node("x = 1").is_equivalent("x = 2") # False

is_empty

This is syntactic sugar for == Node().

Node().is_empty() # True
Node("x = 1").find_variable("x").is_empty() # False

get the nth statement

stmts = """
if True:
  pass

x = 1
"""

Node(stmts)[1].is_equivalent("x = 1") # True

value_is_call

This allows you to check if the return value of function call is assigned to a variable.

explorer = Node("def foo():\n  x = bar()")

explorer.find_function("foo").find_variable("x").value_is_call("bar") # True

is_integer

Node("x = 1").find_variable("x").is_integer() # True
Node("x = '1'").find_variable("x").is_integer() # False

inherits_from

Node("class C(A, B):\n  pass").find_class("C").inherits_from("A") # True
Node("class C(A, B):\n  pass").find_class("C").inherits_from("A", "B") # True

is_ordered

Returs a boolean indicating if the statements passed as arguments are found in the same order in the tree (statements can be non-consecutive)

code_str = """
x = 1
if x:
  print("x is:")
  y = 0
  print(x)
  return y
x = 0
"""

if_str = """
if x:
  print("x is:")
  y = 0
  print(x)
  return y
"""
explorer = Node(code_str)
explorer.is_ordered("x=1", "x=0") # True
explorer.is_ordered("x=1", if_str, "x=0") # True
explorer.find_ifs()[0].is_ordered("print('x is:')", "print(x)", "return y") # True
explorer.is_ordered("x=0", "x=1") # False
explorer.find_ifs()[0].is_ordered("print(x)", "print('x is:')") # False

Notes on Python

  • Python does not allow newline characters between keywords and their arguments. E.g:
def
    add(x, y):
    return x + y
  • Python does allow newline characters between function arguments. E.g:
def add(
  x,
  y
):
    return x + y

Common Tools

Some of the examples assume global access to the above tools. Usage of these helpers is denoted by code referencing __helpers or __pyodide.

Testing in General

Tips

For any test of any language, the result should be preferred to be tested as opposed to the implementation. For example, if testing a function that adds two numbers, assume the body of the function is unknown, and test the result of running the function with various inputs.

This cannot be done in all cases. E.g:

  • Testing Rust in the browser with no way to compile/run the code
  • The code does not run, because it is incomplete or contains syntax errors

freeCodeCamp

Note

2023/12/03: The below might be outdated, by the time you read

The freeCodeCamp editor/test-runner uses \r\n as line endings. This needs to be taken into account when directly writing tests on the code strings.

Rust

Regex

Testing a struct:

const code = `
fn main() {
  let _ = StructName {
    one_field: 1,
    field_name: [1, 2]
  };
}

struct StructName {
  one_field: usize,
  field_name: [usize; 2]
}
`;
{
  const struct = code.match(/struct StructName[^\{]*?{([^\}]*)}/s)?.[1];
  console.assert(struct.match(/field_name\s*:/));
  console.log(struct);
}

Remove two or more space/tab characters to reduce the number of \s checks:

const code = `
fn main() {
  let _ = StructName {
    one_field: "example",
    field_name: [1, 2]
  };
}

struct     StructName<'a> {
  one_field : &'a str ,
  field_name: [usize; 2]
}
`;
{
  // Keywords **require** one or more spaces. So, replace instances of 2+ spaces
  // with one space, to turn `/struct\s+StringName/` into `/struct StringName/`
  const reducedCode = code.replaceAll(/[ \t]{2,}/g, ' ');
  const struct = reducedCode.match(/struct StructName[^\{]*?{([^\}]*)}/s)?.[1];
  console.assert(struct.match(/field_name\s*:/));
  console.log(struct);
}

Contributing

Library API

Documentation

Tooling

Development

mdbook serve