diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a42ed42..9137ca3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -34,7 +34,7 @@ jobs:
- run: npm run test:hints
# The runtime under CPython: the visualizer's columns, docstrings,
# condition comments, readable types and argument checks, the doctest
- # runner, and which errors get a hint.
+ # runner and its coverage, and which errors get a hint.
- run: npm run test:runtime
# The console in Chrome (on the runner) with Pyodide from its CDN,
# driving the embed built above: Tests → edit → Tests shows the new
diff --git a/CLAUDE.md b/CLAUDE.md
index 6dc1e53..83a0bc1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -13,7 +13,7 @@ npm run build # tsc -b && vite build && copy src/style.css -> dist/ (li
npm run dev # vite build --watch, for use with npm link/yalc in a host shell
npm run lint # oxlint
npm run test:protocol # builds the library, then runs scripts/check-embed-protocol.mjs against dist/
-npm run test:runtime # the runtime under CPython: scripts/check-runtime-{columns,values,doctests,calls,hints,fidelity,replay}.py
+npm run test:runtime # the runtime under CPython: scripts/check-runtime-{columns,values,doctests,coverage,calls,hints,fidelity,replay,…}.py
npm run test:hints # the error hints' wording (de/en) against the built dist/ (run a build first)
npm run test:console # the console in Chrome with Pyodide, against dist-embed/ (run build:embed first)
npm run build:embed # builds the iframe app + demo page into dist-embed/
@@ -56,7 +56,7 @@ There is no unit test framework. The automated checks are `test:protocol` (plain
Worker messages:
- `run` → `result`/`error`; while it runs, `output` messages (`{id, text}`, `id` undefined for a program, the input's id for a REPL input) stream what was printed, at most 50 per 50 ms. The final message brings the whole output again.
-- `test` → first as `run` (`result`/`error`, a fresh REPL session from these files), then `test-result` (the doctests' report; `{skipped: true}` if the file failed)
+- `test` → first as `run` (`result`/`error`, a fresh REPL session from these files), then `test-result` (the doctests' report; `{skipped: true}` if the file failed). With `coverage: true` the report has `uncovered` (`doctests.py` records the lines with `sys.monitoring` during the examples only; `check-runtime-coverage.py` compares with Python's `trace`); the UI shows it only when all examples pass.
- `run-visual` → `trace-result` (`{trace, ast, output, graphics, error, errorMessage, truncated}`)
- `repl` → `repl-result`
- `run-visual-repl` (a doctest example) → `trace-result` with `rootPath`/`sources`, or `repl-result` if the input doesn't parse
diff --git a/README.md b/README.md
index 2c5cd66..9624b59 100644
--- a/README.md
+++ b/README.md
@@ -159,6 +159,20 @@ glpython hint that it probably should `return` instead. Texts: `runTests`,
`testsRunning`, `testsSummary`, `testsNone`, `testsLoadError`,
`visualizeExample`, `testsPrintHint`.
+**Coverage** (capability `coverage`, off by default): when all examples pass,
+the report also lists which lines of the tested file's functions no example
+executed, each with its code («Zeile 11: `return "ungenügend"`»; consecutive
+lines of one function as «Zeilen 8–12:»). Counted are the statements in
+function bodies (also methods and nested functions), as Python's own `trace`
+module counts them during the examples - not `def` lines, docstrings, code
+outside functions, or other files such as libraries. With the editor, the same
+lines are marked in it (line background and a bar in the gutter) until the
+file is edited or the program runs again. When every line ran, a neutral note
+says so and that this doesn't show whether the examples check the edge cases:
+coverage finds untested code, not wrong code. Recorded with `sys.monitoring`,
+each line once, so it doesn't slow the examples down. Texts:
+`testsUncoveredHeading`, `testsUncoveredLines`, `testsAllCovered`.
+
Call boxes in the visualizer show a function without its comments and
docstring, which don't change what runs; the file itself is shown as written.
@@ -295,7 +309,9 @@ https://example.test/embed/?parentOrigin=https%3A%2F%2Fcourse.example
Optional query parameters adapt the embed to a host such as the LiaScript
macro: `features=run,repl,visualize,…` hides the REPL (`repl`), the visualizer
(`visualize`) or its → chain export (`export`) unless listed (capabilities
-`repl`, `visualization`, `export`), and `size=large` with
+`repl`, `visualization`, `export`); `coverage` in that list switches on the
+coverage after Tests (capability `coverage`), which a missing `features`
+doesn't; and `size=large` with
`visualizerHeight=` shows bigger code and a visualizer at least that tall.
The UI language is the optional `lang` query parameter (e.g. the LiaScript
@@ -353,14 +369,14 @@ and monospace fonts, no web fonts). A host can override any of them:
| Variable | Role |
|---|---|
| `--glp-bg` | panel background |
-| `--glp-surface` | code in the visualizer, output, REPL |
+| `--glp-surface` | code in the visualizer, output, REPL; coverage after Tests |
| `--glp-border` | decorative borders and separator lines |
-| `--glp-border-control` | borders of controls, call boxes in box mode |
+| `--glp-border-control` | borders of controls, call boxes in box mode; coverage after Tests (its bar) |
| `--glp-text`, `--glp-text-muted` | text and code; line numbers, step counter, secondary info, connector lines |
| `--glp-accent`, `--glp-on-accent` | active mode, scrubber, focus, primary button; text on it |
| `--glp-current`, `--glp-current-outline` | current subexpression and new value, current line; next step (dashed) |
| `--glp-error`, `--glp-error-bg` | errors, failing tests, «Erhalten · Erwartet» |
-| `--glp-warning`, `--glp-warning-bg` | hints |
+| `--glp-warning`, `--glp-warning-bg` | hints; the coverage marks in the editor |
| `--glp-success` | passed tests, runtime ready |
| `--glp-syntax-keyword`, `-string`, `-number`, `-comment` | syntax colours (names, operators, brackets use `--glp-text`) |
| `--glp-font-code`, `--glp-font-ui`, `--glp-radius` | fonts and corner radius |
diff --git a/package.json b/package.json
index d22b4d8..ac63184 100644
--- a/package.json
+++ b/package.json
@@ -29,7 +29,7 @@
"test:protocol": "npm run build && node scripts/check-embed-protocol.mjs",
"test:hints": "node scripts/check-error-hints.mjs",
"test:console": "node scripts/check-console-browser.mjs",
- "test:runtime": "python3 -B scripts/check-runtime-columns.py && python3 -B scripts/check-runtime-values.py && python3 -B scripts/check-runtime-doctests.py && python3 -B scripts/check-runtime-calls.py && python3 -B scripts/check-runtime-hints.py && python3 -B scripts/check-runtime-fidelity.py && python3 -B scripts/check-runtime-replay.py && python3 -B scripts/check-runtime-imports.py && python3 -B scripts/check-runtime-repl.py",
+ "test:runtime": "python3 -B scripts/check-runtime-columns.py && python3 -B scripts/check-runtime-values.py && python3 -B scripts/check-runtime-doctests.py && python3 -B scripts/check-runtime-coverage.py && python3 -B scripts/check-runtime-calls.py && python3 -B scripts/check-runtime-hints.py && python3 -B scripts/check-runtime-fidelity.py && python3 -B scripts/check-runtime-replay.py && python3 -B scripts/check-runtime-imports.py && python3 -B scripts/check-runtime-repl.py",
"prepare": "npm run build"
},
"peerDependencies": {
diff --git a/scripts/check-console-browser.mjs b/scripts/check-console-browser.mjs
index b743e13..8d49187 100644
--- a/scripts/check-console-browser.mjs
+++ b/scripts/check-console-browser.mjs
@@ -4,6 +4,9 @@
// the second report used to land out of view below the first one). Tests
// starts as Run does, so the console then holds only the new report, and
// the REPL has the definitions just tested (not the ones of the last Run).
+// With "coverage" in features (P23), a report that all passed lists the
+// lines no example executed - which also checks that Pyodide's Python has
+// sys.monitoring - and without it, or with a failing example, it doesn't.
//
// Drives the built embed (dist-embed/, from npm run build:embed) in Chrome
// through its protocol, as the LiaScript macro does; Pyodide comes from its
@@ -21,7 +24,8 @@ const stopServer = () => server.kill()
process.on('exit', stopServer)
// The parent page, served on the embed's own origin: it loads the embed and
-// passes messages on, version 4.
+// passes messages on, version 4. Its own query (e.g. "?features=…") goes on
+// to the embed.
const harness = `
`
const program = (examples) => `def schnitt(a, b):\n """\n${examples.map(([call, want]) => ` >>> ${call}\n ${want}\n`).join('')} """\n return (a + b) / 2\n`
@@ -113,6 +117,35 @@ try {
console.error(error.message)
}
await page.close()
+
+ // Coverage after Tests.
+ const bewertung = `def bewertung(note):\n """\n >>> bewertung(5)\n 'genügend'\n >>> bewertung(6)\n 'genügend'\n """\n if note >= 4:\n return "genügend"\n else:\n return "ungenügend"\n`
+ const istGenuegend = `def ist_genuegend(note):\n """\n >>> ist_genuegend(5.5)\n True\n >>> ist_genuegend(3.5)\n False\n """\n return note >= 4\n`
+ const bewertungFailing = bewertung.replace(">>> bewertung(6)\n 'genügend'", ">>> bewertung(6)\n 'gut'")
+ for (const [label, query, source, expected] of [
+ ['coverage: a line no example ran', '?features=run,repl,visualize,tests,coverage', bewertung, 'Von keinem Beispiel ausgeführt:\nZeile 11: return "ungenügend"'],
+ ['coverage: every line ran', '?features=run,repl,visualize,tests,coverage', istGenuegend, 'Jede Zeile der Funktionen wurde von mindestens einem Beispiel ausgeführt. Ob die Beispiele auch die Grenzfälle prüfen, zeigt das nicht.'],
+ ['coverage: not with a failing example', '?features=run,repl,visualize,tests,coverage', bewertungFailing, null],
+ ['coverage: not without it in features', '?features=run,repl,visualize,tests', bewertung, null],
+ ['coverage: not without features', '', bewertung, null],
+ ]) {
+ const page = await browser.newPage({ viewport: { width: 800, height: 950 } })
+ await page.route((url) => url.pathname === '/check-console.html', (route) => route.fulfill({ contentType: 'text/html', body: harness }))
+ await page.goto(`${origin}/check-console.html${query}`)
+ await page.waitForFunction(() => window.ready, null, { timeout: 120000 })
+ const frame = page.frameLocator('#embed')
+ await page.evaluate((source) => window.send('test', source), source)
+ await frame.locator('.test-summary').waitFor({ timeout: 60000 })
+ try {
+ const coverage = (await frame.locator('.test-coverage').allInnerTexts()).map((text) => text.replace(/\n+/g, '\n'))
+ assert.deepEqual(coverage, expected === null ? [] : [expected], label)
+ console.log(`${label}: ok`)
+ } catch (error) {
+ failed += 1
+ console.error(error.message)
+ }
+ await page.close()
+ }
} finally {
await browser.close()
stopServer()
diff --git a/scripts/check-runtime-coverage.py b/scripts/check-runtime-coverage.py
new file mode 100644
index 0000000..fe325c0
--- /dev/null
+++ b/scripts/check-runtime-coverage.py
@@ -0,0 +1,281 @@
+"""Regression test: coverage after Tests (src/runtime/doctests.py, the
+"coverage" capability) - which statements in the functions of the tested file
+no doctest example executed. Each case is also checked against Python's own
+trace module: the lines it counts as executed must leave exactly the same
+statements uncovered.
+
+Runs the runtime with CPython (no Pyodide needed):
+ python3 -B scripts/check-runtime-coverage.py
+"""
+import contextlib
+import doctest
+import io
+import json
+import os
+import sys
+import tempfile
+import trace
+import types
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+RUNTIME = os.path.join(ROOT, "src", "runtime")
+
+namespace = {"__name__": "glpython_runtime"}
+for name in ("hints.py", "doctests.py"):
+ with open(os.path.join(RUNTIME, name), encoding="utf-8") as handle:
+ exec(compile(handle.read(), name, "exec"), namespace)
+run_doctests = namespace["_glpython_run_doctests"]
+coverage_statements = namespace["_glpython_coverage_statements"]
+uncovered_from = namespace["_glpython_uncovered"]
+
+failures = []
+folder = tempfile.mkdtemp(prefix="glpython-coverage-")
+sys.path.insert(0, folder) # the worker mounts a project's files next to each other
+
+
+def write(name, source):
+ path = os.path.join(folder, name)
+ with open(path, "w", encoding="utf-8") as handle:
+ handle.write(source)
+ return path
+
+
+def run(source, name="main.py", coverage=True):
+ path = write(name, source)
+ with contextlib.redirect_stdout(io.StringIO()):
+ return json.loads(run_doctests(path, coverage))
+
+
+def by_trace(source, name="main.py"):
+ """The uncovered statements when Python's trace module counts the lines
+ the examples executed."""
+ import ast
+ path = write(name, source)
+ module_name = name.removesuffix(".py")
+ module = types.ModuleType(module_name)
+ module.__file__ = path
+ code = compile(source, path, "exec")
+ with contextlib.redirect_stdout(io.StringIO()):
+ exec(code, module.__dict__)
+ tracer = trace.Trace(count=1, trace=0)
+ runner = doctest.DocTestRunner(verbose=False)
+ with contextlib.redirect_stdout(io.StringIO()):
+ for test in doctest.DocTestFinder().find(module, module_name):
+ tracer.runfunc(runner.run, test, out=lambda text: None, clear_globs=False)
+ executed = {line for (filename, line) in tracer.results().counts if filename == path}
+ return uncovered_from(source, coverage_statements(ast.parse(source), code), executed)
+
+
+def expect(label, source, expected, name="main.py"):
+ """`expected`: the uncovered entries as (start, end, code)."""
+ report = run(source, name)
+ got = [(entry["start"], entry["end"], entry["code"]) for entry in report.get("uncovered", [])]
+ if got != expected:
+ failures.append(f"{label}: got {got!r}, expected {expected!r}")
+ reference = [(entry["start"], entry["end"], entry["code"]) for entry in by_trace(source, name)]
+ if got != reference:
+ failures.append(f"{label}: got {got!r}, but Python's trace module counts {reference!r}")
+ return report
+
+
+# The reference cases. Fully covered, though 4 is missing as an example.
+report = expect("ist_genuegend", '''def ist_genuegend(note):
+ """
+ >>> ist_genuegend(5.5)
+ True
+ >>> ist_genuegend(3.5)
+ False
+ """
+ return note >= 4
+''', [])
+if report.get("failed") != 0 or report.get("attempted") != 2:
+ failures.append(f"ist_genuegend: report {report!r}")
+
+expect("bewertung", '''def bewertung(note):
+ """
+ >>> bewertung(5)
+ 'genügend'
+ >>> bewertung(6)
+ 'genügend'
+ """
+ if note >= 4:
+ return "genügend"
+ else:
+ return "ungenügend"
+''', [(11, 11, 'return "ungenügend"')])
+
+expect("vorzeichen", '''def vorzeichen(x):
+ """
+ >>> vorzeichen(3)
+ 1
+ >>> vorzeichen(-2)
+ -1
+ """
+ if x > 0:
+ return 1
+ elif x < 0:
+ return -1
+ return 0
+''', [(12, 12, "return 0")])
+
+# A library (another file of the project) is never reported, even where no
+# example reaches it.
+write("noten.py", '''def runde(note):
+ if note < 1:
+ return 1
+ return round(note * 2) / 2
+''')
+expect("library", '''from noten import runde
+
+def schnitt(noten):
+ """
+ >>> schnitt([4, 5])
+ 4.5
+ """
+ return runde(sum(noten) / len(noten))
+''', [])
+
+# Only the examples count: the file's own top-level code calling a function
+# doesn't, and neither does the code outside functions.
+expect("top-level code", '''def gross(x):
+ """
+ >>> gross(5)
+ True
+ """
+ if x > 3:
+ return True
+ return False
+
+print(gross(1))
+for i in range(2):
+ pass
+''', [(8, 8, "return False")])
+
+# A function no example reaches: its whole body as one entry (def line and
+# docstring left out); consecutive uncovered statements are grouped, a
+# covered one in between splits them.
+expect("whole function", '''def doppelt(x):
+ """
+ >>> doppelt(2)
+ 4
+ """
+ return 2 * x
+
+def hilfe(x):
+ """Ohne Beispiele."""
+ y = x + 1
+ if y > 2:
+ y = y * 2
+ return y
+''', [(10, 13, "y = x + 1\nif y > 2:\n y = y * 2\nreturn y")])
+
+expect("groups", '''def stufe(x):
+ """
+ >>> stufe(1)
+ 'klein'
+ """
+ if x > 10:
+ a = "gross"
+ return a
+ b = "klein"
+ if x < 0:
+ b = "negativ"
+ return b
+''', [(7, 8, 'a = "gross"\nreturn a'), (11, 11, 'b = "negativ"')])
+
+# A statement over several lines counts when any of its lines ran; the
+# header of a compound statement counts for itself, its body separately.
+expect("several lines", '''def summe(werte,
+ start=0):
+ """
+ >>> summe([1, 2])
+ 3
+ """
+ total = (
+ start
+ )
+ for wert in (
+ werte
+ ):
+ total = total + wert
+ while (total
+ < 0):
+ total = (total
+ + 1)
+ return total
+''', [(16, 17, "total = (total\n + 1)")])
+
+# Statements without bytecode of their own are never reported; neither is
+# the docstring, nor a nested function's def line (its body counts).
+expect("no bytecode", '''zaehler = 0
+
+def zaehle(n):
+ """
+ >>> zaehle(1)
+ 1
+ """
+ global zaehler
+ zaehler = zaehler + n
+ while True:
+ break
+ def innen():
+ return 0
+ return zaehler
+''', [(13, 13, "return 0")])
+
+# try/except, methods, decorators and lambdas.
+expect("try and methods", '''import functools
+
+class Konto:
+ """
+ >>> Konto(5).abheben(2)
+ 3
+ """
+ def __init__(self, stand):
+ self.stand = stand
+
+ @functools.cache
+ def zins(self):
+ return 0
+
+ def abheben(self, betrag):
+ try:
+ self.stand = self.stand - betrag
+ except TypeError:
+ return None
+ sortiert = sorted([3, 1], key=lambda x: -x)
+ return self.stand
+''', [(13, 13, "return 0"), (19, 19, "return None")])
+
+# Failing examples don't stop the recording (the UI shows the coverage only
+# when all pass).
+report = expect("failing example", '''def f(x):
+ """
+ >>> f(1)
+ 2
+ """
+ if x > 0:
+ return x
+ return -x
+''', [(8, 8, "return -x")])
+if report.get("failed") != 1:
+ failures.append(f"failing example: report {report!r}")
+
+# Without the capability: no coverage in the report.
+report = run("def f(x):\n '''\n >>> f(1)\n 1\n '''\n return x\n", coverage=False)
+if "uncovered" in report:
+ failures.append(f"without coverage: report {report!r}")
+
+# The recording is switched off again: a second run counts afresh, and
+# sys.monitoring is free.
+report = run("def f(x):\n '''\n >>> f(1)\n 1\n '''\n return x\n")
+report = run("def f(x):\n '''\n >>> f(1)\n 1\n '''\n return x\n")
+if report.get("uncovered") != []:
+ failures.append(f"second run: report {report!r}")
+if sys.monitoring.get_tool(sys.monitoring.COVERAGE_ID) is not None:
+ failures.append("sys.monitoring's coverage tool is still taken")
+
+if failures:
+ print("\n".join(failures))
+ sys.exit(f"{len(failures)} coverage check(s) failed.")
+print("Runtime coverage checks passed.")
diff --git a/src/GlPythonWorkspace.tsx b/src/GlPythonWorkspace.tsx
index 2bdfed5..d57378c 100644
--- a/src/GlPythonWorkspace.tsx
+++ b/src/GlPythonWorkspace.tsx
@@ -1,7 +1,7 @@
import { createPortal } from 'react-dom'
import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'
import Editor, { useMonaco } from '@monaco-editor/react'
-import type { Monaco } from '@monaco-editor/react'
+import type { Monaco, OnMount } from '@monaco-editor/react'
import { AlertTriangle, BookOpen, Copy, Square, FileCode2, FlaskConical, PanelRightClose, PanelRightOpen, ChevronLeft, ChevronRight, Ellipsis, Play, RotateCcw, SkipBack, SkipForward, Sparkles, SquareTerminal, Terminal, Workflow, X } from 'lucide-react'
import { projectImportsGturtle, type Project } from './project'
import { GraphicsWindow, type TurtleCommand } from './GraphicsWindow'
@@ -96,6 +96,13 @@ type CoreStrings = {
testsLoadError?: (message: string) => string
visualizeExample?: string
testsPrintHint?: (source: string, output: string) => string
+ // Coverage after Tests (capability `coverage`), when all examples passed:
+ // the heading above the lines no example executed, each entry's label
+ // ("Zeile 11:", "Zeilen 8–12:"), and the note when every line ran - which
+ // must not read as "done": covered code can still be wrong.
+ testsUncoveredHeading?: string
+ testsUncoveredLines?: (start: number, end: number) => string
+ testsAllCovered?: string
// Shown under the final value when a failing doctest example is
// visualized: the output doctest got and the one the example expected,
// e.g. "Got: 4.0 · Expected: 4".
@@ -145,6 +152,9 @@ type WorkspaceCapabilities = {
tests: boolean
// The visualizer's "copy → chain" button and its comments switch.
export: boolean
+ // After Tests, when all examples pass: which lines of the file's functions
+ // no example executed, in the report and (with the editor) marked in it.
+ coverage: boolean
// The output panel of a visualization whose program prints nothing and
// raises no error, showing «(Keine Ausgabe)». Off for visualization-only
// embeds, so a short visualization takes less space.
@@ -166,6 +176,7 @@ const defaultWorkspaceCapabilities: WorkspaceCapabilities = {
repl: true,
tests: true,
export: true,
+ coverage: false,
emptyOutput: true,
closeFileTabs: true,
}
@@ -175,6 +186,8 @@ type WorkspaceCommand = {
type: 'run' | 'visualize' | 'reset' | 'test' | 'stop'
}
+type CoverageGap = { start: number; end: number; code: string }
+
// What the worker reports for a Tests run (see doctests.py).
type TestReport = {
// The file failed while running (its traceback is the program's output),
@@ -185,6 +198,9 @@ type TestReport = {
// `want`/`got` only for an example whose output didn't match (not one that raised).
failures?: Array<{ name: string; line: number; source: string; text: string; want?: string; got?: string; errorHint?: ErrorHint | null }>
printHints?: Array<{ source: string; output: string }>
+ // With the coverage capability: what no example executed, as groups of
+ // consecutive statements of one function (lines `start`-`end`, their code).
+ uncovered?: CoverageGap[]
// The file didn't load, or doctest couldn't read its examples; with the
// hint for that error, if any.
error?: string
@@ -858,6 +874,7 @@ function TestReportView({ report, t, onVisualizeExample }: { report: TestReport;
{attempted === 0 ? (t.testsNone ?? 'No examples found in docstrings.') : (t.testsSummary?.(attempted - failed, attempted) ?? `${attempted - failed} of ${attempted} examples passed`)}
{(report.printHints ?? []).map((hint, index) =>
{t.testsPrintHint?.(hint.source, hint.output) ?? `Note: ${hint.source} printed ${hint.output}, but returned None.`}
@@ -869,6 +886,19 @@ function TestReportView({ report, t, onVisualizeExample }: { report: TestReport;
)
}
+// Coverage after Tests that all passed: the lines no example executed, each
+// with its code, or a neutral note when every line ran.
+function TestCoverageView({ gaps, t }: { gaps: CoverageGap[]; t: CoreStrings }) {
+ if (gaps.length === 0) return
{t.testsAllCovered ?? 'Every line of the functions was run by at least one example. This doesn\'t show whether the examples also check the edge cases.'}
{workspaceCapabilities.repl && }}>
// In the stacked layout the graphics show only while the program draws -
// for a visualization: if it draws at all, so the panel doesn't appear in
diff --git a/src/embed/App.tsx b/src/embed/App.tsx
index 41b9b3e..821cc68 100644
--- a/src/embed/App.tsx
+++ b/src/embed/App.tsx
@@ -38,12 +38,14 @@ function createEmbedProject(source: string, files?: GlPythonFile[], mainFile?: s
// LiaScript block's macro option: "?visualizer=box".
// Optional "?features=run,repl,visualize,…" from the macro's features=
// option: which of the embed's own parts to show (here: the REPL, the
-// visualizer and the → chain export). Without it, everything shows.
-const features = (() => {
+// visualizer and the → chain export). Without it, everything shows - except
+// "coverage" (after Tests, what no example executed), which a course switches
+// on explicitly.
+const featureList = (() => {
const list = new URLSearchParams(window.location.search).get('features')
- const enabled = list === null ? null : new Set(list.split(',').map((feature) => feature.trim()))
- return (feature: string) => enabled === null || enabled.has(feature)
+ return list === null ? null : new Set(list.split(',').map((feature) => feature.trim()))
})()
+const features = (feature: string) => featureList === null || featureList.has(feature)
// "?size=large" (the macro's large option): bigger code, and with
// "&visualizerHeight=600" the visualizer at least that many pixels tall (the
@@ -201,6 +203,7 @@ export function App() {
graphics: mode === 'turtle',
repl: mode === 'eval' && features('repl'),
export: features('export'),
+ coverage: featureList?.has('coverage') ?? false,
emptyOutput: mode !== 'visual',
}}
/>
diff --git a/src/i18n.ts b/src/i18n.ts
index 0593877..b4584e7 100644
--- a/src/i18n.ts
+++ b/src/i18n.ts
@@ -57,6 +57,9 @@ export const coreStrings: Record = {
testsLoadError: (message) => `The file could not be loaded: ${message}`,
visualizeExample: 'Visualize example',
testsPrintHint: (source, output) => `Note: ${source} printed ${output}, but returned None.`,
+ testsUncoveredHeading: 'Not run by any example:',
+ testsUncoveredLines: (start, end) => start === end ? `Line ${start}:` : `Lines ${start}\u2013${end}:`,
+ testsAllCovered: 'Every line of the functions was run by at least one example. This doesn\'t show whether the examples also check the edge cases.',
exampleGotExpected: (got, want) => `Got: ${got} \u00b7 Expected: ${want}`,
errorHint: englishErrorHint,
hintLabel: 'Hint:',
@@ -131,6 +134,9 @@ export const coreStrings: Record = {
testsLoadError: (message) => `Die Datei konnte nicht geladen werden: ${message}`,
visualizeExample: 'Beispiel visualisieren',
testsPrintHint: (source, output) => `Hinweis: ${source} hat ${output} ausgegeben, aber None zurückgegeben.`,
+ testsUncoveredHeading: 'Von keinem Beispiel ausgeführt:',
+ testsUncoveredLines: (start, end) => start === end ? `Zeile ${start}:` : `Zeilen ${start}\u2013${end}:`,
+ testsAllCovered: 'Jede Zeile der Funktionen wurde von mindestens einem Beispiel ausgeführt. Ob die Beispiele auch die Grenzfälle prüfen, zeigt das nicht.',
exampleGotExpected: (got, want) => `Erhalten: ${got} \u00b7 Erwartet: ${want}`,
errorHint: germanErrorHint,
hintLabel: 'Hinweis:',
diff --git a/src/pyodide-worker.js b/src/pyodide-worker.js
index 702e472..0b553a9 100644
--- a/src/pyodide-worker.js
+++ b/src/pyodide-worker.js
@@ -247,7 +247,8 @@ self.onmessage = async (event) => {
// definitions the REPL then has - and then run the doctests. If the file
// itself fails, its traceback (from the Run part) is the answer, and no
// report repeats it ("skipped"). What the file printed shows once, from the
- // Run part; the report leaves it out.
+ // Run part; the report leaves it out. With "coverage", the report also has
+ // what no example executed (see doctests.py).
if (event.data.type === 'test') {
if (!await runProgram(event.data.project)) {
self.postMessage({ type: 'test-result', skipped: true })
@@ -259,7 +260,7 @@ self.onmessage = async (event) => {
lastTestMainFile = event.data.project.mainFile
pyodide.setStdout({ batched: () => {} })
pyodide.setStderr({ batched: () => {} })
- const result = JSON.parse(String(callPython(pyodide, '_glpython_run_doctests', `/${event.data.project.mainFile}`)))
+ const result = JSON.parse(String(callPython(pyodide, '_glpython_run_doctests', `/${event.data.project.mainFile}`, event.data.coverage === true)))
self.postMessage({ type: 'test-result', ...result })
} catch (error) {
self.postMessage({ type: 'test-result', error: String(error) })
diff --git a/src/runtime/doctests.py b/src/runtime/doctests.py
index 6a13bd9..3b8dbc4 100644
--- a/src/runtime/doctests.py
+++ b/src/runtime/doctests.py
@@ -1,7 +1,10 @@
+import ast
+import contextlib
import doctest
import json
import re
import sys
+import textwrap
import traceback
import types
@@ -80,10 +83,127 @@ def _glpython_doctest_blank_hint(error, module, module_name, source):
return None
return {"kind": "doctest-blank", "line": start + int(match.group(1))}
+# Coverage (the "coverage" capability): the statements in the functions of
+# the tested file that no example executed, counted by line as Python's own
+# trace module counts them. Not counted: def lines, decorators, docstrings,
+# class bodies and code outside functions, and statements without bytecode of
+# their own (e.g. "global x"). A statement counts as executed when any line of
+# it ran; of a compound statement only its header ("if note >= 4:"), since
+# its body's statements count for themselves.
+def _glpython_statement_header_end(statement):
+ if isinstance(statement, (ast.If, ast.While)):
+ return statement.test.end_lineno
+ if isinstance(statement, (ast.For, ast.AsyncFor)):
+ return statement.iter.end_lineno
+ if isinstance(statement, (ast.With, ast.AsyncWith)):
+ return max(item.context_expr.end_lineno for item in statement.items)
+ if isinstance(statement, ast.Match):
+ return statement.subject.end_lineno
+ if isinstance(statement, (ast.Try, ast.TryStar)):
+ return statement.lineno
+ return statement.end_lineno
+
+def _glpython_code_lines(code):
+ lines = set()
+ for constant in code.co_consts:
+ if isinstance(constant, types.CodeType):
+ lines.update(line for _, _, line in constant.co_lines() if line is not None)
+ lines.update(_glpython_code_lines(constant))
+ return lines
+
+# The statements to count, as (function, first line, last line), in the
+# file's order; `function` tells the functions apart (nested ones too).
+def _glpython_coverage_statements(tree, code):
+ executable = _glpython_code_lines(code)
+ statements = []
+ def visit(body, function):
+ for index, statement in enumerate(body):
+ if index == 0 and isinstance(statement, ast.Expr) and isinstance(statement.value, ast.Constant) and isinstance(statement.value.value, str):
+ continue
+ if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)):
+ visit(statement.body, statement)
+ continue
+ if isinstance(statement, ast.ClassDef):
+ visit_outside(statement.body)
+ continue
+ end = _glpython_statement_header_end(statement)
+ if any(line in executable for line in range(statement.lineno, end + 1)):
+ statements.append((function, statement.lineno, end))
+ for field in ("body", "orelse", "finalbody"):
+ inner = getattr(statement, field, None)
+ if isinstance(inner, list) and inner and isinstance(inner[0], ast.stmt):
+ visit(inner, function)
+ for handler in getattr(statement, "handlers", []):
+ visit(handler.body, function)
+ for case in getattr(statement, "cases", []):
+ visit(case.body, function)
+ # Outside functions only the definitions in it matter.
+ def visit_outside(body):
+ for statement in body:
+ if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)):
+ visit(statement.body, statement)
+ elif isinstance(statement, ast.ClassDef):
+ visit_outside(statement.body)
+ else:
+ for field in ("body", "orelse", "finalbody"):
+ inner = getattr(statement, field, None)
+ if isinstance(inner, list) and inner and isinstance(inner[0], ast.stmt):
+ visit_outside(inner)
+ for handler in getattr(statement, "handlers", []):
+ visit_outside(handler.body)
+ visit_outside(tree.body)
+ return statements
+
+# The statements no example executed, grouped: consecutive ones of the same
+# function form one entry, with their lines (dedented) as code.
+def _glpython_uncovered(source, statements, executed):
+ lines = source.splitlines()
+ groups = []
+ previous = None
+ for function, start, end in statements:
+ covered = any(line in executed for line in range(start, end + 1))
+ if covered:
+ previous = None
+ continue
+ if previous is not None and previous[0] is function:
+ previous[2] = end
+ else:
+ previous = [function, start, end]
+ groups.append(previous)
+ return [{"start": start, "end": end, "code": textwrap.dedent("\n".join(lines[start - 1:end])).strip("\n")} for _, start, end in groups]
+
+# Records the lines of `path` that run while the context is open, with
+# sys.monitoring (Python 3.12): each line reports once and is then switched
+# off, so a loop costs nothing more. None if sys.monitoring is taken.
+@contextlib.contextmanager
+def _glpython_record_lines(path):
+ monitoring = sys.monitoring
+ tool = monitoring.COVERAGE_ID
+ try:
+ monitoring.use_tool_id(tool, "glpython coverage")
+ except ValueError:
+ yield None
+ return
+ executed = set()
+ def on_line(code, line):
+ if code.co_filename == path:
+ executed.add(line)
+ return monitoring.DISABLE
+ try:
+ monitoring.register_callback(tool, monitoring.events.LINE, on_line)
+ monitoring.set_events(tool, monitoring.events.LINE)
+ monitoring.restart_events()
+ yield executed
+ finally:
+ monitoring.set_events(tool, 0)
+ monitoring.register_callback(tool, monitoring.events.LINE, None)
+ monitoring.free_tool_id(tool)
+
# Runs the doctests of one file the way "python -m doctest" does: the file is
# loaded as a module named after it (so "if __name__ == '__main__':" blocks
-# don't run), then every example in its docstrings runs in order.
-def _glpython_run_doctests(main_path):
+# don't run), then every example in its docstrings runs in order. With
+# `coverage`, the report also lists what no example executed ("uncovered").
+def _glpython_run_doctests(main_path, coverage=False):
global _glpython_test_namespace
if "/" not in sys.path:
sys.path.insert(0, "/")
@@ -95,7 +215,8 @@ def _glpython_run_doctests(main_path):
try:
with open(main_path, "r", encoding="utf-8") as handle:
source = handle.read()
- exec(compile(source, main_path, "exec"), module.__dict__)
+ code = compile(source, main_path, "exec")
+ exec(code, module.__dict__)
except BaseException as error:
return json.dumps({"error": "".join(traceback.format_exception_only(type(error), error)).strip(), "errorHint": _glpython_error_hint(error)})
@@ -111,15 +232,20 @@ def _glpython_run_doctests(main_path):
original_displayhook = sys.__displayhook__
runner._original_display = original_displayhook
sys.__displayhook__ = runner.display
+ executed = None
try:
- for test in tests:
- test.filename = display_name
- runner.run(test, out=lambda text: None, clear_globs=False)
+ with _glpython_record_lines(main_path) if coverage else contextlib.nullcontext() as executed:
+ for test in tests:
+ test.filename = display_name
+ runner.run(test, out=lambda text: None, clear_globs=False)
finally:
sys.__displayhook__ = original_displayhook
- return json.dumps({
+ report = {
"attempted": runner.tries,
"failed": runner.failures,
"failures": runner.failed_examples,
"printHints": runner.print_hints,
- })
+ }
+ if executed is not None:
+ report["uncovered"] = _glpython_uncovered(source, _glpython_coverage_statements(ast.parse(source), code), executed)
+ return json.dumps(report)
diff --git a/src/style.css b/src/style.css
index 9088f3e..a236ddb 100644
--- a/src/style.css
+++ b/src/style.css
@@ -309,6 +309,17 @@
.error-hint code { font: 600 1em var(--glp-font-code); }
.visual-error-hint { padding: 0 8px 4px; }
.visual-error-hint .error-hint { margin: 0; }
+/* Coverage after Tests: neutral, neither a success nor a warning (covered
+ code can still be wrong). */
+.test-coverage { margin: 0 0 8px; padding: 6px 10px; color: var(--glp-text); background: var(--glp-surface); border-left: 3px solid var(--glp-border-control); border-radius: var(--glp-radius); font: 13px/1.5 var(--glp-font-ui); }
+.test-coverage-heading { margin: 0 0 2px; }
+.test-coverage ul { margin: 0; padding: 0; list-style: none; }
+.test-coverage code { font: 13px/1.5 var(--glp-font-code); }
+.test-report .test-coverage pre { margin: 0 0 4px 1.5em; }
+/* The lines in the editor no example executed (the IDE; see coverageMarks),
+ in the hints' colours: the editor's own background is --glp-surface. */
+.coverage-line { background: var(--glp-warning-bg); }
+.coverage-gutter { margin-left: 3px; width: 4px !important; background: var(--glp-warning); }
.test-failure { margin: 0 0 10px; padding: 8px 10px; background: var(--glp-error-bg); border-left: 3px solid var(--glp-error); border-radius: var(--glp-radius); }
.test-failure .secondary-button { margin-top: 8px; padding: 4px 10px; font-size: 13px; }