From dc2f42db51df232d7939883d0bb61e805cd92525 Mon Sep 17 00:00:00 2001 From: David Thrane Christiansen Date: Fri, 14 Aug 2026 10:29:22 +0200 Subject: [PATCH 1/2] feat: migrate the test suite to Errata This rearchitects our test suite into a proper testing framework. Before, our tests were all essentially ad hoc. Each kind of test would be an IO action that might throw, and there were some rough conventions, but output reporting was inconsistent and sometimes hard to understand. Tests were noisy as well, which made it hard to find reasons for failure at a glance. Now the suite runs on Errata: `lake test` discovers every `@[test]` in the package, reports results uniformly, and CI displays the JUnit XML report. --- .github/workflows/ci.yml | 29 +- .github/workflows/test-imports.yml | 5 + .github/workflows/update-subverso.yml | 2 +- .../Releases/Entries/TestFramework.lean | 2 + lakefile.lean | 15 +- src/tests/TestMain.lean | 405 ---------- src/tests/Tests.lean | 54 -- src/tests/Tests/Golden.lean | 160 ---- src/tests/Tests/Integration.lean | 127 --- src/tests/Tests/LiterateConfig.lean | 362 --------- src/tests/Tests/SearchJs.lean | 117 --- src/tests/Tests/VersoManual.lean | 12 - src/tests/Tests/Zip.lean | 32 - src/tests/VersoTests.lean | 11 + .../{Tests => VersoTests}/Arbitrary.lean | 32 +- src/tests/{Tests => VersoTests}/Basic.lean | 24 +- .../VersoBlog.lean => VersoTests/Blog.lean} | 106 ++- src/tests/VersoTests/BuildLog.lean | 109 +++ .../CommentSkipping.lean | 15 +- .../CommentSkipping/Doc.lean | 0 .../CommentSkipping/Doc2.lean | 0 .../DocElabExtensions/Define.lean | 17 +- .../DocElabExtensions/LocalExtension.lean | 0 .../DocElabExtensions/Middle.lean | 5 +- .../DocElabExtensions/Use.lean | 11 +- src/tests/{Tests => VersoTests}/DocTerm.lean | 0 .../{Tests => VersoTests}/DocVisibility.lean | 7 +- .../DocVisibility/Doc.lean | 4 +- .../DocstringMissing.lean | 3 +- .../DocstringMissingLegacy.lean | 5 +- src/tests/{Tests => VersoTests}/Elab.lean | 17 +- .../ExpanderSignatures.lean | 7 +- .../ExpanderSignaturesLegacy.lean | 5 +- .../ExtensionResolution.lean | 97 ++- .../{Tests => VersoTests}/GenericCode.lean | 12 +- .../HighlightedToTeX.lean | 3 +- .../{Tests => VersoTests}/HoverMerge.lean | 45 +- src/tests/{Tests => VersoTests}/Html.lean | 13 +- .../{Tests => VersoTests}/HtmlEntities.lean | 22 +- .../InlineStringPositions.lean | 30 +- .../Integration/CodeContent.lean | 0 .../Integration/DiagramDoc.lean | 0 .../Integration/Escape.lean | 0 .../Integration/ExtraFilesDoc.lean | 0 .../Integration/FrontMatter.lean | 0 .../Integration/InheritanceDoc.lean | 0 .../Integration/LeanSection.lean | 0 .../Integration/SampleDoc.lean | 0 .../Integration/TwoSideDoc.lean | 0 src/tests/VersoTests/Interactive.lean | 20 + src/tests/{Tests => VersoTests}/LeanCode.lean | 23 +- src/tests/{Tests => VersoTests}/Linters.lean | 36 +- src/tests/VersoTests/LiterateConfig.lean | 292 +++++++ .../{Tests => VersoTests}/LiterateHtml.lean | 757 +++++++++--------- src/tests/VersoTests/LzCompress.lean | 39 + src/tests/{Tests => VersoTests}/Method.lean | 12 +- .../NestedTacticHtml.lean | 14 +- src/tests/VersoTests/Options.lean | 17 + .../ParserRegression.lean | 0 src/tests/{Tests => VersoTests}/Paths.lean | 43 +- .../{Tests => VersoTests}/PorterStemmer.lean | 57 +- src/tests/{Tests => VersoTests}/Refs.lean | 39 +- src/tests/VersoTests/SearchJs.lean | 82 ++ src/tests/VersoTests/Serialization.lean | 103 +++ .../SerializationGenerators.lean} | 27 +- src/tests/{Tests => VersoTests}/Serve.lean | 84 +- src/tests/VersoTests/SetupLiterate.lean | 56 ++ src/tests/VersoTests/Stemmer.lean | 29 + src/tests/{Tests => VersoTests}/Tags.lean | 9 +- src/tests/{Tests => VersoTests}/TeX.lean | 39 +- src/tests/VersoTests/TeXGolden.lean | 89 ++ src/tests/{Tests => VersoTests}/TexUnit.lean | 11 +- src/tests/{Tests => VersoTests}/TexUtil.lean | 0 .../VersoBlog/LiterateLeanPage.lean | 3 +- src/tests/VersoTests/VersoManual.lean | 12 + .../VersoManual/Docstring.lean | 13 +- .../VersoManual/Html.lean | 9 +- .../VersoManual/Html/SoftHyphenate.lean | 16 +- .../VersoManual/License.lean | 3 +- .../VersoManual/Markdown.lean | 17 +- .../VersoManual/Sections.lean | 24 +- .../VersoManual/WordCount.lean | 19 +- src/tests/{Tests => VersoTests}/Z85.lean | 9 +- src/tests/VersoTests/Zip.lean | 85 ++ 84 files changed, 1801 insertions(+), 2209 deletions(-) delete mode 100644 src/tests/TestMain.lean delete mode 100644 src/tests/Tests.lean delete mode 100644 src/tests/Tests/Golden.lean delete mode 100644 src/tests/Tests/Integration.lean delete mode 100644 src/tests/Tests/LiterateConfig.lean delete mode 100644 src/tests/Tests/SearchJs.lean delete mode 100644 src/tests/Tests/VersoManual.lean delete mode 100644 src/tests/Tests/Zip.lean create mode 100644 src/tests/VersoTests.lean rename src/tests/{Tests => VersoTests}/Arbitrary.lean (94%) rename src/tests/{Tests => VersoTests}/Basic.lean (96%) rename src/tests/{Tests/VersoBlog.lean => VersoTests/Blog.lean} (53%) create mode 100644 src/tests/VersoTests/BuildLog.lean rename src/tests/{Tests => VersoTests}/CommentSkipping.lean (86%) rename src/tests/{Tests => VersoTests}/CommentSkipping/Doc.lean (100%) rename src/tests/{Tests => VersoTests}/CommentSkipping/Doc2.lean (100%) rename src/tests/{Tests => VersoTests}/DocElabExtensions/Define.lean (71%) rename src/tests/{Tests => VersoTests}/DocElabExtensions/LocalExtension.lean (100%) rename src/tests/{Tests => VersoTests}/DocElabExtensions/Middle.lean (88%) rename src/tests/{Tests => VersoTests}/DocElabExtensions/Use.lean (88%) rename src/tests/{Tests => VersoTests}/DocTerm.lean (100%) rename src/tests/{Tests => VersoTests}/DocVisibility.lean (81%) rename src/tests/{Tests => VersoTests}/DocVisibility/Doc.lean (79%) rename src/tests/{Tests => VersoTests}/DocstringMissing.lean (97%) rename src/tests/{Tests => VersoTests}/DocstringMissingLegacy.lean (91%) rename src/tests/{Tests => VersoTests}/Elab.lean (85%) rename src/tests/{Tests => VersoTests}/ExpanderSignatures.lean (94%) rename src/tests/{Tests => VersoTests}/ExpanderSignaturesLegacy.lean (93%) rename src/tests/{Tests => VersoTests}/ExtensionResolution.lean (89%) rename src/tests/{Tests => VersoTests}/GenericCode.lean (95%) rename src/tests/{Tests => VersoTests}/HighlightedToTeX.lean (92%) rename src/tests/{Tests => VersoTests}/HoverMerge.lean (64%) rename src/tests/{Tests => VersoTests}/Html.lean (97%) rename src/tests/{Tests => VersoTests}/HtmlEntities.lean (73%) rename src/tests/{Tests => VersoTests}/InlineStringPositions.lean (81%) rename src/tests/{Tests => VersoTests}/Integration/CodeContent.lean (100%) rename src/tests/{Tests => VersoTests}/Integration/DiagramDoc.lean (100%) rename src/tests/{Tests => VersoTests}/Integration/Escape.lean (100%) rename src/tests/{Tests => VersoTests}/Integration/ExtraFilesDoc.lean (100%) rename src/tests/{Tests => VersoTests}/Integration/FrontMatter.lean (100%) rename src/tests/{Tests => VersoTests}/Integration/InheritanceDoc.lean (100%) rename src/tests/{Tests => VersoTests}/Integration/LeanSection.lean (100%) rename src/tests/{Tests => VersoTests}/Integration/SampleDoc.lean (100%) rename src/tests/{Tests => VersoTests}/Integration/TwoSideDoc.lean (100%) create mode 100644 src/tests/VersoTests/Interactive.lean rename src/tests/{Tests => VersoTests}/LeanCode.lean (94%) rename src/tests/{Tests => VersoTests}/Linters.lean (95%) create mode 100644 src/tests/VersoTests/LiterateConfig.lean rename src/tests/{Tests => VersoTests}/LiterateHtml.lean (61%) create mode 100644 src/tests/VersoTests/LzCompress.lean rename src/tests/{Tests => VersoTests}/Method.lean (85%) rename src/tests/{Tests => VersoTests}/NestedTacticHtml.lean (98%) create mode 100644 src/tests/VersoTests/Options.lean rename src/tests/{Tests => VersoTests}/ParserRegression.lean (100%) rename src/tests/{Tests => VersoTests}/Paths.lean (85%) rename src/tests/{Tests => VersoTests}/PorterStemmer.lean (84%) rename src/tests/{Tests => VersoTests}/Refs.lean (91%) create mode 100644 src/tests/VersoTests/SearchJs.lean create mode 100644 src/tests/VersoTests/Serialization.lean rename src/tests/{Tests/Serialization.lean => VersoTests/SerializationGenerators.lean} (96%) rename src/tests/{Tests => VersoTests}/Serve.lean (91%) create mode 100644 src/tests/VersoTests/SetupLiterate.lean create mode 100644 src/tests/VersoTests/Stemmer.lean rename src/tests/{Tests => VersoTests}/Tags.lean (97%) rename src/tests/{Tests => VersoTests}/TeX.lean (86%) create mode 100644 src/tests/VersoTests/TeXGolden.lean rename src/tests/{Tests => VersoTests}/TexUnit.lean (86%) rename src/tests/{Tests => VersoTests}/TexUtil.lean (100%) rename src/tests/{Tests => VersoTests}/VersoBlog/LiterateLeanPage.lean (95%) create mode 100644 src/tests/VersoTests/VersoManual.lean rename src/tests/{Tests => VersoTests}/VersoManual/Docstring.lean (88%) rename src/tests/{Tests => VersoTests}/VersoManual/Html.lean (96%) rename src/tests/{Tests => VersoTests}/VersoManual/Html/SoftHyphenate.lean (88%) rename src/tests/{Tests => VersoTests}/VersoManual/License.lean (94%) rename src/tests/{Tests => VersoTests}/VersoManual/Markdown.lean (93%) rename src/tests/{Tests => VersoTests}/VersoManual/Sections.lean (77%) rename src/tests/{Tests => VersoTests}/VersoManual/WordCount.lean (85%) rename src/tests/{Tests => VersoTests}/Z85.lean (95%) create mode 100644 src/tests/VersoTests/Zip.lean diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4a8b601c..25f83a0b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,9 @@ jobs: build: name: Build and test runs-on: nscloud-ubuntu-22.04-amd64-8x16 + permissions: + contents: read + checks: write env: # Used for browser tests. Placing them here allows caching to work right. PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers @@ -147,11 +150,31 @@ jobs: - name: Run tests run: | - lake test -- --verbose --check-tex + lake test -- --test-options --verbose --check-tex --junit=errata-report.xml --markdown=errata-summary.md - - name: Run Errata's self-tests + - name: Add test results to the job summary + if: always() run: | - lake run Errata.run ErrataTests --test-options --verbose + if [ -f errata-summary.md ]; then cat errata-summary.md >> "$GITHUB_STEP_SUMMARY"; fi + + - name: Publish the JUnit test report + if: always() + uses: dorny/test-reporter@v1 + with: + name: Errata tests + path: errata-report.xml + reporter: java-junit + fail-on-error: false + + - name: Upload the raw test reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: errata-test-reports + path: | + errata-report.xml + errata-summary.md + if-no-files-found: ignore - name: Test the dev server run: | diff --git a/.github/workflows/test-imports.yml b/.github/workflows/test-imports.yml index 85c778c5c..b1a227647 100644 --- a/.github/workflows/test-imports.yml +++ b/.github/workflows/test-imports.yml @@ -1,3 +1,8 @@ +# This check is retained only to satisfy the required "Check all test modules are imported" status +# check on the protected branch. It is a no-op on this layout: test modules now live under +# src/tests/VersoTests and are discovered by globbing, and src/tests/Tests no longer exists, so the +# scan below finds nothing. Coverage is instead enforced by `errataWarnUncoveredTestModules` during +# `lake test`. Remove this workflow (and drop the required check) once merged. name: All test modules imported on: [pull_request, merge_group] diff --git a/.github/workflows/update-subverso.yml b/.github/workflows/update-subverso.yml index 4e23fef9e..1153e1291 100644 --- a/.github/workflows/update-subverso.yml +++ b/.github/workflows/update-subverso.yml @@ -84,7 +84,7 @@ jobs: - name: Run tests if: steps.check-changes.outputs.changed == 'true' run: | - lake test -- --verbose --check-tex + lake test -- --test-options --verbose --check-tex - name: Create branch and open PR if: steps.check-changes.outputs.changed == 'true' diff --git a/doc/UsersGuide/Releases/Entries/TestFramework.lean b/doc/UsersGuide/Releases/Entries/TestFramework.lean index af7314964..bc25ffa67 100644 --- a/doc/UsersGuide/Releases/Entries/TestFramework.lean +++ b/doc/UsersGuide/Releases/Entries/TestFramework.lean @@ -29,3 +29,5 @@ Each test's docstring and source range are saved for failure reporting. The test runner discovers every test in the package; it can restrict the run to named libraries, rerun property tests with a fixed seed, update golden files, and write JUnit XML, JSON, and Markdown reports. Elaboration-time tests can be written with `#test_msgs` and `#test_guard`, variants of `#guard_msgs` and `#guard` that run their check at compile time and record the outcome as a test case, reported together with the rest of the suite. + +Verso's own test suite runs on Errata: `lake test` discovers and runs every test in the package, and CI publishes the resulting reports. diff --git a/lakefile.lean b/lakefile.lean index 3b7d28671..80e200cfa 100644 --- a/lakefile.lean +++ b/lakefile.lean @@ -134,18 +134,16 @@ lean_exe «verso-literate-plan» where srcDir := "src/verso-literate-plan" supportInterpreter := true +-- All test code: Errata test modules, compile-time tests, fixtures, and generators. Submodules are +-- globbed so each is built and every `@[test]` module is discoverable. @[default_target] -lean_lib Tests where +lean_lib VersoTests where srcDir := "src/tests" - -@[test_driver] -lean_exe «verso-tests» where - root := `TestMain - srcDir := "src/tests" - supportInterpreter := true + roots := #[`VersoTests] + globs := #[Glob.andSubmodules `VersoTests] -- Everything below is Errata's own implementation: its library, its self-tests, the generated --- discovery runner, and the runner script. +-- discovery runner, and the `lake test` driver. namespace Errata @[default_target] @@ -288,6 +286,7 @@ private def warnUncoveredTestModules (ws : Lake.Workspace) : IO Unit := do for mod in missed do IO.eprintln s!" {mod}" +@[test_driver] script run (args) do let ws ← getWorkspace -- Answer `--help` before discovering or building anything. diff --git a/src/tests/TestMain.lean b/src/tests/TestMain.lean deleted file mode 100644 index 28314873f..000000000 --- a/src/tests/TestMain.lean +++ /dev/null @@ -1,405 +0,0 @@ -/- -Copyright (c) 2025 Lean FRO LLC. All rights reserved. -Released under Apache 2.0 license as described in the file LICENSE. -Author: David Thrane Christiansen --/ - -import Verso -import VersoManual -import VersoSearch.PorterStemmer -import VersoUtil.LzCompress -import VersoLiterate -import Tests - -structure Config where - verbose : Bool := false - updateExpected : Bool := false - checkTeX : Bool := false - -open Verso.Search.Stemmer.Porter in -def testStemmer (_ : Config) : IO Unit := do - let voc := include_str "stemmer/voc.txt" - let output := include_str "stemmer/output.txt" - - let data := voc.splitOn "\n" - let outData := output.splitOn "\n" - - let mut failures := #[] - for x in data, y in outData do - let s := porterStem x - unless s == y do - failures := failures.push (x, s, y) - unless failures.isEmpty do - IO.eprintln s!"{failures.size} failures" - for (x, s, y) in failures do - IO.eprintln s!"{x} --> {s} (wanted '{y}')" - throw <| IO.userError "Stemmer tests failed" - -/-- -Tests manual-genre TeX generation. `dir` is a subdirectory specific to a particular test document, -which is where actual output should go, and which contains the expected output directory. -`doc` is the document to be rendered. --/ -def testTexOutput - (dir : System.FilePath) - (doc : Verso.Doc.VersoDoc Verso.Genre.Manual) - (config : Config) - (twoside : Bool := false) - (extraFiles : List (System.FilePath × String) := []) - (extraFilesTeX : List (System.FilePath × String) := []) : IO Unit := do - let versoConfig : Verso.Genre.Manual.Config := { - destination := "src/tests/integration" / dir / "output", - emitTeX := true, - emitHtmlMulti := .no, - twoside, - extraFiles, - extraFilesTeX - } - - let runTest : IO Unit := - open Verso Genre Manual in do - let logger ← Verso.Logger.new - emitTeX versoConfig doc.toPart |>.run extension_impls% |>.run logger - - Verso.Integration.runTests { config with - testDir := "src/tests/integration" / dir, - updateExpected := config.updateExpected, - runTest - } - -def testZip (cfg : Config) : IO Unit := do - IO.println "Running zip tests with fixed files..." - testExtract #[] .store - testExtract #[] .deflate - testExtract #[("empty", .empty)] .store - testExtract #[("empty", .empty)] .deflate - testExtract files .store - testExtract files .deflate - let chunkSize := me.size / 10 - for i in (0 : Nat)...10 do - let me := me.extract 0 (i * chunkSize) - testExtract #[("T2.lean", me)] .store - testExtract #[("T2.lean", me)] .deflate - for i in (0 : Nat)...10 do - let me := me.extract 0 (i * chunkSize) - let bwd := bwd.extract 0 (i * chunkSize) - testExtract #[("T2.lean", me), ("other", bwd)] .store - testExtract #[("T2.lean", me), ("other", bwd)] .deflate - for _ in (0 : Nat)...10 do - let seedValue ← IO.monoNanosNow - if cfg.verbose then IO.println s!"Seed is {seedValue}" - IO.setRandSeed seedValue - let mut randFiles := #[] - for _ in 0...(← IO.rand 0 15) do - let name ← randName - let size ← IO.rand 0 50000 - let content ← IO.getRandomBytes <| .ofNat size - randFiles := randFiles.push (name, content) - if cfg.verbose then - IO.println s!"Running random zip test with {randFiles.size} files, sizes:" - for (x, y) in randFiles do - IO.println s!" * {x}: {y.size} bytes" - else - IO.println s!"Running random zip test with {randFiles.size} files" - testExtract randFiles .store - testExtract randFiles .deflate - -where - files := #[("x.txt", "abcdef\nlkjlkj".toByteArray), ("y.txt", "".toByteArray), ("z.txt", "abc\n\n".toByteArray)] - me := (include_str "TestMain.lean").toByteArray - bwd := me.foldl (init := .empty) fun x y => ByteArray.empty.push y ++ x - randName : IO String := do - let len ← IO.rand 1 10 - let stem ← len.foldM (init := "") fun _ _ acc => do - return acc.push <| Char.ofNat ('a'.toNat + (← IO.rand 0 25)) - let len ← IO.rand 2 4 - let ext ← len.foldM (init := "") fun _ _ acc => do - return acc.push <| Char.ofNat ('a'.toNat + (← IO.rand 0 25)) - return stem ++ "." ++ ext - -open Verso.LzCompress in -def testLz (_ : Config) : IO Unit := do - let actual := lzCompress r#"import Mathlib.Logic.Basic -- basic facts in logic --- theorems in Lean's mathematics library - --- Let P and Q be true-false statements -variable (P Q : Prop) - --- The following is a basic result in logic -example : ¬ (P ∧ Q) ↔ ¬ P ∨ ¬ Q := by - -- its proof is already in Lean's mathematics library - exact not_and_or - --- Here is another basic result in logic -example : ¬ (P ∨ Q) ↔ ¬ P ∧ ¬ Q := by - apply? -- we can search for the proof in the library - -- we can also replace `apply?` with its output -"# - let expected := - "JYWwDg9gTgLgBAWQIYwBYBtgCMB0AZCAc2AGMcAhJAZ1LgFo64traAzJEmKuYAOznRFSAKAZw0AU2gSQ3" ++ - "PnDwSkvAOTcQKVDJSlumLFCRQAnsNGNF8AApxlAEzgBFJhPFQArhLrt0VV1RgUGQleLmEANyNgJCx0VwA" ++ - "KG2cALjgrKAgwAEozMQAVLThWCHRBAHc+Qh5uJCYWEjgoCSp3dHh5QWISYQkADyRwOLhUgBq4RLhAciIn" ++ - "LLhAFMI4MZtACiJFp2GAXiZTOHpGYC44MAyIVmrbdCakO2MefkVlNTgNSRfdAWxDE2Fdvo54XgQGAAfXs" ++ - "wOguUYAAkJE1zsogVooHUaA0mi02ncBEJun9Bq5RuMVjN5msbNMxiktlgdrYwGB0MYAPx7OBlVwkZRwPx" ++ - "GEioIrQcSFY4QU5YyQfAxGWlidlwTn8JC+CCNCQMjiuAAGSHpjKZmrZB35B24EHcMDA5uEQA" - if actual ≠ expected then - throw <| IO.userError "Mismatched lzCompress output" - -def testSerialization (_ : Config) : IO Unit := do - IO.println "Running serialization tests with Plausible..." - let fails ← runSerializationTests - if fails > 0 then - throw <| IO.userError s!"{fails} serialization tests failed" - -def testSearchJs (_ : Config) : IO Unit := do - IO.println "Running search JS wire-format tests..." - let fails ← Verso.Tests.SearchJs.runSearchJsTests - if fails > 0 then - throw <| IO.userError s!"{fails} search JS tests failed" - -def testBlog (_ : Config) : IO Unit := do - IO.println "Running blog tests with Plausible..." - let fails ← runBlogTests - if fails > 0 then - throw <| IO.userError s!"{fails} blog tests failed" - -def testServe (_ : Config) : IO Unit := do - IO.println "Running serve tests..." - let fails ← Verso.Tests.Serve.runServeTests - if fails > 0 then - throw <| IO.userError s!"{fails} serve tests failed" - -def testLiterateConfig (_ : Config) : IO Unit := do - let fails ← Tests.LiterateConfig.runLiterateConfigTests - if fails > 0 then - throw <| IO.userError s!"{fails} literate config tests failed" - -def testLiterateHtml (_ : Config) : IO Unit := - Tests.LiterateHtml.testLiterateHtml - -def testLiterateHtmlMultiRoot (_ : Config) : IO Unit := - Tests.LiterateHtml.testLiterateHtmlMultiRoot - --- Interactive tests via the LSP server -def testInteractive (_ : Config) : IO Unit := do - IO.println "Running interactive (LSP) tests..." - IO.println s!"current dir: {(← IO.Process.getCurrentDir)}" - -- We use the lower-level Process.spawn, which causes the subprocess to inherit the stdio - let child ← IO.Process.spawn { cmd := "src/tests/interactive/run_interactive.sh" } - let exitCode ← child.wait - if exitCode != 0 then - throw <| IO.userError s!"Interactive LSP tests failed with exit code {exitCode}" - -private def hasSubstring (s : String) (sub : String) : Bool := - s.find? sub |>.isSome - -def testSetupLiterate (_ : Config) : IO Unit := do - IO.println "Running setup-literate tests..." - let versoRoot ← IO.FS.realPath "." - IO.FS.withTempDir fun tmpDir => do - let run (cmd : String) (args : Array String) : IO Unit := do - let result ← IO.Process.output { - cmd := cmd - args := args - cwd := some tmpDir.toString - } - if result.exitCode != 0 then - throw <| IO.userError s!"{cmd} failed: {result.stderr}" - - -- Set up a project that depends on the Verso being tested - run "git" #["init", "-q"] - let toolchain ← IO.FS.readFile "lean-toolchain" - IO.FS.writeFile (tmpDir / "lean-toolchain") toolchain - IO.FS.writeFile (tmpDir / "lakefile.toml") - s!"name = \"test-project\"\n\n[[require]]\nname = \"verso\"\npath = \"{versoRoot}\"\n" - - -- Test 1: Fresh generation via lake exe - let result ← IO.Process.output { - cmd := "lake" - args := #["exe", "verso", "setup-literate"] - cwd := some tmpDir.toString - } - if result.exitCode != 0 then - throw <| IO.userError s!"setup-literate failed: {result.stderr}\n{result.stdout}" - - let workflowFile := tmpDir / ".github" / "workflows" / "verso-literate-pages.yml" - unless ← workflowFile.pathExists do - throw <| IO.userError "Workflow file was not created" - - let content ← IO.FS.readFile workflowFile - let checks := #[ - ("lake query :literateHtml", "lake query command"), - ("deploy-pages@v", "deploy-pages action"), - ("upload-pages-artifact@v", "upload-pages-artifact action"), - ("lean-action@v", "lean-action") - ] - for (needle, desc) in checks do - unless hasSubstring content needle do - throw <| IO.userError s!"Workflow file missing {desc} ({needle})" - IO.println " fresh generation: passed" - - -- Test 2: Idempotent (no change) - let result2 ← IO.Process.output { - cmd := "lake" - args := #["exe", "verso", "setup-literate"] - cwd := some tmpDir.toString - } - unless hasSubstring result2.stdout "up to date" do - throw <| IO.userError "Expected 'up to date' message on second run" - IO.println " idempotent: passed" - - -- Test 3: Outdated file gets .bak - IO.FS.writeFile workflowFile "modified content\n" - let result3 ← IO.Process.output { - cmd := "lake" - args := #["exe", "verso", "setup-literate"] - cwd := some tmpDir.toString - } - if result3.exitCode != 0 then - throw <| IO.userError s!"setup-literate (update) failed: {result3.stderr}" - let bakFile := tmpDir / ".github" / "workflows" / "verso-literate-pages.yml.bak" - unless ← bakFile.pathExists do - throw <| IO.userError ".bak file was not created when updating" - let bakContent ← IO.FS.readFile bakFile - unless hasSubstring bakContent "modified content" do - throw <| IO.userError ".bak file should contain old content" - IO.println " backup on update: passed" - - IO.println " All setup-literate tests passed." - -open Verso in -def testBuildLog (_ : Config) : IO Unit := do - IO.println "Running build-log tests..." - -- A message logged with a position is saved with that location (a location always names a file). - let logger ← Logger.new - let pos : Lean.Lsp.Position := { line := 4, character := 2 } - (reportError "boom" (some { file := "PosSave.lean", span := .pos pos }) : BuildLogT IO Unit).run logger - let errs ← logger.errors - let some m := errs[0]? - | throw <| IO.userError s!"expected 1 saved error, got {errs.size}" - unless m.severity == .error do throw <| IO.userError "expected error severity" - match m.loc with - | some { file := "PosSave.lean", span := .pos p } => - unless p.line == 4 && p.character == 2 do - throw <| IO.userError "saved position does not match the logged span" - | _ => throw <| IO.userError "expected a saved `PosSave.lean` `pos` location" - - -- A `range` span is likewise saved. - let logger2 ← Logger.new - let r : Lean.Lsp.Range := - { start := { line := 1, character := 0 }, «end» := { line := 1, character := 5 } } - (reportWarning "careful" (some { file := "RangeSave.lean", span := .range r }) : BuildLogT IO Unit).run logger2 - let some w := (← logger2.warnings)[0]? - | throw <| IO.userError "expected 1 saved warning" - match w.loc with - | some { span := .range _, .. } => pure () - | _ => throw <| IO.userError "expected a `range` span to be saved" - - -- Range formatting defers to Lean's `mkErrorStringWithPos`: `file:line:col-line:col`, 1-based - -- line, 0-based column, with the full end position even within one line (no `line:col-col` collapse). - let crossLine : LogMessage := - { severity := .error, text := "msg", - loc := some { file := "CrossLine.lean", - span := .range { start := { line := 19, character := 4 }, «end» := { line := 20, character := 7 } } } } - unless crossLine.format == "CrossLine.lean:20:4-21:7: msg" do - throw <| IO.userError s!"cross-line range formatted as \"{crossLine.format}\"" - let sameLine : LogMessage := - { severity := .error, text := "msg", - loc := some { file := "SameLine.lean", - span := .range { start := { line := 42, character := 4 }, «end» := { line := 42, character := 21 } } } } - unless sameLine.format == "SameLine.lean:43:4-43:21: msg" do - throw <| IO.userError s!"same-line range formatted as \"{sameLine.format}\"" - - -- A located message is formatted uniformly as `file:line:col: text`. - let loggerF ← Logger.new - let errBufF ← IO.mkRef ({} : IO.FS.Stream.Buffer) - IO.withStderr (IO.FS.Stream.ofBuffer errBufF) <| - (reportError "bad term" (some { file := "FileLoc.lean", span := .pos { line := 6, character := 3 } }) - : BuildLogT IO Unit).run loggerF - let some mF := (← loggerF.errors)[0]? - | throw <| IO.userError "expected 1 saved error with a file location" - unless mF.loc.map (·.file) == some "FileLoc.lean" do - throw <| IO.userError "saved location should carry the filename" - unless hasSubstring (String.fromUTF8! (← errBufF.get).data) "FileLoc.lean:7:3: bad term" do - throw <| IO.userError "a file location should format as file:line:col:" - - -- A single logging action can emit both severities; errors set the exit code, warnings do not. - let logger3 ← Logger.new - (do reportError "e1"; reportWarning "w1"; reportError "e2" : BuildLogT IO Unit).run logger3 - unless (← logger3.errors).size == 2 do throw <| IO.userError "expected 2 errors" - unless (← logger3.warnings).size == 1 do throw <| IO.userError "expected 1 warning" - unless (← logger3.exitCode) == 1 do throw <| IO.userError "errors must yield a non-zero exit code" - - let logger4 ← Logger.new - (reportWarning "just a warning" : BuildLogT IO Unit).run logger4 - unless (← logger4.exitCode) == 0 do - throw <| IO.userError "warnings must not affect the exit code" - - -- Logging prints to the *ambient* stderr, resolved at log time: a logger created before a - -- stderr redirection still writes into the redirected stream, and nothing goes to stdout. - let logger5 ← Logger.new - let outBuf ← IO.mkRef ({} : IO.FS.Stream.Buffer) - let errBuf ← IO.mkRef ({} : IO.FS.Stream.Buffer) - IO.withStdout (IO.FS.Stream.ofBuffer outBuf) <| - IO.withStderr (IO.FS.Stream.ofBuffer errBuf) <| - (do - reportError "first problem" (some { file := "X.lean", span := .pos { line := 0, character := 0 } }) - reportWarning "second problem" : BuildLogT IO Unit).run logger5 - let errText := String.fromUTF8! (← errBuf.get).data - let outText := String.fromUTF8! (← outBuf.get).data - unless hasSubstring errText "X.lean:1:0: first problem" do - throw <| IO.userError s!"stderr buffer is missing the formatted error; got: {errText}" - unless hasSubstring errText "second problem" do - throw <| IO.userError s!"stderr buffer is missing the warning; got: {errText}" - unless outText.isEmpty do - throw <| IO.userError s!"logging must not write to stdout; got: {outText}" - unless (← logger5.errors).size == 1 && (← logger5.warnings).size == 1 do - throw <| IO.userError "redirected logging should still accumulate into the logger's buffers" - IO.println " All build-log tests passed." - -open Verso.Integration in -def tests := [ - testBuildLog, - testSerialization, - testSearchJs, - testBlog, - testServe, - testStemmer, - testTexOutput "sample-doc" SampleDoc.doc, - testTexOutput "inheritance-doc" InheritanceDoc.doc, - testTexOutput "code-content-doc" CodeContent.doc, - testTexOutput "extra-files-doc" ExtraFilesDoc.doc - (extraFiles := [("src/tests/integration/extra-files-doc/test-data/shared", "shared")]) - (extraFilesTeX := [("src/tests/integration/extra-files-doc/test-data/TeX-only", "TeX-only")]), - testTexOutput "escape-doc" Escape.doc, - testTexOutput "front-matter-doc" FrontMatter.doc, - testTexOutput "diagram-doc" DiagramDoc.doc, - testTexOutput "twoside-doc" TwoSideDoc.doc (twoside := true), - testZip, - testInteractive, - testLiterateConfig, - testLiterateHtml, - testLiterateHtmlMultiRoot, - testSetupLiterate -] - -def getConfig (config : Config) : List String → IO Config - | [] => pure config - | "--update-expected" :: args => getConfig { config with updateExpected := true } args - | "--verbose" :: args | "-v" :: args => getConfig { config with verbose := true } args - | "--check-tex" :: args => getConfig { config with checkTeX := true } args - | other :: _ => throw <| IO.userError s!"Didn't understand {other}" - -def main (args : List String) : IO UInt32 := do - let config ← getConfig {} args - let mut failures := 0 - for test in tests do - try - test config - catch - | e => do - IO.eprintln e - failures := failures + 1 - if failures == 0 then - IO.println "All tests passed" - return failures diff --git a/src/tests/Tests.lean b/src/tests/Tests.lean deleted file mode 100644 index 5bcdd422c..000000000 --- a/src/tests/Tests.lean +++ /dev/null @@ -1,54 +0,0 @@ -/- -Copyright (c) 2025-2026 Lean FRO LLC. All rights reserved. -Released under Apache 2.0 license as described in the file LICENSE. -Author: David Thrane Christiansen --/ -import Tests.Basic -import Tests.Elab -import Tests.GenericCode -import Tests.Golden -import Tests.CommentSkipping -import Tests.DocElabExtensions.Use -import Tests.DocTerm -import Tests.DocVisibility -import Tests.DocstringMissing -import Tests.DocstringMissingLegacy -import Tests.HighlightedToTeX -import Tests.ExpanderSignatures -import Tests.ExpanderSignaturesLegacy -import Tests.Html -import Tests.HtmlEntities -import Tests.InlineStringPositions -import Tests.Tags -import Tests.Integration -import Tests.Integration.SampleDoc -import Tests.Integration.CodeContent -import Tests.Integration.DiagramDoc -import Tests.Integration.Escape -import Tests.Integration.ExtraFilesDoc -import Tests.Integration.FrontMatter -import Tests.Integration.InheritanceDoc -import Tests.Integration.LeanSection -import Tests.Integration.TwoSideDoc -import Tests.LeanCode -import Tests.Linters -import Tests.Method -import Tests.NestedTacticHtml -import Tests.ParserRegression -import Tests.Paths -import Tests.PorterStemmer -import Tests.Refs -import Tests.SearchJs -import Tests.ExtensionResolution -import Tests.Serialization -import Tests.HoverMerge -import Tests.TeX -import Tests.TexUnit -import Tests.TexUtil -import Tests.VersoBlog -import Tests.VersoManual -import Tests.Z85 -import Tests.Zip -import Tests.LiterateConfig -import Tests.LiterateHtml -import Tests.Serve diff --git a/src/tests/Tests/Golden.lean b/src/tests/Tests/Golden.lean deleted file mode 100644 index c7b70c3c6..000000000 --- a/src/tests/Tests/Golden.lean +++ /dev/null @@ -1,160 +0,0 @@ -/- -Copyright (c) 2025 Lean FRO LLC. All rights reserved. -Released under Apache 2.0 license as described in the file LICENSE. -Author: David Thrane Christiansen --/ -module - -public import Lean.Util.Diff - -public section - -namespace Verso.GoldenTest - -set_option linter.missingDocs true - -open Lean.Diff - - -/-- Configuration for the test runner -/ -structure Config where - /-- Where are input and expected files located? -/ - testDir : System.FilePath - /-- Should the expected output be replaced with the actual output? -/ - updateExpected : Bool := false - /-- How to test an input file's contents. -/ - runTest : String → IO String - -/-- Result of running a single test. -/ -inductive TestResult where - /-- The test succeeded.-/ - | pass (name : String) : TestResult - /-- The test was a failure. -/ - | fail (name expected actual : String) : TestResult - /-- An error prevented the test from running. -/ - | error (name message : String) : TestResult - -/-- Statistics for a test run. -/ -structure TestStats where - /-- The number of passing tests. -/ - passed : Nat := 0 - /-- The number of failing tests. -/ - failed : Nat := 0 - /-- The number of test that couldn't run. -/ - errors : Nat := 0 - -/-- The total number of tests from a given run. -/ -def TestStats.total (stats : TestStats) : Nat := - stats.passed + stats.failed + stats.errors - -/-- Add a test result to the statistics-/ -def TestStats.add (stats : TestStats) (result : TestResult) : TestStats := - match result with - | .pass _ => { stats with passed := stats.passed + 1 } - | .fail _ _ _ => { stats with failed := stats.failed + 1 } - | .error _ _ => { stats with errors := stats.errors + 1 } - -/-- A single test consists of three paths -/ -structure TestPaths where - /-- The file to parse -/ - input : System.FilePath - /-- The expected result -/ - expected : System.FilePath - /-- The actual result -/ - output : System.FilePath - -/-- Get paths for a test given the input file path -/ -def getTestPaths (testDir : System.FilePath) (testName : String) : TestPaths where - input := testDir / (testName ++ ".input") - expected := testDir / (testName ++ ".expected") - output := testDir / (testName ++ ".output") - -/-- Run a single test -/ -def runSingleTest (config : Config) (testName : String) : IO TestResult := do - let {input, expected, output} := getTestPaths config.testDir testName - - try - let inputString ← IO.FS.readFile input - let outputString ← config.runTest inputString - IO.FS.writeFile output outputString - - if config.updateExpected then - IO.FS.writeFile expected outputString - return TestResult.pass testName - else - if ← System.FilePath.pathExists expected then - let expectedString ← IO.FS.readFile expected - if outputString == expectedString then - return TestResult.pass testName - else - return TestResult.fail testName expectedString outputString - else - return TestResult.error testName s!"Expected file not found: {expected}" - - catch e => - return TestResult.error testName (toString e) - -/-- Find all .input files in the test directory -/ -def findInputFiles (testDir : System.FilePath) : IO (Array String) := do - let entries ← testDir.readDir - return entries.filterMap fun f => - f.fileName.dropSuffix? ".input" <&> (·.toString) - - -/-- Print test result -/ -def TestResult.print (result : TestResult) : IO Unit := do - match result with - | .pass name => - IO.println s!"✓ {name}" - | .fail name expected actual => - IO.println s!"✗ {name}" - IO.println s!" Expected output differs from actual output" - let d := diff (expected.splitToList (· == '\n') |>.toArray) (actual.splitToList (· == '\n') |>.toArray) - IO.println (linesToString d) - | .error name msg => - IO.println s!"✗ {name}" - IO.println s!" Error: {msg}" - -/-- Print final statistics -/ -def printStats (stats : TestStats) : IO Unit := do - let total := stats.total - IO.println "" - IO.println s!"Tests run: {total}" - IO.println s!"Passed: {stats.passed}" - if stats.failed > 0 then - IO.println s!"Failed: {stats.failed}" - if stats.errors > 0 then - IO.println s!"Errors: {stats.errors}" - - if stats.failed == 0 && stats.errors == 0 then - IO.println "All tests passed! ✓" - else - IO.println s!"Some tests failed. ✗" - -/-- Main test runner -/ -def runTests (config : Config) : IO Unit := do - unless ← System.FilePath.pathExists config.testDir do - throw <| .userError s!"Test directory not found: {config.testDir}" - - let inputFiles ← findInputFiles config.testDir - - if inputFiles.isEmpty then - IO.println s!"No .input files found in {config.testDir}" - return - - if config.updateExpected then - IO.println s!"Updating expected outputs in {config.testDir}..." - else - IO.println s!"Running tests in {config.testDir}..." - IO.println "" - - let mut stats : TestStats := {} - for inputFile in inputFiles do - let result ← runSingleTest config inputFile - result.print - stats := stats.add result - - printStats stats - - if stats.failed == 0 && stats.errors == 0 then return - else throw <| .userError s!"Failed with {stats.failed} failures and {stats.errors} errors" diff --git a/src/tests/Tests/Integration.lean b/src/tests/Tests/Integration.lean deleted file mode 100644 index eae1daa30..000000000 --- a/src/tests/Tests/Integration.lean +++ /dev/null @@ -1,127 +0,0 @@ -/- -Copyright (c) 2025 Lean FRO LLC. All rights reserved. -Released under Apache 2.0 license as described in the file LICENSE. -Author: Jason Reed --/ -module - -public import Lean.Util.Diff - -public section - -namespace Verso.Integration - -/-- Configuration for the test runner -/ -structure Config where - /-- Where are expected files located? We expect a subdirectory - `expected` and `runTest` should produce files into a subdirectory - `output`. -/ - testDir : System.FilePath - /-- Should the expected output be replaced with the actual output? -/ - updateExpected : Bool := false - /-- How to run the test -/ - runTest : IO Unit - /-- Whether to see if lualatex builds the file -/ - checkTeX : Bool - -/-- -Returns all non-directory filepaths that are children of `root`, which -must be a directory. Returns these as paths relative to `root`. - -This differs from `System.FilePath.walkRoot`, in that the latter returns -absolute paths, and includes subdirectories. --/ -partial def filesBelow (root : System.FilePath) : - IO (Array System.FilePath) := do - let files ← Prod.snd <$> StateT.run (go ".") #[] - return files.qsort (·.toString < ·.toString) -- Ensure deterministic result -where - go (p : System.FilePath) := do - for d in (← (root / p).readDir) do - if ← d.path.isDir then - go (p / d.fileName) - else - modify (·.push (p / d.fileName)) - -/-- -Given an array of pairs `(src, tgt)` of absolute paths, copy every -`src` to every `tgt`, creating directories as necessary. --/ -partial def copyFiles (pairs : Array (System.FilePath × System.FilePath)) : - IO Unit := do - for (src, tgt) in pairs do - if let .some parent := tgt.parent then - IO.FS.createDirAll parent - IO.FS.writeBinFile tgt (← IO.FS.readBinFile src) - -/-- Main test runner -/ -def runTests (config : Config) : IO Unit := do - let outputRoot := config.testDir / "output" - let expectedRoot := config.testDir / "expected" - - if config.updateExpected then - -- Create the test directory if it doesn't exist - unless ← System.FilePath.pathExists config.testDir do - IO.FS.createDirAll config.testDir - unless ← System.FilePath.pathExists outputRoot do - IO.FS.createDirAll outputRoot - config.runTest - let outputFiles := (← filesBelow outputRoot) - IO.println s!"Updating expected outputs in {config.testDir}..." - if ← System.FilePath.pathExists expectedRoot then do - IO.FS.removeDirAll expectedRoot - copyFiles (outputFiles.map (fun p => (outputRoot / p, expectedRoot / p))) - else - unless ← System.FilePath.pathExists config.testDir do - throw <| .userError s!"Test directory not found: {config.testDir}" - unless ← System.FilePath.pathExists expectedRoot do - IO.FS.createDirAll expectedRoot - let expectedFiles := (← filesBelow expectedRoot) - - IO.println s!"Running test in {config.testDir}..." - if ← outputRoot.pathExists then - IO.FS.removeDirAll outputRoot - config.runTest - let outputFiles := (← filesBelow outputRoot) - - if expectedFiles != outputFiles then - IO.println s!"✗ Expected files differ from actual files" - IO.println s!"Expected files in {expectedRoot}:\n {expectedFiles}" - IO.println s!"Actual files in {outputRoot}:\n {outputFiles}" - throw <| .userError s!"Test in {config.testDir} failed" - - for file in expectedFiles do - let expected ← IO.FS.readFile (expectedRoot / file) - let actual ← IO.FS.readFile (outputRoot / file) - if expected != actual then - let d := Lean.Diff.diff (expected.splitToList (· == '\n') |>.toArray) (actual.splitToList (· == '\n') |>.toArray) - IO.println s!"✗ In test {config.testDir}, output file {file}" - IO.println s!" Expected output differs from actual output" - IO.println (Lean.Diff.linesToString d) - throw <| .userError s!"Test in {config.testDir} failed" - - if config.checkTeX then - -- `-shell-escape` is required so that documents using the `svg` LaTeX package can call - -- Inkscape to rasterise SVG attachments emitted by `diagram` code blocks. - let texDir := outputRoot / "tex" - let result ← IO.Process.output { - cwd := texDir, - cmd := "lualatex", - args := #["-shell-escape", "-halt-on-error", "-interaction=nonstopmode", "main.tex"] - } - if result.exitCode != 0 then - -- lualatex writes its diagnostics to stdout and `main.log`, not stderr, so report all - -- three. Each is wrapped in a GitHub Actions log group so the output stays collapsible. - let group (title : String) (body : String) : IO Unit := do - IO.println s!"::group::{title}" - IO.println body - IO.println "::endgroup::" - let context := s!"lualatex in {config.testDir}" - group s!"{context}: stdout" result.stdout - group s!"{context}: stderr" result.stderr - let logFile := texDir / "main.log" - if ← logFile.pathExists then - group s!"{context}: {logFile}" (← IO.FS.readFile logFile) - throw <| .userError s!"lualatex exited with code {result.exitCode} in {config.testDir}" - - return diff --git a/src/tests/Tests/LiterateConfig.lean b/src/tests/Tests/LiterateConfig.lean deleted file mode 100644 index 8327d3966..000000000 --- a/src/tests/Tests/LiterateConfig.lean +++ /dev/null @@ -1,362 +0,0 @@ -/- -Copyright (c) 2025 Lean FRO LLC. All rights reserved. -Released under Apache 2.0 license as described in the file LICENSE. -Author: David Thrane Christiansen --/ -module -public import VersoLiterate - -public section - -open Lean -open VersoLiterate - -namespace Tests.LiterateConfig - -/-- Parses a TOML string directly into a `LiterateConfig`. -/ -private def loadFromString (toml : String) : IO LiterateConfig := - parseLiterateConfig toml - -/-- Asserts that `actual` equals `expected`, throwing with a descriptive message on failure. -/ -private def assertEq [BEq α] [Repr α] (desc : String) (actual expected : α) : IO Unit := - unless actual == expected do - throw <| IO.userError s!"{desc}: expected {repr expected}, got {repr actual}" - -private def assertTrue (desc : String) (b : Bool) : IO Unit := - unless b do - throw <| IO.userError s!"{desc}: expected true, got false" - -private def assertFalse (desc : String) (b : Bool) : IO Unit := do - if b then - throw <| IO.userError s!"{desc}: expected false, got true" - -private def assertSome [Repr α] (desc : String) (o : Option α) : IO α := - match o with - | some v => pure v - | none => throw <| IO.userError s!"{desc}: expected Some, got None" - -private def assertNone [Repr α] (desc : String) (o : Option α) : IO Unit := do - if o.isSome then - throw <| IO.userError s!"{desc}: expected None, got {repr o}" - --- ===== Individual test cases ===== - -/-- A missing file results in the default config. -/ -private def testMissingFile : IO Unit := do - let config ← loadLiterateConfig "/nonexistent/path/literate.toml" - assertEq "missing file: exclude" config.exclude #[] - assertEq "missing file: order" config.order #[] - assertEq "missing file: targets" config.targets #[] - assertNone "missing file: landingPage" config.landingPage - -/-- An empty file results in the default config. -/ -private def testEmptyFile : IO Unit := do - let config ← loadFromString "" - assertEq "empty file: exclude" config.exclude #[] - assertEq "empty file: order" config.order #[] - assertEq "empty file: targets" config.targets #[] - assertNone "empty file: landingPage" config.landingPage - -/-- A whitespace-only file results in the default config. -/ -private def testWhitespaceFile : IO Unit := do - let config ← loadFromString " \n \n " - assertEq "whitespace file: exclude" config.exclude #[] - -/-- The `exclude` list is parsed into an array of `Name` values. -/ -private def testExclude : IO Unit := do - let config ← loadFromString "exclude = [\"Foo.Bar\", \"Baz\"]\n" - assertEq "exclude length" config.exclude.size 2 - assertEq "exclude[0]" config.exclude[0]! `Foo.Bar - assertEq "exclude[1]" config.exclude[1]! `Baz - -/-- The `order` list is parsed into an array of `Name` values preserving order. -/ -private def testOrder : IO Unit := do - let config ← loadFromString "order = [\"C\", \"A\", \"B\"]\n" - assertEq "order length" config.order.size 3 - assertEq "order[0]" config.order[0]! `C - assertEq "order[1]" config.order[1]! `A - assertEq "order[2]" config.order[2]! `B - -/-- The `landing_page` field is parsed as `some` of a `Name`. -/ -private def testLandingPage : IO Unit := do - let config ← loadFromString "landing_page = \"MyLib.Overview\"\n" - let lp ← assertSome "landing_page" config.landingPage - assertEq "landing_page value" lp `MyLib.Overview - -/-- `[order_children]` entries are parsed into per-parent child orderings. -/ -private def testOrderChildren : IO Unit := do - let config ← loadFromString "[order_children]\n\"Foo\" = [\"Foo.B\", \"Foo.A\"]\n\"Bar\" = [\"Bar.Z\"]\n" - let fooChildren := config.orderChildren.find? `Foo - let fc ← assertSome "order_children: Foo" fooChildren - assertEq "order_children: Foo length" fc.size 2 - assertEq "order_children: Foo[0]" fc[0]! `Foo.B - assertEq "order_children: Foo[1]" fc[1]! `Foo.A - let barChildren := config.orderChildren.find? `Bar - let bc ← assertSome "order_children: Bar" barChildren - assertEq "order_children: Bar length" bc.size 1 - assertEq "order_children: Bar[0]" bc[0]! `Bar.Z - -/-- `[[targets]]` table entries are parsed into `Target` values with optional fields. -/ -private def testTargets : IO Unit := do - let config ← loadFromString "[[targets]]\nmodule = \"Foo\"\n\n[[targets]]\nlibrary = \"Bar\"\n" - assertEq "targets length" config.targets.size 2 - let t0 ← assertSome "targets[0].module" config.targets[0]!.module - assertEq "targets[0].module value" t0 `Foo - assertNone "targets[0].library" config.targets[0]!.library - let t1 ← assertSome "targets[1].library" config.targets[1]!.library - assertEq "targets[1].library value" t1 `Bar - assertNone "targets[1].module" config.targets[1]!.module - -/-- Multiple fields in the same file are all parsed correctly. -/ -private def testCombined : IO Unit := do - let toml := "exclude = [\"Private\"]\norder = [\"Public\", \"Examples\"]\nlanding_page = \"Public\"\n" - let config ← loadFromString toml - assertEq "combined: exclude length" config.exclude.size 1 - assertEq "combined: exclude[0]" config.exclude[0]! `Private - assertEq "combined: order length" config.order.size 2 - assertEq "combined: order[0]" config.order[0]! `Public - assertEq "combined: order[1]" config.order[1]! `Examples - let lp ← assertSome "combined: landing_page" config.landingPage - assertEq "combined: landing_page value" lp `Public - -/-- Invalid TOML produces an error. -/ -private def testInvalidToml : IO Unit := do - let mut caught := false - try - let _ ← loadFromString "this is not valid toml {{{" - catch _ => - caught := true - assertTrue "invalid TOML should throw" caught - -/-- `hide_commands` is parsed into an array of keyword pattern strings. -/ -private def testHideCommands : IO Unit := do - let config ← loadFromString "hide_commands = [\"set_option\", \"#check\"]\n" - assertEq "hide_commands length" config.hideCommands.size 2 - assertEq "hide_commands[0]" config.hideCommands[0]! "set_option" - assertEq "hide_commands[1]" config.hideCommands[1]! "#check" - -/-- `[metadata]` table is parsed into a `Metadata` value. -/ -private def testMetadata : IO Unit := do - let config ← loadFromString "[metadata]\ntitle = \"My Site\"\ndescription = \"A test site\"\nfavicon = \"favicon.ico\"\n" - let title ← assertSome "metadata.title" config.metadata.title - assertEq "metadata.title value" title "My Site" - let desc ← assertSome "metadata.description" config.metadata.description - assertEq "metadata.description value" desc "A test site" - let fav ← assertSome "metadata.favicon" config.metadata.favicon - assertEq "metadata.favicon value" fav "favicon.ico" - -/-- `extra_css` and `extra_js` are parsed into string arrays. -/ -private def testExtraCssJs : IO Unit := do - let config ← loadFromString "extra_css = [\"custom.css\", \"theme.css\"]\nextra_js = [\"analytics.js\"]\n" - assertEq "extra_css length" config.extraCss.size 2 - assertEq "extra_css[0]" config.extraCss[0]! "custom.css" - assertEq "extra_css[1]" config.extraCss[1]! "theme.css" - assertEq "extra_js length" config.extraJs.size 1 - assertEq "extra_js[0]" config.extraJs[0]! "analytics.js" - -/-- `show_docstrings = false` is parsed correctly. -/ -private def testShowDocstrings : IO Unit := do - let config ← loadFromString "show_docstrings = false\n" - assertFalse "show_docstrings" config.showDocstrings - -/-- `show_docstrings_for` is parsed into an array of `Name` values. -/ -private def testShowDocstringsFor : IO Unit := do - let config ← loadFromString "show_docstrings = false\nshow_docstrings_for = [\"Foo.bar\", \"Baz.qux\"]\n" - assertFalse "show_docstrings" config.showDocstrings - assertEq "show_docstrings_for length" config.showDocstringsFor.size 2 - assertEq "show_docstrings_for[0]" config.showDocstringsFor[0]! `Foo.bar - assertEq "show_docstrings_for[1]" config.showDocstringsFor[1]! `Baz.qux - -/-- `hide_docstrings_for` is parsed into an array of `Name` values. -/ -private def testHideDocstringsFor : IO Unit := do - let config ← loadFromString "hide_docstrings_for = [\"Foo.internal\"]\n" - assertTrue "show_docstrings default" config.showDocstrings - assertEq "hide_docstrings_for length" config.hideDocstringsFor.size 1 - assertEq "hide_docstrings_for[0]" config.hideDocstringsFor[0]! `Foo.internal - -/-- `show_output` is parsed into an array of keyword pattern strings. -/ -private def testShowOutput : IO Unit := do - let config ← loadFromString "show_output = [\"#eval\"]\n" - assertEq "show_output length" config.showOutput.size 1 - assertEq "show_output[0]" config.showOutput[0]! "#eval" - -/-- `show_output` defaults to the standard 4-element list. -/ -private def testShowOutputDefault : IO Unit := do - let config ← loadFromString "" - assertEq "show_output default length" config.showOutput.size 4 - -/-- `show_imports = false` is parsed correctly. -/ -private def testShowImports : IO Unit := do - let config ← loadFromString "show_imports = false\n" - assertFalse "show_imports" config.showImports - -/-- `show_imports` defaults to true. -/ -private def testShowImportsDefault : IO Unit := do - let config ← loadFromString "" - assertTrue "show_imports default" config.showImports - -/-- Multiple new fields combined in one config. -/ -private def testCombinedNew : IO Unit := do - let toml := String.intercalate "\n" [ - "exclude = [\"Private\"]", - "hide_commands = [\"set_option\"]", - "extra_css = [\"style.css\"]", - "show_docstrings = false", - "show_docstrings_for = [\"Public.api\"]", - "[metadata]", - "title = \"Test\"", - "" - ] - let config ← loadFromString toml - assertEq "combined new: exclude" config.exclude.size 1 - assertEq "combined new: hide_commands" config.hideCommands.size 1 - assertEq "combined new: extra_css" config.extraCss.size 1 - assertFalse "combined new: show_docstrings" config.showDocstrings - assertEq "combined new: show_docstrings_for" config.showDocstringsFor.size 1 - let title ← assertSome "combined new: metadata.title" config.metadata.title - assertEq "combined new: metadata.title value" title "Test" - -/-- `[theme]` light variables are parsed into theme map. -/ -private def testThemeLight : IO Unit := do - let toml := "[theme]\ncode_box_background_color = \"#fff\"\ntext_color = \"#111\"\n" - let config ← loadFromString toml - assertEq "theme size" config.theme.size 2 - let bg ← assertSome "theme code_box_background_color" (config.theme.get? "code_box_background_color") - assertEq "theme code_box_background_color value" bg "#fff" - let tc ← assertSome "theme text_color" (config.theme.get? "text_color") - assertEq "theme text_color value" tc "#111" - -/-- `[theme.dark]` dark variables are parsed into themeDark map. -/ -private def testThemeDark : IO Unit := do - let toml := "[theme]\ntext_color = \"#333\"\n\n[theme.dark]\ntext_color = \"#eee\"\nbackground_color = \"#111\"\n" - let config ← loadFromString toml - assertEq "theme light size" config.theme.size 1 - assertEq "themeDark size" config.themeDark.size 2 - let dt ← assertSome "themeDark text_color" (config.themeDark.get? "text_color") - assertEq "themeDark text_color value" dt "#eee" - let db ← assertSome "themeDark background_color" (config.themeDark.get? "background_color") - assertEq "themeDark background_color value" db "#111" - -/-- Empty theme produces empty maps. -/ -private def testThemeEmpty : IO Unit := do - let config ← loadFromString "" - assertEq "theme empty size" config.theme.size 0 - assertEq "themeDark empty size" config.themeDark.size 0 - -/-- `[modules."Foo.Bar"]` is parsed into a ModuleConfig. -/ -private def testModulesConfig : IO Unit := do - let toml := "[modules.\"Foo.Bar\"]\ntitle = \"Custom Title\"\nurl = \"custom-url\"\nhide_commands = [\"set_option\"]\nshow_imports = false\n" - let config ← loadFromString toml - let mc ← assertSome "modules Foo.Bar" (config.modules.find? `Foo.Bar) - let t ← assertSome "modules Foo.Bar title" mc.title - assertEq "modules Foo.Bar title value" t "Custom Title" - let u ← assertSome "modules Foo.Bar url" mc.url - assertEq "modules Foo.Bar url value" u "custom-url" - let hc ← assertSome "modules Foo.Bar hideCommands" mc.hideCommands - assertEq "modules Foo.Bar hideCommands length" hc.size 1 - let si ← assertSome "modules Foo.Bar showImports" mc.showImports - assertFalse "modules Foo.Bar showImports value" si - -/-- `resolveForModule` returns global defaults when no module config matches. -/ -private def testResolveNoMatch : IO Unit := do - let config ← loadFromString "hide_commands = [\"set_option\"]\n" - let resolved := config.resolveForModule `Unmatched.Module - assertEq "resolve no match: hideCommands" resolved.hideCommands.size 1 - assertTrue "resolve no match: showImports" resolved.showImports - assertNone "resolve no match: title" resolved.title - -/-- `resolveForModule` picks the most-specific prefix match. -/ -private def testResolvePrefixMatch : IO Unit := do - let toml := String.intercalate "\n" [ - "hide_commands = [\"set_option\"]", - "[modules.\"Foo\"]", - "show_imports = false", - "[modules.\"Foo.Bar\"]", - "title = \"Bar Title\"", - "show_imports = true", - "" - ] - let config ← loadFromString toml - -- Foo.Bar.Baz should match Foo.Bar (longest prefix) - let resolved := config.resolveForModule `Foo.Bar.Baz - let t ← assertSome "resolve prefix: title" resolved.title - assertEq "resolve prefix: title value" t "Bar Title" - assertTrue "resolve prefix: showImports" resolved.showImports - -- Foo.Qux should match Foo - let resolved2 := config.resolveForModule `Foo.Qux - assertFalse "resolve Foo prefix: showImports" resolved2.showImports - assertNone "resolve Foo prefix: title" resolved2.title - -- Exact match on Foo.Bar itself - let resolved3 := config.resolveForModule `Foo.Bar - let t3 ← assertSome "resolve exact: title" resolved3.title - assertEq "resolve exact: title value" t3 "Bar Title" - -/-- Module-level config overrides global defaults. -/ -private def testResolveOverridesGlobal : IO Unit := do - let toml := String.intercalate "\n" [ - "show_imports = true", - "show_docstrings = true", - "[modules.\"MyMod\"]", - "show_imports = false", - "show_docstrings = false", - "" - ] - let config ← loadFromString toml - let resolved := config.resolveForModule `MyMod - assertFalse "resolve override: showImports" resolved.showImports - assertFalse "resolve override: showDocstrings" resolved.showDocstrings - -- Unmatched module still gets global defaults - let resolved2 := config.resolveForModule `Other - assertTrue "resolve global: showImports" resolved2.showImports - assertTrue "resolve global: showDocstrings" resolved2.showDocstrings - --- ===== Test runner ===== - -private def configTests : List (String × IO Unit) := [ - ("missing file", testMissingFile), - ("empty file", testEmptyFile), - ("whitespace file", testWhitespaceFile), - ("exclude", testExclude), - ("order", testOrder), - ("landing_page", testLandingPage), - ("order_children", testOrderChildren), - ("targets", testTargets), - ("combined", testCombined), - ("invalid TOML", testInvalidToml), - ("hide_commands", testHideCommands), - ("metadata", testMetadata), - ("extra_css/js", testExtraCssJs), - ("show_docstrings", testShowDocstrings), - ("show_docstrings_for", testShowDocstringsFor), - ("hide_docstrings_for", testHideDocstringsFor), - ("show_output", testShowOutput), - ("show_output default", testShowOutputDefault), - ("show_imports", testShowImports), - ("show_imports default", testShowImportsDefault), - ("combined new", testCombinedNew), - ("theme light", testThemeLight), - ("theme dark", testThemeDark), - ("theme empty", testThemeEmpty), - ("modules config", testModulesConfig), - ("resolve no match", testResolveNoMatch), - ("resolve prefix match", testResolvePrefixMatch), - ("resolve overrides global", testResolveOverridesGlobal) -] - -def runLiterateConfigTests : IO Nat := do - IO.println "Running literate config unit tests..." - let mut failures := 0 - for (name, test) in configTests do - try - test - IO.println s!" {name}: passed" - catch e => - IO.eprintln s!" {name}: FAILED - {e}" - failures := failures + 1 - if failures == 0 then - IO.println " All literate config tests passed." - else - IO.eprintln s!" {failures} literate config test(s) failed." - return failures - -end Tests.LiterateConfig diff --git a/src/tests/Tests/SearchJs.lean b/src/tests/Tests/SearchJs.lean deleted file mode 100644 index 88008dc52..000000000 --- a/src/tests/Tests/SearchJs.lean +++ /dev/null @@ -1,117 +0,0 @@ -/- -Copyright (c) 2026 Lean FRO LLC. All rights reserved. -Released under Apache 2.0 license as described in the file LICENSE. -Author: David Thrane Christiansen --/ -module -public import VersoSearch -public import VersoSearch.DomainSearch - -/-! -Tests for the JavaScript wire format produced by `Verso.Search.DomainMapper.toJs` and -`Verso.Search.DomainMappers.toJs`. These are structural checks against the emitted JS source: they -assert that priority fields and global priority exports appear with the configured values, so the -browser-side combining code in `search-box.js` has the data it expects. --/ - -namespace Verso.Tests.SearchJs - -open Std -open Verso.Search - -private def hasSub (haystack : String) (needle : String) : Bool := - haystack.find? needle |>.isSome - -private def assertContains (label : String) (haystack : String) (needle : String) : IO Unit := do - unless hasSub haystack needle do - throw <| IO.userError s!"expected {label} output to contain {repr needle}, got:\n{haystack}" - -/-- Verifies that `DomainMapper.toJs` emits the display/class/data fields without a priority. -/ -def testMapperToJs : IO Unit := do - let mapper : DomainMapper := - { displayName := "Term" - className := "term" - dataToSearchables := "x => []" } - let rendered := (DomainMapper.toJs mapper).pretty (width := 70) - assertContains "DomainMapper" rendered "displayName:" - assertContains "DomainMapper" rendered "\"Term\"" - assertContains "DomainMapper" rendered "className:" - assertContains "DomainMapper" rendered "\"term\"" - assertContains "DomainMapper" rendered "dataToSearchables:" - if hasSub rendered "searchPriority" then - throw <| IO.userError - s!"DomainMapper output should not contain `searchPriority` (it lives in SearchPriorities now):\n{rendered}" - -/-- -Verifies that `DomainMappers.toJs` emits both the `domainMappers` constant and the -`searchPriorities` constant with the correct semantic / fullText values plus the per-domain -priorities map. --/ -def testMappersToJs : IO Unit := do - let mapper : DomainMapper := - { displayName := "Term" - className := "term" - dataToSearchables := "x => []" } - let mappers : DomainMappers := HashMap.ofList [("Verso.Test", mapper)] - let priorities : SearchPriorities := - { semantic := 60, fullText := 40, domains := ({} : Verso.NameMap _).insert `Verso.Test 73 } - let rendered := (mappers.toJs priorities).pretty (width := 70) - assertContains "DomainMappers" rendered "export const domainMappers" - assertContains "DomainMappers" rendered "export const searchPriorities" - assertContains "DomainMappers" rendered "semantic:" - assertContains "DomainMappers" rendered "60" - assertContains "DomainMappers" rendered "fullText:" - assertContains "DomainMappers" rendered "40" - assertContains "DomainMappers" rendered "domains:" - assertContains "DomainMappers" rendered "\"Verso.Test\"" - assertContains "DomainMappers" rendered "73" - -/-- -Verifies that `Verso.Search.priorityMapJson` produces a keyed map of only the documents whose priority -differs from neutral, using the same centered-at-50 integer convention as `Searchable.priority`. --/ -def testPriorityMap : IO Unit := do - let docs : Array IndexDoc := #[ - { id := "boosted", header := "", context := #[], content := "", priority := some 80 }, - { id := "no-priority", header := "", context := #[], content := "", priority := none }, - -- A `some 50` is semantically equivalent to `none` and must not bloat the emitted map: - { id := "explicit-neutral", header := "", context := #[], content := "", priority := some 50 }, - { id := "suppressed", header := "", context := #[], content := "", priority := some 10 }, - -- Ancestor-summed priorities can fall outside [0, 99]: - { id := "deep-subsection", header := "", context := #[], content := "", priority := some (-20) } - ] - let rendered := (priorityMapJson docs).compress - assertContains "priorityMapJson" rendered "\"boosted\":80" - assertContains "priorityMapJson" rendered "\"suppressed\":10" - assertContains "priorityMapJson" rendered "\"deep-subsection\":-20" - -- Neutral docs (none or some 50) must be omitted entirely, not serialized as null or 50. - for omitted in ["no-priority", "explicit-neutral"] do - if hasSub rendered omitted then - throw <| IO.userError - s!"priorityMapJson should omit neutral docs ({omitted}), but emitted:\n{rendered}" - -/-- Defaults for `SearchPriorities` are `semantic := 50` and `fullText := 50`. -/ -def testMappersToJsDefaults : IO Unit := do - let mappers : DomainMappers := {} - let rendered := (mappers.toJs).pretty (width := 70) - assertContains "DomainMappers defaults" rendered "export const searchPriorities" - assertContains "DomainMappers defaults" rendered "semantic:" - assertContains "DomainMappers defaults" rendered "fullText:" - assertContains "DomainMappers defaults" rendered "50" - -public def runSearchJsTests : IO Nat := do - let tests : List (Lean.Name × IO Unit) := - [ (`testMapperToJs, testMapperToJs) - , (`testMappersToJs, testMappersToJs) - , (`testMappersToJsDefaults, testMappersToJsDefaults) - , (`testPriorityMap, testPriorityMap) - ] - let mut failures := 0 - for (name, test) in tests do - try - test - IO.println s!"{name}: passed" - catch e => - IO.println s!"{name}: FAILED - {e}" - failures := failures + 1 - return failures diff --git a/src/tests/Tests/VersoManual.lean b/src/tests/Tests/VersoManual.lean deleted file mode 100644 index 4305cd6c1..000000000 --- a/src/tests/Tests/VersoManual.lean +++ /dev/null @@ -1,12 +0,0 @@ -/- -Copyright (c) 2025 Lean FRO LLC. All rights reserved. -Released under Apache 2.0 license as described in the file LICENSE. -Author: David Thrane Christiansen --/ -import Tests.VersoManual.Docstring -import Tests.VersoManual.Html -import Tests.VersoManual.Html.SoftHyphenate -import Tests.VersoManual.License -import Tests.VersoManual.Markdown -import Tests.VersoManual.Sections -import Tests.VersoManual.WordCount diff --git a/src/tests/Tests/Zip.lean b/src/tests/Tests/Zip.lean deleted file mode 100644 index f63aad0b2..000000000 --- a/src/tests/Tests/Zip.lean +++ /dev/null @@ -1,32 +0,0 @@ -/- -Copyright (c) 2025 Lean FRO LLC. All rights reserved. -Released under Apache 2.0 license as described in the file LICENSE. -Author: David Thrane Christiansen --/ -module - -public import VersoUtil.Zip - -public section - -open Verso.Zip - -def testExtract (files : Array (String × ByteArray)) (method : CompressionMethod) : IO Unit := do - IO.FS.withTempDir fun dir => do - let extra ← IO.monoMsNow - let dir := dir / s!"{extra}" - IO.FS.createDirAll dir - let file := dir / "out.zip" - - zipToFile file files method - let out ← IO.Process.output {cmd := "unzip", args := #["-u", file.toString, "-d", dir.toString]} - -- unzip returns 1 on empty archives, 2 on corrupt archives - if out.exitCode == 0 || (files.isEmpty && out.exitCode == 1) then - for (f, contents) in files do - let found ← IO.FS.readBinFile (dir / f) - if found != contents then - throw <| .userError s!"Mismatched file contents of {f}. Expected {contents}, got {found}" - else - throw <| IO.userError s!"process 'unzip' exited with code {out.exitCode}\ - \nstderr:\ - \n{out.stderr}" diff --git a/src/tests/VersoTests.lean b/src/tests/VersoTests.lean new file mode 100644 index 000000000..487403062 --- /dev/null +++ b/src/tests/VersoTests.lean @@ -0,0 +1,11 @@ +/- +Copyright (c) 2026 Lean FRO LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Author: David Thrane Christiansen +-/ +module + +/-! +Errata ports of the Verso test suite. Each feature lives in its own submodule, discovered by the +Errata test driver. +-/ diff --git a/src/tests/Tests/Arbitrary.lean b/src/tests/VersoTests/Arbitrary.lean similarity index 94% rename from src/tests/Tests/Arbitrary.lean rename to src/tests/VersoTests/Arbitrary.lean index bfe69e9a8..737347f40 100644 --- a/src/tests/Tests/Arbitrary.lean +++ b/src/tests/VersoTests/Arbitrary.lean @@ -6,20 +6,19 @@ Author: David Thrane Christiansen module public import Plausible public import Plausible.ArbitraryFueled -public meta import Plausible.ArbitraryFueled import Lean.Data.Json.FromToJson import all MultiVerso.InternalId -public meta import MultiVerso.NameMap -public meta import MultiVerso -public meta import VersoManual.Html.JsFile -public meta import VersoManual.Html.CssFile -public meta import VersoManual.Html.Features -public meta import VersoManual.LicenseInfo -public meta import VersoSearch -public meta import VersoSearch.DomainSearch -public meta import Verso.Output.Html -public meta import MultiVerso.Manifest -public meta import VersoManual.Basic +public import MultiVerso.NameMap +public import MultiVerso +public import VersoManual.Html.JsFile +public import VersoManual.Html.CssFile +public import VersoManual.Html.Features +public import VersoManual.LicenseInfo +public import VersoSearch +public import VersoSearch.DomainSearch +public import Verso.Output.Html +public import MultiVerso.Manifest +public import VersoManual.Basic import all VersoManual.Basic import VersoManual.Html.CssFile @@ -35,7 +34,7 @@ deserializes. -/ -public meta section +public section def sizedArrayOf (gen : Gen α) : Gen (Array α) := do let count ← chooseNat @@ -251,12 +250,11 @@ instance : Shrinkable LetterString where (instShrinkableLetter.shrink first |>.map (String.singleton · ++ rest)) else [] -def slugChars := Slug.validChars.toArray +def slugChars : Array Char := Slug.validChars.toArray def slugChar : Gen Char := do - have : slugChars.size > 0 := by decide +native - let ⟨i, ⟨_, h⟩⟩ ← choose Nat 0 (slugChars.size - 1) (by simp) - return slugChars[i]'(by grind) + let ⟨i, _⟩ ← choose Nat 0 (slugChars.size - 1) (by omega) + return slugChars[i]! def slugString : Gen String := do let len ← chooseNat diff --git a/src/tests/Tests/Basic.lean b/src/tests/VersoTests/Basic.lean similarity index 96% rename from src/tests/Tests/Basic.lean rename to src/tests/VersoTests/Basic.lean index f0e1dd932..cb27067eb 100644 --- a/src/tests/Tests/Basic.lean +++ b/src/tests/VersoTests/Basic.lean @@ -3,10 +3,8 @@ Copyright (c) 2023 Lean FRO LLC. All rights reserved. Released under Apache 2.0 license as described in the file LICENSE. Author: David Thrane Christiansen -/ -module -public import Verso -public meta import Verso -public section +import Errata +import Verso namespace Verso.BasicTest set_option guard_msgs.diff true set_option pp.rawOnError true @@ -18,7 +16,7 @@ set_option pp.rawOnError true ::::::: ::::::: /-- info: Verso.Doc.Part.mk #[Verso.Doc.Inline.text "Nothing"] "Nothing" none #[] #[] -/ -#guard_msgs in +#test_msgs in #eval noDoc.toPart @@ -38,7 +36,7 @@ info: Verso.Doc.Part.mk #[Verso.Doc.Block.para #[Verso.Doc.Inline.text "Hello, I'm a paragraph. Yes I am!"]] #[] -/ -#guard_msgs in +#test_msgs in #eval littleParagraph.toPart @@ -58,7 +56,7 @@ info: Verso.Doc.Part.mk #[Verso.Doc.Block.ul #[{ contents := #[Verso.Doc.Block.para #[Verso.Doc.Inline.text "Just a list with one item"]] }]] #[] -/ -#guard_msgs in +#test_msgs in #eval listOneItem.toPart @@ -85,7 +83,7 @@ info: Verso.Doc.Part.mk #[Verso.Doc.Block.para #[Verso.Doc.Inline.text "a paragraph"]] #[]] -/ -#guard_msgs in +#test_msgs in #eval sectionAndPara.toPart @@ -130,7 +128,7 @@ info: Verso.Doc.Part.mk #[{ contents := #[Verso.Doc.Block.para #[Verso.Doc.Inline.text "and nested"]] }]] }]] #[]]] -/ -#guard_msgs in +#test_msgs in #eval nestedDoc1.toPart @@ -175,7 +173,7 @@ info: Verso.Doc.Part.mk Verso.Doc.Block.ul #[{ contents := #[Verso.Doc.Block.para #[Verso.Doc.Inline.text "and nested"]] }]] #[]]] -/ -#guard_msgs in +#test_msgs in #eval nestedDoc2.toPart @@ -225,7 +223,7 @@ info: Verso.Doc.Part.mk #[{ contents := #[Verso.Doc.Block.para #[Verso.Doc.Inline.text "and nested"]] }]] }]] #[]]] -/ -#guard_msgs in +#test_msgs in #eval nestedDoc3.toPart @@ -262,7 +260,7 @@ info: Verso.Doc.Part.mk Verso.Doc.Block.para #[Verso.Doc.Inline.text "Also, 2 > 3."]] #[]] -/ -#guard_msgs in +#test_msgs in #eval nestedDoc4.toPart @@ -270,7 +268,7 @@ info: Verso.Doc.Part.mk -- https://github.com/leanprover/verso/pull/541 /-- error: Wrong header nesting - got #### but expected at most ### -/ -#guard_msgs in +#test_msgs in #docs (.none) h "Bad nesting" := ::::::: diff --git a/src/tests/Tests/VersoBlog.lean b/src/tests/VersoTests/Blog.lean similarity index 53% rename from src/tests/Tests/VersoBlog.lean rename to src/tests/VersoTests/Blog.lean index 52d1d1fb0..ba7d5e9d3 100644 --- a/src/tests/Tests/VersoBlog.lean +++ b/src/tests/VersoTests/Blog.lean @@ -2,32 +2,21 @@ Copyright (c) 2026 Lean FRO LLC. All rights reserved. Released under Apache 2.0 license as described in the file LICENSE. Author: David Thrane Christiansen + +Property tests for blog identifier generation. This is a non-`module` file because `VersoBlog` +itself is not part of the module system; the Errata runner imports it through its non-module main. -/ -module -public import Plausible -public import Plausible.ArbitraryFueled -public import VersoBlog -public meta import VersoBlog.Basic -public import VersoBlog.LiterateLeanPage -public import Tests.VersoBlog.LiterateLeanPage -public import Tests.Arbitrary -public meta import Tests.Arbitrary - -public section +import VersoBlog +import VersoBlog.LiterateLeanPage +import VersoTests.Arbitrary +import Errata open Lean open Verso Genre Blog open Verso.Multi open Verso.NameMap open Plausible Gen Arbitrary - -/-! ## Tests for NameSuffixMap -/ - -/-- info: #[(`a.b.c, 1), (`a.c, 4), (`b.c, 6), (`c, 3)] -/ -#guard_msgs in -#eval NameSuffixMap.empty |>.insert `a.b.c 1 |>.insert `b.c 2 |>.insert `c 3 |>.insert `a.c 4 |>.insert `a.b 5 |>.insert `b.c 6 |>.get `c - -meta section +open Errata def freshIdOk (hint : LetterString) (path : Path) (howMany : Nat) : Bool := Id.run do let mut st : TraverseState := { remoteContent := {} } @@ -38,44 +27,51 @@ def freshIdOk (hint : LetterString) (path : Path) (howMany : Nat) : Bool := Id.r ids := ids.push i ids.size == howMany && ids.all (ids.count · == 1) -def freshId_first_is_hint (hint : LetterString) (path : Path) : Bool := Id.run do +def freshIdFirstIsHint (hint : LetterString) (path : Path) : Bool := Id.run do let st : TraverseState := { remoteContent := {} } let i := st.freshId path hint.sluggify hint.isEmpty || i == hint.sluggify -def freshId_second_is_hint_with_1 (hint : LetterString) (path : Path) : Bool := Id.run do +def freshIdSecondIsHintWith1 (hint : LetterString) (path : Path) : Bool := Id.run do let mut st : TraverseState := { remoteContent := {} } let i := st.freshId path hint.sluggify st := { st with usedIds := st.usedIds.alter path (fun used? => used?.getD {} |>.insert i) } let i' := st.freshId path hint.sluggify i != i' && (hint.isEmpty || (i == hint.sluggify && i' == (s!"{hint}1").sluggify)) -open scoped Plausible.Decorations in -private def testProp - (p : Prop) (cfg : Configuration := {}) - (p' : Decorations.DecorationsOf p := by mk_decorations) [Testable p'] : - IO (TestResult p') := - Testable.checkIO p' (cfg := cfg) - -def blogTests : List (Name × (Σ p, IO <| TestResult p)) := [ - (`freshIdOk, ⟨_, testProp <| ∀ h p n, freshIdOk h p n⟩), - (`freshId_first_is_hint, ⟨_, testProp <| ∀ h p, freshId_first_is_hint h p⟩), - (`freshId_second_is_hint_with_1, ⟨_, testProp <| ∀ h p, freshId_second_is_hint_with_1 h p⟩), -] - -def runBlogTests : IO Nat := do - let mut failures := 0 - for (name, test) in blogTests do - IO.print s!"{name}: " - let res ← test.2 - IO.println res - unless res matches .success .. do - failures := failures + 1 - return failures - -end - --- Regression test for hidden blog Lean blocks. +/-- Identifiers freshly generated within a path are unique. -/ +@[test] +def freshIdsAreUnique : Test := property (∀ h p n, freshIdOk h p n) + +/-- The first identifier generated for a hint is the hint itself. -/ +@[test] +def freshIdFirst : Test := property (∀ h p, freshIdFirstIsHint h p) + +/-- The second identifier generated for a hint is the hint with `1` appended. -/ +@[test] +def freshIdSecond : Test := property (∀ h p, freshIdSecondIsHintWith1 h p) + +/-! ## Compile-time regression tests for the blog genre -/ + +/-- info: #[(`a.b.c, 1), (`a.c, 4), (`b.c, 6), (`c, 3)] -/ +#test_msgs in +#eval NameSuffixMap.empty |>.insert `a.b.c 1 |>.insert `b.c 2 |>.insert `c 3 |>.insert `a.c 4 |>.insert `a.b 5 |>.insert `b.c 6 |>.get `c + +-- The deprecated inline Lean role warns. This standalone document asserts the warning, ahead of the +-- streaming blog blocks below that a `#test_msgs` wrapper cannot enclose. +/-- +warning: `{leanInline}` is deprecated; use `{lean}` instead. +-/ +#test_msgs in +#docs (Post) inlineLeanRoleNamesDeprecated "Inline Lean Role Names (deprecated alias)" := +::::::: +```leanInit post2 +``` + +Legacy role: {leanInline post2}`Nat.succ 1`. +::::::: + +-- Hidden blog Lean blocks elaborate with their show/keep/error flags. #doc (Post) "Hidden Lean Block Flags" => ```leanInit post ``` @@ -96,22 +92,12 @@ example : base = 40 := rfl #check scratch ``` --- Regression test for inline Lean role naming in Blog: --- canonical `{lean}` works without warnings. +-- The canonical inline Lean role works without warnings. #docs (Post) inlineLeanRoleNames "Inline Lean Role Names" := ```leanInit post ``` Canonical role: {lean post}`Nat.succ 1`. -/-- -warning: `{leanInline}` is deprecated; use `{lean}` instead. --/ -#docs (Post) inlineLeanRoleNamesDeprecated "Inline Lean Role Names (deprecated alias)" := -```leanInit post2 -``` - -Legacy role: {lean post2}`Nat.succ 1`. - -#guard inlineLeanRoleNames.toPart.content.size > 0 -#guard inlineLeanRoleNamesDeprecated.toPart.content.size > 0 +#test_guard inlineLeanRoleNames.toPart.content.size > 0 +#test_guard inlineLeanRoleNamesDeprecated.toPart.content.size > 0 diff --git a/src/tests/VersoTests/BuildLog.lean b/src/tests/VersoTests/BuildLog.lean new file mode 100644 index 000000000..05e90a6b2 --- /dev/null +++ b/src/tests/VersoTests/BuildLog.lean @@ -0,0 +1,109 @@ +/- +Copyright (c) 2026 Lean FRO LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Author: David Thrane Christiansen +-/ +module + +public import Verso +import Errata + +open Verso Errata + +/-! +Tests for Verso's build log: where a message's location and severity are recorded, how locations +format, and how logging interacts with the exit code and the ambient output streams. +-/ + +/-- A message logged with a `pos` location is saved with that file and position. -/ +@[test] +def savesPosition : Test := do + let logger ← Logger.new + let pos : Lean.Lsp.Position := { line := 4, character := 2 } + (reportError "boom" (some { file := "PosSave.lean", span := .pos pos }) : BuildLogT IO Unit).run logger + let errs ← logger.errors + let some m := errs[0]? + | fail s!"expected 1 saved error, got {errs.size}" + assert (m.severity == .error) "expected error severity" + match m.loc with + | some { file := "PosSave.lean", span := .pos p } => + assertEq 4 p.line + assertEq 2 p.character + | _ => fail "expected a saved `PosSave.lean` `pos` location" + +/-- A `range` span is likewise saved. -/ +@[test] +def savesRange : Test := do + let logger ← Logger.new + let r : Lean.Lsp.Range := + { start := { line := 1, character := 0 }, «end» := { line := 1, character := 5 } } + (reportWarning "careful" (some { file := "RangeSave.lean", span := .range r }) : BuildLogT IO Unit).run logger + let some w := (← logger.warnings)[0]? + | fail "expected 1 saved warning" + match w.loc with + | some { span := .range _, .. } => pure () + | _ => fail "expected a `range` span to be saved" + +/-- +Range formatting defers to Lean's `mkErrorStringWithPos`: `file:line:col-line:col`, with a 1-based +line and 0-based column, keeping the full end position even within one line. +-/ +@[test] +def rangeFormat : Test := do + let crossLine : LogMessage := { + severity := .error, text := "msg", + loc := some { + file := "CrossLine.lean", + span := .range { start := { line := 19, character := 4 }, «end» := { line := 20, character := 7 } } + } + } + assertEq "CrossLine.lean:20:4-21:7: msg" crossLine.format + let sameLine : LogMessage := { + severity := .error, text := "msg", + loc := some { + file := "SameLine.lean", + span := .range { start := { line := 42, character := 4 }, «end» := { line := 42, character := 21 } } + } + } + assertEq "SameLine.lean:43:4-43:21: msg" sameLine.format + +/-- A located message is formatted uniformly as `file:line:col: text` on stderr. -/ +@[test] +def fileLocationFormat : Test := do + let logger ← Logger.new + let out ← captureOutput <| + (reportError "bad term" (some { file := "FileLoc.lean", span := .pos { line := 6, character := 3 } }) + : BuildLogT IO Unit).run logger + let some m := (← logger.errors)[0]? + | fail "expected 1 saved error with a file location" + assertEq (some "FileLoc.lean") (m.loc.map (·.file)) + assertContains "FileLoc.lean:7:3: bad term" out.stderr + +/-- Errors set a non-zero exit code; warnings do not. -/ +@[test] +def exitCode : Test := do + let withErrors ← Logger.new + (do reportError "e1"; reportWarning "w1"; reportError "e2" : BuildLogT IO Unit).run withErrors + assertEq 2 (← withErrors.errors).size + assertEq 1 (← withErrors.warnings).size + assertEq 1 (← withErrors.exitCode) + let warningOnly ← Logger.new + (reportWarning "just a warning" : BuildLogT IO Unit).run warningOnly + assertEq 0 (← warningOnly.exitCode) + +/-- +Logging writes to the ambient stderr, resolved at log time: a logger created before a stderr +redirection still writes into the redirected stream, and nothing goes to stdout. +-/ +@[test] +def ambientStderr : Test := do + let logger ← Logger.new + let out ← captureOutput do + (do + reportError "first problem" (some { file := "X.lean", span := .pos { line := 0, character := 0 } }) + reportWarning "second problem" : BuildLogT IO Unit).run logger + assertContains "X.lean:1:0: first problem" out.stderr + assertContains "second problem" out.stderr + assertEq "" out.stdout + assertEq 1 (← logger.errors).size + assertEq 1 (← logger.warnings).size diff --git a/src/tests/Tests/CommentSkipping.lean b/src/tests/VersoTests/CommentSkipping.lean similarity index 86% rename from src/tests/Tests/CommentSkipping.lean rename to src/tests/VersoTests/CommentSkipping.lean index e5b560b10..3b140d783 100644 --- a/src/tests/Tests/CommentSkipping.lean +++ b/src/tests/VersoTests/CommentSkipping.lean @@ -3,10 +3,9 @@ Copyright (c) 2026 Lean FRO LLC. All rights reserved. Released under Apache 2.0 license as described in the file LICENSE. Author: David Thrane Christiansen -/ -module -public meta import Tests.CommentSkipping.Doc -public meta import Tests.CommentSkipping.Doc2 -public section +import Errata +import VersoTests.CommentSkipping.Doc +import VersoTests.CommentSkipping.Doc2 /-! This test ensures that Lean's parser doesn't skip Lean comment syntax while parsing Verso blocks as @@ -28,8 +27,8 @@ info: Verso.Doc.Part.mk Verso.Doc.Block.para #[Verso.Doc.Inline.text "def", Verso.Doc.Inline.linebreak "\n"]] #[] -/ -#guard_msgs in -#eval %doc Tests.CommentSkipping.Doc +#test_msgs in +#eval %doc VersoTests.CommentSkipping.Doc /-- info: Verso.Doc.Part.mk @@ -42,5 +41,5 @@ info: Verso.Doc.Part.mk Verso.Doc.Block.blockquote #[(Verso.Doc.Block.para #[Verso.Doc.Inline.text "C", Verso.Doc.Inline.linebreak "\n"])]] #[] -/ -#guard_msgs in -#eval %doc Tests.CommentSkipping.Doc2 +#test_msgs in +#eval %doc VersoTests.CommentSkipping.Doc2 diff --git a/src/tests/Tests/CommentSkipping/Doc.lean b/src/tests/VersoTests/CommentSkipping/Doc.lean similarity index 100% rename from src/tests/Tests/CommentSkipping/Doc.lean rename to src/tests/VersoTests/CommentSkipping/Doc.lean diff --git a/src/tests/Tests/CommentSkipping/Doc2.lean b/src/tests/VersoTests/CommentSkipping/Doc2.lean similarity index 100% rename from src/tests/Tests/CommentSkipping/Doc2.lean rename to src/tests/VersoTests/CommentSkipping/Doc2.lean diff --git a/src/tests/Tests/DocElabExtensions/Define.lean b/src/tests/VersoTests/DocElabExtensions/Define.lean similarity index 71% rename from src/tests/Tests/DocElabExtensions/Define.lean rename to src/tests/VersoTests/DocElabExtensions/Define.lean index 689f9725c..5532c0b13 100644 --- a/src/tests/Tests/DocElabExtensions/Define.lean +++ b/src/tests/VersoTests/DocElabExtensions/Define.lean @@ -3,13 +3,8 @@ Copyright (c) 2026 Lean FRO LLC. All rights reserved. Released under Apache 2.0 license as described in the file LICENSE. Author: Emilio Jesus Gallego Arias -/ -module -public import Tests.DocElabExtensions.LocalExtension -public meta import Tests.DocElabExtensions.LocalExtension -public import VersoManual -public meta import VersoManual - -public section +import VersoTests.DocElabExtensions.LocalExtension +import VersoManual namespace Verso.Tests.DocElabExtensions @@ -27,19 +22,19 @@ open Lean #register_inherited_test_local_entry @[role] -meta def inheritedRole : RoleExpanderOf Unit +def inheritedRole : RoleExpanderOf Unit | (), _contents => ``(Doc.Inline.text "inherited role") @[code_block] -meta def inheritedCode : CodeBlockExpanderOf Unit +def inheritedCode : CodeBlockExpanderOf Unit | (), str => ``(Doc.Block.code $(quote str.getString)) @[directive] -meta def inheritedDirective : DirectiveExpanderOf Unit +def inheritedDirective : DirectiveExpanderOf Unit | (), blocks => do let blocks ← blocks.mapM elabBlock ``(Doc.Block.concat #[$blocks,*]) @[block_command] -meta def inheritedCommand : BlockCommandOf Unit +def inheritedCommand : BlockCommandOf Unit | () => ``(Doc.Block.para #[Doc.Inline.text "inherited command"]) diff --git a/src/tests/Tests/DocElabExtensions/LocalExtension.lean b/src/tests/VersoTests/DocElabExtensions/LocalExtension.lean similarity index 100% rename from src/tests/Tests/DocElabExtensions/LocalExtension.lean rename to src/tests/VersoTests/DocElabExtensions/LocalExtension.lean diff --git a/src/tests/Tests/DocElabExtensions/Middle.lean b/src/tests/VersoTests/DocElabExtensions/Middle.lean similarity index 88% rename from src/tests/Tests/DocElabExtensions/Middle.lean rename to src/tests/VersoTests/DocElabExtensions/Middle.lean index 04092745e..e89ab15a4 100644 --- a/src/tests/Tests/DocElabExtensions/Middle.lean +++ b/src/tests/VersoTests/DocElabExtensions/Middle.lean @@ -3,10 +3,7 @@ Copyright (c) 2026 Lean FRO LLC. All rights reserved. Released under Apache 2.0 license as described in the file LICENSE. Author: Emilio Jesus Gallego Arias -/ -module -public import Tests.DocElabExtensions.Define - -public section +import VersoTests.DocElabExtensions.Define namespace Verso.Tests.DocElabExtensions diff --git a/src/tests/Tests/DocElabExtensions/Use.lean b/src/tests/VersoTests/DocElabExtensions/Use.lean similarity index 88% rename from src/tests/Tests/DocElabExtensions/Use.lean rename to src/tests/VersoTests/DocElabExtensions/Use.lean index df894c4b6..de9c01b08 100644 --- a/src/tests/Tests/DocElabExtensions/Use.lean +++ b/src/tests/VersoTests/DocElabExtensions/Use.lean @@ -3,11 +3,8 @@ Copyright (c) 2026 Lean FRO LLC. All rights reserved. Released under Apache 2.0 license as described in the file LICENSE. Author: Emilio Jesus Gallego Arias -/ -module -public import Tests.DocElabExtensions.Middle -public meta import Tests.DocElabExtensions.Middle - -public section +import VersoTests.DocElabExtensions.Middle +import Errata namespace Verso.Tests.DocElabExtensions @@ -50,5 +47,5 @@ Directive body {inheritedCommand} ::::::: -#guard importedThroughMiddle -#guard inheritedDocElabExtensions.toPart.content.size == 4 +#test_guard importedThroughMiddle +#test_guard inheritedDocElabExtensions.toPart.content.size == 4 diff --git a/src/tests/Tests/DocTerm.lean b/src/tests/VersoTests/DocTerm.lean similarity index 100% rename from src/tests/Tests/DocTerm.lean rename to src/tests/VersoTests/DocTerm.lean diff --git a/src/tests/Tests/DocVisibility.lean b/src/tests/VersoTests/DocVisibility.lean similarity index 81% rename from src/tests/Tests/DocVisibility.lean rename to src/tests/VersoTests/DocVisibility.lean index 494118691..0b6ed7bcb 100644 --- a/src/tests/Tests/DocVisibility.lean +++ b/src/tests/VersoTests/DocVisibility.lean @@ -4,7 +4,8 @@ Released under Apache 2.0 license as described in the file LICENSE. Author: David Thrane Christiansen -/ module -public meta import Tests.DocVisibility.Doc +public import Errata +public meta import VersoTests.DocVisibility.Doc public section /-! @@ -20,5 +21,5 @@ info: Verso.Doc.Part.mk #[Verso.Doc.Block.para #[Verso.Doc.Inline.text "A paragraph.", Verso.Doc.Inline.linebreak "\n"]] #[] -/ -#guard_msgs in -#eval %doc Tests.DocVisibility.Doc +#test_msgs in +#eval %doc VersoTests.DocVisibility.Doc diff --git a/src/tests/Tests/DocVisibility/Doc.lean b/src/tests/VersoTests/DocVisibility/Doc.lean similarity index 79% rename from src/tests/Tests/DocVisibility/Doc.lean rename to src/tests/VersoTests/DocVisibility/Doc.lean index c5160020c..6f45376ca 100644 --- a/src/tests/Tests/DocVisibility/Doc.lean +++ b/src/tests/VersoTests/DocVisibility/Doc.lean @@ -8,8 +8,8 @@ public import Verso public meta import Verso /-! -This document is deliberately not wrapped in a `public section`, so that `Tests.DocVisibility` can -check that `#doc` results in a public name. +This document is deliberately not wrapped in a `public section`, so that `VersoTests.DocVisibility` +can check that `#doc` results in a public name. -/ #doc (.none) "Title" => diff --git a/src/tests/Tests/DocstringMissing.lean b/src/tests/VersoTests/DocstringMissing.lean similarity index 97% rename from src/tests/Tests/DocstringMissing.lean rename to src/tests/VersoTests/DocstringMissing.lean index f17777584..22947be77 100644 --- a/src/tests/Tests/DocstringMissing.lean +++ b/src/tests/VersoTests/DocstringMissing.lean @@ -4,6 +4,7 @@ Released under Apache 2.0 license as described in the file LICENSE. Author: David Thrane Christiansen -/ module +import Errata public import VersoManual open Lean Elab Command @@ -25,6 +26,6 @@ Hint: If `Signature.mk` is documented, add `import all VersoManual.Docstring.Bas Set option 'verso.docstring.allowMissing' to 'true' to allow missing docstrings. -/ -#guard_msgs in +#test_msgs in run_cmd do discard <| getDocString? (← getEnv) ``Verso.Genre.Manual.Signature.mk diff --git a/src/tests/Tests/DocstringMissingLegacy.lean b/src/tests/VersoTests/DocstringMissingLegacy.lean similarity index 91% rename from src/tests/Tests/DocstringMissingLegacy.lean rename to src/tests/VersoTests/DocstringMissingLegacy.lean index 0ba6d699a..f8b9d6310 100644 --- a/src/tests/Tests/DocstringMissingLegacy.lean +++ b/src/tests/VersoTests/DocstringMissingLegacy.lean @@ -3,6 +3,7 @@ Copyright (c) 2026 Lean FRO LLC. All rights reserved. Released under Apache 2.0 license as described in the file LICENSE. Author: David Thrane Christiansen -/ +import Errata import VersoManual open Lean Elab Command @@ -13,7 +14,7 @@ set_option guard_msgs.diff true /-! The `import all` hint is specific to module documents, where a plain `import` does not load docstrings. A non-`module` document loads docstrings from a plain `import`, so the -diagnostic omits the hint. This is the non-`module` counterpart of `Tests/DocstringMissing.lean`. +diagnostic omits the hint. This is the non-`module` counterpart of `VersoTests/DocstringMissing.lean`. -/ /-- @@ -21,6 +22,6 @@ error: 'Verso.Genre.Manual.Signature.mk' is not documented. Set option 'verso.docstring.allowMissing' to 'true' to allow missing docstrings. -/ -#guard_msgs in +#test_msgs in run_cmd do discard <| getDocString? (← getEnv) ``Verso.Genre.Manual.Signature.mk diff --git a/src/tests/Tests/Elab.lean b/src/tests/VersoTests/Elab.lean similarity index 85% rename from src/tests/Tests/Elab.lean rename to src/tests/VersoTests/Elab.lean index f554454c6..72af1873c 100644 --- a/src/tests/Tests/Elab.lean +++ b/src/tests/VersoTests/Elab.lean @@ -3,12 +3,9 @@ Copyright (c) 2025 Lean FRO LLC. All rights reserved. Released under Apache 2.0 license as described in the file LICENSE. Author: Rob Simmons -/ -module -public import Verso -public meta import Verso -public import VersoManual -public meta import VersoManual -public section +import Errata +import Verso +import VersoManual namespace Verso.BlocksTest open Genre Manual InlineLean @@ -27,7 +24,7 @@ open Lean open Doc.Elab @[role] -meta def insertSyntaxGivingRiseToMetavariables : RoleExpanderOf Unit +def insertSyntaxGivingRiseToMetavariables : RoleExpanderOf Unit | (), _ => do ``(Doc.Inline.text s!"{4}") @@ -37,7 +34,7 @@ I can {insertSyntaxGivingRiseToMetavariables}[] ::::::: @[role] -meta def totallyUndefined : RoleExpanderOf Unit +def totallyUndefined : RoleExpanderOf Unit | (), _content => do `(_) @@ -49,14 +46,14 @@ context: docReconstInBlock✝ : Doc.DocReconstruction ⊢ Doc.Inline Doc.Genre.none -/ -#guard_msgs in +#test_msgs in #docs (.none) var8 "My title here" := ::::::: Attempting to insert something {totallyUndefined}[] ::::::: end -#guard_msgs in +#test_msgs in #docs (Manual) novar "My title here" := ::::::: A variable like {lean +error}`x`. diff --git a/src/tests/Tests/ExpanderSignatures.lean b/src/tests/VersoTests/ExpanderSignatures.lean similarity index 94% rename from src/tests/Tests/ExpanderSignatures.lean rename to src/tests/VersoTests/ExpanderSignatures.lean index 947feaf8a..8214e5ae2 100644 --- a/src/tests/Tests/ExpanderSignatures.lean +++ b/src/tests/VersoTests/ExpanderSignatures.lean @@ -4,6 +4,7 @@ Released under Apache 2.0 license as described in the file LICENSE. Author: David Thrane Christiansen -/ module +public import Errata public import Verso public import VersoManual public meta import Verso @@ -23,8 +24,8 @@ expander is defined. These tests pin down that the signature is computed correct particular that defining an expander whose parser is built from `.many`/`partial` combinators does not crash when the module holding the constant is loaded. -This file defines the expanders in a `module`, so the parsers are `meta`. `Tests/ExpanderSignaturesLegacy.lean` -checks the same thing for a non-`module` source file. +This file defines the expanders in a `module`, so the parsers are `meta`. +`VersoTests/ExpanderSignaturesLegacy.lean` checks the same thing for a non-`module` source file. -/ structure ManyArgs where @@ -68,7 +69,7 @@ Ident attr : String (key/value)* ``` -/ -#guard_msgs in +#test_msgs in run_cmd do let report (label : String) (s : Option SigDoc) : CommandElabM Unit := match s with diff --git a/src/tests/Tests/ExpanderSignaturesLegacy.lean b/src/tests/VersoTests/ExpanderSignaturesLegacy.lean similarity index 93% rename from src/tests/Tests/ExpanderSignaturesLegacy.lean rename to src/tests/VersoTests/ExpanderSignaturesLegacy.lean index d35f77b85..bd1003dc2 100644 --- a/src/tests/Tests/ExpanderSignaturesLegacy.lean +++ b/src/tests/VersoTests/ExpanderSignaturesLegacy.lean @@ -3,6 +3,7 @@ Copyright (c) 2026 Lean FRO LLC. All rights reserved. Released under Apache 2.0 license as described in the file LICENSE. Author: David Thrane Christiansen -/ +import Errata import Verso import VersoManual @@ -13,7 +14,7 @@ open Lean Elab Command open Verso Doc Elab ArgParse /-! -The non-`module` counterpart of `Tests/ExpanderSignatures.lean`. The expanders are defined in a +The non-`module` counterpart of `VersoTests/ExpanderSignatures.lean`. The expanders are defined in a legacy source file, so the parsers are ordinary (non-`meta`) definitions and the generated signature constants are not marked `meta`. The signatures must still be computed correctly, and loading this file must not crash on the `.many` parser's constant. @@ -60,7 +61,7 @@ Ident attr : String (key/value)* ``` -/ -#guard_msgs in +#test_msgs in run_cmd do let report (label : String) (s : Option SigDoc) : CommandElabM Unit := match s with diff --git a/src/tests/Tests/ExtensionResolution.lean b/src/tests/VersoTests/ExtensionResolution.lean similarity index 89% rename from src/tests/Tests/ExtensionResolution.lean rename to src/tests/VersoTests/ExtensionResolution.lean index d3ed01b2a..e7f446bde 100644 --- a/src/tests/Tests/ExtensionResolution.lean +++ b/src/tests/VersoTests/ExtensionResolution.lean @@ -3,11 +3,8 @@ Copyright (c) 2026 Lean FRO LLC. All rights reserved. Released under Apache 2.0 license as described in the file LICENSE. Author: Emilio J. Gallego Arias -/ -module -public import Verso -public meta import Verso - -public section +import Errata +import Verso namespace Verso.ExtensionResolutionTest set_option guard_msgs.diff true @@ -26,7 +23,7 @@ Extension resolution has four relevant outcomes: namespace RoleCases @[role] -meta def registered : RoleExpanderOf Unit +def registered : RoleExpanderOf Unit | (), _ => do `(Verso.Doc.Inline.text "registered-role") @@ -36,11 +33,11 @@ meta def registered : RoleExpanderOf Unit ::::::: /-- info: #[Verso.Doc.Block.para #[Verso.Doc.Inline.text "registered-role"]] -/ -#guard_msgs in +#test_msgs in #eval roleRegistered.toPart.content @[role_expander legacyRegistered] -meta def legacyRegistered : RoleExpander +def legacyRegistered : RoleExpander | _, _ => do pure #[← `(Verso.Doc.Inline.text "legacy-role")] @@ -50,7 +47,7 @@ meta def legacyRegistered : RoleExpander ::::::: /-- info: #[Verso.Doc.Block.para #[Verso.Doc.Inline.text "legacy-role"]] -/ -#guard_msgs in +#test_msgs in #eval roleLegacyRegistered.toPart.content def unregistered : RoleExpander @@ -60,7 +57,7 @@ def unregistered : RoleExpander /-- error: Declaration `unregistered` can be used as a role expander but is not registered as a role. Register it with `@[role]`. -/ -#guard_msgs in +#test_msgs in #docs (.none) roleUnregistered "Unregistered role" := ::::::: {unregistered}[] @@ -71,7 +68,7 @@ def wrongType : Nat := 7 /-- error: Declaration `wrongType` was found but is not registered as a role. -/ -#guard_msgs in +#test_msgs in #docs (.none) roleWrongType "Wrong role type" := ::::::: {wrongType}[] @@ -83,7 +80,7 @@ error: No registered role `registred`. Hint: Did you mean role `registered`? registe̲red -/ -#guard_msgs in +#test_msgs in #docs (.none) roleTypo "Role typo" := ::::::: {registred}[] @@ -95,7 +92,7 @@ error: No registered role `legacyRegistred`. Hint: Did you mean role `legacyRegistered`? legacyRegiste̲red -/ -#guard_msgs in +#test_msgs in #docs (.none) roleLegacyTypo "Legacy role typo" := ::::::: {legacyRegistred}[] @@ -104,7 +101,7 @@ Hint: Did you mean role `legacyRegistered`? /-- error: No registered role `nothereatallzzzz`. -/ -#guard_msgs in +#test_msgs in #docs (.none) roleNoCloseMatch "No close role match" := ::::::: {nothereatallzzzz}[] @@ -127,49 +124,49 @@ because any single-character role is within distance 1 of any single-character t /-- error: No registered role `q`. -/ -#guard_msgs in +#test_msgs in #docs (.none) oneCharDistanceNoMatch "One-character distance no match" := ::::::: {q}[] ::::::: @[role] -meta def r : RoleExpanderOf Unit +def r : RoleExpanderOf Unit | (), _ => do `(Verso.Doc.Inline.text "one-character-cutoff-role") @[role] -meta def ab : RoleExpanderOf Unit +def ab : RoleExpanderOf Unit | (), _ => do `(Verso.Doc.Inline.text "short-distance-role") @[role] -meta def vvv : RoleExpanderOf Unit +def vvv : RoleExpanderOf Unit | (), _ => do `(Verso.Doc.Inline.text "three-character-cutoff-role") @[role] -meta def distanceRegistered : RoleExpanderOf Unit +def distanceRegistered : RoleExpanderOf Unit | (), _ => do `(Verso.Doc.Inline.text "distance-role") @[role] -meta def yyyyy : RoleExpanderOf Unit +def yyyyy : RoleExpanderOf Unit | (), _ => do `(Verso.Doc.Inline.text "middle-cutoff-role") @[role] -meta def zzzzzz : RoleExpanderOf Unit +def zzzzzz : RoleExpanderOf Unit | (), _ => do `(Verso.Doc.Inline.text "long-cutoff-role") @[role] -meta def multiAlpha : RoleExpanderOf Unit +def multiAlpha : RoleExpanderOf Unit | (), _ => do `(Verso.Doc.Inline.text "multi-alpha-role") @[role] -meta def multiAlphi : RoleExpanderOf Unit +def multiAlphi : RoleExpanderOf Unit | (), _ => do `(Verso.Doc.Inline.text "multi-alphi-role") @@ -179,7 +176,7 @@ error: No registered role `q`. Hint: Did you mean role `r`? q̵r̲ -/ -#guard_msgs in +#test_msgs in #docs (.none) oneCharDistanceMatch "One-character distance match" := ::::::: {q}[] @@ -191,7 +188,7 @@ error: No registered role `ac`. Hint: Did you mean role `ab`? ac̵b̲ -/ -#guard_msgs in +#test_msgs in #docs (.none) shortDistanceMatch "Short distance match" := ::::::: {ac}[] @@ -200,7 +197,7 @@ Hint: Did you mean role `ab`? /-- error: No registered role `zz`. -/ -#guard_msgs in +#test_msgs in #docs (.none) shortDistanceNoMatch "Short distance no match" := ::::::: {zz}[] @@ -212,7 +209,7 @@ error: No registered role `vww`. Hint: Did you mean role `vvv`? vw̵w̵v̲v̲ -/ -#guard_msgs in +#test_msgs in #docs (.none) threeCharDistanceMatch "Three-character distance match" := ::::::: {vww}[] @@ -221,7 +218,7 @@ Hint: Did you mean role `vvv`? /-- error: No registered role `www`. -/ -#guard_msgs in +#test_msgs in #docs (.none) threeCharDistanceNoMatch "Three-character distance no match" := ::::::: {www}[] @@ -233,7 +230,7 @@ error: No registered role `yyyxx`. Hint: Did you mean role `yyyyy`? yyyx̵x̵y̲y̲ -/ -#guard_msgs in +#test_msgs in #docs (.none) middleDistanceMatch "Middle distance match" := ::::::: {yyyxx}[] @@ -242,7 +239,7 @@ Hint: Did you mean role `yyyyy`? /-- error: No registered role `yyxxx`. -/ -#guard_msgs in +#test_msgs in #docs (.none) middleDistanceNoMatch "Middle distance no match" := ::::::: {yyxxx}[] @@ -254,7 +251,7 @@ error: No registered role `zzzaaa`. Hint: Did you mean role `zzzzzz`? zzza̵a̵a̵z̲z̲z̲ -/ -#guard_msgs in +#test_msgs in #docs (.none) longBoundaryDistanceMatch "Long boundary distance match" := ::::::: {zzzaaa}[] @@ -263,7 +260,7 @@ Hint: Did you mean role `zzzzzz`? /-- error: No registered role `zzaaaa`. -/ -#guard_msgs in +#test_msgs in #docs (.none) longBoundaryDistanceNoMatch "Long boundary distance no match" := ::::::: {zzaaaa}[] @@ -276,7 +273,7 @@ Hint: Did you mean role `multiAlpha`? • multiAlphx̵a̲ • multiAlphx̵i̲ -/ -#guard_msgs in +#test_msgs in #docs (.none) multiDistanceSuggestions "Multiple distance suggestions" := ::::::: {multiAlphx}[] @@ -288,7 +285,7 @@ error: No registered role `distanceRegistred`. Hint: Did you mean role `distanceRegistered`? distanceRegiste̲red -/ -#guard_msgs in +#test_msgs in #docs (.none) longDistanceMatch "Long distance match" := ::::::: {distanceRegistred}[] @@ -297,7 +294,7 @@ Hint: Did you mean role `distanceRegistered`? /-- error: No registered role `distanceNoMatchzzzz`. -/ -#guard_msgs in +#test_msgs in #docs (.none) longDistanceNoMatch "Long distance no match" := ::::::: {distanceNoMatchzzzz}[] @@ -308,7 +305,7 @@ end DistanceCases namespace ShadowSource @[role] -meta def shadowedRegistered : RoleExpanderOf Unit +def shadowedRegistered : RoleExpanderOf Unit | (), _ => do `(Verso.Doc.Inline.text "shadowed-role") @@ -329,7 +326,7 @@ error: No registered role `ShadowSource.shadowedRegistred`. Hint: Did you mean role `ShadowSource.shadowedRegistered`? ShadowSource.shadowedRegiste̲red -/ -#guard_msgs in +#test_msgs in #docs (.none) roleShadowedSuggestion "Shadowed role suggestion" := ::::::: {ShadowSource.shadowedRegistred}[] @@ -341,7 +338,7 @@ error: No registered role `shadowedRegistred`. Hint: Did you mean role `ShadowSource.shadowedRegistered`? s̵h̵a̵d̵o̵w̵e̵d̵R̵e̵g̵i̵s̵t̵r̵e̵d̵S̲h̲a̲d̲o̲w̲S̲o̲u̲r̲c̲e̲.̲s̲h̲a̲d̲o̲w̲e̲d̲R̲e̲g̲i̲s̲t̲e̲r̲e̲d̲ -/ -#guard_msgs in +#test_msgs in #docs (.none) roleUnqualifiedShadowedSuggestion "Unqualified shadowed role suggestion" := ::::::: {shadowedRegistred}[] @@ -352,7 +349,7 @@ end ShadowUse namespace CodeBlockCases @[code_block] -meta def registeredBlock : CodeBlockExpanderOf Unit +def registeredBlock : CodeBlockExpanderOf Unit | (), str => do `(Verso.Doc.Block.code $(quote str.getString)) @@ -370,7 +367,7 @@ def unregisteredBlock : CodeBlockExpanderOf Unit /-- error: Declaration `unregisteredBlock` can be used as a code block expander but is not registered as a code block. Register it with `@[code_block]`. -/ -#guard_msgs in +#test_msgs in #docs (.none) codeBlockUnregistered "Unregistered code block" := ::::::: ```unregisteredBlock @@ -383,7 +380,7 @@ def wrongBlockType : Nat := 7 /-- error: Declaration `wrongBlockType` was found but is not registered as a code block. -/ -#guard_msgs in +#test_msgs in #docs (.none) codeBlockWrongType "Wrong code block type" := ::::::: ```wrongBlockType @@ -397,7 +394,7 @@ error: No registered code block `registeredBlok`. Hint: Did you mean code block `registeredBlock`? registeredBloc̲k -/ -#guard_msgs in +#test_msgs in #docs (.none) codeBlockTypo "Code block typo" := ::::::: ```registeredBlok @@ -410,7 +407,7 @@ end CodeBlockCases namespace DirectiveCases @[directive] -meta def registeredDirective : DirectiveExpanderOf Unit +def registeredDirective : DirectiveExpanderOf Unit | (), blocks => do let blocks ← blocks.mapM Verso.Doc.Elab.elabBlock `(Verso.Doc.Block.concat #[$blocks,*]) @@ -430,7 +427,7 @@ def unregisteredDirective : DirectiveExpanderOf Unit /-- error: Declaration `unregisteredDirective` can be used as a directive expander but is not registered as a directive. Register it with `@[directive]`. -/ -#guard_msgs in +#test_msgs in #docs (.none) directiveUnregistered "Unregistered directive" := ::::::: :::unregisteredDirective @@ -443,7 +440,7 @@ def wrongDirectiveType : Nat := 7 /-- error: Declaration `wrongDirectiveType` was found but is not registered as a directive. -/ -#guard_msgs in +#test_msgs in #docs (.none) directiveWrongType "Wrong directive type" := ::::::: :::wrongDirectiveType @@ -457,7 +454,7 @@ error: No registered directive `registeredDirektive`. Hint: Did you mean directive `registeredDirective`? registeredDirek̵c̲tive -/ -#guard_msgs in +#test_msgs in #docs (.none) directiveTypo "Directive typo" := ::::::: :::registeredDirektive @@ -470,7 +467,7 @@ end DirectiveCases namespace BlockCommandCases @[block_command] -meta def registeredCommand : BlockCommandOf Unit +def registeredCommand : BlockCommandOf Unit | () => do `(Verso.Doc.Block.para #[Verso.Doc.Inline.text "registered-command"]) @@ -480,7 +477,7 @@ meta def registeredCommand : BlockCommandOf Unit ::::::: /-- info: #[Verso.Doc.Block.concat #[(Verso.Doc.Block.para #[Verso.Doc.Inline.text "registered-command"])]] -/ -#guard_msgs in +#test_msgs in #eval blockCommandRegistered.toPart.content def fallbackCommand : Verso.Doc.Block Verso.Doc.Genre.none := @@ -492,7 +489,7 @@ def fallbackCommand : Verso.Doc.Block Verso.Doc.Genre.none := ::::::: /-- info: #[Verso.Doc.Block.para #[Verso.Doc.Inline.text "fallback-command"]] -/ -#guard_msgs in +#test_msgs in #eval blockCommandFallback.toPart.content /-- @@ -501,7 +498,7 @@ error: No registered block command `registeredComand`. Hint: Did you mean block command `registeredCommand`? registeredComm̲and -/ -#guard_msgs in +#test_msgs in #docs (.none) blockCommandTypo "Block command typo" := ::::::: {registeredComand} diff --git a/src/tests/Tests/GenericCode.lean b/src/tests/VersoTests/GenericCode.lean similarity index 95% rename from src/tests/Tests/GenericCode.lean rename to src/tests/VersoTests/GenericCode.lean index 478481550..44f395989 100644 --- a/src/tests/Tests/GenericCode.lean +++ b/src/tests/VersoTests/GenericCode.lean @@ -3,10 +3,8 @@ Copyright (c) 2023 Lean FRO LLC. All rights reserved. Released under Apache 2.0 license as described in the file LICENSE. Author: David Thrane Christiansen -/ -module -public import Verso -public meta import Verso -public section +import Errata +import Verso namespace Verso.GenericCodeTest set_option guard_msgs.diff true set_option pp.rawOnError true @@ -41,7 +39,7 @@ info: Verso.Doc.Part.mk Verso.Doc.Block.code "(define (zero f z) z)\n(define (succ n) (lambda (f x) (f (n f z))))\n"] #[]] -/ -#guard_msgs in +#test_msgs in #eval code1.toPart /-- info: Verso.Output.Html.tag @@ -60,7 +58,7 @@ info: Verso.Output.Html.tag #[] (Verso.Output.Html.text true "(define (zero f z) z)\n(define (succ n) (lambda (f x) (f (n f z))))\n")])]) -/ -#guard_msgs in +#test_msgs in #eval Doc.Genre.none.toHtml (m := Id) {} () () {} {} {} code1.toPart |>.run .empty |>.fst @@ -94,5 +92,5 @@ info: Verso.Doc.Part.mk Verso.Doc.Block.code "(define (zero f z) z)\n(define (succ n) (lambda (f x) (f (n f z))))\n"] #[]] -/ -#guard_msgs in +#test_msgs in #eval code2.toPart diff --git a/src/tests/Tests/HighlightedToTeX.lean b/src/tests/VersoTests/HighlightedToTeX.lean similarity index 92% rename from src/tests/Tests/HighlightedToTeX.lean rename to src/tests/VersoTests/HighlightedToTeX.lean index dba4aa078..e223f48c4 100644 --- a/src/tests/Tests/HighlightedToTeX.lean +++ b/src/tests/VersoTests/HighlightedToTeX.lean @@ -4,11 +4,12 @@ Released under Apache 2.0 license as described in the file LICENSE. Author: Jason Reed -/ module +import Errata meta import all Verso.Code.HighlightedToTex open Verso.Doc.TeX (escapeForVerbatim) open SubVerso.Highlighting /-- info: "\\symbol{123}\\symbol{124}\\symbol{125}\\symbol{92}" -/ -#guard_msgs in +#test_msgs in #eval escapeForVerbatim "{|}\\" diff --git a/src/tests/Tests/HoverMerge.lean b/src/tests/VersoTests/HoverMerge.lean similarity index 64% rename from src/tests/Tests/HoverMerge.lean rename to src/tests/VersoTests/HoverMerge.lean index 43fb72a39..542871abf 100644 --- a/src/tests/Tests/HoverMerge.lean +++ b/src/tests/VersoTests/HoverMerge.lean @@ -4,6 +4,7 @@ Released under Apache 2.0 license as described in the file LICENSE. Author: David Thrane Christiansen -/ module +import Errata import all Verso.Code.Highlighted meta import Verso.Output.Html meta import SubVerso.Highlighting @@ -24,20 +25,20 @@ def tok : Html := .tag "span" #[("class", "token"), ("data-verso-hover", "5")] ( def tokNoHover : Html := .tag "span" #[("class", "token")] (.text true "x") -- The attribute is taken from a bare element. -#guard takeAttrs #["data-verso-hover"] tok == (#[("data-verso-hover", "5")], tokNoHover) +#test_guard takeAttrs #["data-verso-hover"] tok == (#[("data-verso-hover", "5")], tokNoHover) -- The attribute is found through a wrapping element, such as a link. -#guard takeAttrs #["data-verso-hover"] (.tag "a" #[("href", "x.html")] tok) == +#test_guard takeAttrs #["data-verso-hover"] (.tag "a" #[("href", "x.html")] tok) == (#[("data-verso-hover", "5")], .tag "a" #[("href", "x.html")] tokNoHover) -- Attributes are gathered across the wrappers of a sole element: the hover from the token -- and the extra links from the link element around it. -#guard takeAttrs #["data-verso-hover", "data-verso-links"] +#test_guard takeAttrs #["data-verso-hover", "data-verso-links"] (.tag "a" #[("data-verso-links", "[]")] tok) == (#[("data-verso-links", "[]"), ("data-verso-hover", "5")], .tag "a" #[] tokNoHover) -- Only the attributes that are present appear in the result. -#guard takeAttrs #["data-verso-hover", "data-verso-links"] +#test_guard takeAttrs #["data-verso-hover", "data-verso-links"] (.tag "a" #[("data-verso-links", "[]")] tokNoHover) == (#[("data-verso-links", "[]")], .tag "a" #[] tokNoHover) @@ -47,54 +48,54 @@ def tokLinked : Html := -- Each attribute is taken from the outermost element that carries it, and repeats on -- elements nested inside stay in place. -#guard takeAttrs #["data-verso-hover", "data-verso-links"] +#test_guard takeAttrs #["data-verso-hover", "data-verso-links"] (.tag "a" #[("data-verso-hover", "9")] tokLinked) == (#[("data-verso-hover", "9"), ("data-verso-links", "[2]")], .tag "a" #[] (.tag "span" #[("class", "token"), ("data-verso-hover", "5")] (.text true "x"))) -#guard takeAttrs #["data-verso-hover", "data-verso-links"] +#test_guard takeAttrs #["data-verso-hover", "data-verso-links"] (.tag "a" #[("data-verso-links", "[1]")] tokLinked) == (#[("data-verso-links", "[1]"), ("data-verso-hover", "5")], .tag "a" #[] (.tag "span" #[("class", "token"), ("data-verso-links", "[2]")] (.text true "x"))) -- The outermost attribute wins, and inner ones are left in place. -#guard takeAttrs #["data-verso-hover"] (.tag "a" #[("data-verso-hover", "9")] tok) == +#test_guard takeAttrs #["data-verso-hover"] (.tag "a" #[("data-verso-hover", "9")] tok) == (#[("data-verso-hover", "9")], .tag "a" #[] tok) -- Empty content around a sole element does not block the search. -#guard takeAttrs #["data-verso-hover"] (.seq #[.text true "", tok, .seq #[]]) == +#test_guard takeAttrs #["data-verso-hover"] (.seq #[.text true "", tok, .seq #[]]) == (#[("data-verso-hover", "5")], tokNoHover) -- Adjacent content blocks the search, including whitespace. -#guard takeAttrs #["data-verso-hover"] (.seq #[tok, .text true "y"]) == +#test_guard takeAttrs #["data-verso-hover"] (.seq #[tok, .text true "y"]) == (#[], .seq #[tok, .text true "y"]) -#guard takeAttrs #["data-verso-hover"] (.seq #[tok, tokNoHover]) == (#[], .seq #[tok, tokNoHover]) -#guard takeAttrs #["data-verso-hover"] (.seq #[.text true " ", tok]) == +#test_guard takeAttrs #["data-verso-hover"] (.seq #[tok, tokNoHover]) == (#[], .seq #[tok, tokNoHover]) +#test_guard takeAttrs #["data-verso-hover"] (.seq #[.text true " ", tok]) == (#[], .seq #[.text true " ", tok]) -- Adjacent content inside a wrapper blocks the search. -#guard takeAttrs #["data-verso-hover"] (.tag "a" #[] (.seq #[tok, tokNoHover])) == +#test_guard takeAttrs #["data-verso-hover"] (.tag "a" #[] (.seq #[tok, tokNoHover])) == (#[], .tag "a" #[] (.seq #[tok, tokNoHover])) -- Content without the attributes is unchanged. -#guard takeAttrs #["data-verso-hover"] tokNoHover == (#[], tokNoHover) -#guard takeAttrs #["data-verso-hover"] (.text true "x") == (#[], .text true "x") -#guard takeAttrs #["data-verso-hover"] (.seq #[]) == (#[], .seq #[]) +#test_guard takeAttrs #["data-verso-hover"] tokNoHover == (#[], tokNoHover) +#test_guard takeAttrs #["data-verso-hover"] (.text true "x") == (#[], .text true "x") +#test_guard takeAttrs #["data-verso-hover"] (.seq #[]) == (#[], .seq #[]) def hlTok : Highlighted := .token ⟨.keyword none none none, "rfl"⟩ def hlTok' : Highlighted := .token ⟨.keyword none none none, "skip"⟩ -- A sequence around a single element becomes that element, through nesting and empty text. -#guard (Highlighted.seq #[hlTok]).normalize == hlTok -#guard (Highlighted.seq #[.seq #[hlTok]]).normalize == hlTok -#guard (Highlighted.seq #[.text "", hlTok, .seq #[]]).normalize == hlTok +#test_guard (Highlighted.seq #[hlTok]).normalize == hlTok +#test_guard (Highlighted.seq #[.seq #[hlTok]]).normalize == hlTok +#test_guard (Highlighted.seq #[.text "", hlTok, .seq #[]]).normalize == hlTok -- Whitespace is content, and sequences with several elements keep their structure. -#guard (Highlighted.seq #[.text " ", hlTok]).normalize == .seq #[.text " ", hlTok] -#guard (Highlighted.seq #[hlTok, hlTok']).normalize == .seq #[hlTok, hlTok'] +#test_guard (Highlighted.seq #[.text " ", hlTok]).normalize == .seq #[.text " ", hlTok] +#test_guard (Highlighted.seq #[hlTok, hlTok']).normalize == .seq #[hlTok, hlTok'] -- Normalization reaches inside spans and proof states. -#guard (Highlighted.span #[] (.seq #[hlTok])).normalize == .span #[] hlTok -#guard (Highlighted.tactics #[] 5 10 (.seq #[.text "", hlTok])).normalize == +#test_guard (Highlighted.span #[] (.seq #[hlTok])).normalize == .span #[] hlTok +#test_guard (Highlighted.tactics #[] 5 10 (.seq #[.text "", hlTok])).normalize == .tactics #[] 5 10 hlTok end Verso.HoverMergeTest diff --git a/src/tests/Tests/Html.lean b/src/tests/VersoTests/Html.lean similarity index 97% rename from src/tests/Tests/Html.lean rename to src/tests/VersoTests/Html.lean index 515cd0150..f8293079c 100644 --- a/src/tests/Tests/Html.lean +++ b/src/tests/VersoTests/Html.lean @@ -4,6 +4,7 @@ Released under Apache 2.0 license as described in the file LICENSE. Author: David Thrane Christiansen -/ module +import Errata meta import all Verso.Output.Html namespace Verso.Tests.Html @@ -21,7 +22,7 @@ info: Verso.Output.Html.tag #[("charset", "UTF-8"), ("charset", "UTF-8"), ("a", "b"), ("a-b-c", "44"), ("x", "y")] (Verso.Output.Html.seq #[]) -/ -#guard_msgs in +#test_msgs in #eval testAttrs private def testAttrsAntiquotes := @@ -33,7 +34,7 @@ info: Verso.Output.Html.tag #[("charset", "UTF-8"), ("charset", "UTF-8"), ("a", "b"), ("a-b-c", "44"), ("x", "y")] (Verso.Output.Html.seq #[]) -/ -#guard_msgs in +#test_msgs in #eval testAttrsAntiquotes private def test : Html := {{ @@ -73,7 +74,7 @@ info: Verso.Output.Html.tag #[Verso.Output.Html.text true "foo bar", Verso.Output.Html.tag "br" #[] (Verso.Output.Html.seq #[]), Verso.Output.Html.text true "hey"])])]) -/ -#guard_msgs in +#test_msgs in #eval test private def leanKwTest : Html := {{ @@ -81,7 +82,7 @@ private def leanKwTest : Html := {{ }} /-- info: Verso.Output.Html.tag "label" #[("for", "foo")] (Verso.Output.Html.text true "Blah") -/ -#guard_msgs in +#test_msgs in #eval leanKwTest @@ -91,7 +92,7 @@ error: `
` doesn't allow contents Hint: Remove contents <̵b̵r̵>̵"̵f̵o̵o̵"̵ ̵"̵f̵o̵o̵"̵<̵/̵b̵r̵>̵<̲b̲r̲/̲>̲ -/ -#guard_msgs in +#test_msgs in #eval show Html from {{
"foo" "foo"
}} /-- @@ -107,7 +108,7 @@ info: | -/ -#guard_msgs in +#test_msgs in #eval IO.println <| "|\n" ++ test.asString /-! ## Tests for escaping -/ diff --git a/src/tests/Tests/HtmlEntities.lean b/src/tests/VersoTests/HtmlEntities.lean similarity index 73% rename from src/tests/Tests/HtmlEntities.lean rename to src/tests/VersoTests/HtmlEntities.lean index 23a5dead0..06aada427 100644 --- a/src/tests/Tests/HtmlEntities.lean +++ b/src/tests/VersoTests/HtmlEntities.lean @@ -3,39 +3,35 @@ Copyright (c) 2025 Lean FRO LLC. All rights reserved. Released under Apache 2.0 license as described in the file LICENSE. Author: David Thrane Christiansen -/ -module - -public import Verso.Output.Html.Entities -public meta import Verso.Output.Html.Entities - -public section +import Errata +import Verso.Output.Html.Entities open Verso.Output.Html /-- info: some "&" -/ -#guard_msgs in +#test_msgs in #eval decodeEntity? "&" /-- info: some "&" -/ -#guard_msgs in +#test_msgs in #eval decodeEntity? "&" /-- info: some #["&", "&", "&", "&"] -/ -#guard_msgs in +#test_msgs in #eval namedEntity? '&' |>.map (·.toArray |>.qsort) /-- info: some " " -/ -#guard_msgs in +#test_msgs in #eval decodeEntity? " " /-- info: some " " -/ -#guard_msgs in +#test_msgs in #eval decodeEntity? " " /-- info: none -/ -#guard_msgs in +#test_msgs in #eval decodeEntity? "&#;" /-- info: none -/ -#guard_msgs in +#test_msgs in #eval decodeEntity? "&blah;" diff --git a/src/tests/Tests/InlineStringPositions.lean b/src/tests/VersoTests/InlineStringPositions.lean similarity index 81% rename from src/tests/Tests/InlineStringPositions.lean rename to src/tests/VersoTests/InlineStringPositions.lean index 84a7a5ff7..142dbde94 100644 --- a/src/tests/Tests/InlineStringPositions.lean +++ b/src/tests/VersoTests/InlineStringPositions.lean @@ -3,13 +3,9 @@ Copyright (c) 2023-2026 Lean FRO LLC. All rights reserved. Released under Apache 2.0 license as described in the file LICENSE. Author: David Thrane Christiansen -/ -module -public import Verso -public meta import Verso -public import Verso.Doc.Concrete.InlineString -public meta import Verso.Doc.Concrete.InlineString - -public section +import Errata +import Verso +import Verso.Doc.Concrete.InlineString open Lean open Verso.Doc @@ -20,13 +16,13 @@ set_option pp.rawOnError true /-- info: Inline.concat #[Inline.text "Hello, ", Inline.emph #[Inline.bold #[Inline.text "emph"]]] : Inline Genre.none -/ -#guard_msgs in +#test_msgs in #check (inlines!"Hello, _*emph*_" : Inline .none) /-- info: Block.concat #[Block.para #[Inline.text "Hello, ", Inline.emph #[Inline.bold #[Inline.text "emph"]]]] : Block Genre.none -/ -#guard_msgs in +#test_msgs in #check (blocks!"Hello, _*emph*_" : Block .none) /-- @@ -45,29 +41,29 @@ def checkDecode -- A markup delimiter after an escape maps past the multi-byte source of the escape, not by a -- constant shift: in `"a\n*b*"` the `*` is decoded byte 2 but source byte 4. -#guard checkDecode decodeStrLitWithMap "\"a\\n*b*\"" "a\n*b*" [(1, 2, "\\n"), (2, 3, "*")] +#test_guard checkDecode decodeStrLitWithMap "\"a\\n*b*\"" "a\n*b*" [(1, 2, "\\n"), (2, 3, "*")] -- A unicode escape decodes to a multi-byte character whose source span is the whole `\uHHHH`. -#guard checkDecode decodeStrLitWithMap "\"\\u00e9x\"" "éx" [(0, 2, "\\u00e9"), (2, 3, "x")] +#test_guard checkDecode decodeStrLitWithMap "\"\\u00e9x\"" "éx" [(0, 2, "\\u00e9"), (2, 3, "x")] -- A string gap decodes to nothing; the character after it maps past the whole gap to source byte 6. -#guard checkDecode decodeStrLitWithMap "\"a\\\n b\"" "ab" [(1, 2, "b")] +#test_guard checkDecode decodeStrLitWithMap "\"a\\\n b\"" "ab" [(1, 2, "b")] -- Raw string literals are not escape-decoded: `\n` stays two characters. -#guard checkDecode decodeStrLitWithMap "r\"a\\n*\"" "a\\n*" [(3, 4, "*")] +#test_guard checkDecode decodeStrLitWithMap "r\"a\\n*\"" "a\\n*" [(3, 4, "*")] -- Every character a unicode escape: each decoded character maps to its whole six-byte `\uHHHH`. -#guard checkDecode decodeStrLitWithMap "\"\\u002A\\u0062\\u002A\"" "*b*" +#test_guard checkDecode decodeStrLitWithMap "\"\\u002A\\u0062\\u002A\"" "*b*" [(0, 1, "\\u002A"), (1, 2, "\\u0062"), (2, 3, "\\u002A")] -- A bare content region (no surrounding quotes) decodes the same way; this drives re-parsing escaped -- code spans as Lean. -#guard checkDecode decodeContentWithMap "\\u004E\\u0061\\u0074" "Nat" +#test_guard checkDecode decodeContentWithMap "\\u004E\\u0061\\u0074" "Nat" [(0, 1, "\\u004E"), (1, 2, "\\u0061"), (2, 3, "\\u0074")] -- Remapping reanchors a token's leading and trailing whitespace into the source string, so the -- syntax round-trips, and the token's positions become absolute. -#guard +#test_guard let src := "\"a\\n*b*\"" let (_, m) := decodeStrLitWithMap src ⟨0⟩ src.rawEndPos let leading : Substring.Raw := { str := "a\n*b*", startPos := ⟨2⟩, stopPos := ⟨2⟩ } @@ -82,5 +78,5 @@ info: Inline.concat #[Inline.text "a", Inline.linebreak "\n", Inline.text "b ", Inline.emph #[Inline.bold #[Inline.text "c"]]] : Inline Genre.none -/ -#guard_msgs in +#test_msgs in #check (inlines!"a\nb _*c*_" : Inline .none) diff --git a/src/tests/Tests/Integration/CodeContent.lean b/src/tests/VersoTests/Integration/CodeContent.lean similarity index 100% rename from src/tests/Tests/Integration/CodeContent.lean rename to src/tests/VersoTests/Integration/CodeContent.lean diff --git a/src/tests/Tests/Integration/DiagramDoc.lean b/src/tests/VersoTests/Integration/DiagramDoc.lean similarity index 100% rename from src/tests/Tests/Integration/DiagramDoc.lean rename to src/tests/VersoTests/Integration/DiagramDoc.lean diff --git a/src/tests/Tests/Integration/Escape.lean b/src/tests/VersoTests/Integration/Escape.lean similarity index 100% rename from src/tests/Tests/Integration/Escape.lean rename to src/tests/VersoTests/Integration/Escape.lean diff --git a/src/tests/Tests/Integration/ExtraFilesDoc.lean b/src/tests/VersoTests/Integration/ExtraFilesDoc.lean similarity index 100% rename from src/tests/Tests/Integration/ExtraFilesDoc.lean rename to src/tests/VersoTests/Integration/ExtraFilesDoc.lean diff --git a/src/tests/Tests/Integration/FrontMatter.lean b/src/tests/VersoTests/Integration/FrontMatter.lean similarity index 100% rename from src/tests/Tests/Integration/FrontMatter.lean rename to src/tests/VersoTests/Integration/FrontMatter.lean diff --git a/src/tests/Tests/Integration/InheritanceDoc.lean b/src/tests/VersoTests/Integration/InheritanceDoc.lean similarity index 100% rename from src/tests/Tests/Integration/InheritanceDoc.lean rename to src/tests/VersoTests/Integration/InheritanceDoc.lean diff --git a/src/tests/Tests/Integration/LeanSection.lean b/src/tests/VersoTests/Integration/LeanSection.lean similarity index 100% rename from src/tests/Tests/Integration/LeanSection.lean rename to src/tests/VersoTests/Integration/LeanSection.lean diff --git a/src/tests/Tests/Integration/SampleDoc.lean b/src/tests/VersoTests/Integration/SampleDoc.lean similarity index 100% rename from src/tests/Tests/Integration/SampleDoc.lean rename to src/tests/VersoTests/Integration/SampleDoc.lean diff --git a/src/tests/Tests/Integration/TwoSideDoc.lean b/src/tests/VersoTests/Integration/TwoSideDoc.lean similarity index 100% rename from src/tests/Tests/Integration/TwoSideDoc.lean rename to src/tests/VersoTests/Integration/TwoSideDoc.lean diff --git a/src/tests/VersoTests/Interactive.lean b/src/tests/VersoTests/Interactive.lean new file mode 100644 index 000000000..291964ac5 --- /dev/null +++ b/src/tests/VersoTests/Interactive.lean @@ -0,0 +1,20 @@ +/- +Copyright (c) 2026 Lean FRO LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Author: David Thrane Christiansen +-/ +module + +import Errata + +open Errata + +/-- +Use a shell harness to test the LSP server. +-/ +@[test] +def interactive : Test := do + let out ← IO.Process.output { cmd := "src/tests/interactive/run_interactive.sh" } + IO.print out.stdout + IO.eprint out.stderr + assert (out.exitCode == 0) s!"interactive LSP tests failed with exit code {out.exitCode}" diff --git a/src/tests/Tests/LeanCode.lean b/src/tests/VersoTests/LeanCode.lean similarity index 94% rename from src/tests/Tests/LeanCode.lean rename to src/tests/VersoTests/LeanCode.lean index 0c61c2357..4fc223a3c 100644 --- a/src/tests/Tests/LeanCode.lean +++ b/src/tests/VersoTests/LeanCode.lean @@ -3,6 +3,7 @@ Copyright (c) 2025 Lean FRO LLC. All rights reserved. Released under Apache 2.0 license as described in the file LICENSE. Author: Rob Simmons -/ +import Errata import VersoManual namespace Verso.LeanCodeTest set_option guard_msgs.diff true @@ -40,7 +41,7 @@ error: Unknown identifier `z` --- error: No error expected in code block, one occurred -/ -#guard_msgs in +#test_msgs in #docs (Genre.Manual) fail "Test" := ::::::: {lean}`z` @@ -69,7 +70,7 @@ info: (some (Verso.Genre.Manual.InlineLean.Inline.lean, [{"seq": {"tok": {"kind": {"operator": - {"occurrence": "«term_+_»-827", + {"occurrence": "«term_+_»-840", "name": ["term_+_"], "docs": "`a + b` computes the sum of `a` and `b`.\nThe meaning of this notation is type-dependent. \n\nConventions for notations in identifiers:\n\n * The recommended spelling of `+` in identifiers is `add`."}}, @@ -81,7 +82,7 @@ info: (some (Verso.Genre.Manual.InlineLean.Inline.lean, [{"seq": "content": "3"}}}]}}, []])) -/ -#guard_msgs in +#test_msgs in #eval match inspect.toPart.content[0]! with | .para x => match x[0]! with | .other code _ => Option.some (code.name, code.data) @@ -108,7 +109,7 @@ end -- In term like `(x : Nat) → String`, `x` is a named binder that doesn't appear in the body, -- but the metalanguage's unused variable linter should not re-fire on the info tree pushed -- by leanInline. -#guard_msgs in +#test_msgs in #docs (Genre.Manual) inlineNamedBinderType "Inline Named Binder Type" := ::::::: {lean}`(x : Nat) → String` @@ -134,13 +135,13 @@ Note: This linter can be disabled with `set_option linter.unusedVariables false` ::::::: /-- -error: Didn't match - got: ⏎ +error: Didn't match - got: [a b c] but expected: b - ⏎ + Hint: Replace with the actual message: information: a̲ @@ -148,7 +149,7 @@ Hint: Replace with the actual message: c̲ ̲ -/ -#guard_msgs in +#test_msgs in #docs (Genre.Manual) allowDiff30 "Not enough allowDiff" := ::::::: ```lean (name := foo) @@ -160,13 +161,13 @@ b ::::::: /-- -error: Didn't match even with allowDiff := 1 - got: ⏎ +error: Didn't match even with allowDiff := 1 - got: [a b c] but expected: b - ⏎ + Hint: Replace with the actual message: information: a̲ @@ -174,7 +175,7 @@ Hint: Replace with the actual message: c̲ ̲ -/ -#guard_msgs in +#test_msgs in #docs (Genre.Manual) allowDiff31 "Not enough allowDiff" := ::::::: ```lean (name := foo) @@ -185,7 +186,7 @@ b ``` ::::::: -#guard_msgs in +#test_msgs in #docs (Genre.Manual) allowDiff32 "Enough allowDiff" := ::::::: ```lean (name := foo) diff --git a/src/tests/Tests/Linters.lean b/src/tests/VersoTests/Linters.lean similarity index 95% rename from src/tests/Tests/Linters.lean rename to src/tests/VersoTests/Linters.lean index 14eb2115c..b26e46361 100644 --- a/src/tests/Tests/Linters.lean +++ b/src/tests/VersoTests/Linters.lean @@ -3,11 +3,9 @@ Copyright (c) 2026 Lean FRO LLC. All rights reserved. Released under Apache 2.0 license as described in the file LICENSE. Author: David Thrane Christiansen -/ -module -public import Verso -public import VersoManual - -public section +import Errata +import Verso +import VersoManual namespace Verso.LinterTests @@ -21,7 +19,7 @@ set_option pp.rawOnError true /-! By default, it is off: straight quotes in text do not result in warnings. -/ -#guard_msgs in +#test_msgs in #docs (.none) quotesDefault "Quotes default" := ::::::: @@ -47,7 +45,7 @@ Hint: Replace with Unicode Note: This linter can be disabled with `set_option linter.typography.quotes false` -/ -#guard_msgs in +#test_msgs in set_option linter.typography.quotes true in #docs (.none) quotesOn "Quotes on" := ::::::: @@ -63,7 +61,7 @@ Say "hello" to the world. /-! By default, it is off: a triple dash in text does not produce a warning. -/ -#guard_msgs in +#test_msgs in #docs (.none) dashesDefault "Dashes default" := ::::::: @@ -82,7 +80,7 @@ Hint: Replace with Unicode Note: This linter can be disabled with `set_option linter.typography.dashes false` -/ -#guard_msgs in +#test_msgs in set_option linter.typography.dashes true in #docs (.none) dashesOn "Dashes on" := ::::::: @@ -105,7 +103,7 @@ Hint: Replace with Unicode Note: This linter can be disabled with `set_option linter.typography.dashes false` -/ -#guard_msgs in +#test_msgs in set_option linter.typography.dashes true in #docs (.none) typoDashesOnlyMixed "Dashes only mixed" := ::::::: @@ -129,7 +127,7 @@ Hint: Replace with Unicode Note: This linter can be disabled with `set_option linter.typography.quotes false` -/ -#guard_msgs in +#test_msgs in set_option linter.typography.quotes true in #docs (.none) typoQuotesOnlyMixed "Quotes only mixed" := ::::::: @@ -153,7 +151,7 @@ Hint: Use the minimal number of '_'s Note: This linter can be disabled with `set_option linter.verso.markup.emph false` -/ -#guard_msgs in +#test_msgs in #docs (.none) emphDefault "Emph default" := ::::::: @@ -164,7 +162,7 @@ This is __emphatic__ text. /-! When it is disabled, redundant `__` does not produce a warning. -/ -#guard_msgs in +#test_msgs in set_option linter.verso.markup.emph false in #docs (.none) emphOff "Emph off" := ::::::: @@ -188,7 +186,7 @@ Hint: Use the minimal number of '`'s Note: This linter can be disabled with `set_option linter.verso.markup.code false` -/ -#guard_msgs in +#test_msgs in #docs (.none) codeDefault "Code default" := ::::::: @@ -199,7 +197,7 @@ See ``foo`` for details. /-! When it is disabled, redundant inline-code backticks do not produce a warning. -/ -#guard_msgs in +#test_msgs in set_option linter.verso.markup.code false in #docs (.none) codeOff "Code off" := ::::::: @@ -225,7 +223,7 @@ Hint: Use the minimal number of '`'s Note: This linter can be disabled with `set_option linter.verso.markup.codeBlock false` -/ -#guard_msgs in +#test_msgs in #docs (.none) codeBlockDefault "Code block default" := ::::::: @@ -238,7 +236,7 @@ foo /-! When it is disabled, redundant code-block backticks do not produce a warning. -/ -#guard_msgs in +#test_msgs in set_option linter.verso.markup.codeBlock false in #docs (.none) codeBlockOff "Code block off" := ::::::: @@ -256,7 +254,7 @@ foo /-! By default, it is off: untagged headers do not produce a warning. -/ -#guard_msgs in +#test_msgs in #docs (Verso.Genre.Manual) headerTagsDefault "Header tags default" := ::::::: @@ -286,7 +284,7 @@ Note: The tag is used as a permanent name for the section or chapter. Writers of Note: This linter can be disabled with `set_option linter.verso.manual.headerTags false` -/ -#guard_msgs in +#test_msgs in set_option linter.verso.manual.headerTags true in #docs (Verso.Genre.Manual) headerTagsOn "Header tags on" := ::::::: diff --git a/src/tests/VersoTests/LiterateConfig.lean b/src/tests/VersoTests/LiterateConfig.lean new file mode 100644 index 000000000..937f18fb6 --- /dev/null +++ b/src/tests/VersoTests/LiterateConfig.lean @@ -0,0 +1,292 @@ +/- +Copyright (c) 2026 Lean FRO LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Author: David Thrane Christiansen + +Unit tests for the literate-document TOML configuration parser. This is a non-`module` file because +`VersoLiterate` is not part of the module system; the Errata runner imports it through its +non-module main. +-/ +import VersoLiterate +import Errata + +open Lean +open VersoLiterate +open Errata + +/-- Parses a TOML string directly into a `LiterateConfig`. -/ +private def loadFromString (toml : String) : IO LiterateConfig := + parseLiterateConfig toml + +/-- A missing file results in the default config. -/ +@[test] +def missingFile : Test := do + let config ← loadLiterateConfig "/nonexistent/path/literate.toml" + assertEq #[] config.exclude + assertEq #[] config.order + assertEq #[] config.targets + assertNone config.landingPage + +/-- An empty file results in the default config. -/ +@[test] +def emptyFile : Test := do + let config ← loadFromString "" + assertEq #[] config.exclude + assertEq #[] config.order + assertEq #[] config.targets + assertNone config.landingPage + +/-- A whitespace-only file results in the default config. -/ +@[test] +def whitespaceFile : Test := do + let config ← loadFromString " \n \n " + assertEq #[] config.exclude + +/-- The `exclude` list is parsed into an array of names. -/ +@[test] +def exclude : Test := do + let config ← loadFromString "exclude = [\"Foo.Bar\", \"Baz\"]\n" + assertEq 2 config.exclude.size + assertEq `Foo.Bar config.exclude[0]! + assertEq `Baz config.exclude[1]! + +/-- The `order` list is parsed into an array of names, preserving order. -/ +@[test] +def order : Test := do + let config ← loadFromString "order = [\"C\", \"A\", \"B\"]\n" + assertEq 3 config.order.size + assertEq `C config.order[0]! + assertEq `A config.order[1]! + assertEq `B config.order[2]! + +/-- The `landing_page` field is parsed as a name. -/ +@[test] +def landingPage : Test := do + let config ← loadFromString "landing_page = \"MyLib.Overview\"\n" + let lp ← assertSome config.landingPage + assertEq `MyLib.Overview lp + +/-- The `[order_children]` table is parsed into per-parent child orderings. -/ +@[test] +def orderChildren : Test := do + let config ← loadFromString "[order_children]\n\"Foo\" = [\"Foo.B\", \"Foo.A\"]\n\"Bar\" = [\"Bar.Z\"]\n" + let fc ← assertSome (config.orderChildren.find? `Foo) + assertEq 2 fc.size + assertEq `Foo.B fc[0]! + assertEq `Foo.A fc[1]! + let bc ← assertSome (config.orderChildren.find? `Bar) + assertEq 1 bc.size + assertEq `Bar.Z bc[0]! + +/-- The `[[targets]]` entries are parsed into targets with optional fields. -/ +@[test] +def targets : Test := do + let config ← loadFromString "[[targets]]\nmodule = \"Foo\"\n\n[[targets]]\nlibrary = \"Bar\"\n" + assertEq 2 config.targets.size + let t0 ← assertSome config.targets[0]!.module + assertEq `Foo t0 + assertNone config.targets[0]!.library + let t1 ← assertSome config.targets[1]!.library + assertEq `Bar t1 + assertNone config.targets[1]!.module + +/-- Multiple fields in one file are all parsed. -/ +@[test] +def combined : Test := do + let toml := "exclude = [\"Private\"]\norder = [\"Public\", \"Examples\"]\nlanding_page = \"Public\"\n" + let config ← loadFromString toml + assertEq 1 config.exclude.size + assertEq `Private config.exclude[0]! + assertEq 2 config.order.size + assertEq `Public config.order[0]! + assertEq `Examples config.order[1]! + let lp ← assertSome config.landingPage + assertEq `Public lp + +/-- Invalid TOML produces an error. -/ +@[test] +def invalidToml : Test := do + let threw ← show IO Bool from do + try + let _ ← loadFromString "this is not valid toml {{{" + return false + catch _ => + return true + assert threw "invalid TOML should throw" + +/-- `hide_commands` is parsed into keyword pattern strings. -/ +@[test] +def hideCommands : Test := do + let config ← loadFromString "hide_commands = [\"set_option\", \"#check\"]\n" + assertEq 2 config.hideCommands.size + assertEq "set_option" config.hideCommands[0]! + assertEq "#check" config.hideCommands[1]! + +/-- The `[metadata]` table is parsed. -/ +@[test] +def metadata : Test := do + let config ← loadFromString "[metadata]\ntitle = \"My Site\"\ndescription = \"A test site\"\nfavicon = \"favicon.ico\"\n" + let title ← assertSome config.metadata.title + assertEq "My Site" title + let desc ← assertSome config.metadata.description + assertEq "A test site" desc + let fav ← assertSome config.metadata.favicon + assertEq "favicon.ico" fav + +/-- `extra_css` and `extra_js` are parsed into string arrays. -/ +@[test] +def extraCssJs : Test := do + let config ← loadFromString "extra_css = [\"custom.css\", \"theme.css\"]\nextra_js = [\"analytics.js\"]\n" + assertEq 2 config.extraCss.size + assertEq "custom.css" config.extraCss[0]! + assertEq "theme.css" config.extraCss[1]! + assertEq 1 config.extraJs.size + assertEq "analytics.js" config.extraJs[0]! + +/-- `show_docstrings = false` is parsed. -/ +@[test] +def showDocstrings : Test := do + let config ← loadFromString "show_docstrings = false\n" + assert (!config.showDocstrings) "show_docstrings" + +/-- `show_docstrings_for` is parsed into names. -/ +@[test] +def showDocstringsFor : Test := do + let config ← loadFromString "show_docstrings = false\nshow_docstrings_for = [\"Foo.bar\", \"Baz.qux\"]\n" + assert (!config.showDocstrings) "show_docstrings" + assertEq 2 config.showDocstringsFor.size + assertEq `Foo.bar config.showDocstringsFor[0]! + assertEq `Baz.qux config.showDocstringsFor[1]! + +/-- `hide_docstrings_for` is parsed into names. -/ +@[test] +def hideDocstringsFor : Test := do + let config ← loadFromString "hide_docstrings_for = [\"Foo.internal\"]\n" + assert config.showDocstrings "show_docstrings default" + assertEq 1 config.hideDocstringsFor.size + assertEq `Foo.internal config.hideDocstringsFor[0]! + +/-- `show_output` is parsed into keyword pattern strings. -/ +@[test] +def showOutput : Test := do + let config ← loadFromString "show_output = [\"#eval\"]\n" + assertEq 1 config.showOutput.size + assertEq "#eval" config.showOutput[0]! + +/-- `show_output` defaults to the standard four-element list. -/ +@[test] +def showOutputDefault : Test := do + let config ← loadFromString "" + assertEq 4 config.showOutput.size + +/-- `show_imports = false` is parsed. -/ +@[test] +def showImports : Test := do + let config ← loadFromString "show_imports = false\n" + assert (!config.showImports) "show_imports" + +/-- `show_imports` defaults to true. -/ +@[test] +def showImportsDefault : Test := do + let config ← loadFromString "" + assert config.showImports "show_imports default" + +/-- Multiple new fields combine in one config. -/ +@[test] +def combinedNew : Test := do + let toml := String.intercalate "\n" + ["exclude = [\"Private\"]", "hide_commands = [\"set_option\"]", "extra_css = [\"style.css\"]", + "show_docstrings = false", "show_docstrings_for = [\"Public.api\"]", "[metadata]", + "title = \"Test\"", ""] + let config ← loadFromString toml + assertEq 1 config.exclude.size + assertEq 1 config.hideCommands.size + assertEq 1 config.extraCss.size + assert (!config.showDocstrings) "combined new: show_docstrings" + assertEq 1 config.showDocstringsFor.size + let title ← assertSome config.metadata.title + assertEq "Test" title + +/-- The `[theme]` light variables are parsed into the theme map. -/ +@[test] +def themeLight : Test := do + let config ← loadFromString "[theme]\ncode_box_background_color = \"#fff\"\ntext_color = \"#111\"\n" + assertEq 2 config.theme.size + let bg ← assertSome (config.theme.get? "code_box_background_color") + assertEq "#fff" bg + let tc ← assertSome (config.theme.get? "text_color") + assertEq "#111" tc + +/-- The `[theme.dark]` variables are parsed into the dark theme map. -/ +@[test] +def themeDark : Test := do + let config ← loadFromString "[theme]\ntext_color = \"#333\"\n\n[theme.dark]\ntext_color = \"#eee\"\nbackground_color = \"#111\"\n" + assertEq 1 config.theme.size + assertEq 2 config.themeDark.size + let dt ← assertSome (config.themeDark.get? "text_color") + assertEq "#eee" dt + let db ← assertSome (config.themeDark.get? "background_color") + assertEq "#111" db + +/-- An empty theme produces empty maps. -/ +@[test] +def themeEmpty : Test := do + let config ← loadFromString "" + assertEq 0 config.theme.size + assertEq 0 config.themeDark.size + +/-- A `[modules."Foo.Bar"]` table is parsed into a module config. -/ +@[test] +def modulesConfig : Test := do + let toml := "[modules.\"Foo.Bar\"]\ntitle = \"Custom Title\"\nurl = \"custom-url\"\nhide_commands = [\"set_option\"]\nshow_imports = false\n" + let config ← loadFromString toml + let mc ← assertSome (config.modules.find? `Foo.Bar) + let t ← assertSome mc.title + assertEq "Custom Title" t + let u ← assertSome mc.url + assertEq "custom-url" u + let hc ← assertSome mc.hideCommands + assertEq 1 hc.size + let si ← assertSome mc.showImports + assert (!si) "modules Foo.Bar showImports value" + +/-- `resolveForModule` returns global defaults when no module config matches. -/ +@[test] +def resolveNoMatch : Test := do + let config ← loadFromString "hide_commands = [\"set_option\"]\n" + let resolved := config.resolveForModule `Unmatched.Module + assertEq 1 resolved.hideCommands.size + assert resolved.showImports "resolve no match: showImports" + assertNone resolved.title + +/-- `resolveForModule` picks the most-specific prefix match. -/ +@[test] +def resolvePrefixMatch : Test := do + let toml := String.intercalate "\n" + ["hide_commands = [\"set_option\"]", "[modules.\"Foo\"]", "show_imports = false", + "[modules.\"Foo.Bar\"]", "title = \"Bar Title\"", "show_imports = true", ""] + let config ← loadFromString toml + let resolved := config.resolveForModule `Foo.Bar.Baz + let t ← assertSome resolved.title + assertEq "Bar Title" t + assert resolved.showImports "resolve prefix: showImports" + let resolved2 := config.resolveForModule `Foo.Qux + assert (!resolved2.showImports) "resolve Foo prefix: showImports" + assertNone resolved2.title + let resolved3 := config.resolveForModule `Foo.Bar + let t3 ← assertSome resolved3.title + assertEq "Bar Title" t3 + +/-- A module-level config overrides global defaults. -/ +@[test] +def resolveOverridesGlobal : Test := do + let toml := String.intercalate "\n" + ["show_imports = true", "show_docstrings = true", "[modules.\"MyMod\"]", + "show_imports = false", "show_docstrings = false", ""] + let config ← loadFromString toml + let resolved := config.resolveForModule `MyMod + assert (!resolved.showImports) "resolve override: showImports" + assert (!resolved.showDocstrings) "resolve override: showDocstrings" + let resolved2 := config.resolveForModule `Other + assert resolved2.showImports "resolve global: showImports" + assert resolved2.showDocstrings "resolve global: showDocstrings" diff --git a/src/tests/Tests/LiterateHtml.lean b/src/tests/VersoTests/LiterateHtml.lean similarity index 61% rename from src/tests/Tests/LiterateHtml.lean rename to src/tests/VersoTests/LiterateHtml.lean index 39eb49e01..622d6afd8 100644 --- a/src/tests/Tests/LiterateHtml.lean +++ b/src/tests/VersoTests/LiterateHtml.lean @@ -3,18 +3,15 @@ Copyright (c) 2025 Lean FRO LLC. All rights reserved. Released under Apache 2.0 license as described in the file LICENSE. Author: David Thrane Christiansen -/ -module -public import VersoLiterate -public import VersoLiterateCode - -public section +import VersoLiterate +import VersoLiterateCode +import Errata set_option maxRecDepth 1024 -namespace Tests.LiterateHtml +namespace VersoTests.LiterateHtml -private def hasSubstring (s : String) (sub : String) : Bool := - s.find? sub |>.isSome +open Errata private def cleanDir (dir : System.FilePath) : IO Unit := do if ← dir.pathExists then @@ -144,7 +141,7 @@ Runs a test in an independent temporary directory. The callback receives the shared JSON dir, a fresh HTML output dir, and paths for plan/toml files. -/ private def withTestDir (data : TestData) - (test : System.FilePath → System.FilePath → System.FilePath → System.FilePath → IO Unit) : IO Unit := + (test : System.FilePath → System.FilePath → System.FilePath → System.FilePath → Test) : Test := IO.FS.withTempDir fun tmpDir => do let htmlDir := tmpDir / "html" let planFile := tmpDir / "plan" @@ -155,7 +152,7 @@ private def withTestDir (data : TestData) -- ===== Individual tests ===== /-- All modules produce HTML files with expected structure, navigation, and content. -/ -private def testDefaultBehavior (data : TestData) : IO Unit := withTestDir data fun jsonDir htmlDir _ _ => do +private def testDefaultBehavior (data : TestData) : Test := withTestDir data fun jsonDir htmlDir _ _ => do runLiterateHtml jsonDir htmlDir let expectedFiles := #[ @@ -167,41 +164,41 @@ private def testDefaultBehavior (data : TestData) : IO Unit := withTestDir data ] for f in expectedFiles do unless ← f.pathExists do - throw <| IO.userError s!"Expected HTML file not found: {f}" + fail s!"Expected HTML file not found: {f}" let landingHtml ← IO.FS.readFile (htmlDir / "index.html") - unless hasSubstring landingHtml "LitConfig" do - throw <| IO.userError "Landing page does not contain 'LitConfig'" + assertContains "LitConfig" landingHtml + "Landing page does not contain 'LitConfig'" let litConfigHtml ← IO.FS.readFile (htmlDir / "LitConfig" / "index.html") - unless hasSubstring litConfigHtml "LitConfig" do - throw <| IO.userError "LitConfig page title is not 'LitConfig'" - unless hasSubstring litConfigHtml "A Test Module" do - throw <| IO.userError "LitConfig page does not contain module docstring content 'A Test Module'" - unless hasSubstring litConfigHtml "code-box" do - throw <| IO.userError "LitConfig page does not contain any code boxes" - unless hasSubstring litConfigHtml "module-tree" do - throw <| IO.userError "LitConfig page does not contain module tree navigation" - unless hasSubstring litConfigHtml "breadcrumbs" do - throw <| IO.userError "LitConfig page does not contain breadcrumbs" + assertContains "LitConfig" litConfigHtml + "LitConfig page title is not 'LitConfig'" + assertContains "A Test Module" litConfigHtml + "LitConfig page does not contain module docstring content 'A Test Module'" + assertContains "code-box" litConfigHtml + "LitConfig page does not contain any code boxes" + assertContains "module-tree" litConfigHtml + "LitConfig page does not contain module tree navigation" + assertContains "breadcrumbs" litConfigHtml + "LitConfig page does not contain breadcrumbs" let coreHtml ← IO.FS.readFile (htmlDir / "LitConfig" / "Core" / "index.html") - unless hasSubstring coreHtml "Core Module" do - throw <| IO.userError "Core page does not contain module docstring content 'Core Module'" + assertContains "Core Module" coreHtml + "Core page does not contain module docstring content 'Core Module'" let noDocHtml ← IO.FS.readFile (htmlDir / "LitConfig" / "NoDocstrings" / "index.html") - unless hasSubstring noDocHtml "code-box" do - throw <| IO.userError "NoDocstrings page does not contain code boxes" + assertContains "code-box" noDocHtml + "NoDocstrings page does not contain code boxes" /-- The `{kw}` docstring role renders keyword atoms in the HTML output. -/ -private def testKeywordRole (data : TestData) : IO Unit := withTestDir data fun jsonDir htmlDir _ _ => do +private def testKeywordRole (data : TestData) : Test := withTestDir data fun jsonDir htmlDir _ _ => do runLiterateHtml jsonDir htmlDir let coreHtml ← IO.FS.readFile (htmlDir / "LitConfig" / "Core" / "index.html") -- The module docstring contains {kw}`where`, which should render as a keyword-highlighted token - unless hasSubstring coreHtml "where" do - throw <| IO.userError "Core page does not contain keyword 'where' from {kw} role" - unless hasSubstring coreHtml "keyword" do - throw <| IO.userError "Core page does not contain 'keyword' CSS class for {kw} role" + assertContains "where" coreHtml + "Core page does not contain keyword 'where' from {kw} role" + assertContains "keyword" coreHtml + "Core page does not contain 'keyword' CSS class for {kw} role" /-- Per-module JSON path used by the tests. The literate facet writes @@ -215,44 +212,44 @@ Ensures that the HTML rendering pass accepts every built-in docstring extension have handlers), and that the tactic and conv handlers attach the syntax kind's docstring for hovers. -/ -private def testAllBuiltinDocRoles (data : TestData) : IO Unit := withTestDir data fun jsonDir htmlDir _ _ => do +private def testAllBuiltinDocRoles (data : TestData) : Test := withTestDir data fun jsonDir htmlDir _ _ => do runLiterateHtml jsonDir htmlDir let builtinsHtml := htmlDir / "LitConfig" / "Builtins" / "index.html" unless ← builtinsHtml.pathExists do - throw <| IO.userError s!"Expected Builtins HTML page at {builtinsHtml}" + fail s!"Expected Builtins HTML page at {builtinsHtml}" let jsonContent ← IO.FS.readFile (jsonPath jsonDir "LitConfig.Builtins") - unless hasSubstring jsonContent "\"content\":\"rfl\",\"kind\":{\"keyword\":{\"docs\":\"" do - throw <| IO.userError "Builtins JSON has no docs on the `rfl` keyword token. \ + assertContains "\"content\":\"rfl\",\"kind\":{\"keyword\":{\"docs\":\"" jsonContent + "Builtins JSON has no docs on the `rfl` keyword token. \ The tactic handler did not attach the syntax kind's docstring." - unless hasSubstring jsonContent "\"content\":\"lhs\",\"kind\":{\"keyword\":{\"docs\":\"" do - throw <| IO.userError "Builtins JSON has no docs on the `lhs` keyword token. \ + assertContains "\"content\":\"lhs\",\"kind\":{\"keyword\":{\"docs\":\"" jsonContent + "Builtins JSON has no docs on the `lhs` keyword token. \ The conv handler did not attach the syntax kind's docstring." - unless hasSubstring jsonContent "{\"content\":\"funext\",\"kind\":{\"keyword\":{\"docs\":\"" do - throw <| IO.userError "Builtins JSON has no docs on the `funext` keyword token. \ + assertContains "{\"content\":\"funext\",\"kind\":{\"keyword\":{\"docs\":\"" jsonContent + "Builtins JSON has no docs on the `funext` keyword token. \ The kw handler did not attach the syntax kind's docstring." /-- Checks that user-registered `@[inline_to_literate]` and `@[block_to_literate]` handlers shadow the built-ins. -/ -private def testCustomLiterateHandlers (data : TestData) : IO Unit := withTestDir data fun jsonDir htmlDir _ _ => do +private def testCustomLiterateHandlers (data : TestData) : Test := withTestDir data fun jsonDir htmlDir _ _ => do runLiterateHtml jsonDir htmlDir let jsonFile := jsonPath jsonDir "LitConfig.UserExt" unless ← jsonFile.pathExists do - throw <| IO.userError s!"Expected JSON for LitConfig.UserExt at {jsonFile}" + fail s!"Expected JSON for LitConfig.UserExt at {jsonFile}" let jsonContent ← IO.FS.readFile jsonFile - unless hasSubstring jsonContent "USER-CONST-MARKER" do - throw <| IO.userError "UserExt JSON missing USER-CONST-MARKER: `@[inline_to_literate]` handler did not run" - unless hasSubstring jsonContent "USER-LEANBLOCK-MARKER" do - throw <| IO.userError "UserExt JSON missing USER-LEANBLOCK-MARKER: `@[block_to_literate]` handler did not run" + assertContains "USER-CONST-MARKER" jsonContent + "UserExt JSON missing USER-CONST-MARKER: `@[inline_to_literate]` handler did not run" + assertContains "USER-LEANBLOCK-MARKER" jsonContent + "UserExt JSON missing USER-LEANBLOCK-MARKER: `@[block_to_literate]` handler did not run" let html ← IO.FS.readFile (htmlDir / "LitConfig" / "UserExt" / "index.html") - unless hasSubstring html "looks like you're defining a const" do - throw <| IO.userError "UserExt HTML missing the inline replacement text. The user handler's children weren't rendered." - unless hasSubstring html "Replacement For A Lean Block" do - throw <| IO.userError "UserExt HTML missing the block replacement text. The user handler's children weren't rendered." - if hasSubstring html "trivial" then - throw <| IO.userError "UserExt HTML contains 'trivial'. The built-in lean code-block handler ran instead of the user handler." + assertContains "looks like you're defining a const" html + "UserExt HTML missing the inline replacement text. The user handler's children weren't rendered." + assertContains "Replacement For A Lean Block" html + "UserExt HTML missing the block replacement text. The user handler's children weren't rendered." + assertNotContains "trivial" html + "UserExt HTML contains 'trivial'. The built-in lean code-block handler ran instead of the user handler." /-- Checks that messages produced by code blocks in docstrings are attached to the rendered code block @@ -263,17 +260,17 @@ rather than to the command that carries the docstring. comment. The literate pipeline re-attaches it to the rendered code block, so the JSON contains exactly one message span, and that span wraps the {lit}`#eval` token. -/ -private def testDocstringCodeBlockMessages (data : TestData) : IO Unit := do +private def testDocstringCodeBlockMessages (data : TestData) : Test := do let jsonFile := jsonPath data.jsonDir "LitConfig.Builtins" unless ← jsonFile.pathExists do - throw <| IO.userError s!"Expected JSON for LitConfig.Builtins at {jsonFile}" + fail s!"Expected JSON for LitConfig.Builtins at {jsonFile}" let jsonContent ← IO.FS.readFile jsonFile - unless hasSubstring jsonContent "\"span\":{\"content\":{\"token\":{\"tok\":{\"content\":\"#eval\"" do - throw <| IO.userError "Builtins JSON has no message span on the docstring's `#eval`. \ + assertContains "\"span\":{\"content\":{\"token\":{\"tok\":{\"content\":\"#eval\"" jsonContent + "Builtins JSON has no message span on the docstring's `#eval`. \ Messages from docstring code blocks were not re-attached to the rendered code." let spanCount := (jsonContent.splitOn "\"span\":").length - 1 unless spanCount == 1 do - throw <| IO.userError s!"Expected exactly one message span in Builtins JSON, got {spanCount}. \ + fail s!"Expected exactly one message span in Builtins JSON, got {spanCount}. \ A message from a docstring code block may have been attached to the surrounding command." /-- @@ -286,44 +283,44 @@ facet exercises both the conversion fallback (which logs the warning) and the HT (which recurses into the children); this test then searches the build output for the warning and the generated HTML for the marker text. -/ -private def testUnknownExtensionFallback : IO Unit := do +private def testUnknownExtensionFallback : Test := do let result ← IO.Process.output { cmd := "lake" args := #["build", ":literateHtml"] cwd := "test-projects/literate-config" } if result.exitCode != 0 then - throw <| IO.userError s!"lake build :literateHtml failed (exit {result.exitCode}):\nstdout: {result.stdout}\nstderr: {result.stderr}" - unless hasSubstring result.stdout "No inline handler for LitConfig.UserExt.FallbackPayload" do - throw <| IO.userError s!"Expected warning about unhandled extension in build output, got stdout: {result.stdout}\nstderr: {result.stderr}" + fail s!"lake build :literateHtml failed (exit {result.exitCode}):\nstdout: {result.stdout}\nstderr: {result.stderr}" + assertContains "No inline handler for LitConfig.UserExt.FallbackPayload" result.stdout + s!"Expected warning about unhandled extension in build output, got stdout: {result.stdout}\nstderr: {result.stderr}" let htmlFile : System.FilePath := "test-projects/literate-config" / ".lake" / "build" / "literate-html" / "LitConfig" / "UserExt" / "index.html" unless ← htmlFile.pathExists do - throw <| IO.userError s!"Expected HTML page at {htmlFile}" + fail s!"Expected HTML page at {htmlFile}" let html ← IO.FS.readFile htmlFile - unless hasSubstring html "THIS IS THE FALLBACK" do - throw <| IO.userError "HTML missing 'THIS IS THE FALLBACK' marker. The conversion's fallback children were not rendered." + assertContains "THIS IS THE FALLBACK" html + "HTML missing 'THIS IS THE FALLBACK' marker. The conversion's fallback children were not rendered." /-- Excluded modules produce no HTML output and are absent from the navbar. -/ -private def testExclude (data : TestData) : IO Unit := withTestDir data fun jsonDir htmlDir planFile tomlFile => do +private def testExclude (data : TestData) : Test := withTestDir data fun jsonDir htmlDir planFile tomlFile => do IO.FS.writeFile tomlFile "exclude = [\"LitConfig.NoDocstrings\"]\n" runLiteratePlan data.moduleListFile planFile (some tomlFile) runLiterateHtml jsonDir htmlDir (some planFile) (some tomlFile) if ← (htmlDir / "LitConfig" / "NoDocstrings" / "index.html").pathExists then - throw <| IO.userError "Excluded module LitConfig.NoDocstrings should not have HTML output" + fail "Excluded module LitConfig.NoDocstrings should not have HTML output" unless ← (htmlDir / "LitConfig" / "index.html").pathExists do - throw <| IO.userError "LitConfig should still have HTML output after exclude" + fail "LitConfig should still have HTML output after exclude" unless ← (htmlDir / "LitConfig" / "Core" / "index.html").pathExists do - throw <| IO.userError "LitConfig.Core should still have HTML output after exclude" + fail "LitConfig.Core should still have HTML output after exclude" let litConfigHtml ← IO.FS.readFile (htmlDir / "LitConfig" / "index.html") let navbarSection := litConfigHtml.splitOn "module-tree" |>.getD 1 "" |>.splitOn "" |>.head! - if hasSubstring navbarSection "NoDocstrings" then - throw <| IO.userError "Navbar should not contain excluded module 'NoDocstrings'" + assertNotContains "NoDocstrings" navbarSection + "Navbar should not contain excluded module 'NoDocstrings'" /-- The `order` config controls the ordering of modules in the navbar. -/ -private def testNavbarOrder (data : TestData) : IO Unit := withTestDir data fun jsonDir htmlDir planFile tomlFile => do +private def testNavbarOrder (data : TestData) : Test := withTestDir data fun jsonDir htmlDir planFile tomlFile => do IO.FS.writeFile tomlFile "order = [\"LitConfig.NoDocstrings\", \"LitConfig.Core\"]\n" runLiteratePlan data.moduleListFile planFile (some tomlFile) runLiterateHtml jsonDir htmlDir (some planFile) (some tomlFile) @@ -333,22 +330,22 @@ private def testNavbarOrder (data : TestData) : IO Unit := withTestDir data fun let noDocPos := navbarSection.splitOn "NoDocstrings" |>.head! |>.length let corePos := navbarSection.splitOn ">Core<" |>.head! |>.length unless noDocPos < corePos do - throw <| IO.userError s!"NoDocstrings (pos {noDocPos}) should appear before Core (pos {corePos}) in navbar" + fail s!"NoDocstrings (pos {noDocPos}) should appear before Core (pos {corePos}) in navbar" /-- A configured landing page module's content replaces the default table of contents. -/ -private def testLandingPage (data : TestData) : IO Unit := withTestDir data fun jsonDir htmlDir planFile tomlFile => do +private def testLandingPage (data : TestData) : Test := withTestDir data fun jsonDir htmlDir planFile tomlFile => do IO.FS.writeFile tomlFile "landing_page = \"LitConfig.Core\"\n" runLiteratePlan data.moduleListFile planFile (some tomlFile) runLiterateHtml jsonDir htmlDir (some planFile) (some tomlFile) let landingHtml ← IO.FS.readFile (htmlDir / "index.html") - unless hasSubstring landingHtml "Core Module" do - throw <| IO.userError "Landing page should contain 'Core Module' content from the configured landing module" + assertContains "Core Module" landingHtml + "Landing page should contain 'Core Module' content from the configured landing module" unless ← (htmlDir / "LitConfig" / "Core" / "index.html").pathExists do - throw <| IO.userError "Core module should still exist at its normal location" + fail "Core module should still exist at its normal location" /-- HTML generation fails when landing_page names a module not in the loaded module tree. -/ -private def testHtmlLandingPageNotFound (data : TestData) : IO Unit := withTestDir data fun jsonDir htmlDir planFile tomlFile => do +private def testHtmlLandingPageNotFound (data : TestData) : Test := withTestDir data fun jsonDir htmlDir planFile tomlFile => do -- Use a landing_page that won't be in the module tree. -- Write a plan that includes only the real modules (so planning succeeds), -- but the TOML references a module that doesn't exist. @@ -356,27 +353,27 @@ private def testHtmlLandingPageNotFound (data : TestData) : IO Unit := withTestD runLiteratePlan data.moduleListFile planFile none let (exitCode, _, stderr) ← runLiterateHtmlCapture jsonDir htmlDir (some planFile) (some tomlFile) if exitCode == 0 then - throw <| IO.userError "HTML landing_page not found: should have failed with non-zero exit code" - unless hasSubstring stderr "not found" do - throw <| IO.userError "HTML landing_page not found: stderr should mention 'not found'" + fail "HTML landing_page not found: should have failed with non-zero exit code" + assertContains "not found" stderr + "HTML landing_page not found: stderr should mention 'not found'" /-- Excluding a parent module also removes all its children from the output. -/ -private def testRecursiveExclusion (data : TestData) : IO Unit := withTestDir data fun jsonDir htmlDir planFile tomlFile => do +private def testRecursiveExclusion (data : TestData) : Test := withTestDir data fun jsonDir htmlDir planFile tomlFile => do IO.FS.writeFile tomlFile "exclude = [\"LitConfig.Core\"]\n" runLiteratePlan data.moduleListFile planFile (some tomlFile) runLiterateHtml jsonDir htmlDir (some planFile) (some tomlFile) if ← (htmlDir / "LitConfig" / "Core" / "index.html").pathExists then - throw <| IO.userError "Excluded module LitConfig.Core should not have HTML output" + fail "Excluded module LitConfig.Core should not have HTML output" if ← (htmlDir / "LitConfig" / "Core" / "Basic" / "index.html").pathExists then - throw <| IO.userError "Child of excluded module LitConfig.Core.Basic should not have HTML output" + fail "Child of excluded module LitConfig.Core.Basic should not have HTML output" unless ← (htmlDir / "LitConfig" / "index.html").pathExists do - throw <| IO.userError "LitConfig should still have HTML output after excluding Core" + fail "LitConfig should still have HTML output after excluding Core" unless ← (htmlDir / "LitConfig" / "NoDocstrings" / "index.html").pathExists do - throw <| IO.userError "LitConfig.NoDocstrings should still have HTML output after excluding Core" + fail "LitConfig.NoDocstrings should still have HTML output after excluding Core" /-- The `order_children` config controls the ordering of children under a specific parent. -/ -private def testOrderChildren (data : TestData) : IO Unit := withTestDir data fun jsonDir htmlDir planFile tomlFile => do +private def testOrderChildren (data : TestData) : Test := withTestDir data fun jsonDir htmlDir planFile tomlFile => do IO.FS.writeFile tomlFile "[order_children]\n\"LitConfig\" = [\"LitConfig.NoDocstrings\", \"LitConfig.Core\"]\n" runLiteratePlan data.moduleListFile planFile (some tomlFile) runLiterateHtml jsonDir htmlDir (some planFile) (some tomlFile) @@ -386,19 +383,19 @@ private def testOrderChildren (data : TestData) : IO Unit := withTestDir data fu let noDocPos := navbarSection.splitOn "NoDocstrings" |>.head! |>.length let corePos := navbarSection.splitOn ">Core<" |>.head! |>.length unless noDocPos < corePos do - throw <| IO.userError s!"order_children: NoDocstrings (pos {noDocPos}) should appear before Core (pos {corePos}) in navbar" + fail s!"order_children: NoDocstrings (pos {noDocPos}) should appear before Core (pos {corePos}) in navbar" /-- A non-empty `xref.json` cross-reference file is generated in the output. -/ -private def testXrefJsonGenerated (data : TestData) : IO Unit := withTestDir data fun jsonDir htmlDir _ _ => do +private def testXrefJsonGenerated (data : TestData) : Test := withTestDir data fun jsonDir htmlDir _ _ => do runLiterateHtml jsonDir htmlDir unless ← (htmlDir / "xref.json").pathExists do - throw <| IO.userError "xref.json was not generated" + fail "xref.json was not generated" let xrefContent ← IO.FS.readFile (htmlDir / "xref.json") unless xrefContent.trimAscii.toString.length > 2 do - throw <| IO.userError "xref.json is empty or trivial" + fail "xref.json is empty or trivial" /-- The plan file lists all modules by default and respects exclusions. -/ -private def testPlanFileContent (data : TestData) : IO Unit := IO.FS.withTempDir fun tmpDir => do +private def testPlanFileContent (data : TestData) : Test := IO.FS.withTempDir fun tmpDir => do let planFile := tmpDir / "plan" let tomlFile := tmpDir / "literate.toml" -- No config: plan should contain all modules @@ -407,21 +404,21 @@ private def testPlanFileContent (data : TestData) : IO Unit := IO.FS.withTempDir let planModules := planContent.splitOn "\n" |>.filter (!·.isEmpty) for mod in data.modules do unless planModules.contains mod do - throw <| IO.userError s!"Plan file should contain module '{mod}' but doesn't" + fail s!"Plan file should contain module '{mod}' but doesn't" -- With exclusion: excluded modules should be absent from plan IO.FS.writeFile tomlFile "exclude = [\"LitConfig.Core\"]\n" runLiteratePlan data.moduleListFile planFile (some tomlFile) let planContent ← IO.FS.readFile planFile let planModules := planContent.splitOn "\n" |>.filter (!·.isEmpty) if planModules.contains "LitConfig.Core" then - throw <| IO.userError "Plan file should not contain excluded module 'LitConfig.Core'" + fail "Plan file should not contain excluded module 'LitConfig.Core'" if planModules.contains "LitConfig.Core.Basic" then - throw <| IO.userError "Plan file should not contain child of excluded module 'LitConfig.Core.Basic'" + fail "Plan file should not contain child of excluded module 'LitConfig.Core.Basic'" unless planModules.contains "LitConfig" do - throw <| IO.userError "Plan file should still contain 'LitConfig' after excluding Core" + fail "Plan file should still contain 'LitConfig' after excluding Core" /-- Target filtering restricts the plan to only the specified module and its children. -/ -private def testTargetsFiltering (data : TestData) : IO Unit := IO.FS.withTempDir fun tmpDir => do +private def testTargetsFiltering (data : TestData) : Test := IO.FS.withTempDir fun tmpDir => do let planFile := tmpDir / "plan" let tomlFile := tmpDir / "literate.toml" IO.FS.writeFile tomlFile "[[targets]]\nmodule = \"LitConfig.Core\"\n" @@ -429,14 +426,14 @@ private def testTargetsFiltering (data : TestData) : IO Unit := IO.FS.withTempDi let planContent ← IO.FS.readFile planFile let planModules := planContent.splitOn "\n" |>.filter (!·.isEmpty) unless planModules.contains "LitConfig.Core" do - throw <| IO.userError "Plan with target LitConfig.Core should contain LitConfig.Core" + fail "Plan with target LitConfig.Core should contain LitConfig.Core" unless planModules.contains "LitConfig.Core.Basic" do - throw <| IO.userError "Plan with target LitConfig.Core should contain child LitConfig.Core.Basic" + fail "Plan with target LitConfig.Core should contain child LitConfig.Core.Basic" if planModules.contains "LitConfig.NoDocstrings" then - throw <| IO.userError "Plan with target LitConfig.Core should not contain LitConfig.NoDocstrings" + fail "Plan with target LitConfig.Core should not contain LitConfig.NoDocstrings" /-- Library-level target filtering includes all modules belonging to that library. -/ -private def testTargetsLibrary (data : TestData) : IO Unit := IO.FS.withTempDir fun tmpDir => do +private def testTargetsLibrary (data : TestData) : Test := IO.FS.withTempDir fun tmpDir => do let planFile := tmpDir / "plan" let tomlFile := tmpDir / "literate.toml" IO.FS.writeFile tomlFile "[[targets]]\nlibrary = \"LitConfig\"\n" @@ -445,25 +442,25 @@ private def testTargetsLibrary (data : TestData) : IO Unit := IO.FS.withTempDir let planModules := planContent.splitOn "\n" |>.filter (!·.isEmpty) -- All modules in the LitConfig library should be included unless planModules.contains "LitConfig" do - throw <| IO.userError "Library target should include LitConfig" + fail "Library target should include LitConfig" unless planModules.contains "LitConfig.Core" do - throw <| IO.userError "Library target should include LitConfig.Core" + fail "Library target should include LitConfig.Core" unless planModules.contains "LitConfig.Core.Basic" do - throw <| IO.userError "Library target should include LitConfig.Core.Basic" + fail "Library target should include LitConfig.Core.Basic" unless planModules.contains "LitConfig.NoDocstrings" do - throw <| IO.userError "Library target should include LitConfig.NoDocstrings" + fail "Library target should include LitConfig.NoDocstrings" /-- Library-level target filtering with a non-matching library produces an empty set. -/ -private def testTargetsLibraryNonexistent (data : TestData) : IO Unit := IO.FS.withTempDir fun tmpDir => do +private def testTargetsLibraryNonexistent (data : TestData) : Test := IO.FS.withTempDir fun tmpDir => do let planFile := tmpDir / "plan" let tomlFile := tmpDir / "literate.toml" IO.FS.writeFile tomlFile "[[targets]]\nlibrary = \"NonexistentLib\"\n" let (exitCode, _, _) ← runLiteratePlanCapture data.moduleListFile planFile (some tomlFile) unless exitCode != 0 do - throw <| IO.userError "Library target with nonexistent library should fail" + fail "Library target with nonexistent library should fail" /-- Combined library + module target filters to modules that match both constraints. -/ -private def testTargetsLibraryAndModule (data : TestData) : IO Unit := IO.FS.withTempDir fun tmpDir => do +private def testTargetsLibraryAndModule (data : TestData) : Test := IO.FS.withTempDir fun tmpDir => do let planFile := tmpDir / "plan" let tomlFile := tmpDir / "literate.toml" IO.FS.writeFile tomlFile "[[targets]]\nlibrary = \"LitConfig\"\nmodule = \"LitConfig.Core\"\n" @@ -471,39 +468,39 @@ private def testTargetsLibraryAndModule (data : TestData) : IO Unit := IO.FS.wit let planContent ← IO.FS.readFile planFile let planModules := planContent.splitOn "\n" |>.filter (!·.isEmpty) unless planModules.contains "LitConfig.Core" do - throw <| IO.userError "Library+module target should include LitConfig.Core" + fail "Library+module target should include LitConfig.Core" unless planModules.contains "LitConfig.Core.Basic" do - throw <| IO.userError "Library+module target should include LitConfig.Core.Basic" + fail "Library+module target should include LitConfig.Core.Basic" if planModules.contains "LitConfig.NoDocstrings" then - throw <| IO.userError "Library+module target should not include LitConfig.NoDocstrings" + fail "Library+module target should not include LitConfig.NoDocstrings" /-- Commands listed in `hide_commands` produce no output while other commands remain visible. -/ -private def testHideCommands (data : TestData) : IO Unit := withTestDir data fun jsonDir htmlDir _ tomlFile => do +private def testHideCommands (data : TestData) : Test := withTestDir data fun jsonDir htmlDir _ tomlFile => do IO.FS.writeFile tomlFile "hide_commands = [\"set_option\"]\n" runLiterateHtml jsonDir htmlDir (configFile := some tomlFile) let litConfigHtml ← IO.FS.readFile (htmlDir / "LitConfig" / "index.html") - if hasSubstring litConfigHtml "set_option" then - throw <| IO.userError "hide_commands: LitConfig page should not contain 'set_option' text" - unless hasSubstring litConfigHtml "hello" do - throw <| IO.userError "hide_commands: LitConfig page should still contain 'hello'" + assertNotContains "set_option" litConfigHtml + "hide_commands: LitConfig page should not contain 'set_option' text" + assertContains "hello" litConfigHtml + "hide_commands: LitConfig page should still contain 'hello'" /-- The metadata title appears in the landing page and module page `` tags. -/ -private def testMetadataTitle (data : TestData) : IO Unit := withTestDir data fun jsonDir htmlDir _ tomlFile => do +private def testMetadataTitle (data : TestData) : Test := withTestDir data fun jsonDir htmlDir _ tomlFile => do IO.FS.writeFile tomlFile "[metadata]\ntitle = \"Test Site\"\n" runLiterateHtml jsonDir htmlDir (configFile := some tomlFile) let landingHtml ← IO.FS.readFile (htmlDir / "index.html") - unless hasSubstring landingHtml "<title>Test Site" do - throw <| IO.userError "metadata title: landing page should contain 'Test Site'" + assertContains "<title>Test Site" landingHtml + "metadata title: landing page should contain 'Test Site'" let litConfigHtml ← IO.FS.readFile (htmlDir / "LitConfig" / "index.html") - unless hasSubstring litConfigHtml "LitConfig" do - throw <| IO.userError "metadata title: module page should still contain module name 'LitConfig'" - unless hasSubstring litConfigHtml "Test Site" do - throw <| IO.userError "metadata title: module page title should contain 'Test Site'" + assertContains "LitConfig" litConfigHtml + "metadata title: module page should still contain module name 'LitConfig'" + assertContains "Test Site" litConfigHtml + "metadata title: module page title should contain 'Test Site'" /-- Extra CSS files are copied to the output directory and linked in the HTML head. -/ -private def testExtraCss (data : TestData) : IO Unit := IO.FS.withTempDir fun tmpDir => do +private def testExtraCss (data : TestData) : Test := IO.FS.withTempDir fun tmpDir => do let htmlDir := tmpDir / "html" let tomlFile := tmpDir / "literate.toml" IO.FS.createDirAll htmlDir @@ -513,73 +510,73 @@ private def testExtraCss (data : TestData) : IO Unit := IO.FS.withTempDir fun tm runLiterateHtml data.jsonDir htmlDir (configFile := some tomlFile) unless ← (htmlDir / "custom-test.css").pathExists do - throw <| IO.userError "extra CSS: custom-test.css was not copied to output" + fail "extra CSS: custom-test.css was not copied to output" let litConfigHtml ← IO.FS.readFile (htmlDir / "LitConfig" / "index.html") - unless hasSubstring litConfigHtml "custom-test.css" do - throw <| IO.userError "extra CSS: HTML does not reference custom-test.css" + assertContains "custom-test.css" litConfigHtml + "extra CSS: HTML does not reference custom-test.css" /-- Declaration docstrings are hidden globally while module docstrings remain visible. -/ -private def testShowDocstringsFalse (data : TestData) : IO Unit := withTestDir data fun jsonDir htmlDir _ tomlFile => do +private def testShowDocstringsFalse (data : TestData) : Test := withTestDir data fun jsonDir htmlDir _ tomlFile => do IO.FS.writeFile tomlFile "show_docstrings = false\n" runLiterateHtml jsonDir htmlDir (configFile := some tomlFile) let litConfigHtml ← IO.FS.readFile (htmlDir / "LitConfig" / "index.html") - if hasSubstring litConfigHtml "A greeting message" then - throw <| IO.userError "show_docstrings=false: declaration docstring 'A greeting message' should be hidden" - unless hasSubstring litConfigHtml "A Test Module" do - throw <| IO.userError "show_docstrings=false: module docstring 'A Test Module' should still appear" + assertNotContains "A greeting message" litConfigHtml + "show_docstrings=false: declaration docstring 'A greeting message' should be hidden" + assertContains "A Test Module" litConfigHtml + "show_docstrings=false: module docstring 'A Test Module' should still appear" /-- `show_imports = false` hides the imports list. -/ -private def testShowImportsFalse (data : TestData) : IO Unit := withTestDir data fun jsonDir htmlDir _ tomlFile => do +private def testShowImportsFalse (data : TestData) : Test := withTestDir data fun jsonDir htmlDir _ tomlFile => do IO.FS.writeFile tomlFile "show_imports = false\n" runLiterateHtml jsonDir htmlDir (configFile := some tomlFile) let coreHtml ← IO.FS.readFile (htmlDir / "LitConfig" / "Core" / "index.html") - if hasSubstring coreHtml "imports-list" then - throw <| IO.userError "show_imports=false: page should not contain 'imports-list'" + assertNotContains "imports-list" coreHtml + "show_imports=false: page should not contain 'imports-list'" /-- Default config shows imports in a collapsible details element. -/ -private def testShowImportsDefault (data : TestData) : IO Unit := withTestDir data fun jsonDir htmlDir _ _ => do +private def testShowImportsDefault (data : TestData) : Test := withTestDir data fun jsonDir htmlDir _ _ => do runLiterateHtml jsonDir htmlDir let coreHtml ← IO.FS.readFile (htmlDir / "LitConfig" / "Core" / "index.html") - unless hasSubstring coreHtml "imports-list" do - throw <| IO.userError "show_imports default: Core page should contain 'imports-list'" - unless hasSubstring coreHtml "<details" do - throw <| IO.userError "show_imports default: imports should be in a collapsible <details> element" + assertContains "imports-list" coreHtml + "show_imports default: Core page should contain 'imports-list'" + assertContains "<details" coreHtml + "show_imports default: imports should be in a collapsible <details> element" /-- Default config renders output blocks for #eval commands. -/ -private def testShowOutput (data : TestData) : IO Unit := withTestDir data fun jsonDir htmlDir _ _ => do +private def testShowOutput (data : TestData) : Test := withTestDir data fun jsonDir htmlDir _ _ => do runLiterateHtml jsonDir htmlDir let coreHtml ← IO.FS.readFile (htmlDir / "LitConfig" / "Core" / "index.html") -- Check for an actual lean-output element (class on a <pre> tag), not just the CSS rules - unless hasSubstring coreHtml "class=\"hl lean lean-output" do - throw <| IO.userError "show_output default: Core page should contain output block elements for #eval commands" + assertContains "class=\"hl lean lean-output" coreHtml + "show_output default: Core page should contain output block elements for #eval commands" /-- `show_output = []` suppresses all output blocks. -/ -private def testShowOutputEmpty (data : TestData) : IO Unit := withTestDir data fun jsonDir htmlDir _ tomlFile => do +private def testShowOutputEmpty (data : TestData) : Test := withTestDir data fun jsonDir htmlDir _ tomlFile => do IO.FS.writeFile tomlFile "show_output = []\n" runLiterateHtml jsonDir htmlDir (configFile := some tomlFile) let coreHtml ← IO.FS.readFile (htmlDir / "LitConfig" / "Core" / "index.html") -- Check that no actual lean-output elements exist (CSS rules in `<style>` don't count) - if hasSubstring coreHtml "class=\"hl lean lean-output" then - throw <| IO.userError "show_output=[]: Core page should not contain output block elements" + assertNotContains "class=\"hl lean lean-output" coreHtml + "show_output=[]: Core page should not contain output block elements" /-- Docstrings are hidden for specific named declarations while other content remains. -/ -private def testHideDocstringsFor (data : TestData) : IO Unit := withTestDir data fun jsonDir htmlDir _ tomlFile => do +private def testHideDocstringsFor (data : TestData) : Test := withTestDir data fun jsonDir htmlDir _ tomlFile => do IO.FS.writeFile tomlFile "hide_docstrings_for = [\"hello\"]\n" runLiterateHtml jsonDir htmlDir (configFile := some tomlFile) let litConfigHtml ← IO.FS.readFile (htmlDir / "LitConfig" / "index.html") - if hasSubstring litConfigHtml "A greeting message" then - throw <| IO.userError "hide_docstrings_for: 'A greeting message' should be hidden for 'hello'" - unless hasSubstring litConfigHtml "A Test Module" do - throw <| IO.userError "hide_docstrings_for: module docstring 'A Test Module' should still appear" + assertNotContains "A greeting message" litConfigHtml + "hide_docstrings_for: 'A greeting message' should be hidden for 'hello'" + assertContains "A Test Module" litConfigHtml + "hide_docstrings_for: module docstring 'A Test Module' should still appear" /-- Favicon is copied to the output directory and linked in the HTML. -/ -private def testFavicon (data : TestData) : IO Unit := IO.FS.withTempDir fun tmpDir => do +private def testFavicon (data : TestData) : Test := IO.FS.withTempDir fun tmpDir => do let htmlDir := tmpDir / "html" let tomlFile := tmpDir / "literate.toml" IO.FS.createDirAll htmlDir @@ -589,13 +586,13 @@ private def testFavicon (data : TestData) : IO Unit := IO.FS.withTempDir fun tmp runLiterateHtml data.jsonDir htmlDir (configFile := some tomlFile) unless ← (htmlDir / "test-favicon.png").pathExists do - throw <| IO.userError "favicon: test-favicon.png was not copied to output" + fail "favicon: test-favicon.png was not copied to output" let litConfigHtml ← IO.FS.readFile (htmlDir / "LitConfig" / "index.html") - unless hasSubstring litConfigHtml "test-favicon.png" do - throw <| IO.userError "favicon: HTML does not reference test-favicon.png" + assertContains "test-favicon.png" litConfigHtml + "favicon: HTML does not reference test-favicon.png" /-- Extra JS files are copied to the output directory and linked in the HTML. -/ -private def testExtraJs (data : TestData) : IO Unit := IO.FS.withTempDir fun tmpDir => do +private def testExtraJs (data : TestData) : Test := IO.FS.withTempDir fun tmpDir => do let htmlDir := tmpDir / "html" let tomlFile := tmpDir / "literate.toml" IO.FS.createDirAll htmlDir @@ -605,60 +602,60 @@ private def testExtraJs (data : TestData) : IO Unit := IO.FS.withTempDir fun tmp runLiterateHtml data.jsonDir htmlDir (configFile := some tomlFile) unless ← (htmlDir / "custom-test.js").pathExists do - throw <| IO.userError "extra JS: custom-test.js was not copied to output" + fail "extra JS: custom-test.js was not copied to output" let litConfigHtml ← IO.FS.readFile (htmlDir / "LitConfig" / "index.html") - unless hasSubstring litConfigHtml "custom-test.js" do - throw <| IO.userError "extra JS: HTML does not reference custom-test.js" + assertContains "custom-test.js" litConfigHtml + "extra JS: HTML does not reference custom-test.js" /-- Targets + exclude: exclusion narrows the target set. -/ -private def testTargetsPlusExclude (data : TestData) : IO Unit := withTestDir data fun jsonDir htmlDir planFile tomlFile => do +private def testTargetsPlusExclude (data : TestData) : Test := withTestDir data fun jsonDir htmlDir planFile tomlFile => do IO.FS.writeFile tomlFile "exclude = [\"LitConfig.Core.Basic\"]\n\n[[targets]]\nmodule = \"LitConfig.Core\"\n" runLiteratePlan data.moduleListFile planFile (some tomlFile) runLiterateHtml jsonDir htmlDir (some planFile) (some tomlFile) unless ← (htmlDir / "LitConfig" / "Core" / "index.html").pathExists do - throw <| IO.userError "targets+exclude: LitConfig.Core should exist" + fail "targets+exclude: LitConfig.Core should exist" if ← (htmlDir / "LitConfig" / "Core" / "Basic" / "index.html").pathExists then - throw <| IO.userError "targets+exclude: LitConfig.Core.Basic should be excluded" + fail "targets+exclude: LitConfig.Core.Basic should be excluded" if ← (htmlDir / "LitConfig" / "NoDocstrings" / "index.html").pathExists then - throw <| IO.userError "targets+exclude: LitConfig.NoDocstrings should not be in targets" + fail "targets+exclude: LitConfig.NoDocstrings should not be in targets" /-- show_docstrings = false with show_docstrings_for exceptions still shows the excepted docstring. -/ -private def testShowDocstringsForExceptions (data : TestData) : IO Unit := withTestDir data fun jsonDir htmlDir _ tomlFile => do +private def testShowDocstringsForExceptions (data : TestData) : Test := withTestDir data fun jsonDir htmlDir _ tomlFile => do IO.FS.writeFile tomlFile "show_docstrings = false\nshow_docstrings_for = [\"hello\"]\n" runLiterateHtml jsonDir htmlDir (configFile := some tomlFile) let litConfigHtml ← IO.FS.readFile (htmlDir / "LitConfig" / "index.html") - unless hasSubstring litConfigHtml "A greeting message" do - throw <| IO.userError "show_docstrings_for exception: 'A greeting message' should be visible for 'hello'" + assertContains "A greeting message" litConfigHtml + "show_docstrings_for exception: 'A greeting message' should be visible for 'hello'" -- Other declaration docstrings should be hidden (e.g., in Core module) let coreHtml ← IO.FS.readFile (htmlDir / "LitConfig" / "Core" / "index.html") - if hasSubstring coreHtml "Doubles a natural number" then - throw <| IO.userError "show_docstrings_for exception: 'Doubles a natural number' should be hidden" + assertNotContains "Doubles a natural number" coreHtml + "show_docstrings_for exception: 'Doubles a natural number' should be hidden" /-- Metadata description appears as a meta tag in the HTML. -/ -private def testMetadataDescription (data : TestData) : IO Unit := withTestDir data fun jsonDir htmlDir _ tomlFile => do +private def testMetadataDescription (data : TestData) : Test := withTestDir data fun jsonDir htmlDir _ tomlFile => do IO.FS.writeFile tomlFile "[metadata]\ndescription = \"A test description\"\n" runLiterateHtml jsonDir htmlDir (configFile := some tomlFile) let litConfigHtml ← IO.FS.readFile (htmlDir / "LitConfig" / "index.html") - unless hasSubstring litConfigHtml "A test description" do - throw <| IO.userError "metadata description: HTML should contain 'A test description'" - unless hasSubstring litConfigHtml "meta" do - throw <| IO.userError "metadata description: HTML should contain a meta tag" + assertContains "A test description" litConfigHtml + "metadata description: HTML should contain 'A test description'" + assertContains "meta" litConfigHtml + "metadata description: HTML should contain a meta tag" /-- The current page is highlighted in the navbar with the 'current' class. -/ -private def testCurrentPageHighlighting (data : TestData) : IO Unit := withTestDir data fun jsonDir htmlDir _ _ => do +private def testCurrentPageHighlighting (data : TestData) : Test := withTestDir data fun jsonDir htmlDir _ _ => do runLiterateHtml jsonDir htmlDir let coreHtml ← IO.FS.readFile (htmlDir / "LitConfig" / "Core" / "index.html") let navbarSection := coreHtml.splitOn "module-tree" |>.getD 1 "" |>.splitOn "</nav>" |>.head! -- The Core entry should have a 'current' class - unless hasSubstring navbarSection "current" do - throw <| IO.userError "current page highlighting: navbar should contain 'current' class" + assertContains "current" navbarSection + "current page highlighting: navbar should contain 'current' class" /-- Plan with targets + exclude combined produces the correct reduced module set. -/ -private def testPlanTargetsPlusExclude (data : TestData) : IO Unit := IO.FS.withTempDir fun tmpDir => do +private def testPlanTargetsPlusExclude (data : TestData) : Test := IO.FS.withTempDir fun tmpDir => do let planFile := tmpDir / "plan" let tomlFile := tmpDir / "literate.toml" IO.FS.writeFile tomlFile "exclude = [\"LitConfig.Core.Basic\"]\n\n[[targets]]\nmodule = \"LitConfig.Core\"\n" @@ -666,61 +663,61 @@ private def testPlanTargetsPlusExclude (data : TestData) : IO Unit := IO.FS.with let planContent ← IO.FS.readFile planFile let planModules := planContent.splitOn "\n" |>.filter (!·.isEmpty) unless planModules.contains "LitConfig.Core" do - throw <| IO.userError "plan targets+exclude: should contain LitConfig.Core" + fail "plan targets+exclude: should contain LitConfig.Core" if planModules.contains "LitConfig.Core.Basic" then - throw <| IO.userError "plan targets+exclude: should not contain excluded LitConfig.Core.Basic" + fail "plan targets+exclude: should not contain excluded LitConfig.Core.Basic" if planModules.contains "LitConfig" then - throw <| IO.userError "plan targets+exclude: should not contain LitConfig (not in targets)" + fail "plan targets+exclude: should not contain LitConfig (not in targets)" /-- Plan fails with error when landing_page names a module not in the included set. -/ -private def testPlanLandingPageNotInSet (data : TestData) : IO Unit := IO.FS.withTempDir fun tmpDir => do +private def testPlanLandingPageNotInSet (data : TestData) : Test := IO.FS.withTempDir fun tmpDir => do let planFile := tmpDir / "plan" let tomlFile := tmpDir / "literate.toml" IO.FS.writeFile tomlFile "landing_page = \"NonExistent.Module\"\n" let (exitCode, _, stderr) ← runLiteratePlanCapture data.moduleListFile planFile (some tomlFile) if exitCode == 0 then - throw <| IO.userError "plan landing_page validation: should have failed with non-zero exit code" - unless hasSubstring stderr "landing_page" do - throw <| IO.userError "plan landing_page validation: stderr should mention 'landing_page'" + fail "plan landing_page validation: should have failed with non-zero exit code" + assertContains "landing_page" stderr + "plan landing_page validation: stderr should mention 'landing_page'" /-- Plan fails with error when all modules are excluded (empty module set). -/ -private def testPlanEmptyModuleSet (data : TestData) : IO Unit := IO.FS.withTempDir fun tmpDir => do +private def testPlanEmptyModuleSet (data : TestData) : Test := IO.FS.withTempDir fun tmpDir => do let planFile := tmpDir / "plan" let tomlFile := tmpDir / "literate.toml" IO.FS.writeFile tomlFile "exclude = [\"LitConfig\"]\n" let (exitCode, _, stderr) ← runLiteratePlanCapture data.moduleListFile planFile (some tomlFile) if exitCode == 0 then - throw <| IO.userError "plan empty module set: should have failed with non-zero exit code" - unless hasSubstring stderr "no modules" do - throw <| IO.userError "plan empty module set: stderr should mention 'no modules'" + fail "plan empty module set: should have failed with non-zero exit code" + assertContains "no modules" stderr + "plan empty module set: stderr should mention 'no modules'" /-- Plan succeeds with a warning when an ordered module does not exist. -/ -private def testPlanOrderWarning (data : TestData) : IO Unit := IO.FS.withTempDir fun tmpDir => do +private def testPlanOrderWarning (data : TestData) : Test := IO.FS.withTempDir fun tmpDir => do let planFile := tmpDir / "plan" let tomlFile := tmpDir / "literate.toml" IO.FS.writeFile tomlFile "order = [\"NonExistent.Module\"]\n" let (exitCode, _, stderr) ← runLiteratePlanCapture data.moduleListFile planFile (some tomlFile) if exitCode != 0 then - throw <| IO.userError "plan order warning: should succeed (warning only, not error)" - unless hasSubstring stderr "Warning" do - throw <| IO.userError "plan order warning: stderr should contain a warning" - unless hasSubstring stderr "NonExistent.Module" do - throw <| IO.userError "plan order warning: stderr should mention 'NonExistent.Module'" + fail "plan order warning: should succeed (warning only, not error)" + assertContains "Warning" stderr + "plan order warning: stderr should contain a warning" + assertContains "NonExistent.Module" stderr + "plan order warning: stderr should mention 'NonExistent.Module'" /-- HTML generation fails when hide_docstrings_for names a nonexistent declaration. -/ -private def testHtmlInvalidDocstringFor (data : TestData) : IO Unit := IO.FS.withTempDir fun tmpDir => do +private def testHtmlInvalidDocstringFor (data : TestData) : Test := IO.FS.withTempDir fun tmpDir => do let htmlDir := tmpDir / "html" let tomlFile := tmpDir / "literate.toml" IO.FS.createDirAll htmlDir IO.FS.writeFile tomlFile "hide_docstrings_for = [\"nonexistent_decl\"]\n" let (exitCode, _, stderr) ← runLiterateHtmlCapture data.jsonDir htmlDir (configFile := some tomlFile) if exitCode == 0 then - throw <| IO.userError "HTML invalid docstring_for: should have failed with non-zero exit code" - unless hasSubstring stderr "nonexistent_decl" do - throw <| IO.userError "HTML invalid docstring_for: stderr should mention 'nonexistent_decl'" + fail "HTML invalid docstring_for: should have failed with non-zero exit code" + assertContains "nonexistent_decl" stderr + "HTML invalid docstring_for: stderr should mention 'nonexistent_decl'" /-- Theme CSS file is generated and linked when theme overrides are present. -/ -private def testThemeCss (data : TestData) : IO Unit := IO.FS.withTempDir fun tmpDir => do +private def testThemeCss (data : TestData) : Test := IO.FS.withTempDir fun tmpDir => do let htmlDir := tmpDir / "html" let tomlFile := tmpDir / "literate.toml" IO.FS.createDirAll htmlDir @@ -736,33 +733,33 @@ private def testThemeCss (data : TestData) : IO Unit := IO.FS.withTempDir fun tm runLiterateHtml data.jsonDir htmlDir (configFile := some tomlFile) unless ← (htmlDir / "literate-theme.css").pathExists do - throw <| IO.userError "theme: literate-theme.css was not generated" + fail "theme: literate-theme.css was not generated" let themeCss ← IO.FS.readFile (htmlDir / "literate-theme.css") - unless hasSubstring themeCss "--verso-code-box-background-color" do - throw <| IO.userError "theme: literate-theme.css does not contain code box variable" - unless hasSubstring themeCss "#f0f0f0" do - throw <| IO.userError "theme: literate-theme.css does not contain light value" - unless hasSubstring themeCss "prefers-color-scheme: dark" do - throw <| IO.userError "theme: literate-theme.css does not contain dark media query" - unless hasSubstring themeCss "#ddd" do - throw <| IO.userError "theme: literate-theme.css does not contain dark value" - unless hasSubstring themeCss "data-theme" do - throw <| IO.userError "theme: literate-theme.css does not contain data-theme selector" + assertContains "--verso-code-box-background-color" themeCss + "theme: literate-theme.css does not contain code box variable" + assertContains "#f0f0f0" themeCss + "theme: literate-theme.css does not contain light value" + assertContains "prefers-color-scheme: dark" themeCss + "theme: literate-theme.css does not contain dark media query" + assertContains "#ddd" themeCss + "theme: literate-theme.css does not contain dark value" + assertContains "data-theme" themeCss + "theme: literate-theme.css does not contain data-theme selector" let litConfigHtml ← IO.FS.readFile (htmlDir / "LitConfig" / "index.html") - unless hasSubstring litConfigHtml "literate-theme.css" do - throw <| IO.userError "theme: HTML does not link literate-theme.css" + assertContains "literate-theme.css" litConfigHtml + "theme: HTML does not link literate-theme.css" /-- No theme CSS file is generated when theme is empty. -/ -private def testThemeCssEmpty (data : TestData) : IO Unit := withTestDir data fun jsonDir htmlDir _ _ => do +private def testThemeCssEmpty (data : TestData) : Test := withTestDir data fun jsonDir htmlDir _ _ => do runLiterateHtml jsonDir htmlDir if ← (htmlDir / "literate-theme.css").pathExists then - throw <| IO.userError "theme empty: literate-theme.css should not exist with default config" + fail "theme empty: literate-theme.css should not exist with default config" let litConfigHtml ← IO.FS.readFile (htmlDir / "LitConfig" / "index.html") - if hasSubstring litConfigHtml "literate-theme.css" then - throw <| IO.userError "theme empty: HTML should not link literate-theme.css when no theme is set" + assertNotContains "literate-theme.css" litConfigHtml + "theme empty: HTML should not link literate-theme.css when no theme is set" /-- Per-module hide_commands overrides global config. -/ -private def testPerModuleHideCommands (data : TestData) : IO Unit := withTestDir data fun jsonDir htmlDir _ tomlFile => do +private def testPerModuleHideCommands (data : TestData) : Test := withTestDir data fun jsonDir htmlDir _ tomlFile => do IO.FS.writeFile tomlFile (String.intercalate "\n" [ "[modules.\"LitConfig\"]", "hide_commands = [\"set_option\"]", @@ -771,15 +768,15 @@ private def testPerModuleHideCommands (data : TestData) : IO Unit := withTestDir runLiterateHtml jsonDir htmlDir (configFile := some tomlFile) let litConfigHtml ← IO.FS.readFile (htmlDir / "LitConfig" / "index.html") - if hasSubstring litConfigHtml "set_option" then - throw <| IO.userError "per-module hide_commands: LitConfig should not contain 'set_option'" + assertNotContains "set_option" litConfigHtml + "per-module hide_commands: LitConfig should not contain 'set_option'" -- Core should NOT be affected (no module config) let coreHtml ← IO.FS.readFile (htmlDir / "LitConfig" / "Core" / "index.html") - unless hasSubstring coreHtml "code-box" do - throw <| IO.userError "per-module hide_commands: Core should still have code boxes" + assertContains "code-box" coreHtml + "per-module hide_commands: Core should still have code boxes" /-- Per-module title appears in the page title and navbar. -/ -private def testPerModuleTitle (data : TestData) : IO Unit := withTestDir data fun jsonDir htmlDir _ tomlFile => do +private def testPerModuleTitle (data : TestData) : Test := withTestDir data fun jsonDir htmlDir _ tomlFile => do IO.FS.writeFile tomlFile (String.intercalate "\n" [ "[modules.\"LitConfig.Core\"]", "title = \"Core Library\"", @@ -788,16 +785,16 @@ private def testPerModuleTitle (data : TestData) : IO Unit := withTestDir data f runLiterateHtml jsonDir htmlDir (configFile := some tomlFile) let coreHtml ← IO.FS.readFile (htmlDir / "LitConfig" / "Core" / "index.html") - unless hasSubstring coreHtml "Core Library" do - throw <| IO.userError "per-module title: page should contain 'Core Library'" + assertContains "Core Library" coreHtml + "per-module title: page should contain 'Core Library'" -- Check navbar let litConfigHtml ← IO.FS.readFile (htmlDir / "LitConfig" / "index.html") let navbarSection := litConfigHtml.splitOn "module-tree" |>.getD 1 "" |>.splitOn "</nav>" |>.head! - unless hasSubstring navbarSection "Core Library" do - throw <| IO.userError "per-module title: navbar should contain 'Core Library'" + assertContains "Core Library" navbarSection + "per-module title: navbar should contain 'Core Library'" /-- Per-module title appears in breadcrumbs without code formatting. -/ -private def testPerModuleTitleBreadcrumbs (data : TestData) : IO Unit := withTestDir data fun jsonDir htmlDir _ tomlFile => do +private def testPerModuleTitleBreadcrumbs (data : TestData) : Test := withTestDir data fun jsonDir htmlDir _ tomlFile => do IO.FS.writeFile tomlFile (String.intercalate "\n" [ "[modules.\"LitConfig.Core\"]", "title = \"Core Library\"", @@ -809,24 +806,24 @@ private def testPerModuleTitleBreadcrumbs (data : TestData) : IO Unit := withTes let coreHtml ← IO.FS.readFile (htmlDir / "LitConfig" / "Core" / "index.html") let breadcrumbSection := coreHtml.splitOn "breadcrumbs" |>.getD 1 "" |>.splitOn "</ol>" |>.head! -- Custom title should appear without <code> wrapping - unless hasSubstring breadcrumbSection "Core Library" do - throw <| IO.userError "title breadcrumbs: should display custom title 'Core Library'" - if hasSubstring breadcrumbSection "<code>Core Library</code>" then - throw <| IO.userError "title breadcrumbs: custom title should not be wrapped in <code>" + assertContains "Core Library" breadcrumbSection + "title breadcrumbs: should display custom title 'Core Library'" + assertNotContains "<code>Core Library</code>" breadcrumbSection + "title breadcrumbs: custom title should not be wrapped in <code>" -- On a child page, the ancestor breadcrumb should show "Core Library" as a link let basicHtml ← IO.FS.readFile (htmlDir / "LitConfig" / "Core" / "Basic" / "index.html") let childBcSection := basicHtml.splitOn "breadcrumbs" |>.getD 1 "" |>.splitOn "</ol>" |>.head! - unless hasSubstring childBcSection "Core Library" do - throw <| IO.userError "title breadcrumbs: child page should show ancestor custom title 'Core Library'" + assertContains "Core Library" childBcSection + "title breadcrumbs: child page should show ancestor custom title 'Core Library'" -- The ancestor link with custom title should not use <code> - if hasSubstring childBcSection "<code>Core Library</code>" then - throw <| IO.userError "title breadcrumbs: ancestor custom title should not be wrapped in <code>" + assertNotContains "<code>Core Library</code>" childBcSection + "title breadcrumbs: ancestor custom title should not be wrapped in <code>" -- But the "LitConfig" ancestor should still use <code> (no custom title) - unless hasSubstring childBcSection "<code>LitConfig</code>" do - throw <| IO.userError "title breadcrumbs: module name ancestor should be in <code>" + assertContains "<code>LitConfig</code>" childBcSection + "title breadcrumbs: module name ancestor should be in <code>" /-- Per-module URL override places the HTML at the custom path and updates navbar links. -/ -private def testPerModuleUrl (data : TestData) : IO Unit := withTestDir data fun jsonDir htmlDir _ tomlFile => do +private def testPerModuleUrl (data : TestData) : Test := withTestDir data fun jsonDir htmlDir _ tomlFile => do IO.FS.writeFile tomlFile (String.intercalate "\n" [ "[modules.\"LitConfig.Core\"]", "url = \"core-docs\"", @@ -836,35 +833,35 @@ private def testPerModuleUrl (data : TestData) : IO Unit := withTestDir data fun -- HTML should be at the custom URL path, not the default unless ← (htmlDir / "core-docs" / "index.html").pathExists do - throw <| IO.userError "per-module url: expected HTML at core-docs/index.html" + fail "per-module url: expected HTML at core-docs/index.html" if ← (htmlDir / "LitConfig" / "Core" / "index.html").pathExists then - throw <| IO.userError "per-module url: HTML should not exist at default path LitConfig/Core/index.html" + fail "per-module url: HTML should not exist at default path LitConfig/Core/index.html" -- Navbar should link to the custom URL let litConfigHtml ← IO.FS.readFile (htmlDir / "LitConfig" / "index.html") let navbarSection := litConfigHtml.splitOn "module-tree" |>.getD 1 "" |>.splitOn "</nav>" |>.head! - unless hasSubstring navbarSection "core-docs/" do - throw <| IO.userError "per-module url: navbar should link to 'core-docs/'" + assertContains "core-docs/" navbarSection + "per-module url: navbar should link to 'core-docs/'" -- Base href should reflect custom URL depth (1 segment = "../"), not module name depth let coreDocsHtml ← IO.FS.readFile (htmlDir / "core-docs" / "index.html") - unless hasSubstring coreDocsHtml "base href=\"../\"" do - throw <| IO.userError "per-module url: base href should be '../' (depth 1), not '../../../' (depth 3)" + assertContains "base href=\"../\"" coreDocsHtml + "per-module url: base href should be '../' (depth 1), not '../../../' (depth 3)" -- Breadcrumbs should show module name labels (not URL segments) let breadcrumbSection := coreDocsHtml.splitOn "breadcrumbs" |>.getD 1 "" |>.splitOn "</ol>" |>.head! -- The breadcrumb should display "Core" (module name), not "core-docs" (URL segment) - unless hasSubstring breadcrumbSection ">Core<" do - throw <| IO.userError "per-module url: breadcrumb should display module name 'Core'" + assertContains ">Core<" breadcrumbSection + "per-module url: breadcrumb should display module name 'Core'" -- The ancestor breadcrumb should link to LitConfig/ - unless hasSubstring breadcrumbSection "href=\"LitConfig/\"" do - throw <| IO.userError "per-module url: ancestor breadcrumb should link to 'LitConfig/'" + assertContains "href=\"LitConfig/\"" breadcrumbSection + "per-module url: ancestor breadcrumb should link to 'LitConfig/'" -- Landing page should link to custom URL let landingHtml ← IO.FS.readFile (htmlDir / "index.html") - unless hasSubstring landingHtml "core-docs/" do - throw <| IO.userError "per-module url: landing page should link to 'core-docs/'" - if hasSubstring (landingHtml.splitOn "module-toc" |>.getD 1 "" |>.splitOn "</ul>" |>.head!) "LitConfig/Core/" then - throw <| IO.userError "per-module url: landing page should not link to 'LitConfig/Core/'" + assertContains "core-docs/" landingHtml + "per-module url: landing page should link to 'core-docs/'" + assertNotContains "LitConfig/Core/" (landingHtml.splitOn "module-toc" |>.getD 1 "" |>.splitOn "</ul>" |>.head!) + "per-module url: landing page should not link to 'LitConfig/Core/'" /-- URL overrides on a parent module propagate to children via relative append. -/ -private def testPerModuleUrlInheritance (data : TestData) : IO Unit := withTestDir data fun jsonDir htmlDir _ tomlFile => do +private def testPerModuleUrlInheritance (data : TestData) : Test := withTestDir data fun jsonDir htmlDir _ tomlFile => do IO.FS.writeFile tomlFile (String.intercalate "\n" [ "[modules.\"LitConfig.Core\"]", "url = \"core-docs\"", @@ -874,20 +871,20 @@ private def testPerModuleUrlInheritance (data : TestData) : IO Unit := withTestD -- Child module LitConfig.Core.Basic should be at core-docs/Basic/, not LitConfig/Core/Basic/ unless ← (htmlDir / "core-docs" / "Basic" / "index.html").pathExists do - throw <| IO.userError "url inheritance: expected HTML at core-docs/Basic/index.html" + fail "url inheritance: expected HTML at core-docs/Basic/index.html" if ← (htmlDir / "LitConfig" / "Core" / "Basic" / "index.html").pathExists then - throw <| IO.userError "url inheritance: HTML should not exist at default path LitConfig/Core/Basic/" + fail "url inheritance: HTML should not exist at default path LitConfig/Core/Basic/" -- Base href for child should reflect depth 2 (core-docs/Basic) let childHtml ← IO.FS.readFile (htmlDir / "core-docs" / "Basic" / "index.html") - unless hasSubstring childHtml "base href=\"../../\"" do - throw <| IO.userError "url inheritance: child base href should be '../../' (depth 2)" + assertContains "base href=\"../../\"" childHtml + "url inheritance: child base href should be '../../' (depth 2)" -- Navbar should link to the child at core-docs/Basic/ let navbarSection := childHtml.splitOn "module-tree" |>.getD 1 "" |>.splitOn "</nav>" |>.head! - unless hasSubstring navbarSection "core-docs/Basic/" do - throw <| IO.userError "url inheritance: navbar should link to 'core-docs/Basic/'" + assertContains "core-docs/Basic/" navbarSection + "url inheritance: navbar should link to 'core-docs/Basic/'" /-- Plan fails when two modules resolve to the same URL. -/ -private def testPlanDuplicateUrl (data : TestData) : IO Unit := IO.FS.withTempDir fun tmpDir => do +private def testPlanDuplicateUrl (data : TestData) : Test := IO.FS.withTempDir fun tmpDir => do let planFile := tmpDir / "plan" let tomlFile := tmpDir / "literate.toml" -- Set LitConfig.Core's url to "LitConfig/NoDocstrings" which collides with the default @@ -899,12 +896,12 @@ private def testPlanDuplicateUrl (data : TestData) : IO Unit := IO.FS.withTempDi ]) let (exitCode, _, stderr) ← runLiteratePlanCapture data.moduleListFile planFile (some tomlFile) if exitCode == 0 then - throw <| IO.userError "plan duplicate url: should have failed with non-zero exit code" - unless hasSubstring stderr "same URL" do - throw <| IO.userError s!"plan duplicate url: stderr should mention 'same URL', got: {stderr}" + fail "plan duplicate url: should have failed with non-zero exit code" + assertContains "same URL" stderr + s!"plan duplicate url: stderr should mention 'same URL', got: {stderr}" /-- URLs that differ only by a trailing slash are detected as duplicates. -/ -private def testPlanDuplicateUrlTrailingSlash (data : TestData) : IO Unit := IO.FS.withTempDir fun tmpDir => do +private def testPlanDuplicateUrlTrailingSlash (data : TestData) : Test := IO.FS.withTempDir fun tmpDir => do let planFile := tmpDir / "plan" let tomlFile := tmpDir / "literate.toml" IO.FS.writeFile tomlFile (String.intercalate "\n" [ @@ -914,12 +911,12 @@ private def testPlanDuplicateUrlTrailingSlash (data : TestData) : IO Unit := IO. ]) let (exitCode, _, stderr) ← runLiteratePlanCapture data.moduleListFile planFile (some tomlFile) if exitCode == 0 then - throw <| IO.userError "plan duplicate url trailing slash: should have failed with non-zero exit code" - unless hasSubstring stderr "same URL" do - throw <| IO.userError s!"plan duplicate url trailing slash: stderr should mention 'same URL', got: {stderr}" + fail "plan duplicate url trailing slash: should have failed with non-zero exit code" + assertContains "same URL" stderr + s!"plan duplicate url trailing slash: stderr should mention 'same URL', got: {stderr}" /-- URLs that differ only in case are detected as duplicates. -/ -private def testPlanDuplicateUrlCase (data : TestData) : IO Unit := IO.FS.withTempDir fun tmpDir => do +private def testPlanDuplicateUrlCase (data : TestData) : Test := IO.FS.withTempDir fun tmpDir => do let planFile := tmpDir / "plan" let tomlFile := tmpDir / "literate.toml" IO.FS.writeFile tomlFile (String.intercalate "\n" [ @@ -931,46 +928,46 @@ private def testPlanDuplicateUrlCase (data : TestData) : IO Unit := IO.FS.withTe ]) let (exitCode, _, stderr) ← runLiteratePlanCapture data.moduleListFile planFile (some tomlFile) if exitCode == 0 then - throw <| IO.userError "plan duplicate url case: should have failed with non-zero exit code" - unless hasSubstring stderr "differ only in case" do - throw <| IO.userError s!"plan duplicate url case: stderr should mention 'differ only in case', got: {stderr}" + fail "plan duplicate url case: should have failed with non-zero exit code" + assertContains "differ only in case" stderr + s!"plan duplicate url case: stderr should mention 'differ only in case', got: {stderr}" /-- CSS contains focus-visible indicators. -/ -private def testAccessibilityFocusVisible (data : TestData) : IO Unit := withTestDir data fun jsonDir htmlDir _ _ => do +private def testAccessibilityFocusVisible (data : TestData) : Test := withTestDir data fun jsonDir htmlDir _ _ => do runLiterateHtml jsonDir htmlDir let css ← IO.FS.readFile (htmlDir / "literate.css") - unless hasSubstring css "focus-visible" do - throw <| IO.userError "accessibility: literate.css does not contain focus-visible rules" + assertContains "focus-visible" css + "accessibility: literate.css does not contain focus-visible rules" /-- CSS contains prefers-reduced-motion rules. -/ -private def testAccessibilityReducedMotion (data : TestData) : IO Unit := withTestDir data fun jsonDir htmlDir _ _ => do +private def testAccessibilityReducedMotion (data : TestData) : Test := withTestDir data fun jsonDir htmlDir _ _ => do runLiterateHtml jsonDir htmlDir let css ← IO.FS.readFile (htmlDir / "literate.css") - unless hasSubstring css "prefers-reduced-motion" do - throw <| IO.userError "accessibility: literate.css does not contain prefers-reduced-motion" + assertContains "prefers-reduced-motion" css + "accessibility: literate.css does not contain prefers-reduced-motion" /-- Hamburger menu has ARIA attributes. -/ -private def testAccessibilityAria (data : TestData) : IO Unit := withTestDir data fun jsonDir htmlDir _ _ => do +private def testAccessibilityAria (data : TestData) : Test := withTestDir data fun jsonDir htmlDir _ _ => do runLiterateHtml jsonDir htmlDir let litConfigHtml ← IO.FS.readFile (htmlDir / "LitConfig" / "index.html") - unless hasSubstring litConfigHtml "aria-label=\"Menu\"" do - throw <| IO.userError "accessibility: hamburger input missing aria-label" - unless hasSubstring litConfigHtml "aria-label=\"Toggle navigation\"" do - throw <| IO.userError "accessibility: hamburger label missing aria-label" + assertContains "aria-label=\"Menu\"" litConfigHtml + "accessibility: hamburger input missing aria-label" + assertContains "aria-label=\"Toggle navigation\"" litConfigHtml + "accessibility: hamburger label missing aria-label" /-- LitConfig root module (with headings) gets a page ToC. -/ -private def testPageToc (data : TestData) : IO Unit := withTestDir data fun jsonDir htmlDir _ _ => do +private def testPageToc (data : TestData) : Test := withTestDir data fun jsonDir htmlDir _ _ => do runLiterateHtml jsonDir htmlDir let litConfigHtml ← IO.FS.readFile (htmlDir / "LitConfig" / "index.html") - unless hasSubstring litConfigHtml "page-toc" do - throw <| IO.userError "page ToC: LitConfig page should contain page-toc" - unless hasSubstring litConfigHtml "Page table of contents" do - throw <| IO.userError "page ToC: page-toc should have aria-label" - unless hasSubstring litConfigHtml "On this page" do - throw <| IO.userError "page ToC: page-toc should contain 'On this page' title" + assertContains "page-toc" litConfigHtml + "page ToC: LitConfig page should contain page-toc" + assertContains "Page table of contents" litConfigHtml + "page ToC: page-toc should have aria-label" + assertContains "On this page" litConfigHtml + "page ToC: page-toc should contain 'On this page' title" /-- Page ToC entries for headings in the same modDoc block have distinct anchors. -/ -private def testPageTocDistinctAnchors (data : TestData) : IO Unit := withTestDir data fun jsonDir htmlDir _ _ => do +private def testPageTocDistinctAnchors (data : TestData) : Test := withTestDir data fun jsonDir htmlDir _ _ => do runLiterateHtml jsonDir htmlDir let litConfigHtml ← IO.FS.readFile (htmlDir / "LitConfig" / "index.html") -- Extract the page-toc nav element content @@ -979,57 +976,57 @@ private def testPageTocDistinctAnchors (data : TestData) : IO Unit := withTestDi let hrefs := tocSection.splitOn "href=\"" |>.drop 1 |>.map fun s => s.splitOn "\"" |>.head! -- There should be at least 2 headings unless hrefs.length >= 2 do - throw <| IO.userError s!"page ToC distinct anchors: expected at least 2 ToC entries, got {hrefs.length}" + fail s!"page ToC distinct anchors: expected at least 2 ToC entries, got {hrefs.length}" -- All hrefs should be distinct (not sharing the same anchor) let uniqueHrefs := hrefs.eraseDups unless uniqueHrefs.length == hrefs.length do - throw <| IO.userError s!"page ToC distinct anchors: ToC entries share anchors: {hrefs}" + fail s!"page ToC distinct anchors: ToC entries share anchors: {hrefs}" -- Each anchor should correspond to an id in the HTML for href in hrefs do let parts := href.splitOn "#" if let _ :: anchor :: _ := parts then - unless hasSubstring litConfigHtml s!"id=\"{anchor}\"" do - throw <| IO.userError s!"page ToC distinct anchors: anchor '{anchor}' not found as an id in the HTML" + assertContains s!"id=\"{anchor}\"" litConfigHtml + s!"page ToC distinct anchors: anchor '{anchor}' not found as an id in the HTML" /-- Nested Verso sections produce distinct ToC entries at each level. -/ -private def testPageTocNestedSections (data : TestData) : IO Unit := withTestDir data fun jsonDir htmlDir _ _ => do +private def testPageTocNestedSections (data : TestData) : Test := withTestDir data fun jsonDir htmlDir _ _ => do runLiterateHtml jsonDir htmlDir let coreHtml ← IO.FS.readFile (htmlDir / "LitConfig" / "Core" / "index.html") -- Should have a page ToC - unless hasSubstring coreHtml "page-toc" do - throw <| IO.userError "nested ToC: Core page should have a page-toc" + assertContains "page-toc" coreHtml + "nested ToC: Core page should have a page-toc" let tocSection := coreHtml.splitOn "<nav class=\"page-toc\"" |>.getD 1 "" |>.splitOn "</nav>" |>.head! let hrefs := tocSection.splitOn "href=\"" |>.drop 1 |>.map fun s => s.splitOn "\"" |>.head! -- Should have at least 3 headings (Core Module, Natural Number Utilities, Doubling) unless hrefs.length >= 3 do - throw <| IO.userError s!"nested ToC: expected at least 3 ToC entries, got {hrefs.length}" + fail s!"nested ToC: expected at least 3 ToC entries, got {hrefs.length}" -- All distinct let uniqueHrefs := hrefs.eraseDups unless uniqueHrefs.length == hrefs.length do - throw <| IO.userError s!"nested ToC: ToC entries share anchors: {hrefs}" + fail s!"nested ToC: ToC entries share anchors: {hrefs}" -- Each anchor exists in the HTML for href in hrefs do let parts := href.splitOn "#" if let _ :: anchor :: _ := parts then - unless hasSubstring coreHtml s!"id=\"{anchor}\"" do - throw <| IO.userError s!"nested ToC: anchor '{anchor}' not found as id in HTML" + assertContains s!"id=\"{anchor}\"" coreHtml + s!"nested ToC: anchor '{anchor}' not found as id in HTML" /-- NoDocstrings module (no headings) should not get a page ToC. -/ -private def testPageTocAbsent (data : TestData) : IO Unit := withTestDir data fun jsonDir htmlDir _ _ => do +private def testPageTocAbsent (data : TestData) : Test := withTestDir data fun jsonDir htmlDir _ _ => do runLiterateHtml jsonDir htmlDir let noDocHtml ← IO.FS.readFile (htmlDir / "LitConfig" / "NoDocstrings" / "index.html") - if hasSubstring noDocHtml "page-toc" then - throw <| IO.userError "page ToC absent: NoDocstrings page should not have a page-toc" + assertNotContains "page-toc" noDocHtml + "page ToC absent: NoDocstrings page should not have a page-toc" /-- CSS contains dark mode defaults. -/ -private def testCssDarkMode (data : TestData) : IO Unit := withTestDir data fun jsonDir htmlDir _ _ => do +private def testCssDarkMode (data : TestData) : Test := withTestDir data fun jsonDir htmlDir _ _ => do runLiterateHtml jsonDir htmlDir let css ← IO.FS.readFile (htmlDir / "literate.css") - unless hasSubstring css "prefers-color-scheme: dark" do - throw <| IO.userError "dark mode: literate.css does not contain dark mode media query" + assertContains "prefers-color-scheme: dark" css + "dark mode: literate.css does not contain dark mode media query" /-- Images referenced in module docstrings are copied to the output and their URLs are rewritten. -/ -private def testImageCopying (data : TestData) (projectDir : System.FilePath) : IO Unit := IO.FS.withTempDir fun tmpDir => do +private def testImageCopying (data : TestData) (projectDir : System.FilePath) : Test := IO.FS.withTempDir fun tmpDir => do let htmlDir := tmpDir / "html" IO.FS.createDirAll htmlDir let srcDir ← IO.FS.realPath projectDir @@ -1038,26 +1035,26 @@ private def testImageCopying (data : TestData) (projectDir : System.FilePath) : -- Verify copied image file exists in the flat -verso-images directory let imgDest := htmlDir / "-verso-images" / "LitConfig--test-diagram.png" unless ← imgDest.pathExists do - throw <| IO.userError s!"image copying: expected image at {imgDest}" + fail s!"image copying: expected image at {imgDest}" -- Verify no subdirectories exist inside -verso-images (flat layout) let imgDirContents ← (htmlDir / "-verso-images").readDir for entry in imgDirContents do if (← entry.path.isDir) then - throw <| IO.userError s!"image copying: -verso-images should be flat, but found subdirectory {entry.path}" + fail s!"image copying: -verso-images should be flat, but found subdirectory {entry.path}" -- Verify the HTML references the rewritten URL let litConfigHtml ← IO.FS.readFile (htmlDir / "LitConfig" / "index.html") - unless hasSubstring litConfigHtml "-verso-images/LitConfig--test-diagram.png" do - throw <| IO.userError "image copying: HTML should reference rewritten image URL '-verso-images/LitConfig--test-diagram.png'" + assertContains "-verso-images/LitConfig--test-diagram.png" litConfigHtml + "image copying: HTML should reference rewritten image URL '-verso-images/LitConfig--test-diagram.png'" -- Verify the raw source-relative path does NOT appear as an unprocessed img src let srcAttrRaw := "src=\"images/test-diagram.png\"" - if hasSubstring litConfigHtml srcAttrRaw then - throw <| IO.userError s!"image copying: HTML should not contain unprocessed '{srcAttrRaw}'" + assertNotContains srcAttrRaw litConfigHtml + s!"image copying: HTML should not contain unprocessed '{srcAttrRaw}'" /-- Image paths with '..' are resolved correctly and copied into the flat output directory. -/ -private def testImagePathTraversal : IO Unit := IO.FS.withTempDir fun tmpDir => do +private def testImagePathTraversal : Test := IO.FS.withTempDir fun tmpDir => do -- srcDir is the library root; moduleParentPath prepends the module's parent dirs let srcDir := tmpDir / "src" let outDir := tmpDir / "out" @@ -1076,49 +1073,49 @@ private def testImagePathTraversal : IO Unit := IO.FS.withTempDir fun tmpDir => -- The image should be copied into the flat -verso-images directory let imgDir := outDir / "-verso-images" unless ← imgDir.pathExists do - throw <| IO.userError "image traversal: -verso-images directory should exist" + fail "image traversal: -verso-images directory should exist" unless ← (imgDir / "Sub-Mod--shared.png").pathExists do - throw <| IO.userError "image traversal: expected flattened image 'Sub-Mod--shared.png'" + fail "image traversal: expected flattened image 'Sub-Mod--shared.png'" -- No subdirectories should exist let entries ← imgDir.readDir for entry in entries do if ← entry.path.isDir then - throw <| IO.userError s!"image traversal: -verso-images should be flat, found subdirectory {entry.path}" + fail s!"image traversal: -verso-images should be flat, found subdirectory {entry.path}" /-- Single-root project: navbar uses nav-title header instead of collapsible details. -/ -private def testSingleRootNavFlattening (data : TestData) : IO Unit := withTestDir data fun jsonDir htmlDir _ _ => do +private def testSingleRootNavFlattening (data : TestData) : Test := withTestDir data fun jsonDir htmlDir _ _ => do runLiterateHtml jsonDir htmlDir let litConfigHtml ← IO.FS.readFile (htmlDir / "LitConfig" / "index.html") let navbarSection := litConfigHtml.splitOn "module-tree" |>.getD 1 "" |>.splitOn "</nav>" |>.head! -- Should have a nav-title div for the single root - unless hasSubstring navbarSection "nav-title" do - throw <| IO.userError "single-root nav: navbar should contain 'nav-title' class" + assertContains "nav-title" navbarSection + "single-root nav: navbar should contain 'nav-title' class" -- The top-level children should be direct leaves/details, not nested inside a root <details> -- Check that LitConfig appears in a nav-title, not in a <summary> - unless hasSubstring navbarSection "<div class=\"nav-title" do - throw <| IO.userError "single-root nav: root entry should be a nav-title div, not a collapsible details" + assertContains "<div class=\"nav-title" navbarSection + "single-root nav: root entry should be a nav-title div, not a collapsible details" /-- `docstrings_as_text = true` renders declaration docstrings as prose (mod-doc class). -/ -private def testDocstringsAsText (data : TestData) : IO Unit := withTestDir data fun jsonDir htmlDir _ tomlFile => do +private def testDocstringsAsText (data : TestData) : Test := withTestDir data fun jsonDir htmlDir _ tomlFile => do IO.FS.writeFile tomlFile "docstrings_as_text = true\n" runLiterateHtml jsonDir htmlDir (configFile := some tomlFile) let litConfigHtml ← IO.FS.readFile (htmlDir / "LitConfig" / "index.html") -- "A greeting message" docstring should appear as prose with mod-doc class - unless hasSubstring litConfigHtml "A greeting message" do - throw <| IO.userError "docstrings_as_text: 'A greeting message' should still appear" - unless hasSubstring litConfigHtml "mod-doc" do - throw <| IO.userError "docstrings_as_text: page should contain 'mod-doc' class for declaration docstrings" + assertContains "A greeting message" litConfigHtml + "docstrings_as_text: 'A greeting message' should still appear" + assertContains "mod-doc" litConfigHtml + "docstrings_as_text: page should contain 'mod-doc' class for declaration docstrings" /-- `docstrings_as_text` defaults to false: declaration docstrings render inside code boxes. -/ -private def testDocstringsAsTextDefault (data : TestData) : IO Unit := withTestDir data fun jsonDir htmlDir _ _ => do +private def testDocstringsAsTextDefault (data : TestData) : Test := withTestDir data fun jsonDir htmlDir _ _ => do runLiterateHtml jsonDir htmlDir let litConfigHtml ← IO.FS.readFile (htmlDir / "LitConfig" / "index.html") -- "A greeting message" should appear but NOT with mod-doc class on the declaration docstring div - unless hasSubstring litConfigHtml "A greeting message" do - throw <| IO.userError "docstrings_as_text default: 'A greeting message' should appear" + assertContains "A greeting message" litConfigHtml + "docstrings_as_text default: 'A greeting message' should appear" -- The declaration docstring should be in a verso-text or md-text div WITHOUT mod-doc -- Check that the docstring text is not in a mod-doc div let parts := litConfigHtml.splitOn "A greeting message" @@ -1129,24 +1126,24 @@ private def testDocstringsAsTextDefault (data : TestData) : IO Unit := withTestD -- If there's a </div> between the last mod-doc and "A greeting message", the docstring -- is not inside a mod-doc div unless lastDivClose > 1 do - throw <| IO.userError "docstrings_as_text default: declaration docstring should not be in a mod-doc div" + fail "docstrings_as_text default: declaration docstring should not be in a mod-doc div" /-- CSS uses custom properties (var(--verso-*)) throughout. -/ -private def testCssCustomProperties (data : TestData) : IO Unit := withTestDir data fun jsonDir htmlDir _ _ => do +private def testCssCustomProperties (data : TestData) : Test := withTestDir data fun jsonDir htmlDir _ _ => do runLiterateHtml jsonDir htmlDir let css ← IO.FS.readFile (htmlDir / "literate.css") - unless hasSubstring css "--verso-text-color" do - throw <| IO.userError "CSS vars: literate.css does not define --verso-text-color" - unless hasSubstring css "--verso-background-color" do - throw <| IO.userError "CSS vars: literate.css does not define --verso-background-color" - unless hasSubstring css "--verso-link-color" do - throw <| IO.userError "CSS vars: literate.css does not define --verso-link-color" - unless hasSubstring css "var(--verso-text-color)" do - throw <| IO.userError "CSS vars: literate.css does not use var(--verso-text-color)" + assertContains "--verso-text-color" css + "CSS vars: literate.css does not define --verso-text-color" + assertContains "--verso-background-color" css + "CSS vars: literate.css does not define --verso-background-color" + assertContains "--verso-link-color" css + "CSS vars: literate.css does not define --verso-link-color" + assertContains "var(--verso-text-color)" css + "CSS vars: literate.css does not use var(--verso-text-color)" -- ===== Test runner ===== -private def htmlTests (data : TestData) (projectDir : System.FilePath) : List (String × IO Unit) := [ +private def htmlTests (data : TestData) (projectDir : System.FilePath) : List (String × Test) := [ ("default behavior", testDefaultBehavior data), ("exclude", testExclude data), ("navbar order", testNavbarOrder data), @@ -1211,8 +1208,9 @@ private def htmlTests (data : TestData) (projectDir : System.FilePath) : List (S ("unknown extension fallback", testUnknownExtensionFallback) ] -def testLiterateHtml : IO Unit := do - IO.println "Running literate HTML tests..." +/-- The literate HTML generator produces the expected output for the single-root test project. -/ +@[test] +def literateHtml : Test := do let projectDir := "test-projects/literate-config" let modules := #["LitConfig", "LitConfig.Core", "LitConfig.Core.Basic", "LitConfig.NoDocstrings", "LitConfig.Builtins", "LitConfig.UserExt"] @@ -1220,7 +1218,7 @@ def testLiterateHtml : IO Unit := do let rootToolchain := (← IO.FS.readFile "lean-toolchain").trimAscii let testToolchain := (← IO.FS.readFile (projectDir / "lean-toolchain")).trimAscii unless rootToolchain == testToolchain do - throw <| IO.userError s!"test-projects/literate-config/lean-toolchain ({testToolchain}) does not match root lean-toolchain ({rootToolchain})" + failHere s!"test-projects/literate-config/lean-toolchain ({testToolchain}) does not match root lean-toolchain ({rootToolchain})" -- Next, ensure test project manifest is up to date let lakeVars := @@ -1234,9 +1232,9 @@ def testLiterateHtml : IO Unit := do cwd := projectDir env := lakeVars.map (·, none) } - if updateResult.exitCode != 0 then - IO.eprintln s!"lake update stderr: {updateResult.stderr}" - throw <| IO.userError s!"lake update verso failed with exit code {updateResult.exitCode}" + unless updateResult.exitCode == 0 do + failHere s!"lake update verso failed with exit code {updateResult.exitCode}" + (detail? := some updateResult.stderr) -- Build shared test data (JSON) in a persistent temp dir IO.FS.withTempDir fun sharedTmpDir => do @@ -1258,54 +1256,43 @@ def testLiterateHtml : IO Unit := do let data : TestData := { jsonDir, modules, moduleListFile } - let mut failures := 0 for (name, test) in htmlTests data projectDir do - IO.print s!" {name}... " - try - test - IO.println "passed" - catch e => - IO.eprintln s!"FAILED - {e}" - failures := failures + 1 - - if failures == 0 then - IO.println " All literate HTML tests passed!" - else - throw <| IO.userError s!"{failures} literate HTML test(s) failed" + result name test -- ===== Multi-root project tests ===== /-- Multi-root project: navbar uses collapsible details for top-level entries, not nav-title. -/ -private def testMultiRootNavTree (data : TestData) : IO Unit := withTestDir data fun jsonDir htmlDir _ _ => do +private def testMultiRootNavTree (data : TestData) : Test := withTestDir data fun jsonDir htmlDir _ _ => do runLiterateHtml jsonDir htmlDir let libAHtml ← IO.FS.readFile (htmlDir / "LibA" / "index.html") let navbarSection := libAHtml.splitOn "module-tree" |>.getD 1 "" |>.splitOn "</nav>" |>.head! -- Should NOT have nav-title (that's for single-root only) - if hasSubstring navbarSection "nav-title" then - throw <| IO.userError "multi-root nav: navbar should not contain 'nav-title' class" + assertNotContains "nav-title" navbarSection + "multi-root nav: navbar should not contain 'nav-title' class" -- Should have both LibA and LibB as collapsible details - unless hasSubstring navbarSection "LibA" do - throw <| IO.userError "multi-root nav: navbar should contain 'LibA'" - unless hasSubstring navbarSection "LibB" do - throw <| IO.userError "multi-root nav: navbar should contain 'LibB'" + assertContains "LibA" navbarSection + "multi-root nav: navbar should contain 'LibA'" + assertContains "LibB" navbarSection + "multi-root nav: navbar should contain 'LibB'" -- Should use <details> for top-level entries - unless hasSubstring navbarSection "<details" do - throw <| IO.userError "multi-root nav: navbar should use <details> for top-level entries" + assertContains "<details" navbarSection + "multi-root nav: navbar should use <details> for top-level entries" -private def multiRootHtmlTests (data : TestData) : List (String × IO Unit) := [ +private def multiRootHtmlTests (data : TestData) : List (String × Test) := [ ("multi-root nav tree", testMultiRootNavTree data) ] -def testLiterateHtmlMultiRoot : IO Unit := do - IO.println "Running multi-root literate HTML tests..." +/-- The literate HTML generator produces the expected output for the multi-root test project. -/ +@[test] +def literateHtmlMultiRoot : Test := do let projectDir := "test-projects/literate-multi-root" let modules := #["LibA", "LibA.Core", "LibB", "LibB.Utils"] let rootToolchain := (← IO.FS.readFile "lean-toolchain").trimAscii let testToolchain := (← IO.FS.readFile (projectDir / "lean-toolchain")).trimAscii unless rootToolchain == testToolchain do - throw <| IO.userError s!"{projectDir}/lean-toolchain ({testToolchain}) does not match root lean-toolchain ({rootToolchain})" + failHere s!"{projectDir}/lean-toolchain ({testToolchain}) does not match root lean-toolchain ({rootToolchain})" let lakeVars := #["LAKE", "LAKE_HOME", "LAKE_PKG_URL_MAP", @@ -1318,9 +1305,9 @@ def testLiterateHtmlMultiRoot : IO Unit := do cwd := projectDir env := lakeVars.map (·, none) } - if updateResult.exitCode != 0 then - IO.eprintln s!"lake update stderr: {updateResult.stderr}" - throw <| IO.userError s!"lake update verso failed with exit code {updateResult.exitCode}" + unless updateResult.exitCode == 0 do + failHere s!"lake update verso failed with exit code {updateResult.exitCode}" + (detail? := some updateResult.stderr) IO.FS.withTempDir fun sharedTmpDir => do let jsonDir := sharedTmpDir / "json" @@ -1342,19 +1329,7 @@ def testLiterateHtmlMultiRoot : IO Unit := do let data : TestData := { jsonDir, modules, moduleListFile } - let mut failures := 0 for (name, test) in multiRootHtmlTests data do - IO.print s!" {name}... " - try - test - IO.println "passed" - catch e => - IO.eprintln s!"FAILED - {e}" - failures := failures + 1 - - if failures == 0 then - IO.println " All multi-root literate HTML tests passed!" - else - throw <| IO.userError s!"{failures} multi-root literate HTML test(s) failed" - -end Tests.LiterateHtml + result name test + +end VersoTests.LiterateHtml diff --git a/src/tests/VersoTests/LzCompress.lean b/src/tests/VersoTests/LzCompress.lean new file mode 100644 index 000000000..b37594231 --- /dev/null +++ b/src/tests/VersoTests/LzCompress.lean @@ -0,0 +1,39 @@ +/- +Copyright (c) 2026 Lean FRO LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Author: David Thrane Christiansen +-/ +module + +public import VersoUtil.LzCompress +import Errata + +open Verso.LzCompress Errata + +/-- The LZ compressor produces the expected encoding for a sample Lean snippet. -/ +@[test] +def compresses : Test := do + let actual := lzCompress r#"import Mathlib.Logic.Basic -- basic facts in logic +-- theorems in Lean's mathematics library + +-- Let P and Q be true-false statements +variable (P Q : Prop) + +-- The following is a basic result in logic +example : ¬ (P ∧ Q) ↔ ¬ P ∨ ¬ Q := by + -- its proof is already in Lean's mathematics library + exact not_and_or + +-- Here is another basic result in logic +example : ¬ (P ∨ Q) ↔ ¬ P ∧ ¬ Q := by + apply? -- we can search for the proof in the library + -- we can also replace `apply?` with its output +"# + let expected := + "JYWwDg9gTgLgBAWQIYwBYBtgCMB0AZCAc2AGMcAhJAZ1LgFo64traAzJEmKuYAOznRFSAKAZw0AU2gSQ3" ++ + "PnDwSkvAOTcQKVDJSlumLFCRQAnsNGNF8AApxlAEzgBFJhPFQArhLrt0VV1RgUGQleLmEANyNgJCx0VwA" ++ + "KG2cALjgrKAgwAEozMQAVLThWCHRBAHc+Qh5uJCYWEjgoCSp3dHh5QWISYQkADyRwOLhUgBq4RLhAciIn" ++ + "LLhAFMI4MZtACiJFp2GAXiZTOHpGYC44MAyIVmrbdCakO2MefkVlNTgNSRfdAWxDE2Fdvo54XgQGAAfXs" ++ + "wOguUYAAkJE1zsogVooHUaA0mi02ncBEJun9Bq5RuMVjN5msbNMxiktlgdrYwGB0MYAPx7OBlVwkZRwPx" ++ + "GEioIrQcSFY4QU5YyQfAxGWlidlwTn8JC+CCNCQMjiuAAGSHpjKZmrZB35B24EHcMDA5uEQA" + assertEq expected actual diff --git a/src/tests/Tests/Method.lean b/src/tests/VersoTests/Method.lean similarity index 85% rename from src/tests/Tests/Method.lean rename to src/tests/VersoTests/Method.lean index 6c474e52f..3fca9a9e8 100644 --- a/src/tests/Tests/Method.lean +++ b/src/tests/VersoTests/Method.lean @@ -3,12 +3,8 @@ Copyright (c) 2026 Lean FRO LLC. All rights reserved. Released under Apache 2.0 license as described in the file LICENSE. Author: David Thrane Christiansen -/ -module - -public import Verso.Method -public meta import Verso.Method - -public section +import Errata +import Verso.Method /-! ## Tests for defmethod macro -/ @@ -35,11 +31,11 @@ error: 'List' is ambiguous - found: A.B.C.List, _root_.List Please write a more specific namespace. -/ -#guard_msgs in +#test_msgs in defmethod List.wat (xs : List Nat) : Nat := 3 end Other /-- info: { field := 6 } -/ -#guard_msgs in +#test_msgs in #eval (A.B.C.D.mk 3).double diff --git a/src/tests/Tests/NestedTacticHtml.lean b/src/tests/VersoTests/NestedTacticHtml.lean similarity index 98% rename from src/tests/Tests/NestedTacticHtml.lean rename to src/tests/VersoTests/NestedTacticHtml.lean index 9eb774e8e..7e140c904 100644 --- a/src/tests/Tests/NestedTacticHtml.lean +++ b/src/tests/VersoTests/NestedTacticHtml.lean @@ -3,13 +3,9 @@ Copyright (c) 2026 Lean FRO LLC. All rights reserved. Released under Apache 2.0 license as described in the file LICENSE. Author: David Thrane Christiansen -/ -module -public import Verso -public meta import Verso -public import SubVerso.Highlighting.Code -public import Lean.Elab.Frontend - -public section +import Errata +import Verso +import SubVerso.Highlighting.Code /-! Highlighting a proof that uses compound tactics such as `obtain` produces nested proof states: the @@ -220,7 +216,7 @@ def checkElision : CommandElabM Unit := do if htmlHasRedundant (proofStates (renderBlock raw)) then throwError "the rendered HTML still nests a no-goals region inside a goal-ful one" -#guard_msgs in +#test_msgs in #eval checkElision /-! @@ -263,5 +259,5 @@ def checkDuplicateElision : CommandElabM Unit := do unless tacticNodeCount elided == 1 do throwError "expected exactly one proof state to remain, but found {tacticNodeCount elided}" -#guard_msgs in +#test_msgs in #eval checkDuplicateElision diff --git a/src/tests/VersoTests/Options.lean b/src/tests/VersoTests/Options.lean new file mode 100644 index 000000000..f337cad25 --- /dev/null +++ b/src/tests/VersoTests/Options.lean @@ -0,0 +1,17 @@ +/- +Copyright (c) 2026 Lean FRO LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Author: David Thrane Christiansen +-/ +import Errata + +open Errata + +/-- +Reads the `check-tex` option so the runner counts it as recognized on every run. The runner warns +about options it never reads, and `check-tex` is otherwise read only by the TeX golden tests, which a +partial run can leave out. +-/ +@[test] +def checkTexRecognized : Test := do + let _ ← flag "check-tex" diff --git a/src/tests/Tests/ParserRegression.lean b/src/tests/VersoTests/ParserRegression.lean similarity index 100% rename from src/tests/Tests/ParserRegression.lean rename to src/tests/VersoTests/ParserRegression.lean diff --git a/src/tests/Tests/Paths.lean b/src/tests/VersoTests/Paths.lean similarity index 85% rename from src/tests/Tests/Paths.lean rename to src/tests/VersoTests/Paths.lean index 3a56063b1..4578f230d 100644 --- a/src/tests/Tests/Paths.lean +++ b/src/tests/VersoTests/Paths.lean @@ -7,6 +7,7 @@ Author: David Thrane Christiansen module +import Errata import MultiVerso.Path set_option doc.verso true @@ -19,78 +20,78 @@ open Path -- TODO: adapt to module system. Right now, non-meta imports work in server, but not command line. /- /-- info: "/" -/ -#guard_msgs in +#test_msgs in #eval link #[] /-- info: "/a/b/" -/ -#guard_msgs in +#test_msgs in #eval link #["a", "b"] /-- info: "/a/b/#c" -/ -#guard_msgs in +#test_msgs in #eval link #["a", "b"] (htmlId := some "c") /- Tests for relativization. -/ /-- info: "a/b/c/" -/ -#guard_msgs in +#test_msgs in #eval Path.relativize #[] "/a/b/c/" /-- info: "a/b/c/#foo" -/ -#guard_msgs in +#test_msgs in #eval Path.relativize #[] "/a/b/c/#foo" /-- info: "a/b/c#foo" -/ -#guard_msgs in +#test_msgs in #eval Path.relativize #[] "/a/b/c#foo" /-- info: "b/c/" -/ -#guard_msgs in +#test_msgs in #eval Path.relativize #["a"] "/a/b/c/" /-- info: "b/c/#foo" -/ -#guard_msgs in +#test_msgs in #eval Path.relativize #["a"] "/a/b/c/#foo" /-- info: "b/c#foo" -/ -#guard_msgs in +#test_msgs in #eval Path.relativize #["a"] "/a/b/c#foo" /-- info: "c/" -/ -#guard_msgs in +#test_msgs in #eval Path.relativize #["a", "b"] "/a/b/c/" /-- info: "c/#foo" -/ -#guard_msgs in +#test_msgs in #eval Path.relativize #["a", "b"] "/a/b/c/#foo" /-- info: "c#foo" -/ -#guard_msgs in +#test_msgs in #eval Path.relativize #["a", "b"] "/a/b/c#foo" /-- info: "../../aa/b/c#foo" -/ -#guard_msgs in +#test_msgs in #eval Path.relativize #["a", "b"] "/aa/b/c#foo" /-- info: "../" -/ -#guard_msgs in +#test_msgs in #eval Path.relativize #["a", "b", "c", "d"] "/a/b/c/" /-- info: "../../c" -/ -#guard_msgs in +#test_msgs in #eval Path.relativize #["a", "b", "c", "d"] "/a/b/c" /-- info: "../#foo" -/ -#guard_msgs in +#test_msgs in #eval Path.relativize #["a", "b", "c", "d"] "/a/b/c/#foo" /-- info: "../../" -/ -#guard_msgs in +#test_msgs in #eval Path.relativize #["a", "b", "c", "d", "e"] "/a/b/c/" /-- info: "../../#foo" -/ -#guard_msgs in +#test_msgs in #eval Path.relativize #["a", "b", "c", "d", "e"] "/a/b/c/#foo" /-- info: "../../../c#foo" -/ -#guard_msgs in +#test_msgs in #eval Path.relativize #["a", "b", "c", "d", "e"] "/a/b/c#foo" /-- info: "../../../c" -/ -#guard_msgs in +#test_msgs in #eval Path.relativize #["a", "b", "c", "d", "e"] "/a/b/c" /-- info: "" -/ -#guard_msgs in +#test_msgs in #eval Path.relativize #[] "/" -/ diff --git a/src/tests/Tests/PorterStemmer.lean b/src/tests/VersoTests/PorterStemmer.lean similarity index 84% rename from src/tests/Tests/PorterStemmer.lean rename to src/tests/VersoTests/PorterStemmer.lean index 65be9a63a..7397a173c 100644 --- a/src/tests/Tests/PorterStemmer.lean +++ b/src/tests/VersoTests/PorterStemmer.lean @@ -5,6 +5,7 @@ Author: David Thrane Christiansen -/ module meta import all VersoSearch.PorterStemmer +import Errata namespace Verso.Tests.PorterStemmer @@ -13,112 +14,112 @@ open Verso.Search.Stemmer.Porter /-! ## Tests for measure function -/ /-- info: 0 -/ -#guard_msgs in +#test_msgs in #eval measure "tr".toSlice /-- info: 0 -/ -#guard_msgs in +#test_msgs in #eval measure "ee".toSlice /-- info: 0 -/ -#guard_msgs in +#test_msgs in #eval measure "tree".toSlice /-- info: 2 -/ -#guard_msgs in +#test_msgs in #eval measure "private".toSlice /-! ## Tests for step1a -/ /-- info: "abiliti" -/ -#guard_msgs in +#test_msgs in #eval step1a "abilities".toSlice |>.copy /-! ## Tests for step1b -/ /-- info: "abiliti" -/ -#guard_msgs in +#test_msgs in #eval step1b "abiliti".toSlice |>.copy /-- info: "caress" -/ -#guard_msgs in +#test_msgs in #eval step1b (step1a "caresses".toSlice) |>.copy /-- info: "poni" -/ -#guard_msgs in +#test_msgs in #eval step1b (step1a "ponies".toSlice) |>.copy /-- info: "ti" -/ -#guard_msgs in +#test_msgs in #eval step1b (step1a "ties".toSlice) |>.copy /-- info: "caress" -/ -#guard_msgs in +#test_msgs in #eval step1b (step1a "caress".toSlice) |>.copy /-- info: "cat" -/ -#guard_msgs in +#test_msgs in #eval step1b (step1a "cats".toSlice) |>.copy /-- info: "feed" -/ -#guard_msgs in +#test_msgs in #eval step1b (step1a "feed".toSlice) |>.copy /-- info: "agree" -/ -#guard_msgs in +#test_msgs in #eval step1b (step1a "agreed".toSlice) |>.copy /-- info: "disable" -/ -#guard_msgs in +#test_msgs in #eval step1b (step1a "disabled".toSlice) |>.copy /-- info: "mat" -/ -#guard_msgs in +#test_msgs in #eval step1b (step1a "matting".toSlice) |>.copy /-- info: "mate" -/ -#guard_msgs in +#test_msgs in #eval step1b (step1a "mating".toSlice) |>.copy /-- info: "meet" -/ -#guard_msgs in +#test_msgs in #eval step1b (step1a "meeting".toSlice) |>.copy /-- info: "mill" -/ -#guard_msgs in +#test_msgs in #eval step1b (step1a "milling".toSlice) |>.copy /-- info: "mess" -/ -#guard_msgs in +#test_msgs in #eval step1b (step1a "messing".toSlice) |>.copy /-- info: "meet" -/ -#guard_msgs in +#test_msgs in #eval step1b (step1a "meetings".toSlice) |>.copy /-! ## Tests for step1c -/ /-- info: "happi" -/ -#guard_msgs in +#test_msgs in #eval step1c "happy".toSlice |>.copy /-- info: "abiliti" -/ -#guard_msgs in +#test_msgs in #eval step1c "abiliti".toSlice |>.copy /-! ## Tests for step2 -/ /-- info: "sensible" -/ -#guard_msgs in +#test_msgs in #eval step2 "sensibiliti".toSlice |>.copy /-- info: "abiliti" -/ -#guard_msgs in +#test_msgs in #eval step2 "abiliti".toSlice |>.copy /-! ## Tests for step3 -/ /-- info: "form" -/ -#guard_msgs in +#test_msgs in #eval step3 "formative".toSlice |>.copy /-- info: "able" -/ -#guard_msgs in +#test_msgs in #eval step3 "able".toSlice |>.copy /-! ## Tests for step5b -/ /-- info: "control" -/ -#guard_msgs in +#test_msgs in #eval step5b "controll".toSlice |>.copy /-- info: "roll" -/ -#guard_msgs in +#test_msgs in #eval step5b "roll".toSlice |>.copy diff --git a/src/tests/Tests/Refs.lean b/src/tests/VersoTests/Refs.lean similarity index 91% rename from src/tests/Tests/Refs.lean rename to src/tests/VersoTests/Refs.lean index e20926e22..d6adce14f 100644 --- a/src/tests/Tests/Refs.lean +++ b/src/tests/VersoTests/Refs.lean @@ -3,11 +3,8 @@ Copyright (c) 2023 Lean FRO LLC. All rights reserved. Released under Apache 2.0 license as described in the file LICENSE. Author: Rob Simmons -/ -module -public import Verso -public meta import Verso -meta import all Verso.Doc.Elab.Monad -public section +import Errata +import Verso namespace Verso.RefsTest set_option guard_msgs.diff true @@ -27,7 +24,7 @@ info: Verso.Doc.Part.mk Verso.Doc.Inline.link #[(Verso.Doc.Inline.text "a link")] "http://example.com"]] #[] -/ -#guard_msgs in +#test_msgs in #eval regularLink.toPart @@ -49,7 +46,7 @@ info: Verso.Doc.Part.mk Verso.Doc.Inline.link #[(Verso.Doc.Inline.text "a link")] "http://example.com"]] #[] -/ -#guard_msgs in +#test_msgs in #eval refLink.toPart @@ -71,7 +68,7 @@ info: Verso.Doc.Part.mk Verso.Doc.Inline.footnote "note" #[(Verso.Doc.Inline.text "The footnote text")], Verso.Doc.Inline.text "!"]] #[] -/ -#guard_msgs in +#test_msgs in #eval noteLink.toPart @@ -98,7 +95,7 @@ info: Verso.Doc.Part.mk Verso.Doc.Inline.footnote "note" #[(Verso.Doc.Inline.text "The footnote text")], Verso.Doc.Inline.text "!"]] #[] -/ -#guard_msgs in +#test_msgs in #eval refAndLink.toPart #docs (.none) refAndLink2 "Ref/link ordering" := @@ -127,13 +124,13 @@ Here's [a link][to here][^note]! ::::::: /-- info: true -/ -#guard_msgs in #eval refAndLink.toPart == refAndLink2.toPart +#test_msgs in #eval refAndLink.toPart == refAndLink2.toPart /-- info: true -/ -#guard_msgs in #eval refAndLink.toPart == refAndLink3.toPart +#test_msgs in #eval refAndLink.toPart == refAndLink3.toPart /-- info: true -/ -#guard_msgs in #eval refAndLink.toPart == refAndLink4.toPart +#test_msgs in #eval refAndLink.toPart == refAndLink4.toPart #docs (.none) refAndLinkRecursion "Ref/link recursion" := ::::::: @@ -165,13 +162,13 @@ info: Verso.Doc.Part.mk (Verso.Doc.Inline.text ".")]]] #[] -/ -#guard_msgs in +#test_msgs in #eval refAndLinkRecursion.toPart /-- error: Already defined link [foo] as 'https://example.com' -/ -#guard_msgs in +#test_msgs in #docs (.none) failDupLink "Fail" := ::::::: [foo]: https://example.com @@ -183,7 +180,7 @@ error: Already defined link [foo] as 'https://example.com' /-- error: Already defined footnote [^note] -/ -#guard_msgs in +#test_msgs in #docs (.none) failDupFoot "Fail" := ::::::: [^note]: Note @@ -196,7 +193,7 @@ There are no caveats.[^note] /-- error: Footnote reference [^bar] does not have a definition -/ -#guard_msgs in +#test_msgs in #docs (.none) failForwardRefFootnote "Fail" := ::::::: [^foo]: Disallowing forward reference in footnotes[^bar] @@ -209,7 +206,7 @@ And used[^bar] /-- error: Link reference [bar] does not have a definition -/ -#guard_msgs in +#test_msgs in #docs (.none) failForwardRefLink "Fail" := ::::::: [^foo]: Disallowing [forward reference in footnotes][bar] @@ -225,7 +222,7 @@ warning: Unused footnote [^hidden] --- warning: Unused footnote [^baz] -/ -#guard_msgs in +#test_msgs in #docs (.none) fail4 "Fail" := ::::::: [^baz]: Unused footnote @@ -236,7 +233,7 @@ warning: Unused footnote [^baz] /-- error: No definition for footnote [^caveat] -/ -#guard_msgs in +#test_msgs in #docs (.none) fail "Fail" := ::::::: There's no caveat.[^caveat] @@ -245,7 +242,7 @@ There's no caveat.[^caveat] /-- warning: Unused link [forlorn] -/ -#guard_msgs in +#test_msgs in #docs (.none) warnForlorn "Fail" := ::::::: [forlorn]: http://example.com @@ -254,7 +251,7 @@ warning: Unused link [forlorn] /-- error: No definition for link [fourOhFour] -/ -#guard_msgs in +#test_msgs in #docs (.none) failHangingLink "Fail" := ::::::: There's no [destination][fourOhFour] diff --git a/src/tests/VersoTests/SearchJs.lean b/src/tests/VersoTests/SearchJs.lean new file mode 100644 index 000000000..871aae16b --- /dev/null +++ b/src/tests/VersoTests/SearchJs.lean @@ -0,0 +1,82 @@ +/- +Copyright (c) 2026 Lean FRO LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Author: David Thrane Christiansen + +Tests for the JavaScript wire format produced by the search domain mappers. These are structural +checks against the emitted JS source, confirming that priority fields and global priority exports +appear with their configured values. +-/ +module + +public import VersoSearch +public import VersoSearch.DomainSearch +import Errata + +open Std +open Verso Search +open Errata + +/-- Whether `needle` occurs in `haystack`. -/ +private def omits (haystack needle : String) : Bool := + (haystack.splitOn needle).length == 1 + +/-- A domain mapper emits its display, class, and data fields, and no priority. -/ +@[test] +def mapperToJs : Test := do + let mapper : DomainMapper := + { displayName := "Term", className := "term", dataToSearchables := "x => []" } + let rendered := (DomainMapper.toJs mapper).pretty (width := 70) + assertContains "displayName:" rendered + assertContains "\"Term\"" rendered + assertContains "className:" rendered + assertContains "\"term\"" rendered + assertContains "dataToSearchables:" rendered + -- The priority lives in `SearchPriorities` now, not on the mapper. + assert (omits rendered "searchPriority") "mapper output should not contain `searchPriority`" + +/-- A mapper collection emits the mappers and the search priorities with the configured values. -/ +@[test] +def mappersToJs : Test := do + let mapper : DomainMapper := + { displayName := "Term", className := "term", dataToSearchables := "x => []" } + let mappers : DomainMappers := HashMap.ofList [("Verso.Test", mapper)] + let priorities : SearchPriorities := + { semantic := 60, fullText := 40, domains := ({} : Verso.NameMap _).insert `Verso.Test 73 } + let rendered := (mappers.toJs priorities).pretty (width := 70) + assertContains "export const domainMappers" rendered + assertContains "export const searchPriorities" rendered + assertContains "semantic:" rendered + assertContains "60" rendered + assertContains "fullText:" rendered + assertContains "40" rendered + assertContains "domains:" rendered + assertContains "\"Verso.Test\"" rendered + assertContains "73" rendered + +/-- An empty mapper collection emits the neutral default priorities of `50`. -/ +@[test] +def mappersToJsDefaults : Test := do + let mappers : DomainMappers := {} + let rendered := (mappers.toJs).pretty (width := 70) + assertContains "export const searchPriorities" rendered + assertContains "semantic:" rendered + assertContains "fullText:" rendered + assertContains "50" rendered + +/-- The priority map keys only the documents whose priority differs from neutral. -/ +@[test] +def priorityMap : Test := do + let docs : Array IndexDoc := #[ + { id := "boosted", header := "", context := #[], content := "", priority := some 80 }, + { id := "no-priority", header := "", context := #[], content := "", priority := none }, + { id := "explicit-neutral", header := "", context := #[], content := "", priority := some 50 }, + { id := "suppressed", header := "", context := #[], content := "", priority := some 10 }, + { id := "deep-subsection", header := "", context := #[], content := "", priority := some (-20) }] + let rendered := (priorityMapJson docs).compress + assertContains "\"boosted\":80" rendered + assertContains "\"suppressed\":10" rendered + assertContains "\"deep-subsection\":-20" rendered + -- Neutral docs (`none` or `some 50`) are omitted entirely. + for omitted in ["no-priority", "explicit-neutral"] do + assert (omits rendered omitted) s!"priorityMapJson should omit the neutral doc {omitted}" diff --git a/src/tests/VersoTests/Serialization.lean b/src/tests/VersoTests/Serialization.lean new file mode 100644 index 000000000..10e46e420 --- /dev/null +++ b/src/tests/VersoTests/Serialization.lean @@ -0,0 +1,103 @@ +/- +Copyright (c) 2026 Lean FRO LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Author: David Thrane Christiansen + +Round-trip property tests for Verso's serialization. The generators and the `roundTripOk`/`isEqOk` +helpers live in `VersoTests.SerializationGenerators`; they construct Verso types whose constructors are private, so +they stay module-internal there and are reached here through `import all`. +-/ +module + +import Errata +import all VersoTests.SerializationGenerators + +open Lean +open Verso Multi +open Errata + +/-- Internal identifiers round-trip through JSON. -/ +@[test] +def internalId : Test := property (∀ id : InternalId, roundTripOk id) + +/-- Objects round-trip through JSON. -/ +@[test] +def object : Test := property (∀ obj : Object, roundTripOk obj) + +/-- Domains round-trip through JSON. -/ +@[test] +def domain : Test := property (∀ dom : Domain, roundTripOk dom) + +/-- Reference domains round-trip through JSON. -/ +@[test] +def refDomain : Test := property (∀ dom : RefDomain, roundTripOk dom) + +/-- Reference objects round-trip through JSON. -/ +@[test] +def refObject : Test := property (∀ obj : RefObject, roundTripOk obj) + +/-- Remote information round-trips through JSON. -/ +@[test] +def remoteInfo : Test := property (∀ info : RemoteInfo, roundTripOk info) + +/-- The collection of remotes round-trips through JSON. -/ +@[test] +def allRemotes : Test := property (∀ remotes : AllRemotes, roundTripOk remotes) + +/-- Manual traverse state round-trips through JSON. -/ +@[test] +def traverseState : Test := + property (∀ st : Verso.Genre.Manual.TraverseState, roundTripOk st) + +/-- HTML round-trips through JSON. -/ +@[test] +def html : Test := property (∀ html : Verso.Output.Html, roundTripOk html) + +/-- Manual data files round-trip through JSON. -/ +@[test] +def dataFile : Test := property (∀ f : Verso.Genre.Manual.DataFile, roundTripOk f) + +/-- Manual numbering round-trips through JSON. -/ +@[test] +def numbering : Test := property (∀ n : Verso.Genre.Manual.Numbering, roundTripOk n) + +/-- Cross-reference sources round-trip through JSON. -/ +@[test] +def xrefSource : Test := + property (∀ src : XrefSource, isEqOk (XrefSource.fromJson? src.toJson) src) + +/-- Remotes round-trip through JSON. -/ +@[test] +def remote : Test := + property (∀ r : Remote, isEqOk (Remote.fromJson? "" r.toJson) r) + +/-- Search domain mappers and search priorities round-trip through JSON. -/ +@[test] +def searchPriorities : Test := + property <| ∀ (semantic fullText : Fin 100) (domains : Verso.NameMap (Fin 100)), + let mapper : Search.DomainMapper := + { displayName := "d", className := "c", dataToSearchables := "x => []" } + let priorities : Search.SearchPriorities := { semantic, fullText, domains } + roundTripOk mapper ∧ roundTripOk priorities + +/-- +Every entry `Verso.Search.priorityMapJson` produces is a non-neutral integer tied to an input +doc's {name}`IndexDoc.id` and {name}`IndexDoc.priority`, and every input doc with a non-neutral +priority has its id present. Documents with no priority or the neutral value `50` are omitted. +-/ +@[test] +def priorityMapJson : Test := + property <| ∀ docs : Array Search.IndexDoc, + let j : Json := Search.priorityMapJson docs + let entries : Array (String × Json) := + match Json.getObj? j with + | .error _ => #[] + | .ok obj => obj.toArray + let forward := entries.all fun (k, v) => + match Json.getInt? v with + | .error _ => false + | .ok p => p != 50 && docs.any fun d => d.id == k && d.priority == some p + let backward := docs.all fun d => + let isNeutral := d.priority.isNone || d.priority == some 50 + isNeutral || (Json.getObjVal? j d.id).toOption.isSome + forward ∧ backward diff --git a/src/tests/Tests/Serialization.lean b/src/tests/VersoTests/SerializationGenerators.lean similarity index 96% rename from src/tests/Tests/Serialization.lean rename to src/tests/VersoTests/SerializationGenerators.lean index 70729f8af..d60dbab74 100644 --- a/src/tests/Tests/Serialization.lean +++ b/src/tests/VersoTests/SerializationGenerators.lean @@ -6,23 +6,22 @@ Author: David Thrane Christiansen module public import Plausible public import Plausible.ArbitraryFueled -public meta import Plausible.ArbitraryFueled import Lean.Data.Json.FromToJson import all MultiVerso.InternalId -public meta import MultiVerso.NameMap -public meta import MultiVerso -public meta import VersoManual.Html.JsFile -public meta import VersoManual.Html.CssFile -public meta import VersoManual.Html.Features -public meta import VersoManual.LicenseInfo -public meta import VersoSearch -public meta import VersoSearch.DomainSearch -public meta import Verso.Output.Html -public meta import MultiVerso.Manifest -public meta import VersoManual.Basic +public import MultiVerso.NameMap +public import MultiVerso +public import VersoManual.Html.JsFile +public import VersoManual.Html.CssFile +public import VersoManual.Html.Features +public import VersoManual.LicenseInfo +public import VersoSearch +public import VersoSearch.DomainSearch +public import Verso.Output.Html +public import MultiVerso.Manifest +public import VersoManual.Basic import all VersoManual.Basic import VersoManual.Html.CssFile -public meta import Tests.Arbitrary +public import VersoTests.Arbitrary open Lean open Plausible Gen Arbitrary @@ -30,7 +29,7 @@ open Verso Multi open Shrinkable open Std -meta section +section def isEqOk [BEq α] (actual : Except ε α) (expected : α) : Bool := match actual with diff --git a/src/tests/Tests/Serve.lean b/src/tests/VersoTests/Serve.lean similarity index 91% rename from src/tests/Tests/Serve.lean rename to src/tests/VersoTests/Serve.lean index 5900c29a1..3eca8d1d8 100644 --- a/src/tests/Tests/Serve.lean +++ b/src/tests/VersoTests/Serve.lean @@ -4,12 +4,14 @@ Released under Apache 2.0 license as described in the file LICENSE. Author: David Thrane Christiansen -/ +import Errata import Std.Http import Plausible import Plausible.ArbitraryFueled import VersoServe import VersoServe.Static +open Errata open Plausible open Std Async Http open VersoServe @@ -18,28 +20,23 @@ namespace Verso.Tests.Serve /-! ## Property-based checks (Plausible) -/ -open scoped Plausible.Decorations in -/-- Runs a Plausible property as an `IO` test. -/ -def testProp - (p : Prop) (cfg : Configuration := {}) - (p' : Decorations.DecorationsOf p := by mk_decorations) [Testable p'] : - IO (TestResult p') := - Testable.checkIO p' (cfg := cfg) - /-- A range result stays within bounds whenever it selects a sub-range. -/ -def propRangeBounds := testProp <| ∀ (a b size : Nat), show Bool from +@[test] +def rangeBounds : Test := property <| ∀ (a b size : Nat), show Bool from match parseRange s!"bytes={a}-{b}" size with | .range s e => s ≤ e && e < size | _ => true /-- The resolved mount's prefix is genuinely a prefix of the request, and no match is missed. -/ -def propMountPrefix := testProp <| ∀ (prefixes segs : Array String), show Bool from +@[test] +def mountPrefix : Test := property <| ∀ (prefixes segs : Array String), show Bool from match resolveMountBy id prefixes segs with | some (p, _) => (prefixSegments p).isPrefixOf segs | none => prefixes.all fun q => !(prefixSegments q).isPrefixOf segs /-- The chosen mount has the longest matching prefix of any candidate. -/ -def propMountLongest := testProp <| ∀ (prefixes segs : Array String), show Bool from +@[test] +def mountLongest : Test := property <| ∀ (prefixes segs : Array String), show Bool from match resolveMountBy id prefixes segs with | some (p, _) => prefixes.all fun q => @@ -47,19 +44,11 @@ def propMountLongest := testProp <| ∀ (prefixes segs : Array String), show Boo | none => True /-- Mount resolution does not depend on the order of the mount table. -/ -def propMountShuffle := testProp <| ∀ (prefixes segs : Array String), +@[test] +def mountShuffle : Test := property <| ∀ (prefixes segs : Array String), (resolveMountBy id prefixes segs).map (·.1) == (resolveMountBy id prefixes.reverse segs).map (·.1) -open Lean in -/-- The properties to check, paired with display names. -/ -meta def props : List (Name × (Σ p, IO (TestResult p))) := [ - (`propRangeBounds, ⟨_, propRangeBounds⟩), - (`propMountPrefix, ⟨_, propMountPrefix⟩), - (`propMountLongest, ⟨_, propMountLongest⟩), - (`propMountShuffle, ⟨_, propMountShuffle⟩), -] - /-! ## Unit checks -/ /-- The mount table from the user-guide example. -/ @@ -75,7 +64,7 @@ def resolvedPrefix (mounts : Array Mount) (path : String) : Option String := (resolveMount mounts segs).map (·.1.urlPrefix) /-- The deterministic unit checks, paired with display names. -/ -def units : List (String × Bool) := [ +private def units : List (String × Bool) := [ -- MIME ("mime html", mimeType? "HTML" == some ⟨"text", "html"⟩), ("mime css charset", contentTypeForPath "a.css" == "text/css; charset=utf-8"), @@ -225,16 +214,16 @@ def units : List (String × Bool) := [ (({} : ServeConfig).withCli { port := Port.ofNat? 9000 }).toOption |>.map (·.port.toNat) |>.isEqSome 9000), -- argument parsing accepts valid forms and rejects malformed ones - ("args long port", parseArgs ["--port", "9000"] |>.toOption.bind (·.port) |>.map (·.toNat) |>.isEqSome 9000), - ("args short port", parseArgs ["-p", "3000"] |>.toOption.bind (·.port) |>.map (·.toNat) |>.isEqSome 3000), - ("args positional dir", parseArgs ["site"] |>.toOption.bind (·.dir) |>.map (·.toString) |>.isEqSome "site"), + ("args long port", VersoServe.parseArgs ["--port", "9000"] |>.toOption.bind (·.port) |>.map (·.toNat) |>.isEqSome 9000), + ("args short port", VersoServe.parseArgs ["-p", "3000"] |>.toOption.bind (·.port) |>.map (·.toNat) |>.isEqSome 3000), + ("args positional dir", VersoServe.parseArgs ["site"] |>.toOption.bind (·.dir) |>.map (·.toString) |>.isEqSome "site"), ("args boolean flags", - parseArgs ["--quiet"] |>.toOption.map (fun a => a.quiet) |>.isEqSome true), - ("args unknown option rejected", (parseArgs ["--nope"]).toOption.isNone), - ("args missing port value rejected", (parseArgs ["--port"]).toOption.isNone), - ("args non-numeric port rejected", (parseArgs ["--port", "x"]).toOption.isNone), - ("args out-of-range port rejected", (parseArgs ["--port", "0"]).toOption.isNone), - ("args extra positional rejected", (parseArgs ["a", "b"]).toOption.isNone), + VersoServe.parseArgs ["--quiet"] |>.toOption.map (fun a => a.quiet) |>.isEqSome true), + ("args unknown option rejected", (VersoServe.parseArgs ["--nope"]).toOption.isNone), + ("args missing port value rejected", (VersoServe.parseArgs ["--port"]).toOption.isNone), + ("args non-numeric port rejected", (VersoServe.parseArgs ["--port", "x"]).toOption.isNone), + ("args out-of-range port rejected", (VersoServe.parseArgs ["--port", "0"]).toOption.isNone), + ("args extra positional rejected", (VersoServe.parseArgs ["a", "b"]).toOption.isNone), -- port scanning skips taken ports and reports the one it settled on ("port scan skips taken", (Id.run <| firstAvailable (m := Id) (fun p => if [8000, 8001].contains p.toNat then none else some p) 8000) @@ -246,6 +235,12 @@ def units : List (String × Bool) := [ (Id.run <| firstAvailable (m := Id) (fun p => if p.toNat == 65535 then none else some p) 65535).isNone), ] +/-- Every deterministic unit check in {name}`units` passes. -/ +@[test] +def unitChecks : Test := do + let failed := units.filter (!·.2) |>.map (·.1) + assert failed.isEmpty s!"failed unit checks: {", ".intercalate failed}" + /-! ## In-process integration (Mock transport) -/ /-- Sends a raw HTTP request to a handler over an in-memory connection and returns the raw response. -/ @@ -276,7 +271,7 @@ def unicodeNames : List String := ["øllebrød", "اَلْعَرَبِيَّةُ", "中文文件", "नमस्ते", "All goals proved!🎉", "𝔏𝔢𝔞𝔫"] /-- Runs the integration checks against a temporary directory tree, returning failure messages. -/ -def integrationFailures : IO (Array String) := do +private def integrationFailures : IO (Array String) := do let tmp ← IO.FS.createTempDir -- The served directory is a subdirectory, so a sibling file lets us probe traversal escapes. let root := tmp / "site" @@ -471,23 +466,8 @@ def integrationFailures : IO (Array String) := do IO.FS.removeDirAll tmp return fails -/-! ## Entry point -/ - -/-- Runs every serve test, printing each result and returning the number of failures. -/ -public def runServeTests : IO Nat := do - let mut failures := 0 - for (name, test) in props do - IO.print s!"{name}: " - let res ← test.2 - IO.println res - unless res matches .success .. do failures := failures + 1 - for (name, ok) in units do - if ok then - IO.println s!"{name}: ok" - else - IO.println s!"{name}: FAILED" - failures := failures + 1 - for name in ← integrationFailures do - IO.println s!"integration {name}: FAILED" - failures := failures + 1 - return failures +/-- Every in-process integration check against the mock transport passes. -/ +@[test] +def integration : Test := do + let fails ← integrationFailures + assert fails.isEmpty s!"failed integration checks: {", ".intercalate fails.toList}" diff --git a/src/tests/VersoTests/SetupLiterate.lean b/src/tests/VersoTests/SetupLiterate.lean new file mode 100644 index 000000000..fc9e8530f --- /dev/null +++ b/src/tests/VersoTests/SetupLiterate.lean @@ -0,0 +1,56 @@ +/- +Copyright (c) 2026 Lean FRO LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Author: David Thrane Christiansen + +Tests for `verso setup-literate`, which scaffolds the GitHub Pages workflow in a downstream project. +-/ +module + +import Errata + +open Errata + +/-- The `verso-literate-pages.yml` workflow path within a project. -/ +private def workflowPath (root : System.FilePath) : System.FilePath := + root / ".github" / "workflows" / "verso-literate-pages.yml" + +/-- +`verso setup-literate` generates the Pages workflow on a fresh project, reports that it is up to +date on a second run, and backs up a hand-edited workflow before rewriting it. +-/ +@[test] +def setupLiterate : Test := do + let versoRoot ← IO.FS.realPath "." + IO.FS.withTempDir fun tmpDir => do + let setupLiterate : TestM IO.Process.Output := do + let out ← IO.Process.output { + cmd := "lake", args := #["exe", "verso", "setup-literate"], cwd := some tmpDir.toString } + pure out + + -- A project that depends on the Verso under test. + assertExitCode 0 (← IO.Process.output { + cmd := "git", args := #["init", "-q"], cwd := some tmpDir.toString }) + IO.FS.writeFile (tmpDir / "lean-toolchain") (← IO.FS.readFile "lean-toolchain") + IO.FS.writeFile (tmpDir / "lakefile.toml") + s!"name = \"test-project\"\n\n[[require]]\nname = \"verso\"\npath = \"{versoRoot}\"\n" + + -- Fresh generation writes a workflow with the expected steps. + let fresh ← setupLiterate + assertExitCode 0 fresh + assertFileExists (workflowPath tmpDir) + let content ← IO.FS.readFile (workflowPath tmpDir) + for needle in ["lake query :literateHtml", "deploy-pages@v", "upload-pages-artifact@v", "lean-action@v"] do + assertContains needle content + + -- A second run changes nothing. + let again ← setupLiterate + assertContains "up to date" again.stdout + + -- Editing the workflow makes the next run back up the old content. + IO.FS.writeFile (workflowPath tmpDir) "modified content\n" + let updated ← setupLiterate + assertExitCode 0 updated + let backup := (workflowPath tmpDir).toString ++ ".bak" + assertFileExists backup + assertContains "modified content" (← IO.FS.readFile backup) diff --git a/src/tests/VersoTests/Stemmer.lean b/src/tests/VersoTests/Stemmer.lean new file mode 100644 index 000000000..1c7b3f919 --- /dev/null +++ b/src/tests/VersoTests/Stemmer.lean @@ -0,0 +1,29 @@ +/- +Copyright (c) 2026 Lean FRO LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Author: David Thrane Christiansen +-/ +module + +public import VersoSearch.PorterStemmer +import Errata + +open Verso.Search.Stemmer.Porter Errata + +/-! +Tests the Porter stemmer against the standard vocabulary and its expected output. +-/ + +/-- The Porter stemmer reproduces the reference output for every word in the standard vocabulary. -/ +@[test] +def porterStemmer : Test := do + let vocabulary := (include_str "../stemmer/voc.txt").splitOn "\n" + let expected := (include_str "../stemmer/output.txt").splitOn "\n" + let mut mismatches : Array String := #[] + for word in vocabulary, want in expected do + let got := porterStem word + unless got == want do + mismatches := mismatches.push s!"{word} --> {got} (wanted '{want}')" + unless mismatches.isEmpty do + fail s!"{mismatches.size} stemmer mismatches" + (detail? := some ("\n".intercalate mismatches.toList)) diff --git a/src/tests/Tests/Tags.lean b/src/tests/VersoTests/Tags.lean similarity index 97% rename from src/tests/Tests/Tags.lean rename to src/tests/VersoTests/Tags.lean index 18c19fa89..a74f872ec 100644 --- a/src/tests/Tests/Tags.lean +++ b/src/tests/VersoTests/Tags.lean @@ -4,6 +4,7 @@ Released under Apache 2.0 license as described in the file LICENSE. Author: David Thrane Christiansen -/ module +import Errata import VersoManual set_option doc.verso true @@ -32,7 +33,7 @@ private def htmlId (state : TraverseState) (id : InternalId) : Option String := A tag that nobody else holds is assigned exactly as written, and gives the element its HTML id. -/ /-- info: (true, some "my-tag", false) -/ -#guard_msgs in +#test_msgs in #eval show IO _ from do let ((tag, id), state, failed) ← run do let id ← freshId @@ -45,7 +46,7 @@ Assigning the same tag to the same element again is what later traversal rounds error. -/ /-- info: (true, some "my-tag", false) -/ -#guard_msgs in +#test_msgs in #eval show IO _ from do let ((tag, id), state, failed) ← run do let id ← freshId @@ -64,7 +65,7 @@ An error was encountered! --- info: (false, some "my-tag", none, true) -/ -#guard_msgs in +#test_msgs in #eval show IO _ from do let ((tag, first, second), state, failed) ← run do let first ← freshId @@ -84,7 +85,7 @@ An error was encountered! --- info: (false, some "my-tag", none, true) -/ -#guard_msgs in +#test_msgs in #eval show IO _ from do let ((tag, machine, chosen), state, failed) ← run do let machine ← freshId diff --git a/src/tests/Tests/TeX.lean b/src/tests/VersoTests/TeX.lean similarity index 86% rename from src/tests/Tests/TeX.lean rename to src/tests/VersoTests/TeX.lean index bcb9f7980..0056720ee 100644 --- a/src/tests/Tests/TeX.lean +++ b/src/tests/VersoTests/TeX.lean @@ -3,14 +3,9 @@ Copyright (c) 2026 Lean FRO LLC. All rights reserved. Released under Apache 2.0 license as described in the file LICENSE. Author: David Thrane Christiansen -/ -module - -public import Verso.Doc.TeX -public import Verso.Output.TeX -public meta import Verso.Doc.TeX -public meta import Verso.Output.TeX - -public section +import Errata +import Verso.Doc.TeX +import Verso.Output.TeX namespace Verso.Tests.TeX @@ -20,40 +15,40 @@ open Verso.Output.TeX /-! ## Tests for escapeForVerbatim -/ /-- info: "\\symbol{123}\\symbol{124}\\symbol{125}\\symbol{92}" -/ -#guard_msgs in +#test_msgs in #eval escapeForVerbatim "{|}\\" -- Tests for lineBreaks functionality /-- info: "Nat.\\allowbreak{}add\\-One" -/ -#guard_msgs in +#test_msgs in #eval escapeForVerbatim "Nat.addOne" (lineBreaks := true) /-- info: "List.\\allowbreak{}map2\\-Fun" -/ -#guard_msgs in +#test_msgs in #eval escapeForVerbatim "List.map2Fun" (lineBreaks := true) /-- info: "x2\\-y" -/ -#guard_msgs in +#test_msgs in #eval escapeForVerbatim "x2y" (lineBreaks := true) /-- info: "Foo123" -/ -#guard_msgs in +#test_msgs in #eval escapeForVerbatim "Foo123" (lineBreaks := true) -- no break before digits /-- info: "a..\\allowbreak{}b" -/ -#guard_msgs in +#test_msgs in #eval escapeForVerbatim "a..b" (lineBreaks := true) -- only one break after dot sequence /-- info: "\\symbol{123}foo\\-Bar" -/ -#guard_msgs in +#test_msgs in #eval escapeForVerbatim "{fooBar" (lineBreaks := true) -- escaping + line breaks /-- info: "plain" -/ -#guard_msgs in +#test_msgs in #eval escapeForVerbatim "plain" (lineBreaks := true) -- no transitions /-- info: "Nat.addOne" -/ -#guard_msgs in +#test_msgs in #eval escapeForVerbatim "Nat.addOne" -- lineBreaks := false (default), no breaks end Verso.Tests.TeX @@ -63,11 +58,11 @@ end Verso.Tests.TeX open scoped Verso.Output.TeX /-- info: Verso.Output.TeX.seq #[] -/ -#guard_msgs in +#test_msgs in #eval IO.println <| (repr <| \TeX{}).pretty 80 /-- info: Verso.Output.TeX.text "Hello, world!" -/ -#guard_msgs in +#test_msgs in #eval IO.println <| (repr <| \TeX{"Hello, world!"}).pretty 80 /-- @@ -76,7 +71,7 @@ info: Verso.Output.TeX.command #[] #[Verso.Output.TeX.raw "foo", Verso.Output.TeX.text ""] -/ -#guard_msgs in +#test_msgs in #eval IO.println <| (repr<| \TeX{\hyperlink{\Lean{.raw "foo" }}{\Lean{""}}}).pretty 80 /-- @@ -84,7 +79,7 @@ info: Verso.Output.TeX.seq #[Verso.Output.TeX.text "Hello, ", Verso.Output.TeX.command "textbf" #[] #[Verso.Output.TeX.text "world"]] -/ -#guard_msgs in +#test_msgs in #eval IO.println <| (repr <| \TeX{"Hello, " \textbf{"world"}}).pretty 80 /-- @@ -95,5 +90,5 @@ info: Verso.Output.TeX.environment #[Verso.Output.TeX.text "Hello, ", Verso.Output.TeX.command "textbf" #[] #[Verso.Output.TeX.text "world"]] -/ -#guard_msgs in +#test_msgs in #eval IO.println <| (repr <| \TeX{\begin{Verbatim}{s!"commandChars=\\\\"}"Hello, " \textbf{"world"}\end{Verbatim}}).pretty 80 diff --git a/src/tests/VersoTests/TeXGolden.lean b/src/tests/VersoTests/TeXGolden.lean new file mode 100644 index 000000000..516842044 --- /dev/null +++ b/src/tests/VersoTests/TeXGolden.lean @@ -0,0 +1,89 @@ +/- +Copyright (c) 2026 Lean FRO LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Author: David Thrane Christiansen + +Golden tests for manual-genre TeX generation. This is a non-`module` file because `VersoManual` +and the integration document fixtures are not part of the module system; the Errata runner imports +it through its non-module main. +-/ +import VersoManual +import VersoTests.Integration.SampleDoc +import VersoTests.Integration.InheritanceDoc +import VersoTests.Integration.CodeContent +import VersoTests.Integration.ExtraFilesDoc +import VersoTests.Integration.Escape +import VersoTests.Integration.FrontMatter +import VersoTests.Integration.DiagramDoc +import VersoTests.Integration.TwoSideDoc +import Errata + +open Verso Genre Manual +open Verso.Integration +open Errata + +/-- +Renders `doc` to TeX under `integration/<dir>/output`, checks the produced tree against the golden +`expected` tree, and, under `--check-tex`, confirms `lualatex` builds the result. The extra-file +lists place additional assets alongside the output, matching the document's expectations. +-/ +def texGolden (dir : System.FilePath) (doc : Verso.Doc.VersoDoc Manual) + (twoside : Bool := false) + (extraFiles extraFilesTeX : List (System.FilePath × String) := []) : Test := do + let base : System.FilePath := "src/tests/integration" / dir + let output := base / "output" + if ← output.pathExists then IO.FS.removeDirAll output + let config : Manual.Config := + { destination := output, emitTeX := true, emitHtmlMulti := .no, twoside, extraFiles, + extraFilesTeX } + let logger ← Verso.Logger.new + emitTeX config doc.toPart |>.run extension_impls% |>.run logger + goldenDir (base / "expected") output + if ← flag "check-tex" then + -- `-shell-escape` lets the `svg` package call Inkscape to rasterize `diagram` attachments. + let out ← IO.Process.output { + cwd := output / "tex" + cmd := "lualatex" + args := #["-shell-escape", "-halt-on-error", "-interaction=nonstopmode", "main.tex"] + } + unless out.exitCode == 0 do + -- lualatex writes its diagnostics to stdout and `main.log`, not stderr, so report all three. + let logFile := output / "tex" / "main.log" + let log ← if ← logFile.pathExists then IO.FS.readFile logFile else pure "" + fail s!"lualatex exited with code {out.exitCode}" + (detail? := some s!"stdout:\n{out.stdout}\nstderr:\n{out.stderr}\n{logFile}:\n{log}") + +/-- The sample document renders to its golden TeX. -/ +@[test] +def sampleDoc : Test := texGolden "sample-doc" SampleDoc.doc + +/-- A document using inheritance renders to its golden TeX. -/ +@[test] +def inheritanceDoc : Test := texGolden "inheritance-doc" InheritanceDoc.doc + +/-- A document exercising code content renders to its golden TeX. -/ +@[test] +def codeContentDoc : Test := texGolden "code-content-doc" CodeContent.doc + +/-- A document with extra bundled files renders to its golden TeX. -/ +@[test] +def extraFilesDoc : Test := + texGolden "extra-files-doc" ExtraFilesDoc.doc + (extraFiles := [("src/tests/integration/extra-files-doc/test-data/shared", "shared")]) + (extraFilesTeX := [("src/tests/integration/extra-files-doc/test-data/TeX-only", "TeX-only")]) + +/-- A document exercising escaped `]` in item descriptions renders to its golden TeX. -/ +@[test] +def escapeDoc : Test := texGolden "escape-doc" Escape.doc + +/-- A document with front matter renders to its golden TeX. -/ +@[test] +def frontMatterDoc : Test := texGolden "front-matter-doc" FrontMatter.doc + +/-- A document rendered with two-sided layout renders to its golden TeX. -/ +@[test] +def twoSideDoc : Test := texGolden "twoside-doc" TwoSideDoc.doc (twoside := true) + +/-- A document with diagrams renders to its golden TeX. -/ +@[test] +def diagramDoc : Test := texGolden "diagram-doc" DiagramDoc.doc diff --git a/src/tests/Tests/TexUnit.lean b/src/tests/VersoTests/TexUnit.lean similarity index 86% rename from src/tests/Tests/TexUnit.lean rename to src/tests/VersoTests/TexUnit.lean index ea4589a97..7df6361da 100644 --- a/src/tests/Tests/TexUnit.lean +++ b/src/tests/VersoTests/TexUnit.lean @@ -3,11 +3,8 @@ Copyright (c) 2025 Lean FRO LLC. All rights reserved. Released under Apache 2.0 license as described in the file LICENSE. Author: Jason Reed -/ -module -public import Tests.TexUtil -public meta import Tests.TexUtil - -public section +import Errata +import VersoTests.TexUtil /-! Unit tests covering TeX output given given concrete Verso structures. @@ -16,7 +13,7 @@ Unit tests covering TeX output given given concrete Verso structures. open Verso Genre.Manual /-- info: before\LeanVerb|verb|after -/ -#guard_msgs in +#test_msgs in #eval do let b : Doc.Block Genre.Manual := .concat #[ .para #[ @@ -28,7 +25,7 @@ open Verso Genre.Manual IO.println (← toTex b).asString /-- info: before\LeanVerb|verb|after -/ -#guard_msgs in +#test_msgs in #eval do let b : Doc.Block Genre.Manual := .concat #[ .para #[ diff --git a/src/tests/Tests/TexUtil.lean b/src/tests/VersoTests/TexUtil.lean similarity index 100% rename from src/tests/Tests/TexUtil.lean rename to src/tests/VersoTests/TexUtil.lean diff --git a/src/tests/Tests/VersoBlog/LiterateLeanPage.lean b/src/tests/VersoTests/VersoBlog/LiterateLeanPage.lean similarity index 95% rename from src/tests/Tests/VersoBlog/LiterateLeanPage.lean rename to src/tests/VersoTests/VersoBlog/LiterateLeanPage.lean index 05a851b83..34f9be2bd 100644 --- a/src/tests/Tests/VersoBlog/LiterateLeanPage.lean +++ b/src/tests/VersoTests/VersoBlog/LiterateLeanPage.lean @@ -4,6 +4,7 @@ Released under Apache 2.0 license as described in the file LICENSE. Author: David Thrane Christiansen -/ module +import Errata meta import all VersoBlog.LiterateLeanPage namespace Verso.Tests.VersoBlog.LiterateLeanPage @@ -16,5 +17,5 @@ open Verso.Genre.Blog.Literate.Internal -/ /-- info: some (Except.ok "foo/foo/bar/baz/f.png") -/ -#guard_msgs in +#test_msgs in #eval (url_subst "xy/" z "/static/" pic ".jpg" => "foo/" z "/" pic ".png") "xy/foo/static/bar/baz/f.jpg" diff --git a/src/tests/VersoTests/VersoManual.lean b/src/tests/VersoTests/VersoManual.lean new file mode 100644 index 000000000..5155b7b0b --- /dev/null +++ b/src/tests/VersoTests/VersoManual.lean @@ -0,0 +1,12 @@ +/- +Copyright (c) 2025 Lean FRO LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Author: David Thrane Christiansen +-/ +import VersoTests.VersoManual.Docstring +import VersoTests.VersoManual.Html +import VersoTests.VersoManual.Html.SoftHyphenate +import VersoTests.VersoManual.License +import VersoTests.VersoManual.Markdown +import VersoTests.VersoManual.Sections +import VersoTests.VersoManual.WordCount diff --git a/src/tests/Tests/VersoManual/Docstring.lean b/src/tests/VersoTests/VersoManual/Docstring.lean similarity index 88% rename from src/tests/Tests/VersoManual/Docstring.lean rename to src/tests/VersoTests/VersoManual/Docstring.lean index 0b24e7d8c..6c143c820 100644 --- a/src/tests/Tests/VersoManual/Docstring.lean +++ b/src/tests/VersoTests/VersoManual/Docstring.lean @@ -4,6 +4,7 @@ Released under Apache 2.0 license as described in the file LICENSE. Author: David Thrane Christiansen -/ module +import Errata meta import all VersoManual.Docstring namespace Verso.Tests.VersoManual.Docstring @@ -17,20 +18,20 @@ to strip when rendering a docstring's code block. -/ /-- info: 0 -/ -#guard_msgs in +#test_msgs in #eval indentColumn "" /-- info: 0 -/ -#guard_msgs in +#test_msgs in #eval indentColumn "abc" /-- info: 3 -/ -#guard_msgs in +#test_msgs in #eval indentColumn " abc" /-- info: 3 -/ -#guard_msgs in +#test_msgs in #eval indentColumn " abc\n\n def" /-- info: 2 -/ -#guard_msgs in +#test_msgs in #eval indentColumn " abc\n\n def" /-- info: 2 -/ -#guard_msgs in +#test_msgs in #eval indentColumn " abc\n\n def\n a" diff --git a/src/tests/Tests/VersoManual/Html.lean b/src/tests/VersoTests/VersoManual/Html.lean similarity index 96% rename from src/tests/Tests/VersoManual/Html.lean rename to src/tests/VersoTests/VersoManual/Html.lean index 2a8ddf73b..32d0797e4 100644 --- a/src/tests/Tests/VersoManual/Html.lean +++ b/src/tests/VersoTests/VersoManual/Html.lean @@ -3,11 +3,8 @@ Copyright (c) 2024-2025 Lean FRO LLC. All rights reserved. Released under Apache 2.0 license as described in the file LICENSE. Author: David Thrane Christiansen -/ -module -public import VersoManual.Html -meta import all VersoManual.Html - -public section +import Errata +import VersoManual.Html namespace Verso.Genre.Manual.Html @@ -99,7 +96,7 @@ Expected #[D], seeing #[D] Next: none Done -/ -#guard_msgs in +#test_msgs in #eval show IO Unit from do let mut here : Zipper := ⟨[], testToc⟩ let spec := testToc.preorder diff --git a/src/tests/Tests/VersoManual/Html/SoftHyphenate.lean b/src/tests/VersoTests/VersoManual/Html/SoftHyphenate.lean similarity index 88% rename from src/tests/Tests/VersoManual/Html/SoftHyphenate.lean rename to src/tests/VersoTests/VersoManual/Html/SoftHyphenate.lean index 4590eaf79..4e88ddb3d 100644 --- a/src/tests/Tests/VersoManual/Html/SoftHyphenate.lean +++ b/src/tests/VersoTests/VersoManual/Html/SoftHyphenate.lean @@ -3,34 +3,32 @@ Copyright (c) 2024-2025 Lean FRO LLC. All rights reserved. Released under Apache 2.0 license as described in the file LICENSE. Author: David Thrane Christiansen -/ -module -meta import all VersoManual.Html.SoftHyphenate - -public section +import Errata +import VersoManual.Html.SoftHyphenate open Verso.Genre.Manual /-- info: "blahNotCode<code><a>foo­Bar­Baz</a></code>" -/ -#guard_msgs in +#test_msgs in open Verso.Output Html in #eval softHyphenateIdentifiers {{"blahNotCode"<code><a>"fooBarBaz"</a></code>}} |>.asString /-- info: "<code>abc.<wbr>def.<wbr>ghi.<wbr>jkl</code>" -/ -#guard_msgs in +#test_msgs in open Verso.Output Html in #eval softHyphenateIdentifiers {{<code>"abc.def.ghi.jkl"</code>}} |>.asString /-- info: "<code>ABC.<wbr>DEF</code>" -/ -#guard_msgs in +#test_msgs in open Verso.Output Html in #eval softHyphenateIdentifiers {{<code>"ABC.DEF"</code>}} |>.asString /-- info: "blahNotCode<code><a>fooBa.<wbr>rBaz.<wbr>ab­CD</a></code>" -/ -#guard_msgs in +#test_msgs in open Verso.Output Html in #eval softHyphenateIdentifiers {{"blahNotCode"<code><a>"fooBa.rBaz.abCD"</a></code>}} |>.asString /-- info: "blahNotCode<code><a>fooBa...<wbr>rBaz.<wbr>ab­CD</a></code>" -/ -#guard_msgs in +#test_msgs in open Verso.Output Html in #eval softHyphenateIdentifiers {{"blahNotCode"<code><a>"fooBa...rBaz.abCD"</a></code>}} |>.asString diff --git a/src/tests/Tests/VersoManual/License.lean b/src/tests/VersoTests/VersoManual/License.lean similarity index 94% rename from src/tests/Tests/VersoManual/License.lean rename to src/tests/VersoTests/VersoManual/License.lean index 2f31bd587..c03d7af14 100644 --- a/src/tests/Tests/VersoManual/License.lean +++ b/src/tests/VersoTests/VersoManual/License.lean @@ -4,6 +4,7 @@ Released under Apache 2.0 license as described in the file LICENSE. Author: David Thrane Christiansen -/ module +import Errata meta import all VersoManual.License namespace Verso.Tests.VersoManual.License @@ -13,7 +14,7 @@ open Verso.Genre.Manual /-! ## Tests for paragraphed function -/ /-- info: #["One paragraph with lines", "and another", "and more more"] -/ -#guard_msgs in +#test_msgs in #eval paragraphed r#" One paragraph diff --git a/src/tests/Tests/VersoManual/Markdown.lean b/src/tests/VersoTests/VersoManual/Markdown.lean similarity index 93% rename from src/tests/Tests/VersoManual/Markdown.lean rename to src/tests/VersoTests/VersoManual/Markdown.lean index 0c22d50d6..e93137656 100644 --- a/src/tests/Tests/VersoManual/Markdown.lean +++ b/src/tests/VersoTests/VersoManual/Markdown.lean @@ -3,15 +3,10 @@ Copyright (c) 2024-2025 Lean FRO LLC. All rights reserved. Released under Apache 2.0 license as described in the file LICENSE. Author: David Thrane Christiansen -/ -module -public import VersoManual.Markdown -public import Verso.Doc.Elab.Monad -public import Lean.Elab.Term -meta import all VersoManual.Markdown -meta import all Verso.Doc.Elab.Monad -meta import all Lean.Elab.Term - -public section +import Errata +import VersoManual.Markdown +import Verso.Doc.Elab.Monad +import Lean.Elab.Term open Verso Doc Elab open Verso.Genre Manual Markdown @@ -82,7 +77,7 @@ def markdownPartRangesValid (input : String) : Elab.TermElabM Bool := do return part.partContext.priorParts.all partRangesValid /-- info: true -/ -#guard_msgs in +#test_msgs in #eval markdownPartRangesValid r#" # Acknowledgements ## Contributors @@ -102,7 +97,7 @@ info: # another header ## one more -/ -#guard_msgs in +#test_msgs in /- Exercises how inconsistent Markdown header nesting depth is heuristically fixed. -/ #eval do diff --git a/src/tests/Tests/VersoManual/Sections.lean b/src/tests/VersoTests/VersoManual/Sections.lean similarity index 77% rename from src/tests/Tests/VersoManual/Sections.lean rename to src/tests/VersoTests/VersoManual/Sections.lean index 310474497..9c3e8d609 100644 --- a/src/tests/Tests/VersoManual/Sections.lean +++ b/src/tests/VersoTests/VersoManual/Sections.lean @@ -3,10 +3,12 @@ Copyright (c) 2024-2025 Lean FRO LLC. All rights reserved. Released under Apache 2.0 license as described in the file LICENSE. Author: Emilio J. Gallego Arias -/ +import Errata import VersoManual namespace DocstringSectionRegression +open Errata open Verso Output Genre Manual /-- A fixture for Manual docstring subsection HTML rendering. -/ @@ -55,23 +57,19 @@ private def renderDoc : IO String := do throw <| IO.userError "Manual docstring HTML rendering logged errors" rendered.get -private def assertLabeledSection (compact label : String) : IO Unit := do +private def assertLabeledSection (compact label : String) : TestM Unit := do let id := s!"docstring-section-{label}" let group := s!"<divclass=\"docstring-section\"role=\"group\"aria-labelledby=\"{id}\">" - unless hasSubstring compact group do - throw <| IO.userError s!"{label} section should render as a named group" + assert (hasSubstring compact group) s!"{label} section should render as a named group" let labelHtml := s!"<pclass=\"docstring-section-label\"id=\"{id}\">{label}</p>" - unless hasSubstring compact labelHtml do - throw <| IO.userError s!"{label} section should use a paragraph label with the group's ID" - if hasSubstring compact s!"<h1>{label}</h1>" then - throw <| IO.userError s!"{label} section label should not render as h1" + assert (hasSubstring compact labelHtml) + s!"{label} section should use a paragraph label with the group's ID" + assert (!hasSubstring compact s!"<h1>{label}</h1>") + s!"{label} section label should not render as h1" -/-- -info: docstring section labels render as labeled groups --/ -#guard_msgs in -#eval show IO Unit from do +/-- Docstring section labels render as labeled groups. -/ +@[test] +def sectionLabels : Test := do let compact := compactHtml (← renderDoc) assertLabeledSection compact "Fields" assertLabeledSection compact "Constructors" - IO.println "docstring section labels render as labeled groups" diff --git a/src/tests/Tests/VersoManual/WordCount.lean b/src/tests/VersoTests/VersoManual/WordCount.lean similarity index 85% rename from src/tests/Tests/VersoManual/WordCount.lean rename to src/tests/VersoTests/VersoManual/WordCount.lean index 48890791a..90679271d 100644 --- a/src/tests/Tests/VersoManual/WordCount.lean +++ b/src/tests/VersoTests/VersoManual/WordCount.lean @@ -4,6 +4,7 @@ Released under Apache 2.0 license as described in the file LICENSE. Author: David Thrane Christiansen -/ module +import Errata meta import all VersoManual.WordCount namespace Verso.Tests.VersoManual.WordCount @@ -13,32 +14,32 @@ open Verso.Genre.Manual.WordCount /-! ## Tests for countWords function -/ /-- info: 4 -/ -#guard_msgs in +#test_msgs in #eval countWords (fun _ => false) "a b c d" /-- info: 4 -/ -#guard_msgs in +#test_msgs in #eval countWords (fun _ => false) "a b c d" /-- info: 4 -/ -#guard_msgs in +#test_msgs in #eval countWords (fun _ => false) " a b c d" /-! ## Tests for separatedNumber function -/ /-- info: "0" -/ -#guard_msgs in +#test_msgs in #eval separatedNumber 0 /-- info: "55" -/ -#guard_msgs in +#test_msgs in #eval separatedNumber 55 /-- info: "555" -/ -#guard_msgs in +#test_msgs in #eval separatedNumber 555 /-- info: "51,535" -/ -#guard_msgs in +#test_msgs in #eval separatedNumber 51535 /-- info: "8,813,251,535" -/ -#guard_msgs in +#test_msgs in #eval separatedNumber 8813251535 /-- info: "4,002" -/ -#guard_msgs in +#test_msgs in #eval separatedNumber 4002 diff --git a/src/tests/Tests/Z85.lean b/src/tests/VersoTests/Z85.lean similarity index 95% rename from src/tests/Tests/Z85.lean rename to src/tests/VersoTests/Z85.lean index 26f6c1af2..a35b63868 100644 --- a/src/tests/Tests/Z85.lean +++ b/src/tests/VersoTests/Z85.lean @@ -3,12 +3,9 @@ Copyright (c) 2025 Lean FRO LLC. All rights reserved. Released under Apache 2.0 license as described in the file LICENSE. Author: David Thrane Christiansen -/ -module -public import VersoUtil.BinFiles.Z85 -public meta import VersoUtil.BinFiles.Z85 - -public section +import Errata +import VersoUtil.BinFiles.Z85 open Verso.BinFiles.Z85 @@ -75,7 +72,7 @@ Encoded: nm=QNzY&b1A+]nf Decoded: [72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33] Round trip successful: true -/ -#guard_msgs in +#test_msgs in #eval test end Test diff --git a/src/tests/VersoTests/Zip.lean b/src/tests/VersoTests/Zip.lean new file mode 100644 index 000000000..756dc5df9 --- /dev/null +++ b/src/tests/VersoTests/Zip.lean @@ -0,0 +1,85 @@ +/- +Copyright (c) 2026 Lean FRO LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Author: David Thrane Christiansen +-/ +module + +public import VersoUtil.Zip +import Errata + +open Verso.Zip Errata + +/-! +Tests round-tripping files through the zip writer and the external `unzip` tool. +-/ + +/-- A block of Lean-code-shaped bytes to zip: the project's lakefile. -/ +def sampleBytes : ByteArray := (include_str "../../../lakefile.lean").toByteArray + +/-- A random `stem.ext` filename. -/ +private def randName : IO String := do + let len ← IO.rand 1 10 + let stem ← len.foldM (init := "") fun _ _ acc => do + return acc.push <| Char.ofNat ('a'.toNat + (← IO.rand 0 25)) + let len ← IO.rand 2 4 + let ext ← len.foldM (init := "") fun _ _ acc => do + return acc.push <| Char.ofNat ('a'.toNat + (← IO.rand 0 25)) + return stem ++ "." ++ ext + +/-- Zips `files` with `method`, extracts the archive with `unzip`, and checks each file round-trips. -/ +def extractRoundTrips (files : Array (String × ByteArray)) (method : CompressionMethod) + (loc : Location := by exact here%) : TestM Unit := + IO.FS.withTempDir fun dir => do + let dir := dir / s!"{← IO.monoMsNow}" + IO.FS.createDirAll dir + let archive := dir / "out.zip" + zipToFile archive files method + let out ← IO.Process.output { cmd := "unzip", args := #["-u", archive.toString, "-d", dir.toString] } + -- `unzip` returns 1 on an empty archive and 2 on a corrupt one. + unless out.exitCode == 0 || (files.isEmpty && out.exitCode == 1) do + failAt loc s!"unzip exited with code {out.exitCode}" (detail? := some out.stderr) + for (name, contents) in files do + let found ← IO.FS.readBinFile (dir / name) + unless found == contents do + failAt loc s!"contents of {name} do not match" + (detail? := some s!"expected {contents.size} bytes, got {found.size}") + +/-- Fixed file sets round-trip under both compression methods. -/ +@[test] +def zipFixed : Test := do + let files := #[("x.txt", "abcdef\nlkjlkj".toByteArray), ("y.txt", "".toByteArray), + ("z.txt", "abc\n\n".toByteArray)] + for method in [CompressionMethod.store, .deflate] do + extractRoundTrips #[] method + extractRoundTrips #[("empty", .empty)] method + extractRoundTrips files method + +/-- Increasingly large prefixes of a block round-trip, alone and paired with another. -/ +@[test] +def zipChunked : Test := do + let me := sampleBytes + let bwd := me.foldl (init := .empty) fun acc b => ByteArray.empty.push b ++ acc + let chunk := me.size / 10 + for method in [CompressionMethod.store, .deflate] do + for i in [0:11] do + let block := me.extract 0 (i * chunk) + extractRoundTrips #[("T2.lean", block)] method + extractRoundTrips #[("T2.lean", block), ("other", bwd.extract 0 (i * chunk))] method + +/-- Random file sets round-trip under both compression methods. -/ +@[test] +def zipRandom : Test := do + for _ in [0:10] do + let seed ← IO.monoNanosNow + IO.setRandSeed seed + -- Printed only if the test fails, so a failure is reproducible. + IO.println s!"random seed: {seed}" + let count ← IO.rand 0 15 + let mut files := #[] + for i in [0:count] do + let bytes ← IO.getRandomBytes (.ofNat (← IO.rand 0 50000)) + -- A numbered prefix keeps every name distinct even when two random stems collide. + files := files.push (s!"{i + 1}-{← randName}", bytes) + for method in [CompressionMethod.store, .deflate] do + extractRoundTrips files method From 2f66877bd5e11def080b4a569a8f452068ea7187 Mon Sep 17 00:00:00 2001 From: David Thrane Christiansen <david@davidchristiansen.dk> Date: Fri, 14 Aug 2026 10:33:04 +0200 Subject: [PATCH 2/2] feat: run Errata tests interactively in a widget Tests can additionally be run interactively using a widget. When the text cursor is on a test's source span, the InfoView offers a "run" button that runs the test in a separate process, streaming its output as it is produced and killing the process if the run is cancelled. --- .github/workflows/ci.yml | 6 + .../Releases/Entries/TestFramework.lean | 2 + lakefile.lean | 17 +- src/errata-tests/ErrataTests.lean | 56 ++ src/errata/Errata.lean | 2 + src/errata/Errata/Discovery.lean | 82 +++ src/errata/Errata/NameJson.lean | 38 ++ src/errata/Errata/RunOne.lean | 122 +++++ src/errata/Errata/Widget.lean | 283 ++++++++++ src/errata/Errata/widget/jsconfig.json | 8 + src/errata/Errata/widget/run_test_widget.js | 517 ++++++++++++++++++ .../Errata/widget/widget-externals.d.ts | 20 + src/errata/ErrataRunOne.lean | 85 +++ 13 files changed, 1235 insertions(+), 3 deletions(-) create mode 100644 src/errata/Errata/NameJson.lean create mode 100644 src/errata/Errata/RunOne.lean create mode 100644 src/errata/Errata/Widget.lean create mode 100644 src/errata/Errata/widget/jsconfig.json create mode 100644 src/errata/Errata/widget/run_test_widget.js create mode 100644 src/errata/Errata/widget/widget-externals.d.ts create mode 100644 src/errata/ErrataRunOne.lean diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25f83a0b2..6b481ef01 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -235,6 +235,12 @@ jobs: npx tsc --noEmit -p jsconfig.json popd + - name: Type check the run-test widget JS code + run: | + pushd src/errata/Errata/widget + npx tsc --noEmit -p jsconfig.json + popd + - name: Check the ToC width storage key stays in sync run: | # toc-resize.js and toc-resize-preload.js must agree on the localStorage diff --git a/doc/UsersGuide/Releases/Entries/TestFramework.lean b/doc/UsersGuide/Releases/Entries/TestFramework.lean index bc25ffa67..4fa034654 100644 --- a/doc/UsersGuide/Releases/Entries/TestFramework.lean +++ b/doc/UsersGuide/Releases/Entries/TestFramework.lean @@ -31,3 +31,5 @@ The test runner discovers every test in the package; it can restrict the run to Elaboration-time tests can be written with `#test_msgs` and `#test_guard`, variants of `#guard_msgs` and `#guard` that run their check at compile time and record the outcome as a test case, reported together with the rest of the suite. Verso's own test suite runs on Errata: `lake test` discovers and runs every test in the package, and CI publishes the resulting reports. + +Tests can also be run interactively from the editor: a panel widget shown on a test's declaration runs it in a separate process, streaming its output as it is produced. diff --git a/lakefile.lean b/lakefile.lean index 80e200cfa..eb53c1b36 100644 --- a/lakefile.lean +++ b/lakefile.lean @@ -142,8 +142,8 @@ lean_lib VersoTests where roots := #[`VersoTests] globs := #[Glob.andSubmodules `VersoTests] --- Everything below is Errata's own implementation: its library, its self-tests, the generated --- discovery runner, and the `lake test` driver. +-- Everything below is Errata's own implementation: its library, the single-test runner and widget +-- support exe, its self-tests, the generated discovery runner, and the `lake test` driver. namespace Errata @[default_target] @@ -151,10 +151,21 @@ input_file errataUsageFile where text := true path := "src/errata/Errata/usage.txt" +input_file errataRunTestWidgetJs where + text := true + path := "src/errata/Errata/widget/run_test_widget.js" + lean_lib Errata where srcDir := "src/errata" roots := #[`Errata] - needs := #[errataUsageFile] + needs := #[errataUsageFile, errataRunTestWidgetJs] + +-- Runs one test in a fresh process so the widget can stream its output and kill it on cancel. +@[default_target] +lean_exe «errata-run-one» where + srcDir := "src/errata" + root := `ErrataRunOne + supportInterpreter := true -- Tests that exercise Errata using Errata itself. lean_lib ErrataTests where diff --git a/src/errata-tests/ErrataTests.lean b/src/errata-tests/ErrataTests.lean index 349a8c7b0..b3ad27a4c 100644 --- a/src/errata-tests/ErrataTests.lean +++ b/src/errata-tests/ErrataTests.lean @@ -155,6 +155,62 @@ def reportMarkdown : Test := do assertContains "expected 1\nactual 2" md assertContains "Summary by module" md +/-- `runValue` reports a passing value as passed. -/ +@[test] +def runOnePasses : Test := do + let o ← runValue default (pure () : Test) + assertEq "passed" o.status + +/-- `runValue` reports a failing value as failed and carries its message. -/ +@[test] +def runOneFails : Test := do + let o ← runValue default (TestResult.fail { message := "boom" }) + assertEq "failed" o.status + assertEq (some "boom") o.message? + +/-- `runValue` reports a skipped value as skipped. -/ +@[test] +def runOneSkips : Test := do + let o ← runValue default (TestResult.skip "later") + assertEq "skipped" o.status + +/-- A failing run surfaces its captured output in the outcome. -/ +@[test] +def runOneCapturesOutput : Test := do + let o ← runValue default (do IO.println "trace line"; failHere "nope" : Test) + assertEq "failed" o.status + assertEq 1 o.output.size + assertEq "stdout" o.output[0]!.stream + assertContains "trace line" o.output[0]!.text + +/-- An outcome takes the most severe verdict among several named results. -/ +@[test] +def runOneAggregates : Test := do + let o ← runValue default (do result "a" (pure ()); result "b" (failHere "bad") : Test) + assertEq "failed" o.status + +/-- A passing run still surfaces its captured output. -/ +@[test] +def runOnePassOutput : Test := do + let o ← runValue default (do IO.println "printed"; return true : IO Bool) + assertEq "passed" o.status + assertEq 1 o.output.size + assertEq "stdout" o.output[0]!.stream + assertContains "printed" o.output[0]!.text + +/-- Captured output keeps stdout and stderr distinct and interleaved in order. -/ +@[test] +def runOneStreams : Test := do + let o ← runValue default (do + IO.println "out one" + IO.eprintln "err one" + IO.println "out two" + return true : IO Bool) + assertEq "passed" o.status + assertEq 3 o.output.size + assertEq "stdout" o.output[0]!.stream + assertEq "stderr" o.output[1]!.stream + assertEq "stdout" o.output[2]!.stream /-- `failure` from the `Alternative` instance fails a test. -/ @[test] def alternativeFailure : Test := expectFail failure diff --git a/src/errata/Errata.lean b/src/errata/Errata.lean index 1760f91cd..aa5358daf 100644 --- a/src/errata/Errata.lean +++ b/src/errata/Errata.lean @@ -15,6 +15,8 @@ public import Errata.Process public import Errata.Golden public import Errata.Report public import Errata.Runner +public import Errata.NameJson +public import Errata.RunOne public import Errata.Discovery public import Errata.CompileTime public import Errata.Property diff --git a/src/errata/Errata/Discovery.lean b/src/errata/Errata/Discovery.lean index e39527ad5..3fa7327b6 100644 --- a/src/errata/Errata/Discovery.lean +++ b/src/errata/Errata/Discovery.lean @@ -9,6 +9,8 @@ public import Errata.IsTest public import Errata.Runner public import Lean public meta import Lean +public meta import Errata.NameJson +public meta import Errata.Widget open Lean Meta Elab Term @@ -68,6 +70,70 @@ meta def recordTest (decl : Name) : AttrM Unit := do let docstring? ← findDocString? (← getEnv) decl modifyEnv (testExt.addEntry · { name := decl, file := ← getFileName, docstring? }) +/-- A synthetic syntax carrying the given source range, used to position the widget. -/ +meta def rangeSyntax [Monad m] [MonadFileMap m] + (startPos stopPos : String.Pos.Raw) : m Syntax := do + let str := (← getFileMap).source + let leading : Substring.Raw := { str, startPos, stopPos := startPos } + let trailing : Substring.Raw := { str, startPos := stopPos, stopPos } + return Syntax.atom (.original leading startPos trailing stopPos) "" + +/-- The text of line {lean}`i`, trimmed of surrounding whitespace. -/ +private meta def lineText (lines : Array String) (i : Nat) : String := + ((lines[i]?).getD "").trimAscii.copy + +/-- The first non-blank line at or above {lean}`i`, or {lean}`none` if all are blank up to the top. -/ +private meta partial def firstNonBlankUp (lines : Array String) (i : Nat) : Option Nat := + if (lineText lines i).isEmpty then + if i == 0 then none else firstNonBlankUp lines (i - 1) + else some i + +/-- Scanning up from {lean}`i`, the line that opens a doc comment, stopping at a non-comment line. -/ +private meta partial def docOpenLine (lines : Array String) (i : Nat) : Option Nat := + if (lineText lines i).startsWith "/--" then some i + else if (lineText lines i).startsWith "/-" then none + else if i == 0 then none + else docOpenLine lines (i - 1) + +/-- +The 0-based start line of a doc comment immediately above {lean}`markerLineIdx`, if any. To avoid +mistaking an unrelated trailing comment for one, the comment must be a single-line doc comment or +have its closing delimiter on its own line opened by a doc-comment line. +-/ +private meta def docStartLine? (lines : Array String) (markerLineIdx : Nat) : Option Nat := do + guard (markerLineIdx > 0) + let endLine ← firstNonBlankUp lines (markerLineIdx - 1) + let t := lineText lines endLine + if t.startsWith "/--" && t.endsWith "-/" then return endLine + guard (t == "-/" && endLine > 0) + docOpenLine lines (endLine - 1) + +/-- +The source range to show the test's widget over: the whole declaration, including a doc comment above +it. The recorded declaration range is used when available; otherwise the command is re-parsed from the +start of the marker's line, extending up over an immediately preceding doc comment. Falls back to the +marker itself. +-/ +meta def widgetRangeSyntax (decl : Name) (attrStx : Syntax) : AttrM Syntax := do + let fileMap ← getFileMap + if let some ranges ← findDeclarationRanges? decl then + let stx ← rangeSyntax (fileMap.ofPosition ranges.range.pos) (fileMap.ofPosition ranges.range.endPos) + return stx + let some attrPos := attrStx.getPos? | return attrStx + let lineStart := fileMap.ofPosition ⟨(fileMap.toPosition attrPos).line, 0⟩ + let inputCtx := Parser.mkInputContext fileMap.source (← getFileName) + let pmctx : Parser.ParserModuleContext := { env := ← getEnv, options := ← getOptions } + let (cmdStx, _, _) := Parser.parseCommand inputCtx pmctx { pos := lineStart } {} + match cmdStx.getRange? with + | some range => + -- Extend the span up over a doc comment immediately above the marker, when there is one. + let lines := (fileMap.source.splitOn "\n").toArray + let startPos := match docStartLine? lines ((fileMap.toPosition attrPos).line - 1) with + | some docIdx => fileMap.ofPosition ⟨docIdx + 1, 0⟩ + | none => range.start + rangeSyntax startPos range.stop + | none => return attrStx + /-- Marks a definition as a test, discovered and run by the Errata test runner. -/ meta initialize registerBuiltinAttribute { @@ -80,6 +146,22 @@ meta initialize Attribute.Builtin.ensureNoArgs stx unless kind == AttributeKind.global do throwAttrMustBeGlobal `test kind recordTest decl + -- Show the widget when the cursor is anywhere on the declaration, not just on the marker. + let widgetStx ← widgetRangeSyntax decl stx + -- A hash of the test's source, so a run is invalidated when the test is edited. + let source := (← getFileMap).source + let version := match widgetStx.getRange? with + | some range => + let sub : Substring.Raw := { str := source, startPos := range.start, stopPos := range.stop } + toString sub.toString.hash + | none => "" + let props := pure <| json% { + decl: $(Errata.nameToJson decl), + module: $(toString (← getMainModule)), + name: $(toString (privateToUserName decl)), + version: $version + } + Lean.Widget.savePanelWidgetInfo Errata.Widget.runTestWidget.javascriptHash.val props widgetStx } /-- The test's name below its module: the declaration's components past the module prefix, dotted. -/ diff --git a/src/errata/Errata/NameJson.lean b/src/errata/Errata/NameJson.lean new file mode 100644 index 000000000..f92207028 --- /dev/null +++ b/src/errata/Errata/NameJson.lean @@ -0,0 +1,38 @@ +/- +Copyright (c) 2026 Lean FRO LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Author: David Thrane Christiansen +-/ +module + +public import Lean.Data.Json + +public section + +set_option linter.missingDocs true +set_option doc.verso true + +namespace Errata + +open Lean + +/-- +Encodes a {name}`Lean.Name` structurally, preserving the numeric and hygienic components that the +standard string form does not round-trip. The widget and the single-test runner exchange test names +this way. +-/ +def nameToJson : Name → Json + | .anonymous => .null + | .str p s => Json.mkObj [("str", .arr #[nameToJson p, .str s])] + | .num p n => Json.mkObj [("num", .arr #[nameToJson p, .num n])] + +/-- Decodes a {name}`Lean.Name` written by {name}`nameToJson`. -/ +partial def nameOfJson? (j : Json) : Except String Name := do + if j.isNull then return .anonymous + if let .ok arr := j.getObjVal? "str" then + let #[p, s] := (← fromJson? arr : Array Json) | .error "malformed name component" + return .str (← nameOfJson? p) (← fromJson? s) + if let .ok arr := j.getObjVal? "num" then + let #[p, n] := (← fromJson? arr : Array Json) | .error "malformed name component" + return .num (← nameOfJson? p) (← fromJson? n) + .error "expected an encoded `Name`" diff --git a/src/errata/Errata/RunOne.lean b/src/errata/Errata/RunOne.lean new file mode 100644 index 000000000..2232afd18 --- /dev/null +++ b/src/errata/Errata/RunOne.lean @@ -0,0 +1,122 @@ +/- +Copyright (c) 2026 Lean FRO LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Author: David Thrane Christiansen +-/ +module + +public import Errata.IsTest +public import Lean.Data.Json + +public section + +set_option linter.missingDocs true +set_option doc.verso true + +namespace Errata + +/-- A run of captured output from a single stream, used to render output with the streams distinct. -/ +structure OutputChunk where + /-- The stream the text was written to: {lit}`"stdout"` or {lit}`"stderr"`. -/ + stream : String + /-- The text written to that stream. -/ + text : String + /-- When the chunk was received, in milliseconds since the Unix epoch; set by the runner. -/ + time : Nat := 0 +deriving Lean.FromJson, Lean.ToJson, Repr, Inhabited, DecidableEq + +/-- The chunk for a single captured output fragment, tagged by its stream. -/ +def OutputChunk.ofOutput : Output → OutputChunk + | .stdout s => { stream := "stdout", text := s } + | .stderr s => { stream := "stderr", text := s } + +/-- +The outcome of running a single test, in a form the InfoView widget renders. The status is one of +{lit}`"passed"`, {lit}`"failed"`, {lit}`"error"`, or {lit}`"skipped"`. +-/ +structure RunOutcome where + /-- The overall verdict: {lit}`"passed"`, {lit}`"failed"`, {lit}`"error"`, or {lit}`"skipped"`. -/ + status : String + /-- How long the run took, in milliseconds. -/ + durationMs : Nat + /-- The failure or skip message, when the test did not pass. -/ + message? : Option String := none + /-- Supporting detail for a failure, such as a diff or counterexample. -/ + detail? : Option String := none + /-- The captured output, in order, with each chunk tagged by the stream it was written to. -/ + output : Array OutputChunk := #[] + /-- The test's docstring, rendered as Markdown, when it has one. -/ + description? : Option String := none +deriving Lean.FromJson, Lean.ToJson, Repr, Inhabited + +/-- The status name a single result contributes. -/ +private def statusName : Status → String + | .pass => "passed" + | .fail _ => "failed" + | .error _ => "error" + | .skip _ => "skipped" + +/-- The message a status carries, when it did not pass. -/ +private def statusMessage : Status → Option String + | .pass => none + | .fail f => some f.message + | .error m => some m + | .skip r => some r + +/-- Appends one output fragment, merging it into the previous chunk when it is from the same stream. -/ +private def pushFragment (chunks : Array OutputChunk) (o : Output) : Array OutputChunk := + let stream := match o with | .stdout _ => "stdout" | .stderr _ => "stderr" + match chunks.back? with + | some last => if last.stream == stream + then chunks.pop.push { last with text := last.text ++ o.text } + else chunks.push { stream, text := o.text } + | none => chunks.push { stream, text := o.text } + +/-- +Condenses the results of one test run into a single outcome. The verdict is the most severe status +present (error over failed over skipped over passed), the message and detail come from the first +result with that status, and the output is every result's captured fragments in order, each tagged by +its stream. +-/ +def summarizeResults (results : Array Result) : RunOutcome := Id.run do + let rank : Status → Nat + | .error _ => 3 + | .fail _ => 2 + | .skip _ => 1 + | .pass => 0 + let worst := results.foldl (fun acc r => if rank r.status > rank acc then r.status else acc) .pass + let duration := results.foldl (fun acc r => acc + r.durationMs) 0 + let output := results.foldl (fun acc r => r.output.log.foldl pushFragment acc) #[] + return { + status := statusName worst + durationMs := duration + message? := statusMessage worst + detail? := match worst with | .fail f => f.detail? | _ => none + output + } + +/-- +Runs one testable value to completion and condenses its results into a {name}`RunOutcome`. Captured +output is kept on a passing result too, since the widget shows it on demand rather than only on +failure. +-/ +def runValue {α} [IsTest α] (location : Location) (value : α) + (sink : Output → IO Unit := fun _ => pure ()) : IO RunOutcome := do + let log ← IO.mkRef (#[] : Array Result) + let usedOptions ← IO.mkRef ∅ + let cfg : Context := { log, usedOptions, location, writeOutput := sink } + let start ← IO.monoMsNow + let (outcome, output) ← runCapturing cfg (IsTest.toTest value) + let dur := (← IO.monoMsNow) - start + let logged ← log.get + let results := + match cfg.resultOfOutcome outcome output dur (!logged.isEmpty) with + | some r => logged.push r + | none => + -- A passing test with named results: the results stand for it, but keep the test's own + -- top-level output (written outside any result block) so the widget still shows it. + if output.log.isEmpty then logged else logged.push { cfg.pass 0 with output } + return summarizeResults results + +/-- Runs one testable value with a default failure location, for callers without a source range. -/ +def runValueDefault {α} [IsTest α] (value : α) : IO RunOutcome := runValue default value diff --git a/src/errata/Errata/Widget.lean b/src/errata/Errata/Widget.lean new file mode 100644 index 000000000..d53cf0106 --- /dev/null +++ b/src/errata/Errata/Widget.lean @@ -0,0 +1,283 @@ +/- +Copyright (c) 2026 Lean FRO LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Author: David Thrane Christiansen +-/ +module + +public meta import Lean.Widget.UserWidget +public meta import Lean.Server +public meta import Std.Time +public meta import Errata.NameJson +public meta import Errata.RunOne + +public section + +set_option linter.missingDocs true +set_option doc.verso true + +open Lean + +namespace Errata.Widget + +/-- +Shown when the text cursor is on a test's source span. It offers a "run" button that runs the test +in the language server, streaming its output as it is produced. +-/ +@[widget_module] +meta def runTestWidget : Lean.Widget.Module where + javascript := include_str "widget/run_test_widget.js" + +/-- The current wall-clock time in milliseconds since the Unix epoch. -/ +private meta def nowMs : IO Nat := + return (← Std.Time.Timestamp.now).toMillisecondsSinceUnixEpoch.toInt.toNat + +/-- +A live (or just-finished) run. Output chunks accumulate in {name (full := RunState.chunks)}`chunks` +so a widget that reconnects after the cursor leaves and returns can replay them by index. The wakeup +promise is resolved and replaced whenever a chunk arrives or the run finishes, waking any waiter. +-/ +meta structure RunState where + /-- Every output chunk produced so far, in order. -/ + chunks : IO.Ref (Array Errata.OutputChunk) + /-- Whether the run has finished. -/ + finished : IO.Ref Bool + /-- The final outcome, set when the run finishes. -/ + outcome : IO.Ref (Option Errata.RunOutcome) + /-- A promise resolved and replaced on each change, used to wake waiters without polling. -/ + wakeup : IO.Ref (IO.Promise Unit) + /-- The current phase, {lit}`"building"` while compiling the module then {lit}`"running"`. -/ + phase : IO.Ref String + /-- A hash of the test's source when the run started; a later request with a different one is stale. -/ + version : String + /-- When the run started, in milliseconds since the Unix epoch. -/ + startTime : Nat + /-- How long the build took, in milliseconds; 0 while still building. -/ + buildMs : IO.Ref Nat + /-- When the test body started (reported by the runner), in epoch ms; 0 until then. -/ + execStartTime : IO.Ref Nat + /-- + The process to be killed if the run is cancelled. Contains first the build, then the runner. + Updated as each is spawned. + -/ + kill : IO.Ref (IO Unit) + +/-- The live runs, keyed by the test's declaration name so a run survives re-elaboration. -/ +meta initialize runRegistry : IO.Ref (Std.HashMap Name RunState) ← IO.mkRef {} + +/-- A request to start running a test: the declaration and the module that defines it. -/ +meta structure StartRequest where + /-- The test declaration to run, encoded by {name}`nameToJson`. -/ + decl : Json + /-- The module that defines the test, as a dotted name. -/ + module : String + /-- A hash of the test's source, recorded with the run so an edit can invalidate it. -/ + version : String +deriving Lean.FromJson, Lean.ToJson + +/-- A request for output past a known position, naming the test by its encoded declaration. -/ +meta structure AwaitRequest where + /-- The test declaration, encoded by {name}`nameToJson`. -/ + decl : Json + /-- The number of chunks the widget already has, so only later ones are returned. -/ + since : Nat + /-- The test's source hash; a run recorded under a different one is stale and ignored. -/ + version : String + /-- The phase the widget last saw; a reply is returned at once when the run's phase differs. -/ + phase : String +deriving Lean.FromJson, Lean.ToJson + +/-- A request that names a running test by its declaration, encoded by {name}`nameToJson`. -/ +meta structure RunRef where + /-- The test declaration, encoded by {name}`nameToJson`. -/ + decl : Json +deriving Lean.FromJson, Lean.ToJson + +/-- One reply from {lit}`awaitOutput`: any output chunks past the requested position, or the outcome. -/ +meta structure AwaitResult where + /-- The output chunks past the requested position. -/ + chunks : Array Errata.OutputChunk := #[] + /-- The position past the returned chunks, to pass as the next request's start. -/ + nextSince : Nat := 0 + /-- When the run started, in milliseconds since the Unix epoch. -/ + startTime : Nat := 0 + /-- How long the build took, in milliseconds; 0 while still building. -/ + buildMs : Nat := 0 + /-- When the test body started, in epoch ms; 0 until then. Output offsets are relative to it. -/ + execStartTime : Nat := 0 + /-- The current phase, {lit}`"building"` or {lit}`"running"`. -/ + phase : String := "running" + /-- Whether the run has finished and no more output will arrive. -/ + done : Bool := false + /-- The final outcome, present once {lit}`done` is set. -/ + outcome : Option Errata.RunOutcome := none +deriving Lean.FromJson, Lean.ToJson + +open Server in +/-- Decodes the declaration name from a request, failing the request if it is malformed. -/ +private meta def decodeDecl (j : Json) : RequestM Name := + match nameOfJson? j with + | .ok n => pure n + | .error e => throw (.mk .invalidParams e) + +/-- Resolves the run's current wakeup promise and installs a fresh one, waking any waiter. -/ +private meta def signalRun (state : RunState) : IO Unit := do + let p ← state.wakeup.get + state.wakeup.set (← IO.Promise.new) + p.resolve () + +/-- Kills the runner of a name, if any, marks it finished, and forgets it. -/ +private meta def dropRun (declName : Name) : IO Unit := do + if let some state := (← runRegistry.get).get? declName then + try (← state.kill.get) catch _ => pure () + state.finished.set true + signalRun state + runRegistry.modify (·.erase declName) + +/-- +Reads the runner's JSON protocol from its stdout: a {lit}`chunk` line per output fragment, then an +{lit}`outcome` line. Marks the run finished at end of input, which is reached when the process exits +or is killed, so this never holds a thread past the process's lifetime. +-/ +private meta partial def readLoop (out : IO.FS.Handle) (state : RunState) : IO Unit := do + let line ← out.getLine + if line.isEmpty then + state.finished.set true + signalRun state + else + if let .ok j := Json.parse line then + if let .ok c := j.getObjVal? "chunk" then + if let .ok chunk := (fromJson? c : Except String Errata.OutputChunk) then + state.chunks.modify (·.push chunk) + signalRun state + else if let .ok ex := j.getObjVal? "exec" then + if let .ok t := (fromJson? ex : Except String Nat) then + state.execStartTime.set t + signalRun state + else if let .ok o := j.getObjVal? "outcome" then + if let .ok oc := (fromJson? o : Except String Errata.RunOutcome) then + state.outcome.set oc + readLoop out state + +/-- The outcome shown when the build step fails, carrying its message and detail. -/ +private meta def buildFailure (detail : String) : Errata.RunOutcome := { + status := "error", durationMs := 0, message? := some "lake build failed", detail? := some detail +} + +/-- +Builds the test's module from the saved source, then runs the test, streaming its output into the +run state. Building first means a Run reflects the latest saved version of the test. +-/ +private meta def buildAndRun (module declJson : String) (state : RunState) : IO Unit := do + -- `lake query` builds the runner exe and the test's module (so the run reflects the saved source) + -- and prints the exe's absolute path on stdout; progress and errors go to stderr. + let build ← IO.Process.spawn { + stdin := .null, stdout := .piped, stderr := .piped + cmd := "lake", args := #["query", "errata-run-one", module] + } + state.kill.set build.kill + let errTask ← IO.asTask build.stderr.readToEnd + let queryOut ← build.stdout.readToEnd + let buildErr := (← IO.wait errTask).toOption.getD "" + if (← build.wait) != 0 then + state.outcome.set (some (buildFailure buildErr)) + state.finished.set true + signalRun state + return + let some runnerPath := (queryOut.splitOn "\n").find? (!·.trimAscii.isEmpty) |>.map (·.trimAscii.copy) + | state.outcome.set (some (buildFailure "lake query did not report the runner's path")) + state.finished.set true + signalRun state + return + -- Spawn the runner directly rather than through `lake exe` so it inherits the language server's + -- broad `LEAN_PATH`. The runner imports the arbitrary test module at runtime, which is not a + -- dependency of the exe, so `lake exe` would narrow `LEAN_PATH` to the exe's own deps and the + -- import would fail. + let run ← IO.Process.spawn { + stdin := .null, stdout := .piped, stderr := .inherit + cmd := runnerPath, args := #[module, declJson] + } + state.kill.set run.kill + state.buildMs.set ((← nowMs) - state.startTime) + state.phase.set "running" + signalRun state + readLoop run.stdout state + +open Server in +/-- Whether the document's live text matches what is on disk, i.e. it has no unsaved changes. -/ +private meta def bufferIsClean : RequestM Bool := do + let docMeta := (← RequestM.readDoc).meta + let some path := System.Uri.fileUriToPath? docMeta.uri + | return true + match (← (IO.FS.readFile path).toBaseIO).toOption with + | some disk => return docMeta.text.source == disk.crlfToLf + | none => return true + +open Server in +/-- Server RPC method reporting whether the file has no unsaved changes, gating the Run button. -/ +@[server_rpc_method] +meta def bufferClean (_ : RunRef) : RequestM (RequestTask Bool) := do + return RequestTask.pure (← bufferIsClean) + +open Server in +/-- Server RPC method that starts running a test: builds its saved source, then streams its output. -/ +@[server_rpc_method] +meta def startTest (req : StartRequest) : RequestM (RequestTask Unit) := do + let declName ← decodeDecl req.decl + unless ← bufferIsClean do + throw (.mk .invalidParams "the file has unsaved changes; save it before running the test") + dropRun declName + let state : RunState := { + chunks := ← IO.mkRef #[], finished := ← IO.mkRef false, outcome := ← IO.mkRef none, + wakeup := ← IO.mkRef (← IO.Promise.new), phase := ← IO.mkRef "building", version := req.version, + startTime := ← nowMs, buildMs := ← IO.mkRef 0, execStartTime := ← IO.mkRef 0, + kill := ← IO.mkRef (pure ()) + } + runRegistry.modify (·.insert declName state) + let _ ← IO.asTask (buildAndRun req.module req.decl.compress state) + return RequestTask.pure () + +open Server in +/-- Builds the reply for a waiter given the run's current state and the position it already has. -/ +private meta def replyFrom (state : RunState) (since : Nat) : IO AwaitResult := do + let chunks ← state.chunks.get + let phase ← state.phase.get + let startTime := state.startTime + let buildMs ← state.buildMs.get + let execStartTime ← state.execStartTime.get + if chunks.size > since then + let slice := chunks.extract since chunks.size + return { chunks := slice, nextSince := since + slice.size, phase, startTime, buildMs, execStartTime } + let done ← state.finished.get + let outcome ← state.outcome.get + return { nextSince := chunks.size, phase, startTime, buildMs, execStartTime, done, outcome } + +open Server in +/-- +Server RPC method that returns output chunks past {name (full := AwaitRequest.since)}`since`, or the +final outcome. When nothing new is available yet, the reply hangs off the run's wakeup promise, so no +worker thread is held while waiting and a reconnecting widget replays from {lit}`since := 0`. +-/ +@[server_rpc_method] +meta def awaitOutput (req : AwaitRequest) : RequestM (RequestTask AwaitResult) := do + let declName ← decodeDecl req.decl + let some state := (← runRegistry.get).get? declName + | return RequestTask.pure ({ done := true } : AwaitResult) + -- A run recorded under a different source hash is from before an edit; treat it as absent. + if state.version != req.version then + return RequestTask.pure ({ done := true } : AwaitResult) + let p ← state.wakeup.get + let chunks ← state.chunks.get + -- Return at once when there is new output, the run finished, or its phase changed (so a widget + -- reconnecting mid-build learns it is building rather than waiting silently); otherwise wait. + if chunks.size > req.since || (← state.finished.get) || (← state.phase.get) != req.phase then + return RequestTask.pure (← replyFrom state req.since) + RequestM.mapTaskCheap (p.resultD ()).asServerTask fun _ => liftM (replyFrom state req.since) + +open Server in +/-- Server RPC method that cancels a running test by killing its process. -/ +@[server_rpc_method] +meta def cancelTest (req : RunRef) : RequestM (RequestTask Unit) := do + let declName ← decodeDecl req.decl + dropRun declName + return RequestTask.pure () diff --git a/src/errata/Errata/widget/jsconfig.json b/src/errata/Errata/widget/jsconfig.json new file mode 100644 index 000000000..9a3cd8eef --- /dev/null +++ b/src/errata/Errata/widget/jsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "lib": ["ES2024", "DOM", "DOM.Iterable"], + "target": "ES2024", + "noEmit": true + }, + "include": ["*.js", "*.d.ts"] +} diff --git a/src/errata/Errata/widget/run_test_widget.js b/src/errata/Errata/widget/run_test_widget.js new file mode 100644 index 000000000..4e160b707 --- /dev/null +++ b/src/errata/Errata/widget/run_test_widget.js @@ -0,0 +1,517 @@ +// @ts-check +import * as React from "react"; +import { useRpcSession } from "@leanprover/infoview"; + +const e = React.createElement; + +// Persists the last outcome per test for the lifetime of the InfoView session, so leaving and +// returning to a test's `@[test]` marker shows its previous result rather than a blank widget. +const resultCache = new Map(); + +const STATUS_COLORS = { + passed: "#2e7d32", + failed: "#c62828", + error: "#e65100", + skipped: "#6b6b6b", +}; + +const STATUS_SYMBOLS = { + passed: "✓", + failed: "✗", + error: "⚠", + skipped: "○", +}; + +const STATUS_LABELS = { + passed: "Passed", + failed: "FAILED", + error: "ERROR", + skipped: "Skipped", +}; + +const preStyle = { + margin: "4px 0 0 0", + padding: "6px 8px", + whiteSpace: "pre-wrap", + wordBreak: "break-word", + background: "var(--vscode-textCodeBlock-background, rgba(127,127,127,0.1))", + borderRadius: "3px", + fontSize: "12px", +}; + +function formatDuration(ms) { + if (ms < 1000) return ms + " ms"; + return (ms / 1000).toFixed(ms < 10000 ? 2 : 1) + " s"; +} + +function block(text) { + return e("pre", { style: preStyle }, text); +} + +function pad(n, w) { + return String(n).padStart(w || 2, "0"); +} + +const monoFont = "var(--vscode-editor-font-family, monospace)"; + +// A wall-clock time of day, rounded to the nearest second, from a Unix-epoch millisecond timestamp. +function formatClock(ms) { + if (!ms) return ""; + const d = new Date(Math.round(ms / 1000) * 1000); + return pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds()); +} + +// A chunk's offset from the start of execution, in tenths of a second, as `(N.Ns)`. +function chunkOffset(c, execStartTime) { + if (!execStartTime || !c.time) return ""; + return "(" + ((c.time - execStartTime) / 1000).toFixed(1) + "s)"; +} + +// A chunk's stream and offset as a plain string, for the native hover tooltip. +function chunkLabel(c, execStartTime) { + const off = chunkOffset(c, execStartTime); + return off ? c.stream + " " + off : c.stream; +} + +// Renders captured output: stdout and stderr interleaved in order, both in the editor's code font, +// with stderr italicized. Hovering a chunk highlights it and reports its stream and time offset. +function outputBlock(chunks, execStartTime, hovered, setHovered) { + return e( + "pre", + { + style: { ...preStyle, fontFamily: monoFont }, + onMouseLeave: function () { + setHovered(null); + }, + }, + ...chunks.map(function (c, i) { + return e( + "span", + { + key: i, + title: chunkLabel(c, execStartTime), + onMouseEnter: function () { + setHovered(i); + }, + style: { + fontStyle: c.stream === "stderr" ? "italic" : undefined, + borderRadius: "2px", + backgroundColor: + hovered === i + ? "var(--vscode-editor-hoverHighlightBackground, rgba(120,170,255,0.3))" + : undefined, + }, + }, + c.text, + ); + }), + ); +} + +export default function (props) { + const rs = useRpcSession(); + // Keyed by both the test and a hash of its source, so editing the test changes the key and + // invalidates its cached/in-progress run. + const version = props.version || ""; + const cacheKey = JSON.stringify(props.decl) + "@" + version; + + const [outcome, setOutcome] = React.useState(() => resultCache.get(cacheKey) || null); + const [running, setRunning] = React.useState(false); + const [cancelled, setCancelled] = React.useState(false); + const [elapsed, setElapsed] = React.useState(0); + const [error, setError] = React.useState(null); + const [live, setLive] = React.useState([]); + const [phase, setPhase] = React.useState("running"); + // When the run started (epoch ms), recorded server-side so it survives a reconnect. + const [startTime, setStartTime] = React.useState(0); + // How long the build took (ms), and when the test body started (epoch ms) for output offsets. + const [buildMs, setBuildMs] = React.useState(0); + const [execStartTime, setExecStartTime] = React.useState(0); + // The output chunk under the cursor, highlighted with its timestamp shown. + const [hovered, setHovered] = React.useState(null); + // Whether the file has no unsaved changes; the test runs the saved version, so Run is gated on it. + const [clean, setClean] = React.useState(true); + // Briefly true after the output is copied, to confirm the copy in the button label. + const [copied, setCopied] = React.useState(false); + // Whether the output disclosure is expanded; open by default, collapsible to hide large output. + const [outputOpen, setOutputOpen] = React.useState(true); + // Whether the cursor is over the output area, revealing the floating copy button. + const [overOutput, setOverOutput] = React.useState(false); + + // Bumped on each run start, cancel, and unmount so a superseded await loop ignores late replies. + const gen = React.useRef(0); + const liveRef = React.useRef([]); + // The number of chunks already pulled from the server, so a reconnect replays from the start. + const sinceRef = React.useRef(0); + // The last phase the widget saw; "" forces the next await to return the run's current phase at once. + const phaseRef = React.useRef(""); + const startedAt = React.useRef(0); + + React.useEffect( + function () { + if (!running) return undefined; + const timer = setInterval(function () { + setElapsed(Date.now() - startedAt.current); + }, 100); + return function () { + clearInterval(timer); + }; + }, + [running], + ); + + function loop(myGen) { + rs.call("Errata.Widget.awaitOutput", { + decl: props.decl, + since: sinceRef.current, + version: version, + phase: phaseRef.current, + }).then( + function (res) { + if (gen.current !== myGen) return; + if (res.startTime) setStartTime(res.startTime); + if (res.buildMs) setBuildMs(res.buildMs); + if (res.execStartTime) setExecStartTime(res.execStartTime); + if (res.phase) { + setPhase(res.phase); + phaseRef.current = res.phase; + } + if (res.chunks && res.chunks.length) { + liveRef.current = liveRef.current.concat(res.chunks); + setLive(liveRef.current); + sinceRef.current = res.nextSince; + setRunning(true); + } + if (res.done) { + if (res.outcome) { + resultCache.set(cacheKey, res.outcome); + setOutcome(res.outcome); + } + setRunning(false); + return; + } + setRunning(true); + loop(myGen); + }, + function (err) { + if (gen.current !== myGen) return; + setError((err && err.message) || String(err)); + setRunning(false); + }, + ); + } + + // The InfoView reuses one component instance for whichever test the cursor is on, so reset and + // reconnect whenever the test changes (keyed on `cacheKey`), not just on mount. Restores any cached + // outcome for this test and replays an in-progress run from the start. + React.useEffect( + function () { + const myGen = gen.current + 1; + gen.current = myGen; + sinceRef.current = 0; + phaseRef.current = ""; + liveRef.current = []; + setLive([]); + setOutcome(resultCache.get(cacheKey) || null); + setRunning(false); + setCancelled(false); + setError(null); + setPhase("running"); + setStartTime(0); + setBuildMs(0); + setExecStartTime(0); + setHovered(null); + startedAt.current = Date.now(); + loop(myGen); + let cancelledCheck = false; + let cleanTimer = null; + function checkClean() { + rs.call("Errata.Widget.bufferClean", { decl: props.decl }).then( + function (c) { + if (cancelledCheck) return; + setClean(c); + // While the buffer is dirty, re-check so the button re-enables shortly after a save. + if (!c) cleanTimer = setTimeout(checkClean, 1500); + }, + function () {}, + ); + } + checkClean(); + return function () { + gen.current += 1; + cancelledCheck = true; + if (cleanTimer) clearTimeout(cleanTimer); + }; + }, + [cacheKey], + ); + + function run() { + const myGen = gen.current + 1; + gen.current = myGen; + liveRef.current = []; + setLive([]); + sinceRef.current = 0; + setOutcome(null); + setError(null); + setCancelled(false); + setPhase("building"); + phaseRef.current = "building"; + startedAt.current = Date.now(); + setElapsed(0); + setRunning(true); + rs.call("Errata.Widget.startTest", { + decl: props.decl, + module: props.module, + version: version, + }).then( + function () { + if (gen.current === myGen) loop(myGen); + }, + function (err) { + if (gen.current !== myGen) return; + setError((err && err.message) || String(err)); + setRunning(false); + }, + ); + } + + function cancel() { + gen.current += 1; + setRunning(false); + setCancelled(true); + rs.call("Errata.Widget.cancelTest", { decl: props.decl }).catch(function () {}); + } + + const name = props.name || "test"; + + const header = e( + "div", + { style: { display: "flex", alignItems: "center", gap: "8px" } }, + running + ? e("button", { onClick: cancel }, "Cancel") + : e( + "button", + { + onClick: run, + disabled: !clean, + title: clean ? undefined : "Save the file to run the test", + }, + outcome || error || cancelled ? "Run again" : "Run", + ), + e( + "span", + { + style: { + fontFamily: "var(--vscode-editor-font-family, monospace)", + fontSize: "12px", + }, + }, + name, + ), + !clean && !running + ? e("span", { style: { opacity: 0.6, fontSize: "11px" } }, "unsaved — save to run") + : null, + ); + + // Prefer the live, server-timestamped chunks; fall back to a cached outcome's output. + const chunks = live.length ? live : outcome && outcome.output ? outcome.output : []; + + function copyOutput() { + const text = chunks + .map(function (c) { + return c.text; + }) + .join(""); + Promise.resolve(navigator.clipboard.writeText(text)).then( + function () { + setCopied(true); + setTimeout(function () { + setCopied(false); + }, 1500); + }, + function () {}, + ); + } + + // The copy icon (two overlapping sheets), or a check mark once the output has been copied. + const copyIcon = e( + "svg", + { + width: 13, + height: 13, + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + strokeWidth: 2, + strokeLinecap: "round", + strokeLinejoin: "round", + }, + copied + ? e("path", { key: "check", d: "M20 6L9 17l-5-5" }) + : [ + e("rect", { key: "sheet", x: 9, y: 9, width: 13, height: 13, rx: 2, ry: 2 }), + e("path", { + key: "back", + d: "M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1", + }), + ], + ); + + // A copy button floating over the top-right of the output, revealed on hover (or while confirming). + const copyButton = e( + "button", + { + onClick: copyOutput, + title: copied ? "Copied" : "Copy output to clipboard", + "aria-label": "Copy output to clipboard", + style: { + position: "absolute", + top: "4px", + right: "4px", + zIndex: 1, + display: "flex", + alignItems: "center", + padding: "3px", + lineHeight: 0, + opacity: overOutput || copied ? 0.95 : 0, + transition: "opacity 0.1s", + }, + }, + copyIcon, + ); + + const outputSection = + chunks.length === 0 + ? null + : e( + "details", + { + key: "output", + open: outputOpen, + onToggle: /** @param ev {React.ToggleEvent<HTMLDetailsElement>} */ function ( + ev, + ) { + setOutputOpen(ev.currentTarget.open); + }, + style: { marginTop: "4px" }, + }, + e( + "summary", + { style: { opacity: 0.7, fontSize: "11px", cursor: "pointer" } }, + hovered !== null && chunks[hovered] + ? [ + "Output — ", + e( + "span", + { key: "stream", style: { fontFamily: monoFont } }, + chunks[hovered].stream, + ), + " " + chunkOffset(chunks[hovered], execStartTime), + ] + : "Output", + ), + e( + "div", + { + style: { position: "relative" }, + onMouseEnter: function () { + setOverOutput(true); + }, + onMouseLeave: function () { + setOverOutput(false); + }, + }, + copyButton, + outputBlock(chunks, execStartTime, hovered, setHovered), + ), + ); + + // The primary status/progress element, then dimmed badges: start time, build and run durations. + let primary = null; + if (running) { + const label = phase === "building" ? "Building… " : "Running… "; + primary = e( + "span", + { style: { opacity: 0.8 } }, + label, + e("span", { style: { fontFamily: monoFont } }, formatDuration(elapsed)), + ); + } else if (error) { + primary = e("span", { style: { color: STATUS_COLORS.error } }, "could not run: " + error); + } else if (outcome) { + primary = e( + "span", + { style: { color: STATUS_COLORS[outcome.status] || "inherit", fontWeight: 600 } }, + (STATUS_SYMBOLS[outcome.status] || "") + + " " + + (STATUS_LABELS[outcome.status] || outcome.status), + ); + } else if (cancelled) { + primary = e("span", { style: { opacity: 0.7 } }, "cancelled"); + } + + const badges = []; + if (startTime) badges.push("Start " + formatClock(startTime)); + if (buildMs) badges.push("Build " + formatDuration(buildMs)); + if (outcome) badges.push("Run " + formatDuration(outcome.durationMs)); + + const infoRow = + primary || badges.length + ? e( + "div", + { + style: { + display: "flex", + alignItems: "baseline", + gap: "8px", + flexWrap: "wrap", + }, + }, + primary, + ...badges.map(function (b, i) { + return e( + "span", + { key: i, style: { opacity: 0.55, fontSize: "11px" } }, + "· " + b, + ); + }), + ) + : null; + + const extras = []; + if (outcome && outcome.message) extras.push(e("div", { key: "msg" }, block(outcome.message))); + if (outcome && outcome.detail) extras.push(e("div", { key: "detail" }, block(outcome.detail))); + + // The test's docstring, rendered by Lean to Markdown and shown as text alongside its result. + const descriptionSection = + outcome && outcome.description + ? e( + "div", + { + key: "description", + style: { + marginTop: "4px", + fontSize: "12px", + opacity: 0.85, + whiteSpace: "pre-wrap", + }, + }, + outcome.description, + ) + : null; + + const body = + infoRow || descriptionSection || extras.length || outputSection + ? e( + "div", + { style: { marginTop: "4px" } }, + infoRow, + descriptionSection, + ...extras, + outputSection, + ) + : null; + + return e("div", { style: { padding: "2px 0" } }, header, body); +} diff --git a/src/errata/Errata/widget/widget-externals.d.ts b/src/errata/Errata/widget/widget-externals.d.ts new file mode 100644 index 000000000..6fe08ffdf --- /dev/null +++ b/src/errata/Errata/widget/widget-externals.d.ts @@ -0,0 +1,20 @@ +// Minimal ambient declarations for the npm packages the run-test widget imports. The widget is +// served as-is to the InfoView, which supplies React and the InfoView RPC API at runtime, so only +// the surface the widget uses is declared here, enough for `tsc` to type-check the rest. + +declare module "react" { + export function createElement(type: any, props?: any, ...children: any[]): any; + export function useState(initial: any): [any, (value: any) => void]; + export function useEffect(effect: () => void | (() => void), deps?: any[]): void; + export function useRef(initial: any): { current: any }; + export interface ToggleEvent<T = Element> { + currentTarget: T; + target: EventTarget; + } +} + +declare module "@leanprover/infoview" { + export function useRpcSession(): { + call(method: string, params: any): Promise<any>; + }; +} diff --git a/src/errata/ErrataRunOne.lean b/src/errata/ErrataRunOne.lean new file mode 100644 index 000000000..7cae3c6ae --- /dev/null +++ b/src/errata/ErrataRunOne.lean @@ -0,0 +1,85 @@ +/- +Copyright (c) 2026 Lean FRO LLC. All rights reserved. +Released under Apache 2.0 license as described in the file LICENSE. +Author: David Thrane Christiansen +-/ +module + +public import Errata +public import Std.Time +public meta import Lean + +open Lean Meta + +/-- The current wall-clock time in milliseconds since the Unix epoch. -/ +def nowMs : IO Nat := + return (← Std.Time.Timestamp.now).toMillisecondsSinceUnixEpoch.toInt.toNat + +/-- +Evaluates the test named by {lean}`declName` in the current environment to a runnable action. + +The action is reached through {name}`Errata.IsTest.toTest` so any testable type works, and through +{lit}`import all` of its module so a module-private test is still reachable. +-/ +unsafe def evalTestM (declName : Name) : CoreM (Errata.TestM Unit × Option String) := + MetaM.run' do + let env ← getEnv + -- The widget passes the declaration's real name, but a module-private test is mangled, so fall + -- back to matching the user-facing name when the exact name is absent. + let realName ← + if env.contains declName then pure declName + else match env.constants.fold (init := none) (fun acc n _ => + acc <|> (if privateToUserName n == declName then some n else none)) with + | some n => pure n + | none => throwError "unknown test `{declName}`" + let decl := mkConst realName + let declType ← inferType decl + let inst ← + match ← trySynthInstance (mkApp (mkConst ``Errata.IsTest) declType) with + | .some inst => pure inst + | _ => throwError "`{declName}` is not a test" + let act := mkApp3 (mkConst ``Errata.IsTest.toTest) declType inst decl + let ty := mkApp (mkConst ``Errata.TestM) (mkConst ``Unit) + let test ← evalExpr (Errata.TestM Unit) ty act (safety := .unsafe) + return (test, ← findDocString? env realName) + +/-- Writes one JSON protocol line to the runner's real stdout and flushes it for prompt streaming. -/ +private def emitLine (out : IO.FS.Stream) (key : String) (value : Json) : IO Unit := do + out.putStr ((Json.mkObj [(key, value)]).compress ++ "\n") + out.flush + +/-- Imports {lean}`targetModule`, runs the test named by {lean}`declName`, and streams its result. -/ +unsafe def runImpl (args : List String) : IO UInt32 := do + let [modStr, declStr] := args + | IO.eprintln "usage: errata-run-one <module> <decl-json>"; return 2 + let targetModule := modStr.toName + let declName ← + match Json.parse declStr with + | .ok j => IO.ofExcept (Errata.nameOfJson? j) + | .error _ => pure declStr.toName + -- The runner's real stdout carries the JSON protocol; the test's own output is captured by + -- `runValue` and forwarded as chunk lines, so this handle is taken before that redirection. + let out ← IO.getStdout + initSearchPath (← findSysroot) + if let some leanPath ← IO.getEnv "LEAN_PATH" then + searchPathRef.modify (· ++ System.SearchPath.parse leanPath) + enableInitializersExecution + let env ← importModules + #[{ module := targetModule, importAll := true }, { module := `Errata }] {} (loadExts := true) + let coreCtx : Core.Context := { fileName := "<errata-run-one>", fileMap := default } + let ((act, doc?), _) ← (evalTestM declName).toIO coreCtx { env } + -- Mark when the test body starts, so the widget shows output offsets within the test itself, + -- excluding the build and module-import time before this point. + emitLine out "exec" (toJson (← nowMs)) + let sink := fun (o : Errata.Output) => do + let chunk := { Errata.OutputChunk.ofOutput o with time := ← nowMs } + emitLine out "chunk" (toJson chunk) + let outcome ← Errata.runValue default act (sink := sink) + emitLine out "outcome" (toJson { outcome with description? := doc? }) + return 0 + +@[implemented_by runImpl] +opaque run (args : List String) : IO UInt32 + +/-- Runs a single Errata test, streaming its output and final outcome as JSON lines to stdout. -/ +public def main (args : List String) : IO UInt32 := run args