From 14286b0b18c76f344c01d181237237e0cbfaa34e Mon Sep 17 00:00:00 2001 From: David Thrane Christiansen Date: Fri, 14 Aug 2026 10:29:22 +0200 Subject: [PATCH 1/9] 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 | 19 +- src/errata/Errata/usage.txt | 8 +- src/tests/TestMain.lean | 415 ---------- src/tests/Tests.lean | 55 -- src/tests/Tests/ErrataSuite.lean | 12 - 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} | 107 ++- 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 ++ 86 files changed, 1810 insertions(+), 2236 deletions(-) delete mode 100644 src/tests/TestMain.lean delete mode 100644 src/tests/Tests.lean delete mode 100644 src/tests/Tests/ErrataSuite.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} (54%) 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 9e31734d8..1e9957bbb 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 @@ -149,7 +152,31 @@ jobs: - name: Run tests run: | - lake test -- --verbose --check-tex + lake test -- --test-options --verbose --junit=errata-report.xml --markdown=errata-summary.md -- --check-tex + + - name: Add test results to the job summary + if: always() + run: | + 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..cc210987c 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 the test driver's unreachable-modules +# warning 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..6efbcc57e 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 23b46293b..ad3d4a74a 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, fail the run on warnings with `--wfail`, 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 39c162792..f6938c775 100644 --- a/lakefile.lean +++ b/lakefile.lean @@ -135,18 +135,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] @@ -249,12 +247,13 @@ private def splitArgs (args : List String) : Except String (List String × List | some opt => .error s!"unexpected option '{opt}': arguments before the `--test-options` marker name the \ libraries to test. Put runner options after the marker, \ - e.g. `lake run Errata.run --test-options {opt}`." + e.g. `lake test -- --test-options {opt}`." | none => .ok (names, rest) -/-- Usage information for `lake run Errata.run`. -/ +/-- Usage information for `lake test`. -/ private def usage : String := include_str "src/errata/Errata/usage.txt" +@[test_driver] script run (args) do let ws ← getWorkspace -- Answer the driver's own `--help` before discovering or building anything. A `--help` after the diff --git a/src/errata/Errata/usage.txt b/src/errata/Errata/usage.txt index a9ffa278e..bfaa9344c 100644 --- a/src/errata/Errata/usage.txt +++ b/src/errata/Errata/usage.txt @@ -1,12 +1,12 @@ Errata test runner Usage: - lake run Errata.run run every test in the package - lake run Errata.run LIBRARY... run the tests in the given libraries - lake run Errata.run LIBRARY... --test-options OPTION... pass runner options after the marker + lake test run every test in the package + lake test -- LIBRARY... run the tests in the given libraries + lake test -- LIBRARY... --test-options OPTION... pass runner options after the marker Tokens before `--test-options` name libraries. A library is a bare `Library` in this package or a `package/Library` reaching into a dependency. Everything after the marker goes to the test runner. The runner documents its own options, including how to pass options to the tests themselves: - lake run Errata.run --test-options --help + lake test -- --test-options --help diff --git a/src/tests/TestMain.lean b/src/tests/TestMain.lean deleted file mode 100644 index f24dc9638..000000000 --- a/src/tests/TestMain.lean +++ /dev/null @@ -1,415 +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." - -/-- Runs Errata's own tests, reporting them the way the Errata runner does. -/ -def testErrata (config : Config) : IO Unit := do - let verbosity := if config.verbose then Errata.Verbosity.quiet else .silent - let cfg ← Errata.mkContext (updateGolden := config.updateExpected) - let results ← Errata.run cfg errataTests - let failures ← Errata.humanReport verbosity results - unless failures == 0 do - throw <| IO.userError s!"{failures} Errata test(s) failed" - -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, - testErrata -] - -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 52967abf0..000000000 --- a/src/tests/Tests.lean +++ /dev/null @@ -1,55 +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.ErrataSuite -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/ErrataSuite.lean b/src/tests/Tests/ErrataSuite.lean deleted file mode 100644 index ae0a5a218..000000000 --- a/src/tests/Tests/ErrataSuite.lean +++ /dev/null @@ -1,12 +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 Errata -import all ErrataTests - -/-- Errata's own tests, gathered so that a driver outside the module system can run them. -/ -public def errataTests : Array Errata.TestEntry := getAllTests% "verso" ErrataTests 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 54% rename from src/tests/Tests/VersoBlog.lean rename to src/tests/VersoTests/Blog.lean index c29e1a020..6e320247d 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,26 +92,17 @@ 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 +-- The inline Lean role elaborates terms in the saved example environment. ```leanInit env ``` diff --git a/src/tests/VersoTests/BuildLog.lean b/src/tests/VersoTests/BuildLog.lean new file mode 100644 index 000000000..ea97f5271 --- /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}" + assertTrue (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..91ea735cf --- /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 + assertTrue (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..2a7f25870 --- /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 + assertTrue 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" + assertTrue (!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" + assertTrue (!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" + assertTrue 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" + assertTrue (!config.showImports) "show_imports" + +/-- `show_imports` defaults to true. -/ +@[test] +def showImportsDefault : Test := do + let config ← loadFromString "" + assertTrue 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 + assertTrue (!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 + assertTrue (!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 + assertTrue 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 + assertTrue resolved.showImports "resolve prefix: showImports" + let resolved2 := config.resolveForModule `Foo.Qux + assertTrue (!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 + assertTrue (!resolved.showImports) "resolve override: showImports" + assertTrue (!resolved.showDocstrings) "resolve override: showDocstrings" + let resolved2 := config.resolveForModule `Other + assertTrue resolved2.showImports "resolve global: showImports" + assertTrue 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..9ab745746 --- /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. + assertTrue (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 + assertTrue (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..c442f9858 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) + assertTrue 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 + assertTrue 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..9bf8a63b9 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" + assertTrue (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" + assertTrue (hasSubstring compact labelHtml) + s!"{label} section should use a paragraph label with the group's ID" + assertTrue (!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 748ede751a14589549f4235f9acf6be0ba84cc14 Mon Sep 17 00:00:00 2001 From: David Thrane Christiansen <david@davidchristiansen.dk> Date: Fri, 21 Aug 2026 14:36:35 +0200 Subject: [PATCH 2/9] fix: skip the check-run test report for fork PRs Fork pull requests receive a read-only token, so publishing a check run fails; those runs get results from the job summary and artifact instead. --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e9957bbb..d0d8ff10e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -159,8 +159,11 @@ jobs: run: | if [ -f errata-summary.md ]; then cat errata-summary.md >> "$GITHUB_STEP_SUMMARY"; fi + # Publishing a check run needs a token with `checks: write`, which pull requests from + # forks never receive; those runs get their results from the job summary and the + # uploaded artifact instead. - name: Publish the JUnit test report - if: always() + if: always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) uses: dorny/test-reporter@v1 with: name: Errata tests From e2d83a3daf79781b69b2743460afb20a4d91d4af Mon Sep 17 00:00:00 2001 From: David Thrane Christiansen <david@davidchristiansen.dk> Date: Fri, 21 Aug 2026 14:36:35 +0200 Subject: [PATCH 3/9] fix: stream interactive harness output while it runs The child inherits stdio so a hang in CI shows how far the LSP suite got instead of a killed job with no output. --- src/tests/VersoTests/Interactive.lean | 9 +++++---- src/tests/interactive/README.md | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/tests/VersoTests/Interactive.lean b/src/tests/VersoTests/Interactive.lean index 91ea735cf..11eb956b2 100644 --- a/src/tests/VersoTests/Interactive.lean +++ b/src/tests/VersoTests/Interactive.lean @@ -14,7 +14,8 @@ 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 - assertTrue (out.exitCode == 0) s!"interactive LSP tests failed with exit code {out.exitCode}" + -- The child inherits the real stdio so its per-case progress is visible while it runs; a hang in + -- CI then shows how far the suite got instead of a killed job with no output. + let child ← IO.Process.spawn { cmd := "src/tests/interactive/run_interactive.sh" } + let exitCode ← child.wait + assertTrue (exitCode == 0) s!"interactive LSP tests failed with exit code {exitCode}" diff --git a/src/tests/interactive/README.md b/src/tests/interactive/README.md index 3ef897c8e..1359fa828 100644 --- a/src/tests/interactive/README.md +++ b/src/tests/interactive/README.md @@ -28,8 +28,8 @@ root directory. The runner architecture lies in a small custom script `./run_interactive.sh`. This script will call the upstream runner for -each file in `test-cases`. The script is called from the main Verso -test runner, in `src/tests/TestMain.lean`. +each file in `test-cases`. The script is called from the `interactive` +test in `src/tests/VersoTests/Interactive.lean`. Files from upstream: From 7720a485e81e308ce487192323d92385dbd12179 Mon Sep 17 00:00:00 2001 From: David Thrane Christiansen <david@davidchristiansen.dk> Date: Fri, 21 Aug 2026 14:36:35 +0200 Subject: [PATCH 4/9] refactor: report each serve check as its own result --- src/tests/VersoTests/Serve.lean | 309 +++++++++++++++++--------------- 1 file changed, 163 insertions(+), 146 deletions(-) diff --git a/src/tests/VersoTests/Serve.lean b/src/tests/VersoTests/Serve.lean index c442f9858..c012b786e 100644 --- a/src/tests/VersoTests/Serve.lean +++ b/src/tests/VersoTests/Serve.lean @@ -235,11 +235,11 @@ private 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. -/ +/-- Every deterministic unit check in {name}`units` passes, reported as one result per check. -/ @[test] def unitChecks : Test := do - let failed := units.filter (!·.2) |>.map (·.1) - assertTrue failed.isEmpty s!"failed unit checks: {", ".intercalate failed}" + for (name, ok) in units do + result name (assertTrue ok) /-! ## In-process integration (Mock transport) -/ @@ -270,8 +270,18 @@ def headerValue (response : String) (name : String) : Option String := def unicodeNames : List String := ["øllebrød", "اَلْعَرَبِيَّةُ", "中文文件", "नमस्ते", "All goals proved!🎉", "𝔏𝔢𝔞𝔫"] -/-- Runs the integration checks against a temporary directory tree, returning failure messages. -/ -private def integrationFailures : IO (Array String) := do +/-- Expects an action to throw; the check passes exactly when it does. -/ +private def rejects {α} (act : IO α) (loc : Location := by exact here%) : TestM Unit := do + match ← act.toBaseIO with + | .ok _ => failAt loc "expected an error, but the action succeeded" + | .error _ => pure () + +/-- +Every in-process integration check against the mock transport passes, reported as one result per +check. +-/ +@[test] +def integration : Test := 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" @@ -293,181 +303,188 @@ private def integrationFailures : IO (Array String) := do let noListingHandler := mkHandler { directoryListing := false } mounts let noSlashHandler := mkHandler { trailingSlashRedirect := false } mounts let followHandler := mkHandler { followSymlinksOutsideRoot := true } mounts - let check (name : String) (raw : String) (pred : String → Bool) : - StateT (Array String) IO Unit := do - unless pred (← runRequest handler raw) do modify (·.push name) - let (_, fails) ← StateT.run (s := #[]) do - check "index 200" (get "/") fun r => r.startsWith "HTTP/1.1 200" && (r.splitOn "home").length > 1 - check "missing 404" (get "/nope") (·.startsWith "HTTP/1.1 404") - check "post 405" "POST / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n" - (·.startsWith "HTTP/1.1 405") - -- HEAD reports the content length without a body, and `data.txt` holds ten bytes. - check "head no body" (head "/data.txt") fun r => - r.startsWith "HTTP/1.1 200" - && (r.toLower.splitOn "content-length: 10").length == 2 - && (r.splitOn "0123456789").length == 1 - -- Path traversal: an encoded `..` must not escape the mount root or leak the sibling file. - check "encoded traversal blocked" (get "/%2e%2e/secret.txt") fun r => - !r.startsWith "HTTP/1.1 200" && (r.splitOn "TOPSECRET").length == 1 - -- An unknown extension is sniffed: a UTF-8 text script is served inline as text/plain. - IO.FS.writeFile (root / "script.sh") "#!/bin/sh\necho hi\n" - check "unknown text served inline" (get "/script.sh") fun r => - r.startsWith "HTTP/1.1 200" && (r.toLower.splitOn "content-type: text/plain").length == 2 - -- A binary file with an unknown extension stays application/octet-stream. - IO.FS.writeBinFile (root / "blob.xyz") (ByteArray.mk #[0x00, 0x01, 0x02, 0x00]) - check "unknown binary octet-stream" (get "/blob.xyz") fun r => - r.startsWith "HTTP/1.1 200" && (r.toLower.splitOn "content-type: application/octet-stream").length == 2 - -- A control character in the path (here CR LF, percent-encoded) is rejected with 400. - check "control char rejected" (get "/foo%0d%0abar") (·.startsWith "HTTP/1.1 400") - -- Files named in non-ASCII scripts are served when requested with their percent-encoded names. - for name in unicodeNames do - let fileName := name ++ ".txt" - IO.FS.writeFile (root / fileName) s!"BODY {name}" - check s!"unicode file {name}" (get s!"/{percentEncode fileName}") fun r => - r.startsWith "HTTP/1.1 200" && (r.splitOn s!"BODY {name}").length == 2 - -- Caching: validators are present, and a conditional request revalidates to 304. - let first ← runRequest handler (get "/data.txt") + let check (name : String) (raw : String) (pred : String → Bool) : TestM Unit := do + result name do + let response ← runRequest handler raw + unless pred response do fail "unexpected response" (detail? := some response) + check "index 200" (get "/") fun r => r.startsWith "HTTP/1.1 200" && (r.splitOn "home").length > 1 + check "missing 404" (get "/nope") (·.startsWith "HTTP/1.1 404") + check "post 405" "POST / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n" + (·.startsWith "HTTP/1.1 405") + -- HEAD reports the content length without a body, and `data.txt` holds ten bytes. + check "head no body" (head "/data.txt") fun r => + r.startsWith "HTTP/1.1 200" + && (r.toLower.splitOn "content-length: 10").length == 2 + && (r.splitOn "0123456789").length == 1 + -- Path traversal: an encoded `..` must not escape the mount root or leak the sibling file. + check "encoded traversal blocked" (get "/%2e%2e/secret.txt") fun r => + !r.startsWith "HTTP/1.1 200" && (r.splitOn "TOPSECRET").length == 1 + -- An unknown extension is sniffed: a UTF-8 text script is served inline as text/plain. + IO.FS.writeFile (root / "script.sh") "#!/bin/sh\necho hi\n" + check "unknown text served inline" (get "/script.sh") fun r => + r.startsWith "HTTP/1.1 200" && (r.toLower.splitOn "content-type: text/plain").length == 2 + -- A binary file with an unknown extension stays application/octet-stream. + IO.FS.writeBinFile (root / "blob.xyz") (ByteArray.mk #[0x00, 0x01, 0x02, 0x00]) + check "unknown binary octet-stream" (get "/blob.xyz") fun r => + r.startsWith "HTTP/1.1 200" && (r.toLower.splitOn "content-type: application/octet-stream").length == 2 + -- A control character in the path (here CR LF, percent-encoded) is rejected with 400. + check "control char rejected" (get "/foo%0d%0abar") (·.startsWith "HTTP/1.1 400") + -- Files named in non-ASCII scripts are served when requested with their percent-encoded names. + for name in unicodeNames do + let fileName := name ++ ".txt" + IO.FS.writeFile (root / fileName) s!"BODY {name}" + check s!"unicode file {name}" (get s!"/{percentEncode fileName}") fun r => + r.startsWith "HTTP/1.1 200" && (r.splitOn s!"BODY {name}").length == 2 + -- Caching: validators are present, and a conditional request revalidates to 304. + let first ← runRequest handler (get "/data.txt") + result "cache validators" do unless first.startsWith "HTTP/1.1 200" && (first.toLower.splitOn "cache-control: no-cache").length > 1 && (first.toLower.splitOn "last-modified:").length > 1 do - modify (·.push "cache validators") - match headerValue first "etag" with - | none => modify (·.push "etag header") - | some etag => - let cond := s!"GET /data.txt HTTP/1.1\r\nHost: x\r\nIf-None-Match: {etag}\r\nConnection: close\r\n\r\n" - unless (← runRequest handler cond).startsWith "HTTP/1.1 304" do - modify (·.push "conditional 304") - -- A custom Cache-Control rule replaces the default rather than producing a duplicate. - let over ← runRequest overrideHandler (get "/data.txt") + fail "unexpected response" (detail? := some first) + result "conditional 304" do + let some etag := headerValue first "etag" + | fail "no ETag header on the response" (detail? := some first) + let cond := s!"GET /data.txt HTTP/1.1\r\nHost: x\r\nIf-None-Match: {etag}\r\nConnection: close\r\n\r\n" + let response ← runRequest handler cond + unless response.startsWith "HTTP/1.1 304" do + fail "unexpected response" (detail? := some response) + -- A custom Cache-Control rule replaces the default rather than producing a duplicate. + let over ← runRequest overrideHandler (get "/data.txt") + result "custom header override" do unless (over.toLower.splitOn "cache-control: max-age=99").length == 2 && (over.toLower.splitOn "cache-control: no-cache").length == 1 do - modify (·.push "custom header override") - -- A directory without an index file is served as a generated HTML listing of its entries. - IO.FS.createDirAll (root / "listing") - IO.FS.writeFile (root / "listing" / "note.txt") "hi" - check "directory listing" (get "/listing/") fun r => - r.startsWith "HTTP/1.1 200" && (r.splitOn "Index of").length > 1 && (r.splitOn "note.txt").length > 1 - -- With listings disabled, the same directory is refused. - unless (← runRequest noListingHandler (get "/listing/")).startsWith "HTTP/1.1 403" do - modify (·.push "no-listing 403") - -- A directory requested without a trailing slash redirects to add one. - check "trailing slash redirect" (get "/listing") fun r => - r.startsWith "HTTP/1.1 301" && (r.toLower.splitOn "location: /listing/").length == 2 - -- With the redirect disabled, the directory is served in place. - unless (← runRequest noSlashHandler (get "/listing")).startsWith "HTTP/1.1 200" do - modify (·.push "no-trailing-slash serves in place") - -- A configured redirect rule returns a 301 whose location carries the path beneath the prefix. + fail "unexpected response" (detail? := some over) + -- A directory without an index file is served as a generated HTML listing of its entries. + IO.FS.createDirAll (root / "listing") + IO.FS.writeFile (root / "listing" / "note.txt") "hi" + check "directory listing" (get "/listing/") fun r => + r.startsWith "HTTP/1.1 200" && (r.splitOn "Index of").length > 1 && (r.splitOn "note.txt").length > 1 + -- With listings disabled, the same directory is refused. + result "no-listing 403" do + let response ← runRequest noListingHandler (get "/listing/") + unless response.startsWith "HTTP/1.1 403" do + fail "unexpected response" (detail? := some response) + -- A directory requested without a trailing slash redirects to add one. + check "trailing slash redirect" (get "/listing") fun r => + r.startsWith "HTTP/1.1 301" && (r.toLower.splitOn "location: /listing/").length == 2 + -- With the redirect disabled, the directory is served in place. + result "no-trailing-slash serves in place" do + let response ← runRequest noSlashHandler (get "/listing") + unless response.startsWith "HTTP/1.1 200" do + fail "unexpected response" (detail? := some response) + -- A configured redirect rule returns a 301 whose location carries the path beneath the prefix. + result "redirect rule" do let red ← runRequest redirectHandler (get "/old/page") unless red.startsWith "HTTP/1.1 301" && (red.toLower.splitOn "location: /new/page").length == 2 do - modify (·.push "redirect rule") - -- CORS: a preflight is answered with 204, and a GET carries the cross-origin header. + fail "unexpected response" (detail? := some red) + -- CORS: a preflight is answered with 204, and a GET carries the cross-origin header. + result "cors preflight" do let pre ← runRequest corsHandler "OPTIONS / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n" unless pre.startsWith "HTTP/1.1 204" && (pre.toLower.splitOn "access-control-allow-methods").length > 1 do - modify (·.push "cors preflight") - unless ((← runRequest corsHandler (get "/data.txt")).toLower.splitOn "access-control-allow-origin: *").length > 1 do - modify (·.push "cors get header") - -- Without CORS, OPTIONS is not allowed. - unless (← runRequest handler "OPTIONS / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n").startsWith "HTTP/1.1 405" do - modify (·.push "options 405") - -- A Range request returns the requested slice with 206 and a Content-Range header. + fail "unexpected response" (detail? := some pre) + result "cors get header" do + let response ← runRequest corsHandler (get "/data.txt") + unless (response.toLower.splitOn "access-control-allow-origin: *").length > 1 do + fail "unexpected response" (detail? := some response) + -- Without CORS, OPTIONS is not allowed. + result "options 405" do + let response ← runRequest handler "OPTIONS / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n" + unless response.startsWith "HTTP/1.1 405" do + fail "unexpected response" (detail? := some response) + -- A Range request returns the requested slice with 206 and a Content-Range header. + result "range 206" do let ranged ← runRequest handler "GET /data.txt HTTP/1.1\r\nHost: x\r\nRange: bytes=2-5\r\nConnection: close\r\n\r\n" unless ranged.startsWith "HTTP/1.1 206" && (ranged.toLower.splitOn "content-range: bytes 2-5/10").length == 2 && (ranged.splitOn "2345").length > 1 do - modify (·.push "range 206") - -- An unsatisfiable range is rejected with 416. - unless (← runRequest handler "GET /data.txt HTTP/1.1\r\nHost: x\r\nRange: bytes=50-60\r\nConnection: close\r\n\r\n").startsWith "HTTP/1.1 416" do - modify (·.push "range 416") - -- Relaxing symlink confinement still does not permit `..` to climb above the mount. + fail "unexpected response" (detail? := some ranged) + -- An unsatisfiable range is rejected with 416. + result "range 416" do + let response ← runRequest handler "GET /data.txt HTTP/1.1\r\nHost: x\r\nRange: bytes=50-60\r\nConnection: close\r\n\r\n" + unless response.startsWith "HTTP/1.1 416" do + fail "unexpected response" (detail? := some response) + -- Relaxing symlink confinement still does not permit `..` to climb above the mount. + result "follow-symlinks still confines traversal" do let escaped ← runRequest followHandler (get "/%2e%2e/secret.txt") unless !escaped.startsWith "HTTP/1.1 200" && (escaped.splitOn "TOPSECRET").length == 1 do - modify (·.push "follow-symlinks still confines traversal") - -- An encoded slash must not smuggle `..` past confinement, even with symlinks relaxed. + fail "unexpected response" (detail? := some escaped) + -- An encoded slash must not smuggle `..` past confinement, even with symlinks relaxed. + result "follow-symlinks still confines encoded-slash traversal" do let slashEscaped ← runRequest followHandler (get "/..%2Fsecret.txt") unless !slashEscaped.startsWith "HTTP/1.1 200" && (slashEscaped.splitOn "TOPSECRET").length == 1 do - modify (·.push "follow-symlinks still confines encoded-slash traversal") - -- A complete configuration parses into ports, mounts, redirects, and headers. + fail "unexpected response" (detail? := some slashEscaped) + -- A complete configuration parses into ports, mounts, redirects, and headers. + result "valid config" do let goodConfig := "port = 4000\n[[mounts]]\npath = \"/api\"\ndir = \"out\"\n" ++ "[[redirects]]\nfrom = \"/old\"\nto = \"/new\"\nstatus = 302\n" ++ "[[headers]]\npath = \"/\"\nset = { \"X-Frame-Options\" = \"DENY\" }" match ← (parseServeConfig goodConfig).toBaseIO with - | .error _ => modify (·.push "valid config rejected") + | .error e => fail "valid config rejected" (detail? := some (toString e)) | .ok cfg => - unless cfg.port.toNat == 4000 && cfg.mounts.size == 1 - && cfg.redirects.any (·.status == .found) && cfg.headers.size == 1 do - modify (·.push "valid config fields") - -- An unknown top-level key is rejected. - match ← (parseServeConfig "nonsense = 1").toBaseIO with - | .ok _ => modify (·.push "unknown key accepted") - | .error _ => pure () - -- A status that is not a redirect code is rejected. - match ← (parseServeConfig "[[redirects]]\nfrom = \"/a\"\nto = \"/b\"\nstatus = 404").toBaseIO with - | .ok _ => modify (·.push "bad redirect status accepted") - | .error _ => pure () - -- Redirect targets are emitted as Location headers, so invalid header values are rejected. - match ← (parseServeConfig "[[redirects]]\nfrom = \"/a\"\nto = \"/b\nX: y\"").toBaseIO with - | .ok _ => modify (·.push "invalid redirect target accepted") - | .error _ => pure () - -- An invalid header name in the config is rejected when the file is parsed. - let badConfig := "[[headers]]\npath = \"/\"\nset = { \"bad name\" = \"x\" }" - match ← (parseServeConfig badConfig).toBaseIO with - | .ok _ => modify (·.push "invalid header name accepted") - | .error _ => pure () - -- Entries missing a required field are rejected rather than filled with a silent default. - match ← (parseServeConfig "[[mounts]]\npath = \"/api\"").toBaseIO with - | .ok _ => modify (·.push "mount without dir accepted") - | .error _ => pure () - match ← (parseServeConfig "[[redirects]]\nfrom = \"/old\"").toBaseIO with - | .ok _ => modify (·.push "redirect without target accepted") - | .error _ => pure () - match ← (parseServeConfig "[[headers]]\npath = \"/\"").toBaseIO with - | .ok _ => modify (·.push "header without set accepted") - | .error _ => pure () - -- An empty or whitespace-only config behaves the same as no file: defaults throughout. + assertTrue + (cfg.port.toNat == 4000 && cfg.mounts.size == 1 + && cfg.redirects.any (·.status == .found) && cfg.headers.size == 1) + "config fields not parsed as expected" + -- An unknown top-level key is rejected. + result "unknown key rejected" <| + rejects (parseServeConfig "nonsense = 1") + -- A status that is not a redirect code is rejected. + result "bad redirect status rejected" <| + rejects (parseServeConfig "[[redirects]]\nfrom = \"/a\"\nto = \"/b\"\nstatus = 404") + -- Redirect targets are emitted as Location headers, so invalid header values are rejected. + result "invalid redirect target rejected" <| + rejects (parseServeConfig "[[redirects]]\nfrom = \"/a\"\nto = \"/b\nX: y\"") + -- An invalid header name in the config is rejected when the file is parsed. + result "invalid header name rejected" <| + rejects (parseServeConfig "[[headers]]\npath = \"/\"\nset = { \"bad name\" = \"x\" }") + -- Entries missing a required field are rejected rather than filled with a silent default. + result "mount without dir rejected" <| + rejects (parseServeConfig "[[mounts]]\npath = \"/api\"") + result "redirect without target rejected" <| + rejects (parseServeConfig "[[redirects]]\nfrom = \"/old\"") + result "header without set rejected" <| + rejects (parseServeConfig "[[headers]]\npath = \"/\"") + -- An empty or whitespace-only config behaves the same as no file: defaults throughout. + result "empty config defaults" do for blank in ["", " \n \t\n"] do match ← (parseServeConfig blank).toBaseIO with - | .error _ => modify (·.push "empty config rejected") + | .error e => fail "empty config rejected" (detail? := some (toString e)) | .ok cfg => - unless cfg.port.toNat == 8000 && cfg.mounts.isEmpty && cfg.directoryListing - && cfg.trailingSlashRedirect && !cfg.cors do - modify (·.push "empty config not default") - -- Every unknown key in an entry is reported, not only the first. + assertTrue + (cfg.port.toNat == 8000 && cfg.mounts.isEmpty && cfg.directoryListing + && cfg.trailingSlashRedirect && !cfg.cors) + "empty config did not produce the defaults" + -- Every unknown key in an entry is reported, not only the first. + result "all unknown entry keys reported" do let twoBad := "[[mounts]]\npath = \"/\"\ndir = \"d\"\nbad1 = \"x\"\nbad2 = \"y\"" match ← (parseServeConfig twoBad).toBaseIO with - | .ok _ => modify (·.push "unknown entry keys accepted") + | .ok _ => fail "unknown entry keys accepted" | .error e => let msg := toString e unless (msg.splitOn "bad1").length > 1 && (msg.splitOn "bad2").length > 1 do - modify (·.push "not all unknown entry keys reported") - -- Mount directories in a config file are resolved relative to the file's own directory. - let cfgDir := tmp / "proj" - IO.FS.createDirAll cfgDir - IO.FS.writeFile (cfgDir / "verso-serve.toml") "[[mounts]]\npath = \"/\"\ndir = \"site\"" + fail "not every unknown entry key is reported" (detail? := some msg) + -- Mount directories in a config file are resolved relative to the file's own directory. + let cfgDir := tmp / "proj" + IO.FS.createDirAll cfgDir + IO.FS.writeFile (cfgDir / "verso-serve.toml") "[[mounts]]\npath = \"/\"\ndir = \"site\"" + result "config mount rebased to config dir" do let loaded ← loadServeConfig (cfgDir / "verso-serve.toml") - unless loaded.mounts.size == 1 && loaded.mounts[0]!.dir == cfgDir / "site" do - modify (·.push "config mount not rebased to config dir") - -- An explicit config path that is missing is fatal; an existing one is returned. - match ← (resolveConfigFile { configPath := some (tmp / "nope.toml") }).toBaseIO with - | .ok _ => modify (·.push "missing config path accepted") - | .error _ => pure () + assertTrue (loaded.mounts.size == 1 && loaded.mounts[0]!.dir == cfgDir / "site") + -- An explicit config path that is missing is fatal; an existing one is returned. + result "missing config path fatal" <| + rejects (resolveConfigFile { configPath := some (tmp / "nope.toml") }) + result "existing config path found" do match ← (resolveConfigFile { configPath := some (cfgDir / "verso-serve.toml") }).toBaseIO with | .ok (some _) => pure () - | _ => modify (·.push "existing config path not found") - -- A mount whose directory is missing is fatal; an existing one resolves to an absolute root. - match ← (resolveMounts #[{ urlPrefix := "/", dir := tmp / "absent" }]).toBaseIO with - | .ok _ => modify (·.push "missing mount dir accepted") - | .error _ => pure () + | _ => fail "existing config path not found" + -- A mount whose directory is missing is fatal; an existing one resolves to an absolute root. + result "missing mount dir fatal" <| + rejects (resolveMounts #[{ urlPrefix := "/", dir := tmp / "absent" }]) + result "existing mount dir resolved" do match ← (resolveMounts #[{ urlPrefix := "/", dir := root }]).toBaseIO with - | .ok rms => - unless rms.size == 1 && rms[0]!.root.isAbsolute do modify (·.push "mount dir not resolved") - | .error _ => modify (·.push "existing mount dir rejected") + | .ok rms => assertTrue (rms.size == 1 && rms[0]!.root.isAbsolute) "mount dir not resolved" + | .error e => fail "existing mount dir rejected" (detail? := some (toString e)) IO.FS.removeDirAll tmp - return fails - -/-- Every in-process integration check against the mock transport passes. -/ -@[test] -def integration : Test := do - let fails ← integrationFailures - assertTrue fails.isEmpty s!"failed integration checks: {", ".intercalate fails.toList}" From 3bf85a363138c8c10b82168af9afa6ba4b24b437 Mon Sep 17 00:00:00 2001 From: David Thrane Christiansen <david@davidchristiansen.dk> Date: Fri, 21 Aug 2026 14:55:59 +0200 Subject: [PATCH 5/9] feat: fail CI test runs on runner warnings The lake test invocations pass --wfail, so a typo in an option or an undiscovered test module fails the run instead of warning on stderr in a place nobody will see it. --- .github/workflows/ci.yml | 2 +- .github/workflows/update-subverso.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0d8ff10e..310825ff4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -152,7 +152,7 @@ jobs: - name: Run tests run: | - lake test -- --test-options --verbose --junit=errata-report.xml --markdown=errata-summary.md -- --check-tex + lake test -- --test-options --verbose --wfail --junit=errata-report.xml --markdown=errata-summary.md -- --check-tex - name: Add test results to the job summary if: always() diff --git a/.github/workflows/update-subverso.yml b/.github/workflows/update-subverso.yml index 6efbcc57e..aff17aa12 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 -- --test-options --verbose -- --check-tex + lake test -- --test-options --verbose --wfail -- --check-tex - name: Create branch and open PR if: steps.check-changes.outputs.changed == 'true' From 698f656f1fc0549381bf64d4612ac8a5129a05ff Mon Sep 17 00:00:00 2001 From: David Thrane Christiansen <david@davidchristiansen.dk> Date: Fri, 21 Aug 2026 15:00:48 +0200 Subject: [PATCH 6/9] refactor: use assertTrue detail and assertThrowsIO in serve tests The response that failed a predicate rides along as assertion detail, and the local expects-an-error helper gives way to assertThrowsIO. --- src/tests/VersoTests/Serve.lean | 85 ++++++++++++++------------------- 1 file changed, 37 insertions(+), 48 deletions(-) diff --git a/src/tests/VersoTests/Serve.lean b/src/tests/VersoTests/Serve.lean index c012b786e..23842c6c9 100644 --- a/src/tests/VersoTests/Serve.lean +++ b/src/tests/VersoTests/Serve.lean @@ -270,12 +270,6 @@ def headerValue (response : String) (name : String) : Option String := def unicodeNames : List String := ["øllebrød", "اَلْعَرَبِيَّةُ", "中文文件", "नमस्ते", "All goals proved!🎉", "𝔏𝔢𝔞𝔫"] -/-- Expects an action to throw; the check passes exactly when it does. -/ -private def rejects {α} (act : IO α) (loc : Location := by exact here%) : TestM Unit := do - match ← act.toBaseIO with - | .ok _ => failAt loc "expected an error, but the action succeeded" - | .error _ => pure () - /-- Every in-process integration check against the mock transport passes, reported as one result per check. @@ -306,7 +300,7 @@ def integration : Test := do let check (name : String) (raw : String) (pred : String → Bool) : TestM Unit := do result name do let response ← runRequest handler raw - unless pred response do fail "unexpected response" (detail? := some response) + assertTrue (pred response) "unexpected response" (detail? := some response) check "index 200" (get "/") fun r => r.startsWith "HTTP/1.1 200" && (r.splitOn "home").length > 1 check "missing 404" (get "/nope") (·.startsWith "HTTP/1.1 404") check "post 405" "POST / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n" @@ -338,23 +332,22 @@ def integration : Test := do -- Caching: validators are present, and a conditional request revalidates to 304. let first ← runRequest handler (get "/data.txt") result "cache validators" do - unless first.startsWith "HTTP/1.1 200" + assertTrue (first.startsWith "HTTP/1.1 200" && (first.toLower.splitOn "cache-control: no-cache").length > 1 - && (first.toLower.splitOn "last-modified:").length > 1 do - fail "unexpected response" (detail? := some first) + && (first.toLower.splitOn "last-modified:").length > 1) + "unexpected response" (detail? := some first) result "conditional 304" do let some etag := headerValue first "etag" | fail "no ETag header on the response" (detail? := some first) let cond := s!"GET /data.txt HTTP/1.1\r\nHost: x\r\nIf-None-Match: {etag}\r\nConnection: close\r\n\r\n" let response ← runRequest handler cond - unless response.startsWith "HTTP/1.1 304" do - fail "unexpected response" (detail? := some response) + assertTrue (response.startsWith "HTTP/1.1 304") "unexpected response" (detail? := some response) -- A custom Cache-Control rule replaces the default rather than producing a duplicate. let over ← runRequest overrideHandler (get "/data.txt") result "custom header override" do - unless (over.toLower.splitOn "cache-control: max-age=99").length == 2 - && (over.toLower.splitOn "cache-control: no-cache").length == 1 do - fail "unexpected response" (detail? := some over) + assertTrue ((over.toLower.splitOn "cache-control: max-age=99").length == 2 + && (over.toLower.splitOn "cache-control: no-cache").length == 1) + "unexpected response" (detail? := some over) -- A directory without an index file is served as a generated HTML listing of its entries. IO.FS.createDirAll (root / "listing") IO.FS.writeFile (root / "listing" / "note.txt") "hi" @@ -363,58 +356,54 @@ def integration : Test := do -- With listings disabled, the same directory is refused. result "no-listing 403" do let response ← runRequest noListingHandler (get "/listing/") - unless response.startsWith "HTTP/1.1 403" do - fail "unexpected response" (detail? := some response) + assertTrue (response.startsWith "HTTP/1.1 403") "unexpected response" (detail? := some response) -- A directory requested without a trailing slash redirects to add one. check "trailing slash redirect" (get "/listing") fun r => r.startsWith "HTTP/1.1 301" && (r.toLower.splitOn "location: /listing/").length == 2 -- With the redirect disabled, the directory is served in place. result "no-trailing-slash serves in place" do let response ← runRequest noSlashHandler (get "/listing") - unless response.startsWith "HTTP/1.1 200" do - fail "unexpected response" (detail? := some response) + assertTrue (response.startsWith "HTTP/1.1 200") "unexpected response" (detail? := some response) -- A configured redirect rule returns a 301 whose location carries the path beneath the prefix. result "redirect rule" do let red ← runRequest redirectHandler (get "/old/page") - unless red.startsWith "HTTP/1.1 301" && (red.toLower.splitOn "location: /new/page").length == 2 do - fail "unexpected response" (detail? := some red) + assertTrue (red.startsWith "HTTP/1.1 301" && (red.toLower.splitOn "location: /new/page").length == 2) + "unexpected response" (detail? := some red) -- CORS: a preflight is answered with 204, and a GET carries the cross-origin header. result "cors preflight" do let pre ← runRequest corsHandler "OPTIONS / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n" - unless pre.startsWith "HTTP/1.1 204" - && (pre.toLower.splitOn "access-control-allow-methods").length > 1 do - fail "unexpected response" (detail? := some pre) + assertTrue (pre.startsWith "HTTP/1.1 204" + && (pre.toLower.splitOn "access-control-allow-methods").length > 1) + "unexpected response" (detail? := some pre) result "cors get header" do let response ← runRequest corsHandler (get "/data.txt") - unless (response.toLower.splitOn "access-control-allow-origin: *").length > 1 do - fail "unexpected response" (detail? := some response) + assertTrue ((response.toLower.splitOn "access-control-allow-origin: *").length > 1) + "unexpected response" (detail? := some response) -- Without CORS, OPTIONS is not allowed. result "options 405" do let response ← runRequest handler "OPTIONS / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n" - unless response.startsWith "HTTP/1.1 405" do - fail "unexpected response" (detail? := some response) + assertTrue (response.startsWith "HTTP/1.1 405") "unexpected response" (detail? := some response) -- A Range request returns the requested slice with 206 and a Content-Range header. result "range 206" do let ranged ← runRequest handler "GET /data.txt HTTP/1.1\r\nHost: x\r\nRange: bytes=2-5\r\nConnection: close\r\n\r\n" - unless ranged.startsWith "HTTP/1.1 206" + assertTrue (ranged.startsWith "HTTP/1.1 206" && (ranged.toLower.splitOn "content-range: bytes 2-5/10").length == 2 - && (ranged.splitOn "2345").length > 1 do - fail "unexpected response" (detail? := some ranged) + && (ranged.splitOn "2345").length > 1) + "unexpected response" (detail? := some ranged) -- An unsatisfiable range is rejected with 416. result "range 416" do let response ← runRequest handler "GET /data.txt HTTP/1.1\r\nHost: x\r\nRange: bytes=50-60\r\nConnection: close\r\n\r\n" - unless response.startsWith "HTTP/1.1 416" do - fail "unexpected response" (detail? := some response) + assertTrue (response.startsWith "HTTP/1.1 416") "unexpected response" (detail? := some response) -- Relaxing symlink confinement still does not permit `..` to climb above the mount. result "follow-symlinks still confines traversal" do let escaped ← runRequest followHandler (get "/%2e%2e/secret.txt") - unless !escaped.startsWith "HTTP/1.1 200" && (escaped.splitOn "TOPSECRET").length == 1 do - fail "unexpected response" (detail? := some escaped) + assertTrue (!escaped.startsWith "HTTP/1.1 200" && (escaped.splitOn "TOPSECRET").length == 1) + "unexpected response" (detail? := some escaped) -- An encoded slash must not smuggle `..` past confinement, even with symlinks relaxed. result "follow-symlinks still confines encoded-slash traversal" do let slashEscaped ← runRequest followHandler (get "/..%2Fsecret.txt") - unless !slashEscaped.startsWith "HTTP/1.1 200" && (slashEscaped.splitOn "TOPSECRET").length == 1 do - fail "unexpected response" (detail? := some slashEscaped) + assertTrue (!slashEscaped.startsWith "HTTP/1.1 200" && (slashEscaped.splitOn "TOPSECRET").length == 1) + "unexpected response" (detail? := some slashEscaped) -- A complete configuration parses into ports, mounts, redirects, and headers. result "valid config" do let goodConfig := @@ -430,23 +419,23 @@ def integration : Test := do "config fields not parsed as expected" -- An unknown top-level key is rejected. result "unknown key rejected" <| - rejects (parseServeConfig "nonsense = 1") + assertThrowsIO (parseServeConfig "nonsense = 1") -- A status that is not a redirect code is rejected. result "bad redirect status rejected" <| - rejects (parseServeConfig "[[redirects]]\nfrom = \"/a\"\nto = \"/b\"\nstatus = 404") + assertThrowsIO (parseServeConfig "[[redirects]]\nfrom = \"/a\"\nto = \"/b\"\nstatus = 404") -- Redirect targets are emitted as Location headers, so invalid header values are rejected. result "invalid redirect target rejected" <| - rejects (parseServeConfig "[[redirects]]\nfrom = \"/a\"\nto = \"/b\nX: y\"") + assertThrowsIO (parseServeConfig "[[redirects]]\nfrom = \"/a\"\nto = \"/b\nX: y\"") -- An invalid header name in the config is rejected when the file is parsed. result "invalid header name rejected" <| - rejects (parseServeConfig "[[headers]]\npath = \"/\"\nset = { \"bad name\" = \"x\" }") + assertThrowsIO (parseServeConfig "[[headers]]\npath = \"/\"\nset = { \"bad name\" = \"x\" }") -- Entries missing a required field are rejected rather than filled with a silent default. result "mount without dir rejected" <| - rejects (parseServeConfig "[[mounts]]\npath = \"/api\"") + assertThrowsIO (parseServeConfig "[[mounts]]\npath = \"/api\"") result "redirect without target rejected" <| - rejects (parseServeConfig "[[redirects]]\nfrom = \"/old\"") + assertThrowsIO (parseServeConfig "[[redirects]]\nfrom = \"/old\"") result "header without set rejected" <| - rejects (parseServeConfig "[[headers]]\npath = \"/\"") + assertThrowsIO (parseServeConfig "[[headers]]\npath = \"/\"") -- An empty or whitespace-only config behaves the same as no file: defaults throughout. result "empty config defaults" do for blank in ["", " \n \t\n"] do @@ -464,8 +453,8 @@ def integration : Test := do | .ok _ => fail "unknown entry keys accepted" | .error e => let msg := toString e - unless (msg.splitOn "bad1").length > 1 && (msg.splitOn "bad2").length > 1 do - fail "not every unknown entry key is reported" (detail? := some msg) + assertTrue ((msg.splitOn "bad1").length > 1 && (msg.splitOn "bad2").length > 1) + "not every unknown entry key is reported" (detail? := some msg) -- Mount directories in a config file are resolved relative to the file's own directory. let cfgDir := tmp / "proj" IO.FS.createDirAll cfgDir @@ -475,14 +464,14 @@ def integration : Test := do assertTrue (loaded.mounts.size == 1 && loaded.mounts[0]!.dir == cfgDir / "site") -- An explicit config path that is missing is fatal; an existing one is returned. result "missing config path fatal" <| - rejects (resolveConfigFile { configPath := some (tmp / "nope.toml") }) + assertThrowsIO (resolveConfigFile { configPath := some (tmp / "nope.toml") }) result "existing config path found" do match ← (resolveConfigFile { configPath := some (cfgDir / "verso-serve.toml") }).toBaseIO with | .ok (some _) => pure () | _ => fail "existing config path not found" -- A mount whose directory is missing is fatal; an existing one resolves to an absolute root. result "missing mount dir fatal" <| - rejects (resolveMounts #[{ urlPrefix := "/", dir := tmp / "absent" }]) + assertThrowsIO (resolveMounts #[{ urlPrefix := "/", dir := tmp / "absent" }]) result "existing mount dir resolved" do match ← (resolveMounts #[{ urlPrefix := "/", dir := root }]).toBaseIO with | .ok rms => assertTrue (rms.size == 1 && rms[0]!.root.isAbsolute) "mount dir not resolved" From d3399e88a1722acf8ea2a8756178fc83af8885f9 Mon Sep 17 00:00:00 2001 From: David Thrane Christiansen <david@davidchristiansen.dk> Date: Wed, 26 Aug 2026 12:29:53 +0200 Subject: [PATCH 7/9] add PR number --- doc/UsersGuide/Releases/Entries/TestFramework.lean | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/UsersGuide/Releases/Entries/TestFramework.lean b/doc/UsersGuide/Releases/Entries/TestFramework.lean index ad3d4a74a..92c744e7c 100644 --- a/doc/UsersGuide/Releases/Entries/TestFramework.lean +++ b/doc/UsersGuide/Releases/Entries/TestFramework.lean @@ -13,7 +13,7 @@ release_note version := ⟨4, 34, 0⟩ breaking := false tag := "feat-test-framework" - prs := [956] + prs := [956, 957] #doc (Manual) "Test Framework" => From 4541f49e9e3d57dd63c3d5923c254cbf7523b9c0 Mon Sep 17 00:00:00 2001 From: David Thrane Christiansen <david@davidchristiansen.dk> Date: Wed, 26 Aug 2026 12:31:24 +0200 Subject: [PATCH 8/9] prettier ci.yml --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 310825ff4..bd91dc4eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -163,7 +163,9 @@ jobs: # forks never receive; those runs get their results from the job summary and the # uploaded artifact instead. - name: Publish the JUnit test report - if: always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + if: + always() && (github.event_name != 'pull_request' || + github.event.pull_request.head.repo.full_name == github.repository) uses: dorny/test-reporter@v1 with: name: Errata tests From 9aa4232a385873357ba09b11ac77f1e63f2e7324 Mon Sep 17 00:00:00 2001 From: David Thrane Christiansen <david@davidchristiansen.dk> Date: Wed, 26 Aug 2026 17:08:14 +0200 Subject: [PATCH 9/9] style: keep the file header comment to the copyright notice Explanatory text moves to a comment of its own below the header. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- src/tests/VersoTests/Blog.lean | 2 ++ src/tests/VersoTests/LiterateConfig.lean | 2 ++ src/tests/VersoTests/SearchJs.lean | 2 ++ src/tests/VersoTests/Serialization.lean | 2 ++ src/tests/VersoTests/SetupLiterate.lean | 2 ++ src/tests/VersoTests/TeXGolden.lean | 2 ++ 6 files changed, 12 insertions(+) diff --git a/src/tests/VersoTests/Blog.lean b/src/tests/VersoTests/Blog.lean index 6e320247d..8f9d1cc1c 100644 --- a/src/tests/VersoTests/Blog.lean +++ b/src/tests/VersoTests/Blog.lean @@ -2,7 +2,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 +-/ +/- 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. -/ diff --git a/src/tests/VersoTests/LiterateConfig.lean b/src/tests/VersoTests/LiterateConfig.lean index 2a7f25870..04b784dd0 100644 --- a/src/tests/VersoTests/LiterateConfig.lean +++ b/src/tests/VersoTests/LiterateConfig.lean @@ -2,7 +2,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 +-/ +/- 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. diff --git a/src/tests/VersoTests/SearchJs.lean b/src/tests/VersoTests/SearchJs.lean index 9ab745746..189f278f9 100644 --- a/src/tests/VersoTests/SearchJs.lean +++ b/src/tests/VersoTests/SearchJs.lean @@ -2,7 +2,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 +-/ +/- 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. diff --git a/src/tests/VersoTests/Serialization.lean b/src/tests/VersoTests/Serialization.lean index 10e46e420..b22ea80ef 100644 --- a/src/tests/VersoTests/Serialization.lean +++ b/src/tests/VersoTests/Serialization.lean @@ -2,7 +2,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 +-/ +/- 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`. diff --git a/src/tests/VersoTests/SetupLiterate.lean b/src/tests/VersoTests/SetupLiterate.lean index fc9e8530f..dd53a123d 100644 --- a/src/tests/VersoTests/SetupLiterate.lean +++ b/src/tests/VersoTests/SetupLiterate.lean @@ -2,7 +2,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 +-/ +/- Tests for `verso setup-literate`, which scaffolds the GitHub Pages workflow in a downstream project. -/ module diff --git a/src/tests/VersoTests/TeXGolden.lean b/src/tests/VersoTests/TeXGolden.lean index 516842044..a62f80fe0 100644 --- a/src/tests/VersoTests/TeXGolden.lean +++ b/src/tests/VersoTests/TeXGolden.lean @@ -2,7 +2,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 +-/ +/- 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.