diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e31734d8..310825ff4 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,34 @@ jobs: - name: Run tests run: | - lake test -- --verbose --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() + 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() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + 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..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 -- --verbose --check-tex + lake test -- --test-options --verbose --wfail -- --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 180a89fcf..06dd7cf2d 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..11eb956b2 --- /dev/null +++ b/src/tests/VersoTests/Interactive.lean @@ -0,0 +1,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 +-/ +module + +import Errata + +open Errata + +/-- +Use a shell harness to test the LSP server. +-/ +@[test] +def interactive : Test := do + -- 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/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 57% rename from src/tests/Tests/Serve.lean rename to src/tests/VersoTests/Serve.lean index 5900c29a1..23842c6c9 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, reported as one result per check. -/ +@[test] +def unitChecks : Test := do + for (name, ok) in units do + result name (assertTrue ok) + /-! ## In-process integration (Mock transport) -/ /-- Sends a raw HTTP request to a handler over an in-memory connection and returns the raw response. -/ @@ -275,8 +270,12 @@ 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. -/ -def integrationFailures : IO (Array String) := do +/-- +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" @@ -298,196 +297,183 @@ 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") - unless first.startsWith "HTTP/1.1 200" + let check (name : String) (raw : String) (pred : String → Bool) : TestM Unit := do + result name do + let response ← runRequest handler raw + 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" + (·.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 + assertTrue (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") - 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. + && (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 + 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 + 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" + 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/") + 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") + 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 - modify (·.push "redirect rule") - -- CORS: a preflight is answered with 204, and a GET carries the cross-origin header. + 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 - 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. + 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") + 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" + 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 - 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. + && (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" + 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 - modify (·.push "follow-symlinks still confines traversal") - -- An encoded slash must not smuggle `..` past confinement, even with symlinks relaxed. + 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 - modify (·.push "follow-symlinks still confines encoded-slash traversal") - -- A complete configuration parses into ports, mounts, redirects, and headers. + 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 := "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" <| + assertThrowsIO (parseServeConfig "nonsense = 1") + -- A status that is not a redirect code is rejected. + result "bad redirect status rejected" <| + 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" <| + 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" <| + 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" <| + assertThrowsIO (parseServeConfig "[[mounts]]\npath = \"/api\"") + result "redirect without target rejected" <| + assertThrowsIO (parseServeConfig "[[redirects]]\nfrom = \"/old\"") + result "header without set rejected" <| + 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 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\"" + 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 + 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" <| + 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 () - | _ => 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" <| + assertThrowsIO (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 - -/-! ## 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 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 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: