diff --git a/BREAKINGS.md b/BREAKINGS.md index 15dde7d0..a530cff6 100644 --- a/BREAKINGS.md +++ b/BREAKINGS.md @@ -26,7 +26,7 @@ This document outlines all breaking changes introduced in CTBase v0.18.0-beta co ## Non-breaking note (0.18.12-beta) -- **TestRunner progress bar threshold**: Added configurable `full_bar_threshold` parameter to `CTBase.run_tests` (default: 50). Allows users to customize the maximum number of tests for full-resolution progress bar display. Propagated to internal functions `_make_default_on_test_done`, `_format_progress_line`, and `_bar_width`. Documentation and tests updated. No breaking changes; purely additive feature with backward-compatible default. No migration required. +- **TestRunner progress bar threshold**: Added configurable `full_bar_threshold` parameter to `CTBase.Extensions.run_tests` (default: 50). Allows users to customize the maximum number of tests for full-resolution progress bar display. Propagated to internal functions `_make_default_on_test_done`, `_format_progress_line`, and `_bar_width`. Documentation and tests updated. No breaking changes; purely additive feature with backward-compatible default. No migration required. ## Non-breaking note (0.18.11-beta) @@ -237,11 +237,11 @@ CTBase.postprocess_coverage() ```julia # v0.17.4 - Had basic implementation -CTBase.run_tests() +CTBase.Extensions.run_tests() # v0.18.0-beta - Requires extension using CTBase.Extensions.TestRunner -CTBase.run_tests() +CTBase.Extensions.run_tests() ``` #### Extension Error Handling @@ -565,7 +565,7 @@ using CTBase using CTBase.Extensions.TestRunner # Test all functionality -CTBase.run_tests() +CTBase.Extensions.run_tests() ``` ### ๐Ÿงช Testing Migration @@ -591,7 +591,7 @@ end function test_extension_loading() # Test that extensions work using CTBase.Extensions.TestRunner - @test_nowarn CTBase.run_tests(dry_run=true) + @test_nowarn CTBase.Extensions.run_tests(dry_run=true) end ``` diff --git a/CHANGELOGS.md b/CHANGELOGS.md index 76517fb5..239903da 100644 --- a/CHANGELOGS.md +++ b/CHANGELOGS.md @@ -109,7 +109,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 #### **TestRunner Progress Bar Customization** -- **Configurable threshold**: Added `full_bar_threshold` parameter to `CTBase.run_tests` (default: 50) +- **Configurable threshold**: Added `full_bar_threshold` parameter to `CTBase.Extensions.run_tests` (default: 50) - **Flexible display**: Users can now customize the maximum number of tests for full-resolution progress bar - **Terminal adaptation**: Smaller thresholds for narrow terminals, larger for wide displays - **Internal propagation**: Parameter propagated to `_make_default_on_test_done`, `_format_progress_line`, and `_bar_width` diff --git a/docs/src/guide/test-runner.md b/docs/src/guide/test-runner.md index c2fd9e23..35327c91 100644 --- a/docs/src/guide/test-runner.md +++ b/docs/src/guide/test-runner.md @@ -5,7 +5,7 @@ CurrentModule = CTBase # Test Runner Guide This guide explains how to set up a modular testing infrastructure for Julia packages using the **TestRunner** extension of `CTBase.jl`. -The entry point is [`CTBase.run_tests`](@ref), activated by loading the `Test` weak dependency. This setup enables granular test execution and is friendly both for human developers and AI agents. +The entry point is [`CTBase.Extensions.run_tests`](@ref), activated by loading the `Test` weak dependency. This setup enables granular test execution and is friendly both for human developers and AI agents. ## Architecture Overview @@ -37,7 +37,7 @@ MyPackage.jl/ ## Setting up `runtests.jl` -The `runtests.jl` file is the entry point for your test suite. By using [`CTBase.run_tests`](@ref), you enable a powerful mechanism to filter and execute specific tests using command-line arguments. This is crucial for fast iteration cycles. +The `runtests.jl` file is the entry point for your test suite. By using [`CTBase.Extensions.run_tests`](@ref), you enable a powerful mechanism to filter and execute specific tests using command-line arguments. This is crucial for fast iteration cycles. ### Example `test/runtests.jl` @@ -51,7 +51,7 @@ using MyPackage # Your package const TEST_DIR = @__DIR__ # Run tests using the CTBase test runner -CTBase.run_tests(; +CTBase.Extensions.run_tests(; args=String.(ARGS), # Pass command line arguments testset_name="MyPackage Tests", # Name of the main testset available_tests=[ # List of available test groups/files @@ -165,10 +165,10 @@ The threshold can be customized via the `progress_bar_threshold` parameter: ```julia # Use a smaller threshold for narrow terminals -CTBase.run_tests(; args=String.(ARGS), progress_bar_threshold=30) +CTBase.Extensions.run_tests(; args=String.(ARGS), progress_bar_threshold=30) # Use a larger threshold for wide displays -CTBase.run_tests(; args=String.(ARGS), progress_bar_threshold=100) +CTBase.Extensions.run_tests(; args=String.(ARGS), progress_bar_threshold=100) ``` ### Cursor-style display @@ -213,13 +213,13 @@ The progress bar correctly detects **both** types of failures: Set `show_progress_line=false` to disable the entire progress line: ```julia -CTBase.run_tests(; args=String.(ARGS), show_progress_line=false) +CTBase.Extensions.run_tests(; args=String.(ARGS), show_progress_line=false) ``` To display minimal output without the graphical bar, set `show_progress_line=true` and `show_progress_bar=false`: ```julia -CTBase.run_tests(; args=String.(ARGS), show_progress_line=true, show_progress_bar=false) +CTBase.Extensions.run_tests(; args=String.(ARGS), show_progress_line=true, show_progress_bar=false) # Output: โœ“ [01/76] suite/test.jl (0.2s) ``` @@ -268,7 +268,7 @@ Called after eval completes (or after skip/error). The built-in progress bar is ### Example: custom callbacks ```julia -CTBase.run_tests(; +CTBase.Extensions.run_tests(; args=String.(ARGS), on_test_start = info -> begin print(" [$(info.index)/$(info.total)] $(info.spec)...") @@ -293,7 +293,7 @@ CTBase.run_tests(; You can use glob patterns to organize tests hierarchically: ```julia -CTBase.run_tests(; +CTBase.Extensions.run_tests(; args=String.(ARGS), testset_name="MyPackage Tests", available_tests=[ diff --git a/docs/src/index.md b/docs/src/index.md index 9e2a3fc7..0e71f964 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -53,7 +53,7 @@ end - **[Getting Started](getting-started.md)** โ€” installation, mental model, 5-minute walkthrough. - **[Descriptions](guide/descriptions.md)** โ€” catalogue API, pattern matching, error handling. - **[Exceptions](guide/exceptions.md)** โ€” exception hierarchy, choosing the right type, best practices. -- **[Test Runner](guide/test-runner.md)** โ€” modular test infrastructure with `CTBase.run_tests`. +- **[Test Runner](guide/test-runner.md)** โ€” modular test infrastructure with `CTBase.Extensions.run_tests`. - **[Coverage](guide/coverage.md)** โ€” post-processing coverage artifacts with `CTBase.postprocess_coverage`. - **[API Documentation](guide/api-documentation.md)** โ€” auto-generating per-module API pages. diff --git a/ext/CoveragePostprocessing/CoveragePostprocessing.jl b/ext/CoveragePostprocessing/CoveragePostprocessing.jl new file mode 100644 index 00000000..99b0d05d --- /dev/null +++ b/ext/CoveragePostprocessing/CoveragePostprocessing.jl @@ -0,0 +1,19 @@ +""" +Coverage post-processing backend for CTBase. + +This extension implements `CTBase.postprocess_coverage` and provides utilities +to collect `.cov` files, generate reports, and move artifacts into a dedicated +`coverage/` directory. + +Most functions in this module have filesystem side effects. +""" +module CoveragePostprocessing + +using CTBase: CTBase +using Coverage: Coverage +import DocStringExtensions: TYPEDSIGNATURES + +include("helpers.jl") +include("entry_point.jl") + +end diff --git a/ext/CoveragePostprocessing/entry_point.jl b/ext/CoveragePostprocessing/entry_point.jl new file mode 100644 index 00000000..e03fa04c --- /dev/null +++ b/ext/CoveragePostprocessing/entry_point.jl @@ -0,0 +1,108 @@ +""" +$(TYPEDSIGNATURES) + +Post-process coverage artifacts produced by `Pkg.test(; coverage=true)`. + +This implementation: + +- Collects coverage source directories under `root_dir` (`src/`, `test/`, `ext/` when present) +- Resets the `coverage/` directory +- Removes stale `.cov` files (keeping the most complete PID suffix when multiple runs exist) +- Optionally generates reports (LCOV + markdown report) +- Moves `.cov` files into `coverage/cov/` (recursively from source dirs) + +# Keyword Arguments + +- `generate_report::Bool=true`: If `true`, write `coverage/lcov.info` and `coverage/cov_report.md`. +- `root_dir::String=pwd()`: Root directory of the project. +- `dest_dir::String="coverage"`: Destination directory for coverage artifacts. +- `worst_n_files::Int=20`: Maximum number of lowest-covered files to list in the report. +- `max_uncovered_lines::Int=200`: Maximum number of uncovered lines to display in the report. + +# Returns + +- `Nothing` + +# Notes + +This function **creates/removes/moves files and directories** under `root_dir`. + +# Example + +```julia +using CTBase + +# CTBase.postprocess_coverage(; generate_report=true, root_dir=pwd()) +``` +""" +function CTBase.postprocess_coverage( + ::CTBase.Extensions.CoveragePostprocessingTag; + generate_report::Bool=true, + root_dir::String=pwd(), + dest_dir::String="coverage", + worst_n_files::Int=20, + max_uncovered_lines::Int=200, +) + # Validate report limits + worst_n_files > 0 || throw( + CTBase.Exceptions.IncorrectArgument( + "worst_n_files must be > 0, got $(worst_n_files)" + ), + ) + max_uncovered_lines > 0 || throw( + CTBase.Exceptions.IncorrectArgument( + "max_uncovered_lines must be > 0, got $(max_uncovered_lines)" + ), + ) + + println("โœ“ Coverage post-processing start") + + # Define paths relative to root_dir + source_dirs = String[] + for d in ["src", "test", "ext"] + path = joinpath(root_dir, d) + if isdir(path) + push!(source_dirs, path) + end + end + + coverage_dir = joinpath(root_dir, dest_dir) + cov_storage_dir = joinpath(coverage_dir, "cov") + + _reset_coverage_dir(coverage_dir, cov_storage_dir) + + n_cov = _count_cov_files(source_dirs) + if n_cov == 0 + throw( + CTBase.Exceptions.PreconditionError( + "Coverage requested but no .cov files were produced"; + reason="no .cov files found in src/, test/, or ext/", + suggestion="run Pkg.test(...; coverage=true) to generate coverage data", + ), + ) + end + + _clean_stale_cov_files!(source_dirs) + + n_cov = _count_cov_files(source_dirs) + n_cov == 0 && throw( + CTBase.Exceptions.PreconditionError( + "Coverage requested but no usable .cov files were found after cleanup" + ), + ) + + generate_report && _generate_coverage_reports!( + source_dirs, coverage_dir, root_dir, worst_n_files, max_uncovered_lines + ) + + moved = _collect_and_move_cov_files!(source_dirs, cov_storage_dir) + isempty(moved) && throw( + CTBase.Exceptions.PreconditionError( + "Coverage requested but no .cov files were found to move" + ), + ) + println("โœ“ Moved $(length(moved)) .cov files to $cov_storage_dir") + + println("โœ“ Coverage post-processing completed successfully") + return nothing +end diff --git a/ext/CoveragePostprocessing.jl b/ext/CoveragePostprocessing/helpers.jl similarity index 70% rename from ext/CoveragePostprocessing.jl rename to ext/CoveragePostprocessing/helpers.jl index bf954811..3d375185 100644 --- a/ext/CoveragePostprocessing.jl +++ b/ext/CoveragePostprocessing/helpers.jl @@ -1,128 +1,3 @@ -""" -Coverage post-processing backend for CTBase. - -This extension implements `CTBase.postprocess_coverage` and provides utilities -to collect `.cov` files, generate reports, and move artifacts into a dedicated -`coverage/` directory. - -Most functions in this module have filesystem side effects. -""" -module CoveragePostprocessing - -using CTBase: CTBase -using Coverage: Coverage -import DocStringExtensions: TYPEDSIGNATURES - -# Main entry point for coverage post-processing -""" -$(TYPEDSIGNATURES) - -Post-process coverage artifacts produced by `Pkg.test(; coverage=true)`. - -This implementation: - -- Collects coverage source directories under `root_dir` (`src/`, `test/`, `ext/` when present) -- Resets the `coverage/` directory -- Removes stale `.cov` files (keeping the most complete PID suffix when multiple runs exist) -- Optionally generates reports (LCOV + markdown report) -- Moves `.cov` files into `coverage/cov/` (recursively from source dirs) - -# Keyword Arguments - -- `generate_report::Bool=true`: If `true`, write `coverage/lcov.info` and `coverage/cov_report.md`. -- `root_dir::String=pwd()`: Root directory of the project. -- `dest_dir::String="coverage"`: Destination directory for coverage artifacts. -- `worst_n_files::Int=20`: Maximum number of lowest-covered files to list in the report. -- `max_uncovered_lines::Int=200`: Maximum number of uncovered lines to display in the report. - -# Returns - -- `Nothing` - -# Notes - -This function **creates/removes/moves files and directories** under `root_dir`. - -# Example - -```julia -using CTBase - -# CTBase.postprocess_coverage(; generate_report=true, root_dir=pwd()) -``` -""" -function CTBase.postprocess_coverage( - ::CTBase.Extensions.CoveragePostprocessingTag; - generate_report::Bool=true, - root_dir::String=pwd(), - dest_dir::String="coverage", - worst_n_files::Int=20, - max_uncovered_lines::Int=200, -) - # Validate report limits - worst_n_files > 0 || throw( - CTBase.Exceptions.IncorrectArgument( - "worst_n_files must be > 0, got $(worst_n_files)" - ), - ) - max_uncovered_lines > 0 || throw( - CTBase.Exceptions.IncorrectArgument( - "max_uncovered_lines must be > 0, got $(max_uncovered_lines)" - ), - ) - - println("โœ“ Coverage post-processing start") - - # Define paths relative to root_dir - source_dirs = String[] - for d in ["src", "test", "ext"] - path = joinpath(root_dir, d) - if isdir(path) - push!(source_dirs, path) - end - end - - coverage_dir = joinpath(root_dir, dest_dir) - cov_storage_dir = joinpath(coverage_dir, "cov") - - _reset_coverage_dir(coverage_dir, cov_storage_dir) - - n_cov = _count_cov_files(source_dirs) - if n_cov == 0 - throw( - CTBase.Exceptions.PreconditionError( - "Coverage requested but no .cov files were produced"; - reason="no .cov files found in src/, test/, or ext/", - suggestion="run Pkg.test(...; coverage=true) to generate coverage data", - ), - ) - end - - _clean_stale_cov_files!(source_dirs) - - n_cov = _count_cov_files(source_dirs) - n_cov == 0 && throw( - CTBase.Exceptions.PreconditionError( - "Coverage requested but no usable .cov files were found after cleanup" - ), - ) - - generate_report && _generate_coverage_reports!( - source_dirs, coverage_dir, root_dir, worst_n_files, max_uncovered_lines - ) - - moved = _collect_and_move_cov_files!(source_dirs, cov_storage_dir) - isempty(moved) && throw( - CTBase.Exceptions.PreconditionError( - "Coverage requested but no .cov files were found to move" - ), - ) - println("โœ“ Moved $(length(moved)) .cov files to $cov_storage_dir") - - println("โœ“ Coverage post-processing completed successfully") - return nothing -end - """ _reset_coverage_dir(coverage_dir, cov_storage_dir) @@ -404,5 +279,3 @@ function _generate_coverage_reports!( println("โœ“ Wrote coverage report to $lcov_file") return nothing end - -end diff --git a/ext/DocumenterReference.jl b/ext/DocumenterReference.jl deleted file mode 100644 index 5c7e6db2..00000000 --- a/ext/DocumenterReference.jl +++ /dev/null @@ -1,1310 +0,0 @@ -# Copyright 2023, Oscar Dowson and contributors -# This Source Code Form is subject to the terms of the Mozilla Public -# License, v. 2.0. If a copy of the MPL was not distributed with this -# file, You can obtain one at https://mozilla.org/MPL/2.0/. -# -# Modified November 2025 for CTBenchmarks.jl: -# - Separated public and private API documentation into distinct pages -# - Added robust handling for missing docstrings (warnings instead of errors) -# - Included non-exported symbols in API reference -# - Filtered internal compiler-generated symbols (starting with '#') -# -# Refactored December 2025: -# - Extracted helper functions to reduce code duplication -# - Improved documentation and code organization -# - Added Dict-based DocType to string conversion - -module DocumenterReference - -using CTBase: CTBase -using Documenter: Documenter -using Markdown: Markdown -using MarkdownAST: MarkdownAST - -# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• -# Types and Constants -# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• - -""" - DocType - -Enumeration of documentation element types recognized by the API reference generator. - -# Values - -- `DOCTYPE_ABSTRACT_TYPE`: An abstract type declaration -- `DOCTYPE_CONSTANT`: A constant binding (including non-function, non-type values) -- `DOCTYPE_FUNCTION`: A function or callable -- `DOCTYPE_MACRO`: A macro (name starts with `@`) -- `DOCTYPE_MODULE`: A submodule -- `DOCTYPE_STRUCT`: A concrete struct type -""" -@enum( - DocType, - DOCTYPE_ABSTRACT_TYPE, - DOCTYPE_CONSTANT, - DOCTYPE_FUNCTION, - DOCTYPE_MACRO, - DOCTYPE_MODULE, - DOCTYPE_STRUCT, -) - -""" - DOCTYPE_NAMES::Dict{DocType, String} - -Mapping from DocType enum values to their human-readable string representations. -""" -const DOCTYPE_NAMES = Dict{DocType,String}( - DOCTYPE_ABSTRACT_TYPE => "abstract type", - DOCTYPE_CONSTANT => "constant", - DOCTYPE_FUNCTION => "function", - DOCTYPE_MACRO => "macro", - DOCTYPE_MODULE => "module", - DOCTYPE_STRUCT => "struct", -) - -""" - DOCTYPE_ORDER::Dict{DocType, Int} - -Ordering for DocType values used when sorting symbols for display. -Lower values appear first. -""" -const DOCTYPE_ORDER = Dict{DocType,Int}( - DOCTYPE_MODULE => 0, - DOCTYPE_MACRO => 1, - DOCTYPE_FUNCTION => 2, - DOCTYPE_ABSTRACT_TYPE => 3, - DOCTYPE_STRUCT => 4, - DOCTYPE_CONSTANT => 5, -) - -""" - _Config - -Internal configuration for API reference generation. - -# Fields - -- `current_module::Module`: The module being documented. -- `subdirectory::String`: Output directory for generated API pages. -- `modules::Dict{Module,Vector{String}}`: Mapping of modules to their source files. - When a module is specified as `Module => files`, the files are stored here. -- `sort_by::Function`: Custom sort function for symbols. -- `exclude::Set{Symbol}`: Symbol names to exclude from documentation. -- `public::Bool`: Flag to generate public API page. -- `private::Bool`: Flag to generate private API page. -- `title::String`: Title displayed at the top of the generated page. -- `title_in_menu::String`: Title displayed in the navigation menu. -- `source_files::Vector{String}`: Global source file paths (fallback if no module-specific files). -- `filename::String`: Base filename (without extension) for the markdown file. -- `include_without_source::Bool`: If `true`, include symbols whose source file cannot be determined. -- `external_modules_to_document::Vector{Module}`: Additional modules to search for docstrings. -- `public_title::String`: Custom title for public API page (empty string uses default). -- `private_title::String`: Custom title for private API page (empty string uses default). -- `public_description::String`: Custom description for public API page (empty string uses default). -- `private_description::String`: Custom description for private API page (empty string uses default). -""" -struct _Config - current_module::Module - subdirectory::String - modules::Dict{Module,Vector{String}} - sort_by::Function - exclude::Set{Symbol} - public::Bool - private::Bool - title::String - title_in_menu::String - source_files::Vector{String} - filename::String - include_without_source::Bool - external_modules_to_document::Vector{Module} - public_title::String - private_title::String - public_description::String - private_description::String -end - -""" - CONFIG::Vector{_Config} - -Global configuration storage for API reference generation. - -Each call to `CTBase.automatic_reference_documentation` appends a new `_Config` -entry to this vector. Use `reset_config!` to clear it between builds. -""" -const CONFIG = _Config[] - -""" - PAGE_CONTENT_ACCUMULATOR::Dict{String, Vector{Tuple{Module, Vector{String}, Vector{String}}}} - -Global accumulator for multi-module combined pages. -Maps output filename to a list of (module, public_docstrings, private_docstrings) tuples. -""" -const PAGE_CONTENT_ACCUMULATOR = Dict{ - String,Vector{Tuple{Module,Vector{String},Vector{String}}} -}() - -# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• -# Public API -# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• - -""" - reset_config!() - -Clear the global `CONFIG` vector and `PAGE_CONTENT_ACCUMULATOR`. -Useful between documentation builds or for testing. -""" -function reset_config!() - empty!(CONFIG) - empty!(PAGE_CONTENT_ACCUMULATOR) - return nothing -end - -""" - automatic_reference_documentation(; - subdirectory::String, - primary_modules, - sort_by::Function = identity, - exclude::Vector{Symbol} = Symbol[], - public::Bool = true, - private::Bool = true, - title::String = "API Reference", - title_in_menu::String = "", - filename::String = "", - source_files::Vector{String} = String[], - include_without_source::Bool = false, - external_modules_to_document::Vector{Module} = Module[], - public_title::String = "", - private_title::String = "", - public_description::String = "", - private_description::String = "", - ) - -Automatically creates the API reference documentation for one or more modules and -returns a structure which can be used in the `pages` argument of `Documenter.makedocs`. - -## Arguments - - * `subdirectory`: the directory relative to the documentation root in which to - write the API files. - * `primary_modules`: a vector of modules or `Module => source_files` pairs to document. - When source files are provided, only symbols defined in those files are documented. - * `sort_by`: a custom sort function applied to symbol lists. - * `exclude`: vector of symbol names to skip from the generated API. - * `public`: flag to generate public API page (default: `true`). - * `private`: flag to generate private API page (default: `true`). - * `title`: title displayed at the top of the generated page. - * `title_in_menu`: title displayed in the navigation menu (default: same as `title`). - * `filename`: base filename (without extension) for the markdown file. - * `source_files`: global source file paths (fallback if no module-specific files). - **Deprecated**: prefer using `primary_modules=[Module => files]` instead. - * `include_without_source`: if `true`, include symbols whose source file cannot - be determined. Default: `false`. - * `external_modules_to_document`: additional modules to search for docstrings - (e.g., `[Plots]` to include `Plots.plot` methods defined in your source files). - * `public_title`: custom title for public API page. Empty string uses default ("Public API" or "Public"). - * `private_title`: custom title for private API page. Empty string uses default ("Private API" or "Private"). - * `public_description`: custom description text for public API page. Empty string uses default. - * `private_description`: custom description text for private API page. Empty string uses default. - -## Multiple instances - -Each time you call this function, a new object is added to the global variable -`DocumenterReference.CONFIG`. Use `reset_config!()` to clear it between builds. -""" -function CTBase.automatic_reference_documentation( - ::CTBase.Extensions.DocumenterReferenceTag; - subdirectory::String, - primary_modules::Vector, - sort_by::Function=identity, - exclude::Vector{Symbol}=Symbol[], - public::Bool=true, - private::Bool=true, - title::String="API Reference", - title_in_menu::String="", - filename::String="", - source_files::Vector{String}=String[], - include_without_source::Bool=false, - external_modules_to_document::Vector{Module}=Module[], - public_title::String="", - private_title::String="", - public_description::String="", - private_description::String="", -) - # Validate arguments - if !public && !private - throw( - CTBase.Exceptions.IncorrectArgument( - "both `public` and `private` cannot be false"; - context="automatic_reference_documentation", - ), - ) - end - - # Parse primary_modules into a Dict{Module, Vector{String}} - modules_dict = _parse_primary_modules(primary_modules) - exclude_set = Set(exclude) - normalized_source_files = _normalize_paths(source_files) - effective_title_in_menu = isempty(title_in_menu) ? title : title_in_menu - effective_filename = _default_basename(filename, public, private) - - # Single-module case - if length(primary_modules) == 1 - current_module = first(keys(modules_dict)) - _register_config( - current_module, - subdirectory, - modules_dict, - sort_by, - exclude_set, - public, - private, - title, - effective_title_in_menu, - normalized_source_files, - effective_filename, - include_without_source, - external_modules_to_document, - public_title, - private_title, - public_description, - private_description, - ) - return _build_page_return_structure( - effective_title_in_menu, subdirectory, effective_filename, public, private - ) - end - - # Multi-module case with combined page (filename provided) - if !isempty(filename) - for mod in keys(modules_dict) - _register_config( - mod, - subdirectory, - modules_dict, - sort_by, - exclude_set, - public, - private, - title, - effective_title_in_menu, - normalized_source_files, - effective_filename, - include_without_source, - external_modules_to_document, - public_title, - private_title, - public_description, - private_description, - ) - end - return _build_page_return_structure( - effective_title_in_menu, subdirectory, effective_filename, public, private - ) - end - - # Multi-module case with per-module subdirectories - list_of_pages = Any[] - for mod in keys(modules_dict) - module_subdir = joinpath(subdirectory, string(mod)) - module_filename = _default_basename("", public, private) - default_title = _default_title(public, private) - - _register_config( - mod, - module_subdir, - modules_dict, - sort_by, - exclude_set, - public, - private, - default_title, - default_title, - normalized_source_files, - module_filename, - include_without_source, - external_modules_to_document, - public_title, - private_title, - public_description, - private_description, - ) - - pages = _build_page_return_structure( - default_title, module_subdir, module_filename, public, private - ) - push!(list_of_pages, string(mod) => last(pages)) - end - return effective_title_in_menu => list_of_pages -end - -# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• -# Documenter Pipeline Integration -# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• - -""" - APIBuilder <: Documenter.Builder.DocumentPipeline - -Custom Documenter pipeline stage for automatic API reference generation. - -This builder is inserted into the Documenter pipeline at order `0.0` (before -most other stages) to generate API reference pages from the configurations -stored in `CONFIG`. -""" -abstract type APIBuilder <: Documenter.Builder.DocumentPipeline end - -""" - Documenter.Selectors.order(::Type{APIBuilder}) -> Float64 - -Return the pipeline order for `APIBuilder`. -# Run before SetupBuildDirectory (1.0) so that generated files exist when Documenter checks pages. -""" -Documenter.Selectors.order(::Type{APIBuilder}) = 0.5 - -function Documenter.Selectors.runner(::Type{APIBuilder}, document::Documenter.Document) - @info "APIBuilder: creating API reference" - for config in CONFIG - _build_api_page(document, config) - end - _finalize_api_pages(document) - return nothing -end - -# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• -# Helper Functions: Configuration -# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• - -""" - _parse_primary_modules(primary_modules::Vector) -> Dict{Module, Vector{String}} - -Parse the `primary_modules` argument into a dictionary mapping modules to their source files. -Handles both plain modules and `Module => files` pairs. -""" -function _parse_primary_modules(primary_modules::Vector) - result = Dict{Module,Vector{String}}() - for m in primary_modules - if m isa Module - result[m] = String[] - elseif m isa Pair - mod = first(m) - files = last(m) - result[mod] = _normalize_paths(files isa Vector ? files : [files]) - else - throw( - CTBase.Exceptions.IncorrectArgument( - "Invalid element in primary_modules: expected Module or Module => files pair"; - got=string(typeof(m)), - context="_parse_primary_modules", - ), - ) - end - end - return result -end - -""" - _normalize_paths(paths) -> Vector{String} - -Normalize a collection of paths to absolute paths. -""" -function _normalize_paths(paths) - return isempty(paths) ? String[] : [abspath(p) for p in paths] -end - -""" - _register_config(current_module, subdirectory, modules, sort_by, exclude, public, private, - title, title_in_menu, source_files, filename, include_without_source, - external_modules_to_document, public_title, private_title, - public_description, private_description) - -Create and register a `_Config` in the global `CONFIG` vector. -""" -function _register_config( - current_module::Module, - subdirectory::String, - modules::Dict{Module,Vector{String}}, - sort_by::Function, - exclude::Set{Symbol}, - public::Bool, - private::Bool, - title::String, - title_in_menu::String, - source_files::Vector{String}, - filename::String, - include_without_source::Bool, - external_modules_to_document::Vector{Module}, - public_title::String, - private_title::String, - public_description::String, - private_description::String, -) - push!( - CONFIG, - _Config( - current_module, - subdirectory, - modules, - sort_by, - exclude, - public, - private, - title, - title_in_menu, - source_files, - filename, - include_without_source, - external_modules_to_document, - public_title, - private_title, - public_description, - private_description, - ), - ) - return nothing -end - -""" - _default_basename(filename::String, public::Bool, private::Bool) -> String - -Compute the default base filename for the generated markdown file. -""" -function _default_basename(filename::String, public::Bool, private::Bool) - !isempty(filename) && return filename - public && private && return "api" - public && return "public" - return "private" -end - -""" - _default_title(public::Bool, private::Bool) -> String - -Compute the default title based on public/private flags. -Returns empty string for single pages to use configured title. -""" -function _default_title(public::Bool, private::Bool) - public && private && return "API Reference" # Combined page - return "" # Single page - use configured title -end - -""" - _build_page_path(subdirectory::String, filename::String) -> String - -Build the page path by joining subdirectory and filename. -Handles special cases where `subdirectory` is `"."` or empty. -""" -function _build_page_path(subdirectory::String, filename::String) - (subdirectory == "." || isempty(subdirectory)) && return filename - return "$subdirectory/$filename" -end - -""" - _build_page_return_structure(title_in_menu, subdirectory, filename, public, private) -> Pair - -Build the return structure for `automatic_reference_documentation`. -""" -function _build_page_return_structure( - title_in_menu::String, - subdirectory::String, - filename::String, - public::Bool, - private::Bool, -) - if public && private - pub = !isempty(filename) ? "$(filename)_public.md" : "public.md" - priv = !isempty(filename) ? "$(filename)_private.md" : "private.md" - return title_in_menu => [ - "Public" => _build_page_path(subdirectory, pub), - "Private" => _build_page_path(subdirectory, priv), - ] - else - return title_in_menu => _build_page_path(subdirectory, "$filename.md") - end -end - -""" - _get_effective_source_files(config::_Config) -> Vector{String} - -Determine the effective source files for filtering symbols. -Priority: module-specific files > global source_files > empty (no filtering). -""" -function _get_effective_source_files(config::_Config) - module_files = get(config.modules, config.current_module, String[]) - !isempty(module_files) && return module_files - !isempty(config.source_files) && return config.source_files - return String[] -end - -# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• -# Helper Functions: Symbol Classification -# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• - -""" - _to_string(x::DocType) -> String - -Convert a DocType enumeration value to its string representation. -""" -_to_string(x::DocType) = DOCTYPE_NAMES[x] - -""" - _classify_symbol(obj, name_str::String) -> DocType - -Classify a symbol by its type (function, macro, struct, constant, module, abstract type). -""" -function _classify_symbol(obj, name_str::String) - startswith(name_str, "@") && return DOCTYPE_MACRO - obj isa Module && return DOCTYPE_MODULE - obj isa Type && isabstracttype(obj) && return DOCTYPE_ABSTRACT_TYPE - obj isa Type && return DOCTYPE_STRUCT - obj isa Function && return DOCTYPE_FUNCTION - return DOCTYPE_CONSTANT -end - -""" - _exported_symbols(mod::Module) -> NamedTuple - -Classify all symbols in a module into exported and private categories. -Returns a NamedTuple with `exported` and `private` fields, each containing -sorted lists of `(Symbol, DocType)` pairs. -""" -function _exported_symbols(mod::Module) - exported = Pair{Symbol,DocType}[] - private = Pair{Symbol,DocType}[] - exported_names = Set(names(mod; all=false)) - - for n in names(mod; all=true, imported=false) - name_str = String(n) - # Skip compiler-generated symbols and the module itself - startswith(name_str, "#") && continue - n == nameof(mod) && continue - - obj = try - getfield(mod, n) - catch - continue - end - - doc_type = _classify_symbol(obj, name_str) - target = n in exported_names ? exported : private - push!(target, n => doc_type) - end - - sort_fn = x -> (DOCTYPE_ORDER[x[2]], string(x[1])) - return (exported=sort(exported; by=sort_fn), private=sort(private; by=sort_fn)) -end - -# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• -# Helper Functions: Source File Detection -# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• - -""" - _get_source_file(mod::Module, key::Symbol, type::DocType) -> Union{String, Nothing} - -Determine the source file path where a symbol is defined. -Returns `nothing` if the source file cannot be determined. -""" -function _get_source_file(mod::Module, key::Symbol, type::DocType) - try - # Strategy 1: Try docstring metadata - path = _get_source_from_docstring(mod, key) - path !== nothing && return path - - obj = getfield(mod, key) - - # Strategy 2: For functions/macros, use methods() - if obj isa Function - path = _get_source_from_methods(obj) - path !== nothing && return path - end - - # Strategy 3: For concrete types, try constructor methods - if obj isa Type && !isabstracttype(obj) - path = _get_source_from_methods(obj) - path !== nothing && return path - end - - return nothing - catch e - @debug "Could not determine source file for $key in $mod" exception=e - return nothing - end -end - -""" - _get_source_from_docstring(mod::Module, key::Symbol) -> Union{String, Nothing} - -Try to get source file path from docstring metadata. -""" -function _get_source_from_docstring(mod::Module, key::Symbol) - binding = Base.Docs.Binding(mod, key) - meta = Base.Docs.meta(mod) - haskey(meta, binding) || return nothing - - docs = meta[binding] - if isa(docs, Base.Docs.MultiDoc) && !isempty(docs.docs) - for (_, docstr) in docs.docs - if isa(docstr, Base.Docs.DocStr) && haskey(docstr.data, :path) - path = docstr.data[:path] - if path !== nothing && !isempty(path) - return abspath(String(path)) - end - end - end - end - return nothing -end - -""" - _get_source_from_methods(obj) -> Union{String, Nothing} - -Try to get source file path from method definitions. -""" -function _get_source_from_methods(obj) - for m in methods(obj) - file = String(m.file) - if file != "" && file != "none" && !startswith(file, ".") - return abspath(file) - end - end - return nothing -end - -# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• -# Helper Functions: Symbol Iteration -# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• - -""" - _iterate_over_symbols(f, config, symbol_list) - -Iterate over symbols, applying a function to each documented symbol. -Filters symbols based on exclusion list, documentation presence, and source files. -""" -function _iterate_over_symbols(f, config::_Config, symbol_list) - current_module = config.current_module - effective_source_files = _get_effective_source_files(config) - - for (key, type) in sort!(copy(symbol_list); by=config.sort_by) - key isa Symbol || continue - - # Check exclusion - key in config.exclude && continue - - # Check documentation - if !_has_documentation(current_module, key, type, config.modules) - continue - end - - # Check source file filtering - if !_passes_source_filter( - current_module, key, type, effective_source_files, config.include_without_source - ) - continue - end - - f(key, type) - end - return nothing -end - -""" - _has_documentation(mod::Module, key::Symbol, type::DocType, modules::Dict) -> Bool - -Check if a symbol has documentation. Logs a warning if not. -""" -function _has_documentation(mod::Module, key::Symbol, type::DocType, modules::Dict) - binding = Base.Docs.Binding(mod, key) - - has_doc = if isdefined(Base.Docs, :hasdoc) - Base.Docs.hasdoc(binding) - else - doc = Base.Docs.doc(binding) - doc !== nothing && !occursin("No documentation found.", string(doc)) - end - - if !has_doc - if type == DOCTYPE_MODULE - submod = getfield(mod, key) - if submod != mod && haskey(modules, submod) - return true # Module is documented elsewhere - end - end - @warn "No documentation found for $key in $mod. Skipping from API reference." - return false - end - return true -end - -""" - _passes_source_filter(mod, key, type, source_files, include_without_source) -> Bool - -Check if a symbol passes the source file filter. -""" -function _passes_source_filter( - mod::Module, - key::Symbol, - type::DocType, - source_files::Vector{String}, - include_without_source::Bool, -) - isempty(source_files) && return true - - source_path = _get_source_file(mod, key, type) - if source_path === nothing - if !include_without_source - @debug "Cannot determine source file for $key ($type), skipping." - return false - end - return true - end - - return source_path in source_files -end - -# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• -# Helper Functions: Type Formatting for @docs Blocks -# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• - -""" - _method_signature_string(m::Method, mod::Module, key::Symbol) -> String - -Generate a Documenter-compatible signature string for a method. -Returns a string like `Module.func(::Type1, ::Type2)` for use in `@docs` blocks. -""" -function _method_signature_string(m::Method, mod::Module, key::Symbol) - sig = m.sig - while sig isa UnionAll - sig = sig.body - end - - if !(sig <: Tuple) - return "$(mod).$(key)" - end - - params = sig.parameters - arg_types = length(params) > 1 ? params[2:end] : Any[] - - if isempty(arg_types) - return "$(mod).$(key)()" - end - - type_strs = [_format_type_for_docs(T) for T in arg_types] - return "$(mod).$(key)($(join(type_strs, ", ")))" -end - -""" - _format_type_for_docs(T) -> String - -Format a type for use in Documenter's `@docs` block. -Always fully qualifies types to avoid UndefVarError when Documenter evaluates in Main. -""" -function _format_type_for_docs(T) - # Vararg - if T isa Core.TypeofVararg - inner = _format_type_for_docs(T.T) - inner_clean = startswith(inner, "::") ? inner[3:end] : inner - return "::Vararg{$(inner_clean)}" - end - - # TypeVar - T isa TypeVar && return "::$(T.name)" - - # UnionAll - unwrap and format - T isa UnionAll && return _format_type_for_docs(Base.unwrap_unionall(T)) - - # DataType - if T isa DataType - return _format_datatype_for_docs(T) - end - - # Union - if T isa Union - union_types = Base.uniontypes(T) - formatted = [_format_type_for_docs(ut) for ut in union_types] - cleaned = [startswith(s, "::") ? s[3:end] : s for s in formatted] - return "::Union{$(join(cleaned, ", "))}" - end - - return "::$(T)" -end - -""" - _format_datatype_for_docs(T::DataType) -> String - -Format a DataType for use in @docs blocks. -""" -function _format_datatype_for_docs(T::DataType) - type_mod = parentmodule(T) - type_name = T.name.name - is_core_or_base = type_mod === Core || type_mod === Base - - if T === Int - type_name = :Int - elseif T === UInt - type_name = :UInt - end - - # Handle parametric types - if !isempty(T.parameters) - has_typevar_params = any(p -> p isa TypeVar, T.parameters) - - if has_typevar_params - # Strip type parameters to avoid UndefVarError - return is_core_or_base ? "::$(type_name)" : "::$(type_mod).$(type_name)" - else - # Keep concrete type parameters - params = [_format_type_param(p) for p in T.parameters] - params_str = join(params, ", ") - return if is_core_or_base - "::$(type_name){$(params_str)}" - else - "::$(type_mod).$(type_name){$(params_str)}" - end - end - end - - # Simple type - return is_core_or_base ? "::$(type_name)" : "::$(type_mod).$(type_name)" -end - -""" - _format_type_param(p) -> String - -Format a type parameter (can be a type or a value like an integer). -""" -function _format_type_param(p) - if p isa Type - s = _format_type_for_docs(p) - return startswith(s, "::") ? s[3:end] : s - elseif p isa TypeVar - return string(p.name) - else - return string(p) - end -end - -# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• -# Page Building Functions -# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• - -""" - _build_api_page(document::Documenter.Document, config::_Config) - -Generate public and/or private API reference pages for a module. -Accumulates content in `PAGE_CONTENT_ACCUMULATOR` for later finalization. -""" -function _build_api_page(document::Documenter.Document, config::_Config) - current_module = config.current_module - symbols = _exported_symbols(current_module) - - # Determine output filenames - public_basename = if config.public && config.private - (!isempty(config.filename) ? "$(config.filename)_public" : "public") - else - config.filename - end - private_basename = if config.public && config.private - (!isempty(config.filename) ? "$(config.filename)_private" : "private") - else - config.filename - end - - # Collect docstrings - public_docstrings = - config.public ? _collect_module_docstrings(config, symbols.exported) : String[] - private_docstrings = - config.private ? _collect_private_docstrings(config, symbols.private) : String[] - - # Accumulate content - if config.public && config.private - # Split mode: use two separate keys - pub_filename = _build_page_path(config.subdirectory, "$(public_basename).md") - priv_filename = _build_page_path(config.subdirectory, "$(private_basename).md") - - for (fname, docs) in - [(pub_filename, public_docstrings), (priv_filename, private_docstrings)] - if !haskey(PAGE_CONTENT_ACCUMULATOR, fname) - PAGE_CONTENT_ACCUMULATOR[fname] = Tuple{ - Module,Vector{String},Vector{String} - }[] - end - # In split mode, the other docstrings list is intentionally empty for that file - if fname == pub_filename - push!(PAGE_CONTENT_ACCUMULATOR[fname], (current_module, docs, String[])) - else - push!(PAGE_CONTENT_ACCUMULATOR[fname], (current_module, String[], docs)) - end - end - else - # Combined mode: use one key (either public or private) - filename = _build_page_path(config.subdirectory, "$(config.filename).md") - if !haskey(PAGE_CONTENT_ACCUMULATOR, filename) - PAGE_CONTENT_ACCUMULATOR[filename] = Tuple{ - Module,Vector{String},Vector{String} - }[] - end - push!( - PAGE_CONTENT_ACCUMULATOR[filename], - (current_module, public_docstrings, private_docstrings), - ) - end - - return nothing -end - -""" - _collect_module_docstrings(config::_Config, symbol_list) -> Vector{String} - -Collect docstring blocks for symbols from the current module. -""" -function _collect_module_docstrings(config::_Config, symbol_list; include_module::Bool=true) - docstrings = String[] - current_module = config.current_module - effective_source_files = _get_effective_source_files(config) - - # Include the module docstring itself (if present and not filtered out) - if include_module && - _has_documentation( - current_module, nameof(current_module), DOCTYPE_MODULE, config.modules - ) && - _passes_source_filter( - current_module, - nameof(current_module), - DOCTYPE_MODULE, - effective_source_files, - config.include_without_source, - ) - push!(docstrings, "## `$(current_module)`\n\n```@docs\n$(current_module)\n```\n\n") - end - - _iterate_over_symbols(config, symbol_list) do key, type - type == DOCTYPE_MODULE && return nothing - push!(docstrings, "## `$key`\n\n```@docs\n$(current_module).$key\n```\n\n") - return nothing - end - - return docstrings -end - -""" - _collect_private_docstrings(config::_Config, symbol_list) -> Vector{String} - -Collect docstring blocks for private symbols, including external module methods. -""" -function _collect_private_docstrings(config::_Config, symbol_list) - docstrings = _collect_module_docstrings(config, symbol_list; include_module=false) - - # Add docstrings from external modules - if !isempty(config.external_modules_to_document) - external_docs = _collect_external_module_docstrings(config) - append!(docstrings, external_docs) - end - - return docstrings -end - -""" - _collect_external_module_docstrings(config::_Config) -> Vector{String} - -Collect docstrings for methods from external modules defined in source files. -""" -function _collect_external_module_docstrings(config::_Config) - docstrings = String[] - added_signatures = Set{String}() - filtered_source_files = _get_effective_source_files(config) - - for extra_mod in config.external_modules_to_document - methods_by_func = _collect_methods_from_source_files( - extra_mod, filtered_source_files - ) - - for (key, method_list) in sort(collect(methods_by_func); by=first) - for m in method_list - sig_str = _method_signature_string(m, extra_mod, key) - sig_str in added_signatures && continue - - push!(added_signatures, sig_str) - push!(docstrings, "## `$(extra_mod).$key`\n\n```@docs\n$(sig_str)\n```\n\n") - end - end - end - - return docstrings -end - -""" - _collect_methods_from_source_files(mod::Module, source_files::Vector{String}) -> Dict{Symbol, Vector{Method}} - -Collect all methods from a module that are defined in the given source files. -""" -function _collect_methods_from_source_files(mod::Module, source_files::Vector{String}) - methods_by_func = Dict{Symbol,Vector{Method}}() - - for key in names(mod; all=true) - obj = try - getfield(mod, key) - catch - continue - end - - obj isa Function || continue - - for m in methods(obj) - file = String(m.file) - (file == "" || file == "none") && continue - - abs_file = abspath(file) - should_include = isempty(source_files) || (abs_file in source_files) - - if should_include - if !haskey(methods_by_func, key) - methods_by_func[key] = Method[] - end - push!(methods_by_func[key], m) - end - end - end - - return methods_by_func -end - -""" - _finalize_api_pages(document::Documenter.Document) - -Finalize all accumulated API pages by combining content from multiple modules. -""" -function _finalize_api_pages(document::Documenter.Document) - for (filename, module_contents) in PAGE_CONTENT_ACCUMULATOR - is_private_split = occursin("_private", filename) - is_public_split = occursin("_public", filename) - - # Detect if this is a split page by checking if both public and private files exist - # Extract base filename by removing _public.md or _private.md suffixes - base_filename = replace(replace(filename, "_public.md" => ""), "_private.md" => "") - - # Check if the counterpart file exists (if we have _public, check for _private and vice versa) - is_split = if is_public_split - haskey(PAGE_CONTENT_ACCUMULATOR, "$(base_filename)_private.md") - elseif is_private_split - haskey(PAGE_CONTENT_ACCUMULATOR, "$(base_filename)_public.md") - else - false # Not a split page at all - end - - all_modules = [mc[1] for mc in module_contents] - modules_str = join([string(m) for m in all_modules], "`, `") - - # Get custom titles and descriptions from the first module's config - # (assuming all modules in the same page share the same customization) - first_module = first(all_modules) - config = findfirst(c -> c.current_module === first_module, CONFIG) - custom_public_title = config !== nothing ? CONFIG[config].public_title : "" - custom_private_title = config !== nothing ? CONFIG[config].private_title : "" - custom_public_desc = config !== nothing ? CONFIG[config].public_description : "" - custom_private_desc = config !== nothing ? CONFIG[config].private_description : "" - - # Determine if this is a single-type page or truly combined - has_public = any(mc -> !isempty(mc[2]), module_contents) # mc[2] = public_docs - has_private = any(mc -> !isempty(mc[3]), module_contents) # mc[3] = private_docs - - overview, all_docstrings = if is_public_split - # Case 1: Pure Public Split Page - _build_public_page_content( - modules_str, - module_contents, - is_split; - custom_title=custom_public_title, - custom_description=custom_public_desc, - ) - elseif is_private_split - # Case 2: Pure Private Split Page - _build_private_page_content( - modules_str, - module_contents, - is_split; - custom_title=custom_private_title, - custom_description=custom_private_desc, - ) - elseif has_public && !has_private - # Case 3: Single public-only page - _build_public_page_content( - modules_str, - module_contents, - false; - custom_title=custom_public_title, - custom_description=custom_public_desc, - ) - elseif has_private && !has_public - # Case 4: Single private-only page - _build_private_page_content( - modules_str, - module_contents, - false; - custom_title=custom_private_title, - custom_description=custom_private_desc, - ) - else - # Case 5: Combined Page (Public then Private) - _build_combined_page_content(modules_str, module_contents) - end - - combined_md = Markdown.parse(overview * join(all_docstrings, "\n")) - - # Write to source directory so SetupBuildDirectory can find and copy it - source_path = joinpath(document.user.source, filename) - mkpath(dirname(source_path)) - open(source_path, "w") do io - write(io, overview) - return write(io, join(all_docstrings, "\n")) - end - - document.blueprint.pages[filename] = Documenter.Page( - source_path, - joinpath(document.user.build, filename), - document.user.build, - combined_md.content, - Documenter.Globals(), - convert(MarkdownAST.Node, combined_md), - ) - end - - empty!(PAGE_CONTENT_ACCUMULATOR) - return nothing -end - -""" - _build_combined_page_content(modules_str, module_contents) -> Tuple{String, Vector{String}} - -Build the overview and docstrings for a combined (Public + Private) API page. -""" -function _build_combined_page_content(modules_str::String, module_contents) - overview = """ - # API reference - - This page lists documented symbols of `$(modules_str)`. - - """ - - all_docstrings = String[] - for (mod, public_docs, private_docs) in module_contents - if !isempty(public_docs) || !isempty(private_docs) - push!(all_docstrings, "\n---\n\n## From `$(mod)`\n\n") - if !isempty(public_docs) - push!(all_docstrings, "### Public API\n\n") - append!(all_docstrings, public_docs) - end - if !isempty(private_docs) - push!(all_docstrings, "\n### Private API\n\n") - append!(all_docstrings, private_docs) - end - end - end - - return overview, all_docstrings -end - -""" - _build_private_page_content(modules_str, module_contents, is_split; custom_title="", custom_description="") -> Tuple{String, Vector{String}} - -Build the overview and docstrings for a private API page. - -# Arguments -- `modules_str`: Comma-separated list of module names -- `module_contents`: Vector of (module, public_docs, private_docs) tuples -- `is_split`: Whether this is part of a split public/private documentation -- `custom_title`: Optional custom title (empty string uses default) -- `custom_description`: Optional custom description (empty string uses default) -""" -function _build_private_page_content( - modules_str::String, - module_contents, - is_split::Bool; - custom_title::String="", - custom_description::String="", -) - # Choose title based on context and customization - title = if !isempty(custom_title) - custom_title - else - "Private API" - end - - # Choose description based on customization - description = if !isempty(custom_description) - custom_description - else - "This page lists **non-exported** (internal) symbols of `$(modules_str)`." - end - - overview = """ - ```@meta - EditURL = nothing - ``` - - # $(title) - - $(description) - - """ - - all_docstrings = String[] - for (mod, _, private_docs) in module_contents - if !isempty(private_docs) - push!(all_docstrings, "\n---\n\n### From `$(mod)`\n\n") - append!(all_docstrings, private_docs) - end - end - - return overview, all_docstrings -end - -""" - _build_public_page_content(modules_str, module_contents, is_split; custom_title="", custom_description="") -> Tuple{String, Vector{String}} - -Build the overview and docstrings for a public API page. - -# Arguments -- `modules_str`: Comma-separated list of module names -- `module_contents`: Vector of (module, public_docs, private_docs) tuples -- `is_split`: Whether this is part of a split public/private documentation -- `custom_title`: Optional custom title (empty string uses default) -- `custom_description`: Optional custom description (empty string uses default) -""" -function _build_public_page_content( - modules_str::String, - module_contents, - is_split::Bool; - custom_title::String="", - custom_description::String="", -) - # Choose title based on context and customization - title = if !isempty(custom_title) - custom_title - else - "Public API" - end - - # Choose description based on customization - description = if !isempty(custom_description) - custom_description - else - "This page lists **exported** symbols of `$(modules_str)`." - end - - overview = """ - # $(title) - - $(description) - - """ - - all_docstrings = String[] - for (mod, public_docs, _) in module_contents - if !isempty(public_docs) - push!(all_docstrings, "\n---\n\n### From `$(mod)`\n\n") - append!(all_docstrings, public_docs) - end - end - - return overview, all_docstrings -end - -end # module diff --git a/ext/DocumenterReference/DocumenterReference.jl b/ext/DocumenterReference/DocumenterReference.jl new file mode 100644 index 00000000..7a9529c6 --- /dev/null +++ b/ext/DocumenterReference/DocumenterReference.jl @@ -0,0 +1,65 @@ +# Copyright 2023, Oscar Dowson and contributors +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +# +# Modified November 2025 for CTBenchmarks.jl: +# - Separated public and private API documentation into distinct pages +# - Added robust handling for missing docstrings (warnings instead of errors) +# - Included non-exported symbols in API reference +# - Filtered internal compiler-generated symbols (starting with '#') +# +# Refactored December 2025: +# - Extracted helper functions to reduce code duplication +# - Improved documentation and code organization +# - Added Dict-based DocType to string conversion + +module DocumenterReference + +using CTBase: CTBase +using Documenter: Documenter +using Markdown: Markdown +using MarkdownAST: MarkdownAST + +include("types.jl") +include("config_helpers.jl") +include("symbol_classification.jl") +include("source_file_detection.jl") +include("symbol_iteration.jl") +include("type_formatting.jl") +include("page_building.jl") +include("entry_point.jl") + +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +# Documenter Pipeline Integration +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + +""" + APIBuilder <: Documenter.Builder.DocumentPipeline + +Custom Documenter pipeline stage for automatic API reference generation. + +This builder is inserted into the Documenter pipeline at order `0.0` (before +most other stages) to generate API reference pages from the configurations +stored in `CONFIG`. +""" +abstract type APIBuilder <: Documenter.Builder.DocumentPipeline end + +""" + Documenter.Selectors.order(::Type{APIBuilder}) -> Float64 + +Return the pipeline order for `APIBuilder`. +# Run before SetupBuildDirectory (1.0) so that generated files exist when Documenter checks pages. +""" +Documenter.Selectors.order(::Type{APIBuilder}) = 0.5 + +function Documenter.Selectors.runner(::Type{APIBuilder}, document::Documenter.Document) + @info "APIBuilder: creating API reference" + for config in CONFIG + _build_api_page(document, config) + end + _finalize_api_pages(document) + return nothing +end + +end diff --git a/ext/DocumenterReference/config_helpers.jl b/ext/DocumenterReference/config_helpers.jl new file mode 100644 index 00000000..f18d8159 --- /dev/null +++ b/ext/DocumenterReference/config_helpers.jl @@ -0,0 +1,159 @@ +""" + _parse_primary_modules(primary_modules::Vector) -> Dict{Module, Vector{String}} + +Parse the `primary_modules` argument into a dictionary mapping modules to their source files. +Handles both plain modules and `Module => files` pairs. +""" +function _parse_primary_modules(primary_modules::Vector) + result = Dict{Module,Vector{String}}() + for m in primary_modules + if m isa Module + result[m] = String[] + elseif m isa Pair + mod = first(m) + files = last(m) + result[mod] = _normalize_paths(files isa Vector ? files : [files]) + else + throw( + CTBase.Exceptions.IncorrectArgument( + "Invalid element in primary_modules: expected Module or Module => files pair"; + got=string(typeof(m)), + context="_parse_primary_modules", + ), + ) + end + end + return result +end + +""" + _normalize_paths(paths) -> Vector{String} + +Normalize a collection of paths to absolute paths. +""" +function _normalize_paths(paths) + return isempty(paths) ? String[] : [abspath(p) for p in paths] +end + +""" + _register_config(current_module, subdirectory, modules, sort_by, exclude, public, private, + title, title_in_menu, source_files, filename, include_without_source, + external_modules_to_document, public_title, private_title, + public_description, private_description) + +Create and register a `_Config` in the global `CONFIG` vector. +""" +function _register_config( + current_module::Module, + subdirectory::String, + modules::Dict{Module,Vector{String}}, + sort_by::Function, + exclude::Set{Symbol}, + public::Bool, + private::Bool, + title::String, + title_in_menu::String, + source_files::Vector{String}, + filename::String, + include_without_source::Bool, + external_modules_to_document::Vector{Module}, + public_title::String, + private_title::String, + public_description::String, + private_description::String, +) + push!( + CONFIG, + _Config( + current_module, + subdirectory, + modules, + sort_by, + exclude, + public, + private, + title, + title_in_menu, + source_files, + filename, + include_without_source, + external_modules_to_document, + public_title, + private_title, + public_description, + private_description, + ), + ) + return nothing +end + +""" + _default_basename(filename::String, public::Bool, private::Bool) -> String + +Compute the default base filename for the generated markdown file. +""" +function _default_basename(filename::String, public::Bool, private::Bool) + !isempty(filename) && return filename + public && private && return "api" + public && return "public" + return "private" +end + +""" + _default_title(public::Bool, private::Bool) -> String + +Compute the default title based on public/private flags. +Returns empty string for single pages to use configured title. +""" +function _default_title(public::Bool, private::Bool) + public && private && return "API Reference" # Combined page + return "" # Single page - use configured title +end + +""" + _build_page_path(subdirectory::String, filename::String) -> String + +Build the page path by joining subdirectory and filename. +Handles special cases where `subdirectory` is `"."` or empty. +""" +function _build_page_path(subdirectory::String, filename::String) + (subdirectory == "." || isempty(subdirectory)) && return filename + return "$subdirectory/$filename" +end + +""" + _build_page_return_structure(title_in_menu, subdirectory, filename, public, private) -> Pair + +Build the return structure for `automatic_reference_documentation`. +""" +function _build_page_return_structure( + title_in_menu::String, + subdirectory::String, + filename::String, + public::Bool, + private::Bool, +) + if public && private + pub = !isempty(filename) ? "$(filename)_public.md" : "public.md" + priv = !isempty(filename) ? "$(filename)_private.md" : "private.md" + return title_in_menu => [ + "Public" => _build_page_path(subdirectory, pub), + "Private" => _build_page_path(subdirectory, priv), + ] + else + return title_in_menu => _build_page_path(subdirectory, "$filename.md") + end +end + +""" + _get_effective_source_files(config::_Config) -> Vector{String} + +Determine the effective source files for filtering symbols. +Priority: module-specific files > global source_files > empty (no filtering). +""" +function _get_effective_source_files(config::_Config) + module_files = get(config.modules, config.current_module, String[]) + !isempty(module_files) && return module_files + !isempty(config.source_files) && return config.source_files + return String[] +end diff --git a/ext/DocumenterReference/entry_point.jl b/ext/DocumenterReference/entry_point.jl new file mode 100644 index 00000000..5a1c8895 --- /dev/null +++ b/ext/DocumenterReference/entry_point.jl @@ -0,0 +1,189 @@ +""" + reset_config!() + +Clear the global `CONFIG` vector and `PAGE_CONTENT_ACCUMULATOR`. +Useful between documentation builds or for testing. +""" +function reset_config!() + empty!(CONFIG) + empty!(PAGE_CONTENT_ACCUMULATOR) + return nothing +end + +""" + automatic_reference_documentation(; + subdirectory::String, + primary_modules, + sort_by::Function = identity, + exclude::Vector{Symbol} = Symbol[], + public::Bool = true, + private::Bool = true, + title::String = "API Reference", + title_in_menu::String = "", + filename::String = "", + source_files::Vector{String} = String[], + include_without_source::Bool = false, + external_modules_to_document::Vector{Module} = Module[], + public_title::String = "", + private_title::String = "", + public_description::String = "", + private_description::String = "", + ) + +Automatically creates the API reference documentation for one or more modules and +returns a structure which can be used in the `pages` argument of `Documenter.makedocs`. + +## Arguments + + * `subdirectory`: the directory relative to the documentation root in which to + write the API files. + * `primary_modules`: a vector of modules or `Module => source_files` pairs to document. + When source files are provided, only symbols defined in those files are documented. + * `sort_by`: a custom sort function applied to symbol lists. + * `exclude`: vector of symbol names to skip from the generated API. + * `public`: flag to generate public API page (default: `true`). + * `private`: flag to generate private API page (default: `true`). + * `title`: title displayed at the top of the generated page. + * `title_in_menu`: title displayed in the navigation menu (default: same as `title`). + * `filename`: base filename (without extension) for the markdown file. + * `source_files`: global source file paths (fallback if no module-specific files). + **Deprecated**: prefer using `primary_modules=[Module => files]` instead. + * `include_without_source`: if `true`, include symbols whose source file cannot + be determined. Default: `false`. + * `external_modules_to_document`: additional modules to search for docstrings + (e.g., `[Plots]` to include `Plots.plot` methods defined in your source files). + * `public_title`: custom title for public API page. Empty string uses default ("Public API" or "Public"). + * `private_title`: custom title for private API page. Empty string uses default ("Private API" or "Private"). + * `public_description`: custom description text for public API page. Empty string uses default. + * `private_description`: custom description text for private API page. Empty string uses default. + +## Multiple instances + +Each time you call this function, a new object is added to the global variable +`DocumenterReference.CONFIG`. Use `reset_config!()` to clear it between builds. +""" +function CTBase.automatic_reference_documentation( + ::CTBase.Extensions.DocumenterReferenceTag; + subdirectory::String, + primary_modules::Vector, + sort_by::Function=identity, + exclude::Vector{Symbol}=Symbol[], + public::Bool=true, + private::Bool=true, + title::String="API Reference", + title_in_menu::String="", + filename::String="", + source_files::Vector{String}=String[], + include_without_source::Bool=false, + external_modules_to_document::Vector{Module}=Module[], + public_title::String="", + private_title::String="", + public_description::String="", + private_description::String="", +) + # Validate arguments + if !public && !private + throw( + CTBase.Exceptions.IncorrectArgument( + "both `public` and `private` cannot be false"; + context="automatic_reference_documentation", + ), + ) + end + + # Parse primary_modules into a Dict{Module, Vector{String}} + modules_dict = _parse_primary_modules(primary_modules) + exclude_set = Set(exclude) + normalized_source_files = _normalize_paths(source_files) + effective_title_in_menu = isempty(title_in_menu) ? title : title_in_menu + effective_filename = _default_basename(filename, public, private) + + # Single-module case + if length(primary_modules) == 1 + current_module = first(keys(modules_dict)) + _register_config( + current_module, + subdirectory, + modules_dict, + sort_by, + exclude_set, + public, + private, + title, + effective_title_in_menu, + normalized_source_files, + effective_filename, + include_without_source, + external_modules_to_document, + public_title, + private_title, + public_description, + private_description, + ) + return _build_page_return_structure( + effective_title_in_menu, subdirectory, effective_filename, public, private + ) + end + + # Multi-module case with combined page (filename provided) + if !isempty(filename) + for mod in keys(modules_dict) + _register_config( + mod, + subdirectory, + modules_dict, + sort_by, + exclude_set, + public, + private, + title, + effective_title_in_menu, + normalized_source_files, + effective_filename, + include_without_source, + external_modules_to_document, + public_title, + private_title, + public_description, + private_description, + ) + end + return _build_page_return_structure( + effective_title_in_menu, subdirectory, effective_filename, public, private + ) + end + + # Multi-module case with per-module subdirectories + list_of_pages = Any[] + for mod in keys(modules_dict) + module_subdir = joinpath(subdirectory, string(mod)) + module_filename = _default_basename("", public, private) + default_title = _default_title(public, private) + + _register_config( + mod, + module_subdir, + modules_dict, + sort_by, + exclude_set, + public, + private, + default_title, + default_title, + normalized_source_files, + module_filename, + include_without_source, + external_modules_to_document, + public_title, + private_title, + public_description, + private_description, + ) + + pages = _build_page_return_structure( + default_title, module_subdir, module_filename, public, private + ) + push!(list_of_pages, string(mod) => last(pages)) + end + return effective_title_in_menu => list_of_pages +end diff --git a/ext/DocumenterReference/page_building.jl b/ext/DocumenterReference/page_building.jl new file mode 100644 index 00000000..793c1272 --- /dev/null +++ b/ext/DocumenterReference/page_building.jl @@ -0,0 +1,421 @@ +""" + _build_api_page(document::Documenter.Document, config::_Config) + +Generate public and/or private API reference pages for a module. +Accumulates content in `PAGE_CONTENT_ACCUMULATOR` for later finalization. +""" +function _build_api_page(document::Documenter.Document, config::_Config) + current_module = config.current_module + symbols = _exported_symbols(current_module) + + # Determine output filenames + public_basename = if config.public && config.private + (!isempty(config.filename) ? "$(config.filename)_public" : "public") + else + config.filename + end + private_basename = if config.public && config.private + (!isempty(config.filename) ? "$(config.filename)_private" : "private") + else + config.filename + end + + # Collect docstrings + public_docstrings = + config.public ? _collect_module_docstrings(config, symbols.exported) : String[] + private_docstrings = + config.private ? _collect_private_docstrings(config, symbols.private) : String[] + + # Accumulate content + if config.public && config.private + # Split mode: use two separate keys + pub_filename = _build_page_path(config.subdirectory, "$(public_basename).md") + priv_filename = _build_page_path(config.subdirectory, "$(private_basename).md") + + for (fname, docs) in + [(pub_filename, public_docstrings), (priv_filename, private_docstrings)] + if !haskey(PAGE_CONTENT_ACCUMULATOR, fname) + PAGE_CONTENT_ACCUMULATOR[fname] = Tuple{ + Module,Vector{String},Vector{String} + }[] + end + # In split mode, the other docstrings list is intentionally empty for that file + if fname == pub_filename + push!(PAGE_CONTENT_ACCUMULATOR[fname], (current_module, docs, String[])) + else + push!(PAGE_CONTENT_ACCUMULATOR[fname], (current_module, String[], docs)) + end + end + else + # Combined mode: use one key (either public or private) + filename = _build_page_path(config.subdirectory, "$(config.filename).md") + if !haskey(PAGE_CONTENT_ACCUMULATOR, filename) + PAGE_CONTENT_ACCUMULATOR[filename] = Tuple{ + Module,Vector{String},Vector{String} + }[] + end + push!( + PAGE_CONTENT_ACCUMULATOR[filename], + (current_module, public_docstrings, private_docstrings), + ) + end + + return nothing +end + +""" + _collect_module_docstrings(config::_Config, symbol_list) -> Vector{String} + +Collect docstring blocks for symbols from the current module. +""" +function _collect_module_docstrings(config::_Config, symbol_list; include_module::Bool=true) + docstrings = String[] + current_module = config.current_module + effective_source_files = _get_effective_source_files(config) + + # Include the module docstring itself (if present and not filtered out) + if include_module && + _has_documentation( + current_module, nameof(current_module), DOCTYPE_MODULE, config.modules + ) && + _passes_source_filter( + current_module, + nameof(current_module), + DOCTYPE_MODULE, + effective_source_files, + config.include_without_source, + ) + push!(docstrings, "## `$(current_module)`\n\n```@docs\n$(current_module)\n```\n\n") + end + + _iterate_over_symbols(config, symbol_list) do key, type + type == DOCTYPE_MODULE && return nothing + push!(docstrings, "## `$key`\n\n```@docs\n$(current_module).$key\n```\n\n") + return nothing + end + + return docstrings +end + +""" + _collect_private_docstrings(config::_Config, symbol_list) -> Vector{String} + +Collect docstring blocks for private symbols, including external module methods. +""" +function _collect_private_docstrings(config::_Config, symbol_list) + docstrings = _collect_module_docstrings(config, symbol_list; include_module=false) + + # Add docstrings from external modules + if !isempty(config.external_modules_to_document) + external_docs = _collect_external_module_docstrings(config) + append!(docstrings, external_docs) + end + + return docstrings +end + +""" + _collect_external_module_docstrings(config::_Config) -> Vector{String} + +Collect docstrings for methods from external modules defined in source files. +""" +function _collect_external_module_docstrings(config::_Config) + docstrings = String[] + added_signatures = Set{String}() + filtered_source_files = _get_effective_source_files(config) + + for extra_mod in config.external_modules_to_document + methods_by_func = _collect_methods_from_source_files( + extra_mod, filtered_source_files + ) + + for (key, method_list) in sort(collect(methods_by_func); by=first) + for m in method_list + sig_str = _method_signature_string(m, extra_mod, key) + sig_str in added_signatures && continue + + push!(added_signatures, sig_str) + push!(docstrings, "## `$(extra_mod).$key`\n\n```@docs\n$(sig_str)\n```\n\n") + end + end + end + + return docstrings +end + +""" + _collect_methods_from_source_files(mod::Module, source_files::Vector{String}) -> Dict{Symbol, Vector{Method}} + +Collect all methods from a module that are defined in the given source files. +""" +function _collect_methods_from_source_files(mod::Module, source_files::Vector{String}) + methods_by_func = Dict{Symbol,Vector{Method}}() + + for key in names(mod; all=true) + obj = try + getfield(mod, key) + catch + continue + end + + obj isa Function || continue + + for m in methods(obj) + file = String(m.file) + (file == "" || file == "none") && continue + + abs_file = abspath(file) + should_include = isempty(source_files) || (abs_file in source_files) + + if should_include + if !haskey(methods_by_func, key) + methods_by_func[key] = Method[] + end + push!(methods_by_func[key], m) + end + end + end + + return methods_by_func +end + +""" + _finalize_api_pages(document::Documenter.Document) + +Finalize all accumulated API pages by combining content from multiple modules. +""" +function _finalize_api_pages(document::Documenter.Document) + for (filename, module_contents) in PAGE_CONTENT_ACCUMULATOR + is_private_split = occursin("_private", filename) + is_public_split = occursin("_public", filename) + + # Detect if this is a split page by checking if both public and private files exist + # Extract base filename by removing _public.md or _private.md suffixes + base_filename = replace(replace(filename, "_public.md" => ""), "_private.md" => "") + + # Check if the counterpart file exists (if we have _public, check for _private and vice versa) + is_split = if is_public_split + haskey(PAGE_CONTENT_ACCUMULATOR, "$(base_filename)_private.md") + elseif is_private_split + haskey(PAGE_CONTENT_ACCUMULATOR, "$(base_filename)_public.md") + else + false # Not a split page at all + end + + all_modules = [mc[1] for mc in module_contents] + modules_str = join([string(m) for m in all_modules], "`, `") + + # Get custom titles and descriptions from the first module's config + # (assuming all modules in the same page share the same customization) + first_module = first(all_modules) + config = findfirst(c -> c.current_module === first_module, CONFIG) + custom_public_title = config !== nothing ? CONFIG[config].public_title : "" + custom_private_title = config !== nothing ? CONFIG[config].private_title : "" + custom_public_desc = config !== nothing ? CONFIG[config].public_description : "" + custom_private_desc = config !== nothing ? CONFIG[config].private_description : "" + + # Determine if this is a single-type page or truly combined + has_public = any(mc -> !isempty(mc[2]), module_contents) # mc[2] = public_docs + has_private = any(mc -> !isempty(mc[3]), module_contents) # mc[3] = private_docs + + overview, all_docstrings = if is_public_split + # Case 1: Pure Public Split Page + _build_public_page_content( + modules_str, + module_contents, + is_split; + custom_title=custom_public_title, + custom_description=custom_public_desc, + ) + elseif is_private_split + # Case 2: Pure Private Split Page + _build_private_page_content( + modules_str, + module_contents, + is_split; + custom_title=custom_private_title, + custom_description=custom_private_desc, + ) + elseif has_public && !has_private + # Case 3: Single public-only page + _build_public_page_content( + modules_str, + module_contents, + false; + custom_title=custom_public_title, + custom_description=custom_public_desc, + ) + elseif has_private && !has_public + # Case 4: Single private-only page + _build_private_page_content( + modules_str, + module_contents, + false; + custom_title=custom_private_title, + custom_description=custom_private_desc, + ) + else + # Case 5: Combined Page (Public then Private) + _build_combined_page_content(modules_str, module_contents) + end + + combined_md = Markdown.parse(overview * join(all_docstrings, "\n")) + + # Write to source directory so SetupBuildDirectory can find and copy it + source_path = joinpath(document.user.source, filename) + mkpath(dirname(source_path)) + open(source_path, "w") do io + write(io, overview) + return write(io, join(all_docstrings, "\n")) + end + + document.blueprint.pages[filename] = Documenter.Page( + source_path, + joinpath(document.user.build, filename), + document.user.build, + combined_md.content, + Documenter.Globals(), + convert(MarkdownAST.Node, combined_md), + ) + end + + empty!(PAGE_CONTENT_ACCUMULATOR) + return nothing +end + +""" + _build_combined_page_content(modules_str, module_contents) -> Tuple{String, Vector{String}} + +Build the overview and docstrings for a combined (Public + Private) API page. +""" +function _build_combined_page_content(modules_str::String, module_contents) + overview = """ + # API reference + + This page lists documented symbols of `$(modules_str)`. + + """ + + all_docstrings = String[] + for (mod, public_docs, private_docs) in module_contents + if !isempty(public_docs) || !isempty(private_docs) + push!(all_docstrings, "\n---\n\n## From `$(mod)`\n\n") + if !isempty(public_docs) + push!(all_docstrings, "### Public API\n\n") + append!(all_docstrings, public_docs) + end + if !isempty(private_docs) + push!(all_docstrings, "\n### Private API\n\n") + append!(all_docstrings, private_docs) + end + end + end + + return overview, all_docstrings +end + +""" + _build_private_page_content(modules_str, module_contents, is_split; custom_title="", custom_description="") -> Tuple{String, Vector{String}} + +Build the overview and docstrings for a private API page. + +# Arguments +- `modules_str`: Comma-separated list of module names +- `module_contents`: Vector of (module, public_docs, private_docs) tuples +- `is_split`: Whether this is part of a split public/private documentation +- `custom_title`: Optional custom title (empty string uses default) +- `custom_description`: Optional custom description (empty string uses default) +""" +function _build_private_page_content( + modules_str::String, + module_contents, + is_split::Bool; + custom_title::String="", + custom_description::String="", +) + # Choose title based on context and customization + title = if !isempty(custom_title) + custom_title + else + "Private API" + end + + # Choose description based on customization + description = if !isempty(custom_description) + custom_description + else + "This page lists **non-exported** (internal) symbols of `$(modules_str)`." + end + + overview = """ + ```@meta + EditURL = nothing + ``` + + # $(title) + + $(description) + + """ + + all_docstrings = String[] + for (mod, _, private_docs) in module_contents + if !isempty(private_docs) + push!(all_docstrings, "\n---\n\n### From `$(mod)`\n\n") + append!(all_docstrings, private_docs) + end + end + + return overview, all_docstrings +end + +""" + _build_public_page_content(modules_str, module_contents, is_split; custom_title="", custom_description="") -> Tuple{String, Vector{String}} + +Build the overview and docstrings for a public API page. + +# Arguments +- `modules_str`: Comma-separated list of module names +- `module_contents`: Vector of (module, public_docs, private_docs) tuples +- `is_split`: Whether this is part of a split public/private documentation +- `custom_title`: Optional custom title (empty string uses default) +- `custom_description`: Optional custom description (empty string uses default) +""" +function _build_public_page_content( + modules_str::String, + module_contents, + is_split::Bool; + custom_title::String="", + custom_description::String="", +) + # Choose title based on context and customization + title = if !isempty(custom_title) + custom_title + else + "Public API" + end + + # Choose description based on customization + description = if !isempty(custom_description) + custom_description + else + "This page lists **exported** symbols of `$(modules_str)`." + end + + overview = """ + # $(title) + + $(description) + + """ + + all_docstrings = String[] + for (mod, public_docs, _) in module_contents + if !isempty(public_docs) + push!(all_docstrings, "\n---\n\n### From `$(mod)`\n\n") + append!(all_docstrings, public_docs) + end + end + + return overview, all_docstrings +end diff --git a/ext/DocumenterReference/source_file_detection.jl b/ext/DocumenterReference/source_file_detection.jl new file mode 100644 index 00000000..2383f936 --- /dev/null +++ b/ext/DocumenterReference/source_file_detection.jl @@ -0,0 +1,71 @@ +""" + _get_source_file(mod::Module, key::Symbol, type::DocType) -> Union{String, Nothing} + +Determine the source file path where a symbol is defined. +Returns `nothing` if the source file cannot be determined. +""" +function _get_source_file(mod::Module, key::Symbol, type::DocType) + try + # Strategy 1: Try docstring metadata + path = _get_source_from_docstring(mod, key) + path !== nothing && return path + + obj = getfield(mod, key) + + # Strategy 2: For functions/macros, use methods() + if obj isa Function + path = _get_source_from_methods(obj) + path !== nothing && return path + end + + # Strategy 3: For concrete types, try constructor methods + if obj isa Type && !isabstracttype(obj) + path = _get_source_from_methods(obj) + path !== nothing && return path + end + + return nothing + catch e + @debug "Could not determine source file for $key in $mod" exception=e + return nothing + end +end + +""" + _get_source_from_docstring(mod::Module, key::Symbol) -> Union{String, Nothing} + +Try to get source file path from docstring metadata. +""" +function _get_source_from_docstring(mod::Module, key::Symbol) + binding = Base.Docs.Binding(mod, key) + meta = Base.Docs.meta(mod) + haskey(meta, binding) || return nothing + + docs = meta[binding] + if isa(docs, Base.Docs.MultiDoc) && !isempty(docs.docs) + for (_, docstr) in docs.docs + if isa(docstr, Base.Docs.DocStr) && haskey(docstr.data, :path) + path = docstr.data[:path] + if path !== nothing && !isempty(path) + return abspath(String(path)) + end + end + end + end + return nothing +end + +""" + _get_source_from_methods(obj) -> Union{String, Nothing} + +Try to get source file path from method definitions. +""" +function _get_source_from_methods(obj) + for m in methods(obj) + file = String(m.file) + if file != "" && file != "none" && !startswith(file, ".") + return abspath(file) + end + end + return nothing +end diff --git a/ext/DocumenterReference/symbol_classification.jl b/ext/DocumenterReference/symbol_classification.jl new file mode 100644 index 00000000..f76fc19c --- /dev/null +++ b/ext/DocumenterReference/symbol_classification.jl @@ -0,0 +1,53 @@ +""" + _to_string(x::DocType) -> String + +Convert a DocType enumeration value to its string representation. +""" +_to_string(x::DocType) = DOCTYPE_NAMES[x] + +""" + _classify_symbol(obj, name_str::String) -> DocType + +Classify a symbol by its type (function, macro, struct, constant, module, abstract type). +""" +function _classify_symbol(obj, name_str::String) + startswith(name_str, "@") && return DOCTYPE_MACRO + obj isa Module && return DOCTYPE_MODULE + obj isa Type && isabstracttype(obj) && return DOCTYPE_ABSTRACT_TYPE + obj isa Type && return DOCTYPE_STRUCT + obj isa Function && return DOCTYPE_FUNCTION + return DOCTYPE_CONSTANT +end + +""" + _exported_symbols(mod::Module) -> NamedTuple + +Classify all symbols in a module into exported and private categories. +Returns a NamedTuple with `exported` and `private` fields, each containing +sorted lists of `(Symbol, DocType)` pairs. +""" +function _exported_symbols(mod::Module) + exported = Pair{Symbol,DocType}[] + private = Pair{Symbol,DocType}[] + exported_names = Set(names(mod; all=false)) + + for n in names(mod; all=true, imported=false) + name_str = String(n) + # Skip compiler-generated symbols and the module itself + startswith(name_str, "#") && continue + n == nameof(mod) && continue + + obj = try + getfield(mod, n) + catch + continue + end + + doc_type = _classify_symbol(obj, name_str) + target = n in exported_names ? exported : private + push!(target, n => doc_type) + end + + sort_fn = x -> (DOCTYPE_ORDER[x[2]], string(x[1])) + return (exported=sort(exported; by=sort_fn), private=sort(private; by=sort_fn)) +end diff --git a/ext/DocumenterReference/symbol_iteration.jl b/ext/DocumenterReference/symbol_iteration.jl new file mode 100644 index 00000000..259024fa --- /dev/null +++ b/ext/DocumenterReference/symbol_iteration.jl @@ -0,0 +1,86 @@ +""" + _iterate_over_symbols(f, config, symbol_list) + +Iterate over symbols, applying a function to each documented symbol. +Filters symbols based on exclusion list, documentation presence, and source files. +""" +function _iterate_over_symbols(f, config::_Config, symbol_list) + current_module = config.current_module + effective_source_files = _get_effective_source_files(config) + + for (key, type) in sort!(copy(symbol_list); by=config.sort_by) + key isa Symbol || continue + + # Check exclusion + key in config.exclude && continue + + # Check documentation + if !_has_documentation(current_module, key, type, config.modules) + continue + end + + # Check source file filtering + if !_passes_source_filter( + current_module, key, type, effective_source_files, config.include_without_source + ) + continue + end + + f(key, type) + end + return nothing +end + +""" + _has_documentation(mod::Module, key::Symbol, type::DocType, modules::Dict) -> Bool + +Check if a symbol has documentation. Logs a warning if not. +""" +function _has_documentation(mod::Module, key::Symbol, type::DocType, modules::Dict) + binding = Base.Docs.Binding(mod, key) + + has_doc = if isdefined(Base.Docs, :hasdoc) + Base.Docs.hasdoc(binding) + else + doc = Base.Docs.doc(binding) + doc !== nothing && !occursin("No documentation found.", string(doc)) + end + + if !has_doc + if type == DOCTYPE_MODULE + submod = getfield(mod, key) + if submod != mod && haskey(modules, submod) + return true # Module is documented elsewhere + end + end + @warn "No documentation found for $key in $mod. Skipping from API reference." + return false + end + return true +end + +""" + _passes_source_filter(mod, key, type, source_files, include_without_source) -> Bool + +Check if a symbol passes the source file filter. +""" +function _passes_source_filter( + mod::Module, + key::Symbol, + type::DocType, + source_files::Vector{String}, + include_without_source::Bool, +) + isempty(source_files) && return true + + source_path = _get_source_file(mod, key, type) + if source_path === nothing + if !include_without_source + @debug "Cannot determine source file for $key ($type), skipping." + return false + end + return true + end + + return source_path in source_files +end diff --git a/ext/DocumenterReference/type_formatting.jl b/ext/DocumenterReference/type_formatting.jl new file mode 100644 index 00000000..be7b80d0 --- /dev/null +++ b/ext/DocumenterReference/type_formatting.jl @@ -0,0 +1,117 @@ +""" + _method_signature_string(m::Method, mod::Module, key::Symbol) -> String + +Generate a Documenter-compatible signature string for a method. +Returns a string like `Module.func(::Type1, ::Type2)` for use in `@docs` blocks. +""" +function _method_signature_string(m::Method, mod::Module, key::Symbol) + sig = m.sig + while sig isa UnionAll + sig = sig.body + end + + if !(sig <: Tuple) + return "$(mod).$(key)" + end + + params = sig.parameters + arg_types = length(params) > 1 ? params[2:end] : Any[] + + if isempty(arg_types) + return "$(mod).$(key)()" + end + + type_strs = [_format_type_for_docs(T) for T in arg_types] + return "$(mod).$(key)($(join(type_strs, ", ")))" +end + +""" + _format_type_for_docs(T) -> String + +Format a type for use in Documenter's `@docs` block. +Always fully qualifies types to avoid UndefVarError when Documenter evaluates in Main. +""" +function _format_type_for_docs(T) + # Vararg + if T isa Core.TypeofVararg + inner = _format_type_for_docs(T.T) + inner_clean = startswith(inner, "::") ? inner[3:end] : inner + return "::Vararg{$(inner_clean)}" + end + + # TypeVar + T isa TypeVar && return "::$(T.name)" + + # UnionAll - unwrap and format + T isa UnionAll && return _format_type_for_docs(Base.unwrap_unionall(T)) + + # DataType + if T isa DataType + return _format_datatype_for_docs(T) + end + + # Union + if T isa Union + union_types = Base.uniontypes(T) + formatted = [_format_type_for_docs(ut) for ut in union_types] + cleaned = [startswith(s, "::") ? s[3:end] : s for s in formatted] + return "::Union{$(join(cleaned, ", "))}" + end + + return "::$(T)" +end + +""" + _format_datatype_for_docs(T::DataType) -> String + +Format a DataType for use in @docs blocks. +""" +function _format_datatype_for_docs(T::DataType) + type_mod = parentmodule(T) + type_name = T.name.name + is_core_or_base = type_mod === Core || type_mod === Base + + if T === Int + type_name = :Int + elseif T === UInt + type_name = :UInt + end + + # Handle parametric types + if !isempty(T.parameters) + has_typevar_params = any(p -> p isa TypeVar, T.parameters) + + if has_typevar_params + # Strip type parameters to avoid UndefVarError + return is_core_or_base ? "::$(type_name)" : "::$(type_mod).$(type_name)" + else + # Keep concrete type parameters + params = [_format_type_param(p) for p in T.parameters] + params_str = join(params, ", ") + return if is_core_or_base + "::$(type_name){$(params_str)}" + else + "::$(type_mod).$(type_name){$(params_str)}" + end + end + end + + # Simple type + return is_core_or_base ? "::$(type_name)" : "::$(type_mod).$(type_name)" +end + +""" + _format_type_param(p) -> String + +Format a type parameter (can be a type or a value like an integer). +""" +function _format_type_param(p) + if p isa Type + s = _format_type_for_docs(p) + return startswith(s, "::") ? s[3:end] : s + elseif p isa TypeVar + return string(p.name) + else + return string(p) + end +end diff --git a/ext/DocumenterReference/types.jl b/ext/DocumenterReference/types.jl new file mode 100644 index 00000000..d467354b --- /dev/null +++ b/ext/DocumenterReference/types.jl @@ -0,0 +1,118 @@ +""" + DocType + +Enumeration of documentation element types recognized by the API reference generator. + +# Values + +- `DOCTYPE_ABSTRACT_TYPE`: An abstract type declaration +- `DOCTYPE_CONSTANT`: A constant binding (including non-function, non-type values) +- `DOCTYPE_FUNCTION`: A function or callable +- `DOCTYPE_MACRO`: A macro (name starts with `@`) +- `DOCTYPE_MODULE`: A submodule +- `DOCTYPE_STRUCT`: A concrete struct type +""" +@enum( + DocType, + DOCTYPE_ABSTRACT_TYPE, + DOCTYPE_CONSTANT, + DOCTYPE_FUNCTION, + DOCTYPE_MACRO, + DOCTYPE_MODULE, + DOCTYPE_STRUCT, +) + +""" + DOCTYPE_NAMES::Dict{DocType, String} + +Mapping from DocType enum values to their human-readable string representations. +""" +const DOCTYPE_NAMES = Dict{DocType,String}( + DOCTYPE_ABSTRACT_TYPE => "abstract type", + DOCTYPE_CONSTANT => "constant", + DOCTYPE_FUNCTION => "function", + DOCTYPE_MACRO => "macro", + DOCTYPE_MODULE => "module", + DOCTYPE_STRUCT => "struct", +) + +""" + DOCTYPE_ORDER::Dict{DocType, Int} + +Ordering for DocType values used when sorting symbols for display. +Lower values appear first. +""" +const DOCTYPE_ORDER = Dict{DocType,Int}( + DOCTYPE_MODULE => 0, + DOCTYPE_MACRO => 1, + DOCTYPE_FUNCTION => 2, + DOCTYPE_ABSTRACT_TYPE => 3, + DOCTYPE_STRUCT => 4, + DOCTYPE_CONSTANT => 5, +) + +""" + _Config + +Internal configuration for API reference generation. + +# Fields + +- `current_module::Module`: The module being documented. +- `subdirectory::String`: Output directory for generated API pages. +- `modules::Dict{Module,Vector{String}}`: Mapping of modules to their source files. + When a module is specified as `Module => files`, the files are stored here. +- `sort_by::Function`: Custom sort function for symbols. +- `exclude::Set{Symbol}`: Symbol names to exclude from documentation. +- `public::Bool`: Flag to generate public API page. +- `private::Bool`: Flag to generate private API page. +- `title::String`: Title displayed at the top of the generated page. +- `title_in_menu::String`: Title displayed in the navigation menu. +- `source_files::Vector{String}`: Global source file paths (fallback if no module-specific files). +- `filename::String`: Base filename (without extension) for the markdown file. +- `include_without_source::Bool`: If `true`, include symbols whose source file cannot be determined. +- `external_modules_to_document::Vector{Module}`: Additional modules to search for docstrings. +- `public_title::String`: Custom title for public API page (empty string uses default). +- `private_title::String`: Custom title for private API page (empty string uses default). +- `public_description::String`: Custom description for public API page (empty string uses default). +- `private_description::String`: Custom description for private API page (empty string uses default). +""" +struct _Config + current_module::Module + subdirectory::String + modules::Dict{Module,Vector{String}} + sort_by::Function + exclude::Set{Symbol} + public::Bool + private::Bool + title::String + title_in_menu::String + source_files::Vector{String} + filename::String + include_without_source::Bool + external_modules_to_document::Vector{Module} + public_title::String + private_title::String + public_description::String + private_description::String +end + +""" + CONFIG::Vector{_Config} + +Global configuration storage for API reference generation. + +Each call to `CTBase.automatic_reference_documentation` appends a new `_Config` +entry to this vector. Use `reset_config!` to clear it between builds. +""" +const CONFIG = _Config[] + +""" + PAGE_CONTENT_ACCUMULATOR::Dict{String, Vector{Tuple{Module, Vector{String}, Vector{String}}}} + +Global accumulator for multi-module combined pages. +Maps output filename to a list of (module, public_docstrings, private_docstrings) tuples. +""" +const PAGE_CONTENT_ACCUMULATOR = Dict{ + String,Vector{Tuple{Module,Vector{String},Vector{String}}} +}() diff --git a/ext/TestRunner.jl b/ext/TestRunner.jl deleted file mode 100644 index f67af98c..00000000 --- a/ext/TestRunner.jl +++ /dev/null @@ -1,1371 +0,0 @@ -""" -Test runner backend for CTBase. - -This extension implements `CTBase.run_tests`, allowing test selection -via command-line arguments (globs) and configurable filename/function-name builders. - -Most functions in this module have side effects (including file inclusion and -running testsets). -""" -module TestRunner - -using CTBase: CTBase -import DocStringExtensions: TYPEDEF, TYPEDSIGNATURES -using Test: Test, @testset - -""" -$(TYPEDEF) - -Union type representing a test specification. - -A test spec can be either: -- `Symbol`: A logical test name (e.g., `:utils`, `:core`) -- `String`: A relative file path or glob pattern (e.g., `"suite/test_utils.jl"`, `"suite/core/*"`) - -This type is used throughout TestRunner to represent both user-provided selections -and internal test identifiers. - -# Notes -- Symbol specs are resolved via `filename_builder` and `funcname_builder` -- String specs are treated as relative paths from `test_dir` -- Glob patterns are supported for String specs - -See also: [`CTBase.run_tests`](@ref) -""" -const TestSpec = Union{Symbol,String} - -""" -$(TYPEDEF) - -Context information passed to test callbacks (`on_test_start`, `on_test_done`). - -Provides details about the current test being executed, including progress -information (`index`, `total`) and execution results (`status`, `error`, `elapsed`). - -# Fields -- `spec::TestSpec`: test identifier (Symbol or relative path String) -- `filename::String`: absolute path of the included test file -- `func_symbol::Union{Symbol,Nothing}`: function to call (`nothing` if `eval_mode=false`) -- `index::Int`: 1-based index of the current test in the selected list -- `total::Int`: total number of selected tests -- `status::Symbol`: one of `:pre_eval`, `:post_eval`, `:skipped`, `:error`, `:test_failed` -- `error::Union{Exception,Nothing}`: captured exception when `status == :error` -- `elapsed::Union{Float64,Nothing}`: wall-clock seconds for the eval phase (only in `on_test_done`) - -# Example -```julia-repl -julia> using CTBase.TestRunner - -julia> info = TestRunner.TestRunInfo( - :utils, - "/path/to/test_utils.jl", - :test_utils, - 3, 10, - :post_eval, - nothing, - 1.23 - ) -TestRunner.TestRunInfo(:utils, "/path/to/test_utils.jl", :test_utils, 3, 10, :post_eval, nothing, 1.23) - -julia> info.status -:post_eval - -julia> info.elapsed -1.23 -``` -""" -struct TestRunInfo - spec::TestSpec - filename::String - func_symbol::Union{Symbol,Nothing} - index::Int - total::Int - status::Symbol - error::Union{Exception,Nothing} - elapsed::Union{Float64,Nothing} -end - -""" -$(TYPEDSIGNATURES) - -Run tests with configurable file/function name builders and optional available tests filter. - -# Arguments -- `::CTBase.Extensions.TestRunnerTag`: Dispatch tag for the TestRunner extension -- `args::AbstractVector{<:AbstractString}`: Command-line arguments (typically `String.(ARGS)`) -- `testset_name::String`: Name of the main testset (default: `"Tests"`) -- `available_tests`: Allowed tests (Symbols, Strings, or glob patterns). Empty = auto-discovery -- `filename_builder::Function`: `name โ†’ filename` mapping (default: `identity`) -- `funcname_builder::Function`: `name โ†’ function_name` mapping (default: `identity`) -- `eval_mode::Bool`: Whether to call the function after include (default: `true`) -- `verbose::Bool`: Verbose `@testset` output (default: `true`) -- `showtiming::Bool`: Show timing in `@testset` output (default: `true`) -- `test_dir::String`: Root directory for test files (default: `joinpath(pwd(), "test")`) -- `on_test_start::Union{Function,Nothing}`: Callback before eval (default: `nothing`) -- `on_test_done::Union{Function,Nothing}`: Callback after eval (default: `nothing`) -- `show_progress_line::Bool`: Show progress line with symbol, index, spec, and time (default: `true`) -- `show_progress_bar::Bool`: Show graphical progress bar `[โ–ˆโ–‘โ–‘โ–‘...]` within the line (default: `true`) -- `progress_bar_threshold::Int`: Maximum tests for full-resolution progress bar (default: `100`) - -# Returns -- `Nothing`: Tests are executed via side effects - -# Notes -- Test selection is driven by `args` (coverage flags are automatically filtered out) -- Selection arguments are interpreted as glob patterns and matched against both test names and filenames -- Arguments starting with `test/` are automatically stripped for convenience -- When `on_test_done` is provided, the built-in progress line is disabled unless `show_progress_line=true` -- When `show_progress_line=true` but `show_progress_bar=false`, displays minimal output: `โœ“ [01/76] suite/test.jl (0.2s)` without the graphical bar - -# Example -```julia-repl -julia> using CTBase.TestRunner - -julia> # Run all tests with default settings -julia> CTBase.run_tests() - -julia> # Run specific tests with custom callbacks -julia> CTBase.run_tests(; - args=["utils", "core"], - on_test_start = info -> (println("Running: ", info.spec); true), - on_test_done = info -> println("Done: ", info.status) - ) -``` - -See also: [`CTBase.run_tests`](@ref), [`TestRunner.TestRunInfo`](@ref) -""" -function CTBase.run_tests( - ::CTBase.Extensions.TestRunnerTag; - args::AbstractVector{<:AbstractString}=String[], - testset_name::String="Tests", - available_tests=Symbol[], - filename_builder::Function=identity, - funcname_builder::Function=identity, - eval_mode::Bool=true, - verbose::Bool=true, - showtiming::Bool=true, - test_dir::String=joinpath(pwd(), "test"), - on_test_start::Union{Function,Nothing}=nothing, - on_test_done::Union{Function,Nothing}=nothing, - show_progress_line::Bool=true, - show_progress_bar::Bool=true, - progress_bar_threshold::Int=_PROGRESS_BAR_THRESHOLD, -) - # Guard: a subdirectory named "test" inside test_dir would conflict with - # the automatic `test/` prefix stripping in _parse_test_args. - if isdir(joinpath(test_dir, "test")) - throw( - CTBase.Exceptions.PreconditionError( - "A subdirectory \"test\" exists inside the test directory \"$(test_dir)\""; - reason="selection arguments starting with \"test/\" are automatically stripped (e.g. \"test/suite\" โ†’ \"suite\")", - suggestion="rename the subdirectory to avoid the conflict", - ), - ) - end - - # Parse command-line arguments - (selections, run_all, dry_run) = _parse_test_args(String.(args)) - - available_tests_vec = _normalize_available_tests(available_tests) - - # Get selected tests - selected = _select_tests( - selections, available_tests_vec, run_all, filename_builder; test_dir=test_dir - ) - - if dry_run - println("Dry run: the following tests would be executed:") - println(join(selected, "\n")) - return nothing - end - - total = length(selected) - - # Wire up default progress callback when no custom on_test_done is provided - effective_on_test_done = if on_test_done !== nothing - on_test_done - elseif show_progress_line - _make_default_on_test_done(stdout, total, progress_bar_threshold, show_progress_bar) - else - nothing - end - - @testset verbose = verbose showtiming = showtiming "$testset_name" begin - for (idx, spec) in enumerate(selected) - @testset "$(spec)" begin - _run_single_test( - spec; - filename_builder, - funcname_builder, - eval_mode, - test_dir, - index=idx, - total, - on_test_start, - on_test_done=effective_on_test_done, - ) - end - end - end -end - -""" -$(TYPEDSIGNATURES) - -Parse command-line test arguments, filtering out coverage-related flags. - -# Arguments -- `args::Vector{String}`: Raw command-line arguments - -# Returns -- `Tuple{Vector{String}, Bool, Bool}`: `(selections, run_all, dry_run)` where: - - `selections`: selection patterns provided by the user (as strings) - - `run_all`: whether `-a` / `--all` was present - - `dry_run`: whether `-n` / `--dryrun` was present - -# Notes -- Coverage flags (`coverage=true`, `--coverage`, etc.) are automatically filtered out -- Selection patterns starting with `test/` or `test\\` are automatically stripped - so that users can write `test/suite/foo` or `suite/foo` interchangeably - -# Example -```julia-repl -julia> using CTBase.TestRunner - -julia> TestRunner._parse_test_args(["utils", "-a", "--dryrun"]) -(["utils"], true, true) - -julia> TestRunner._parse_test_args(["test/suite", "coverage=true"]) -(["suite"], false, false) -``` -""" -function _parse_test_args(args::Vector{String}) - selections = String[] - run_all = false - dry_run = false - - for arg in args - if arg in ("coverage=true", "coverage", "--coverage", "coverage=false") - continue - elseif arg == "-a" || arg == "--all" - run_all = true - elseif arg == "-n" || arg == "--dryrun" - dry_run = true - else - push!(selections, _strip_test_prefix(arg)) - end - end - return (selections, run_all, dry_run) -end - -""" -$(TYPEDSIGNATURES) - -Strip a leading `test/` or `test\\` prefix from a selection pattern. - -This allows users to type `test/suite/foo` instead of `suite/foo` since -the test directory is already the root for pattern matching. - -# Arguments -- `s::AbstractString`: Selection pattern to process - -# Returns -- `String`: Pattern with `test/` or `test\\` prefix stripped (if present) - -# Example -```julia-repl -julia> using CTBase.TestRunner - -julia> TestRunner._strip_test_prefix("test/suite/foo") -"suite/foo" - -julia> TestRunner._strip_test_prefix("suite/foo") -"suite/foo" - -julia> TestRunner._strip_test_prefix("test\\windows\\path") -"windows\\path" -``` -""" -function _strip_test_prefix(s::AbstractString) - for prefix in ("test/", "test\\") - if startswith(s, prefix) - return String(s[(length(prefix) + 1):end]) - end - end - return String(s) -end - -""" -$(TYPEDSIGNATURES) - -Normalize user-provided selection patterns before glob matching. - -Applied transformations: -- Strip trailing `/` (e.g. `"suite/exceptions/"` โ†’ `"suite/exceptions"`) -- If a selection contains no glob wildcard (`*` or `?`) and matches a directory - prefix of at least one candidate, expand it to `"selection/*"` so that all - files under that directory are selected. - -The original selection is always kept so that exact-name matches still work. - -# Arguments -- `selections::Vector{String}`: User-provided selection patterns -- `candidates::Vector{<:TestSpec}`: Available test candidates - -# Returns -- `Vector{String}`: Normalized selection patterns - -# Example -```julia-repl -julia> using CTBase.TestRunner - -julia> TestRunner._normalize_selections( - ["suite/"], - ["suite/test_a.jl", "suite/test_b.jl"] - ) -2-element Vector{String}: - "suite" - "suite/*" -``` -""" -function _normalize_selections(selections::Vector{String}, candidates::Vector{<:TestSpec}) - candidate_strs = [String(c) for c in candidates] - normalized = String[] - for sel in selections - # Strip trailing slash(es) - s = rstrip(sel, '/') - push!(normalized, s) - # If no glob wildcard, check if it looks like a directory prefix - if !occursin('*', s) && !occursin('?', s) - prefix = s * "/" - is_dir_prefix = any(c -> startswith(c, prefix), candidate_strs) - if is_dir_prefix - push!(normalized, s * "/*") - end - end - end - return unique(normalized) -end - -""" -$(TYPEDSIGNATURES) - -Convert a glob pattern (using `*` and `?`) into a regular expression. - -The returned regex is anchored (matches the full string). - -# Arguments -- `pattern::AbstractString`: Glob pattern to convert - -# Returns -- `Regex`: Anchored regular expression equivalent to the glob pattern - -# Example -```julia-repl -julia> using CTBase.TestRunner - -julia> TestRunner._glob_to_regex("test_*.jl") -r"^test_.*\\.jl\$" - -julia> TestRunner._glob_to_regex("suite/test_?.jl") -r"^suite/test.\\.jl\$" -``` -""" -function _glob_to_regex(pattern::AbstractString) - # Escape special regex characters except * and ? - regex_str = replace( - pattern, - "." => "\\.", - "+" => "\\+", - "(" => "\\(", - ")" => "\\)", - "[" => "\\[", - "]" => "\\]", - "{" => "\\{", - "}" => "\\}", - "^" => "\\^", - "\u0024" => "\\\u0024", - "\\" => "\\\\", - ) - # Convert glob wildcards to regex wildcards - regex_str = replace(regex_str, "*" => ".*", "?" => ".") - # Anchor to full string - return Regex("^" * regex_str * "\u0024") -end - -""" -$(TYPEDSIGNATURES) - -Ensure that a filename ends with `.jl` extension. - -If the filename already ends with `.jl`, returns it unchanged. -Otherwise, appends `.jl` to the filename. - -# Arguments -- `filename::AbstractString`: Base filename with or without `.jl` extension - -# Returns -- `String`: Filename guaranteed to end with `.jl` - -# Example -```julia -julia> TestRunner._ensure_jl("test_utils") -"test_utils.jl" - -julia> TestRunner._ensure_jl("test_utils.jl") -"test_utils.jl" -``` -""" -function _ensure_jl(filename::AbstractString) - return endswith(filename, ".jl") ? filename : filename * ".jl" -end - -""" -$(TYPEDSIGNATURES) - -Convert a Symbol or String to String. - -This helper function ensures that builder function outputs are always -converted to strings for consistent handling. - -# Arguments -- `x`: Symbol or String to convert - -# Returns -- `String`: The string representation of `x` - -# Example -```julia -julia> TestRunner._builder_to_string(:utils) -"utils" - -julia> TestRunner._builder_to_string("utils") -"utils" -``` -""" -function _builder_to_string(x) - return String(x) -end - -""" -$(TYPEDSIGNATURES) - -Normalize and validate the `available_tests` argument. - -Converts the input to a `Vector{TestSpec}` and validates that all entries -are either `Symbol` or `String`. Returns an empty vector if `available_tests` -is `nothing`. - -# Arguments -- `available_tests`: `nothing`, `Vector`, or `Tuple` containing `Symbol` or `String` entries - -# Returns -- `Vector{TestSpec}`: Normalized vector of test specifications - -# Throws -- `ArgumentError`: If `available_tests` is not a Vector/Tuple or contains invalid entries - -# Example -```julia -julia> TestRunner._normalize_available_tests([:utils, "suite/*"]) -2-element Vector{Union{Symbol, String}}: - :utils - "suite/*" - -julia> TestRunner._normalize_available_tests(nothing) -Union{Symbol, String}[] -``` -""" -function _normalize_available_tests(available_tests) - available_tests === nothing && return TestSpec[] - - if !(available_tests isa AbstractVector || available_tests isa Tuple) - throw( - CTBase.Exceptions.IncorrectArgument( - "available_tests must be a Vector or Tuple of Symbol/String"; - got=string(typeof(available_tests)), - ), - ) - end - - out = TestSpec[] - for entry in available_tests - if entry isa Symbol || entry isa String - push!(out, entry) - else - throw( - CTBase.Exceptions.IncorrectArgument( - "available_tests entries must be Symbol or String"; - got=string(typeof(entry)), - ), - ) - end - end - return out -end - -""" -$(TYPEDSIGNATURES) - -Recursively collect all `.jl` files in `test_dir` (excluding `runtests.jl`). - -Returns relative paths from `test_dir`, sorted alphabetically. - -# Arguments -- `test_dir::AbstractString`: Root directory to search - -# Returns -- `Vector{String}`: Relative paths to all `.jl` files (excluding `runtests.jl`) - -# Example -```julia -# Assuming test_dir contains: -# - test/utils.jl -# - test/core/test_core.jl -# - test/runtests.jl - -julia> TestRunner._collect_test_files_recursive("test") -2-element Vector{String}: - "test/core/test_core.jl" - "test/utils.jl" -``` -""" -function _collect_test_files_recursive(test_dir::AbstractString) - files = String[] - for (root, _, fs) in walkdir(test_dir) - for f in fs - if endswith(f, ".jl") && f != "runtests.jl" - full = joinpath(root, f) - push!(files, relpath(full, test_dir)) - end - end - end - sort!(files) - return files -end - -""" -$(TYPEDSIGNATURES) - -Find the relative path to a test file for a given symbol name. - -Uses the `filename_builder` to construct the expected filename, then searches -for files matching that basename. If multiple matches exist (e.g., files in -different subdirectories), prefers the shallowest path. - -# Arguments -- `name::Symbol`: Test name to resolve -- `filename_builder::Function`: Function that maps test names to filenames -- `test_dir::AbstractString`: Root directory containing test files - -# Returns -- `String`: Relative path to the matching test file -- `nothing`: If no matching file is found - -# Notes -- Searches recursively in `test_dir` -- Excludes `runtests.jl` from consideration -- Prefers shallower paths when multiple matches exist -- Returns the exact relative path if found - -See also: [`TestRunner._collect_test_files_recursive`](@ref), [`TestRunner._ensure_jl`](@ref) -""" -function _find_symbol_test_file_rel( - name::Symbol, filename_builder::Function; test_dir::AbstractString -) - wanted = _ensure_jl(_builder_to_string(filename_builder(name))) - all = _collect_test_files_recursive(test_dir) - matches = filter(f -> basename(f) == wanted, all) - - if isempty(matches) - return nothing - end - if wanted in matches - return wanted - end - - sort!(matches; by=f -> (count(==('/'), f), ncodeunits(f), f)) - return first(matches) -end - -""" -$(TYPEDSIGNATURES) - -Determine which tests to run based on selections, available_tests filter, and file globbing. - -1. Identify potential test files in `test_dir` (default: `test/`). -2. Filter by `available_tests` if provided. -3. Filter by `selections` (interpreted as globs) if present. - -# Arguments -- `selections::Vector{String}`: User-provided selection patterns -- `available_tests::AbstractVector{<:TestSpec}`: Allowed tests (empty = auto-discovery) -- `run_all::Bool`: Whether to run all available tests -- `filename_builder::Function`: Function to map test names to filenames -- `test_dir::String`: Root directory containing test files - -# Returns -- `Vector{TestSpec}`: Selected test specifications - -# Notes -- If `available_tests` is empty, this function falls back to an auto-discovery - heuristic using the filename stem as the candidate test name -- Selection arguments are matched against multiple representations of each candidate -""" -function _select_tests( - selections::Vector{String}, - available_tests::AbstractVector{<:TestSpec}, - run_all::Bool, - filename_builder::Function; - test_dir::String=joinpath(pwd(), "test"), # Default assumption -) - candidates = TestSpec[] - - if isempty(available_tests) - for f in _collect_test_files_recursive(test_dir) - push!(candidates, f) - end - else - # If available_tests IS provided, we only consider these. - # We verify if their files exist. - recursive_files = _collect_test_files_recursive(test_dir) - for entry in available_tests - if entry isa Symbol - rel = _find_symbol_test_file_rel(entry, filename_builder; test_dir=test_dir) - if rel !== nothing - push!(candidates, entry) - end - else - full = joinpath(test_dir, entry) - if isdir(full) - prefix = entry * "/" - for f in recursive_files - if startswith(f, prefix) - push!(candidates, f) - end - end - else - regex = _glob_to_regex(entry) - for f in recursive_files - f_no_ext = replace(f, ".jl" => "") - if !isnothing(match(regex, f)) || !isnothing(match(regex, f_no_ext)) - push!(candidates, f) - end - end - end - end - end - end - - # If run_all is requested or no selections, return all candidates - if run_all || isempty(selections) - return candidates - end - - # 3. Normalize selections: expand bare directory paths to dir/* - selections = _normalize_selections(selections, candidates) - - # 4. Filter candidates by selections (Patterns) - filtered = TestSpec[] - - for candidate in candidates - candidate_str = candidate isa Symbol ? String(candidate) : String(candidate) - # Also check the associated filename? - # If I have candidate :utils -> test_utils.jl - # And user passes "test_u*", it should match "test_utils.jl" OR "utils"? - # User said "Scan test/ directory... ARGS are globs". - # So matching against the FILENAME seems primary. - - # Resolve filename for candidate - if candidate isa String - candidate_filename = _ensure_jl(candidate) - elseif isempty(available_tests) - candidate_filename = "$(candidate).jl" - else - candidate_filename = _ensure_jl(_builder_to_string(filename_builder(candidate))) - end - - # Also match strictly against filename without extension? - candidate_filename_no_ext = replace(candidate_filename, ".jl" => "") - - candidate_basename = basename(candidate_filename) - candidate_basename_no_ext = replace(candidate_basename, ".jl" => "") - candidate_basename_no_test_prefix = - if startswith(candidate_basename_no_ext, "test_") - candidate_basename_no_ext[6:end] - else - candidate_basename_no_ext - end - - matched = false - for sel in selections - regex = _glob_to_regex(sel) - - # Match against: - # 1. Candidate name (e.g. :utils) - # 2. Filename (e.g. test_utils.jl) - # 3. Filename without extension (e.g. test_utils) - if !isnothing(match(regex, candidate_str)) || - !isnothing(match(regex, candidate_filename)) || - !isnothing(match(regex, candidate_filename_no_ext)) || - !isnothing(match(regex, candidate_basename)) || - !isnothing(match(regex, candidate_basename_no_ext)) || - !isnothing(match(regex, candidate_basename_no_test_prefix)) - matched = true - break - end - end - - if matched - push!(filtered, candidate) - end - end - - return filtered -end - -""" -$(TYPEDSIGNATURES) - -Run a single selected test. - -This helper: -- Resolves a test filename via `filename_builder` -- Includes the file into `Main` -- Calls `on_test_start` (if provided) after include, before eval -- Optionally evaluates a function (via `funcname_builder`) when `eval_mode=true` -- Calls `on_test_done` (if provided) after eval, skip, or error - -# Arguments -- `spec::TestSpec`: Test specification to run -- `filename_builder::Function`: Function to map test names to filenames -- `funcname_builder::Function`: Function to map test names to function names -- `eval_mode::Bool`: Whether to evaluate the function after include -- `test_dir::String`: Root directory containing test files -- `index::Int`: 1-based index in the selected list (default: `1`) -- `total::Int`: Total number of selected tests (default: `1`) -- `on_test_start::Union{Function,Nothing}`: Callback before eval (default: `nothing`) -- `on_test_done::Union{Function,Nothing}`: Callback after eval (default: `nothing`) - -# Notes -- This function is not part of the public API -- Use `run_tests` for running multiple tests with proper orchestration -""" -function _run_single_test( - spec::TestSpec; - filename_builder::Function, - funcname_builder::Function, - eval_mode::Bool, - test_dir::String, - index::Int=1, - total::Int=1, - on_test_start::Union{Function,Nothing}=nothing, - on_test_done::Union{Function,Nothing}=nothing, -) - # --- Resolve filename and func_symbol --- - filename, func_symbol = _resolve_test( - spec; filename_builder, funcname_builder, eval_mode, test_dir - ) - - # --- Include the file --- - Base.include(Main, filename) - - # --- Check function exists after include --- - if eval_mode && func_symbol !== nothing && !isdefined(Main, func_symbol) - throw( - CTBase.Exceptions.PreconditionError( - "Function \"$(func_symbol)\" not found after including \"$(filename)\""; - reason="the file does not define a function with this name", - suggestion="make sure the file defines a top-level function named $(func_symbol)", - ), - ) - end - - # --- on_test_start callback --- - if on_test_start !== nothing - info = TestRunInfo( - spec, filename, func_symbol, index, total, :pre_eval, nothing, nothing - ) - should_continue = on_test_start(info) - if should_continue === false - if on_test_done !== nothing - done_info = TestRunInfo( - spec, filename, func_symbol, index, total, :skipped, nothing, nothing - ) - on_test_done(done_info) - end - return nothing - end - end - - # --- Skip eval if not in eval mode --- - if !eval_mode || func_symbol === nothing - if on_test_done !== nothing - done_info = TestRunInfo( - spec, filename, func_symbol, index, total, :skipped, nothing, nothing - ) - on_test_done(done_info) - end - return nothing - end - - # --- Snapshot testset results before eval --- - ts = Test.get_testset() - n_before = (ts isa Test.DefaultTestSet) ? length(ts.results) : nothing - - # --- Eval the function --- - t0 = time() - try - Main.eval(Expr(:call, func_symbol)) - elapsed = time() - t0 - if on_test_done !== nothing - # Detect @test failures by scanning only the new results added during eval - status = if n_before !== nothing - _has_failures_in_results(ts, n_before + 1) ? :test_failed : :post_eval - else - :post_eval - end - done_info = TestRunInfo( - spec, filename, func_symbol, index, total, status, nothing, elapsed - ) - on_test_done(done_info) - end - catch ex - elapsed = time() - t0 - if on_test_done !== nothing - done_info = TestRunInfo( - spec, filename, func_symbol, index, total, :error, ex, elapsed - ) - on_test_done(done_info) - end - rethrow() - end - - return nothing -end - -""" -$(TYPEDSIGNATURES) - -Resolve a test spec into an absolute filename and function symbol. - -Handles both `String` specs (relative paths) and `Symbol` specs (logical names). -Raises errors if the file is not found or if `eval_mode=true` but no function can be determined. - -# Arguments -- `spec::TestSpec`: Test specification to resolve -- `filename_builder::Function`: Function to map test names to filenames -- `funcname_builder::Function`: Function to map test names to function names -- `eval_mode::Bool`: Whether to resolve a function name -- `test_dir::String`: Root directory containing test files - -# Returns -- `Tuple{String, Union{Symbol,Nothing}}`: `(filename, func_symbol)` where: - - `filename`: Absolute path to the test file - - `func_symbol`: Function symbol to call (or `nothing` if `eval_mode=false`) - -# Throws -- `ErrorException`: If the test file is not found -- `ErrorException`: If `eval_mode=true` but no function can be determined - -# Notes -- This function is not part of the public API -- Use `run_tests` for running tests with proper error handling -""" -function _resolve_test( - spec::TestSpec; - filename_builder::Function, - funcname_builder::Function, - eval_mode::Bool, - test_dir::String, -) - if spec isa String - rel = _ensure_jl(spec) - filename = joinpath(test_dir, rel) - if !isfile(filename) - throw( - CTBase.Exceptions.IncorrectArgument( - "Test file \"$(filename)\" not found"; - context="current directory: $(pwd())", - ), - ) - end - - func_symbol = if eval_mode - Symbol(replace(basename(rel), ".jl" => "")) - else - nothing - end - - return (filename, func_symbol) - end - - name = spec - - # Build filename - rel = _find_symbol_test_file_rel(name, filename_builder; test_dir=test_dir) - rel === nothing && throw( - CTBase.Exceptions.IncorrectArgument( - "Test file not found for test \"$(name)\""; - context="current directory: $(pwd())", - ), - ) - - filename = joinpath(test_dir, rel) - - # Check file exists - !isfile(filename) && throw( - CTBase.Exceptions.IncorrectArgument( - "Test file \"$(filename)\" not found for test \"$(name)\""; - context="current directory: $(pwd())", - ), - ) - - # Determine function name - func_symbol = funcname_builder(name) - - # Check consistency: eval_mode=true but funcname_builder returns nothing - if eval_mode && func_symbol === nothing - throw( - CTBase.Exceptions.PreconditionError( - "eval_mode=true but funcname_builder returned nothing for test \"$(name)\""; - reason="funcname_builder must return a Symbol when eval_mode=true", - suggestion="set eval_mode=false, or make funcname_builder return a Symbol", - ), - ) - end - - if !eval_mode || func_symbol === nothing - return (filename, nothing) - end - - func_symbol = func_symbol isa Symbol ? func_symbol : Symbol(String(func_symbol)) - - return (filename, func_symbol) -end - -# ============================================================================ -# Progress display -# ============================================================================ - -""" - _PROGRESS_BAR_THRESHOLD - -Internal constant defining the maximum number of tests for full-resolution progress bars. - -When the total number of tests is โ‰ค `_PROGRESS_BAR_THRESHOLD` (100), the progress bar displays -one character per test with cumulative coloring (each test gets its own colored block). -Beyond this threshold, the bar switches to compressed mode with uniform coloring. - -This threshold balances visual clarity with terminal width constraints. -""" -const _PROGRESS_BAR_THRESHOLD = 100 - -""" -$(TYPEDSIGNATURES) - -Recursively scan a `DefaultTestSet` results for `Test.Fail` or `Test.Error` entries, -starting at index `from`. - -This is used to detect `@test` failures that occurred during a specific eval by -comparing the results count before and after the eval. The `anynonpass` field is -unreliable because it is only updated when a testset *finishes* (in `Test.finish`). - -# Arguments -- `ts::Test.DefaultTestSet`: TestSet to scan -- `from::Int`: Starting index for scanning (default: `1`) - -# Returns -- `Bool`: `true` if any failures are found, `false` otherwise - -# Example -```julia-repl -julia> using CTBase.TestRunner, Test - -julia> ts = Test.DefaultTestSet("test", []) -julia> Test.@testset "example" begin - Test.@test 1 == 1 - Test.@test 2 == 0 # This will fail - end -Test.DefaultTestSet("example", Any[Test.Pass(1), Test.Fail("false")]) - -julia> TestRunner._has_failures_in_results(ts) -true -``` -""" -function _has_failures_in_results(ts::Test.DefaultTestSet, from::Int=1) - for i in from:length(ts.results) - r = ts.results[i] - if r isa Test.DefaultTestSet - _has_failures_in_results(r) && return true - elseif r isa Test.Fail || r isa Test.Error - return true - end - end - return false -end - -""" -$(TYPEDSIGNATURES) - -Compute the progress bar character width based on the number of tests. - -- `total โ‰ค progress_bar_threshold`: width equals `total` (one block per test). -- `total > progress_bar_threshold`: fixed width of `progress_bar_threshold` (some tests skip a block advance). - -# Arguments -- `total::Int`: Total number of tests -- `progress_bar_threshold::Int`: Maximum tests for full-resolution progress bar (default: `100`) - -# Returns -- `Int`: Character width for the progress bar (0 if `total โ‰ค 0`) - -# Example -```julia-repl -julia> using CTBase.TestRunner - -julia> TestRunner._bar_width(10) -10 - -julia> TestRunner._bar_width(25) -25 - -julia> TestRunner._bar_width(0) -0 - -julia> TestRunner._bar_width(100, progress_bar_threshold=30) -30 -``` -""" -function _bar_width(total::Int, progress_bar_threshold::Int=_PROGRESS_BAR_THRESHOLD) - total <= 0 && return 0 - return min(total, progress_bar_threshold) -end - -""" -$(TYPEDSIGNATURES) - -Render a progress bar string like `[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]`. - -When `width` is `nothing` (default), the width is computed automatically -via `_bar_width(total)`. Returns an empty string when the bar is hidden. - -# Arguments -- `index::Int`: current progress (1-based) -- `total::Int`: total number of items -- `width::Union{Int,Nothing}`: character width of the bar (default: auto) - -# Returns -- `String`: Progress bar string, or empty string if hidden - -# Example -```julia-repl -julia> using CTBase.TestRunner - -julia> TestRunner._progress_bar(5, 10) -"[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘]" - -julia> TestRunner._progress_bar(5, 10; width=20) -"[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]" - -julia> TestRunner._progress_bar(0, 10; width=5) -"[โ–‘โ–‘โ–‘โ–‘โ–‘]" -``` -""" -function _progress_bar(index::Int, total::Int; width::Union{Int,Nothing}=nothing) - w = width === nothing ? _bar_width(total) : width - w <= 0 && return "" - total <= 0 && return "[" * repeat("โ–‘", w) * "]" - filled = round(Int, index / total * w) - filled = clamp(filled, 0, w) - return "[" * repeat("โ–ˆ", filled) * repeat("โ–‘", w - filled) * "]" -end - -""" - _severity(status::Symbol) -> Int - -Internal helper to map test status to severity level for display formatting. - -# Arguments -- `status::Symbol`: Test status (`:error`, `:test_failed`, `:skipped`, or success) - -# Returns -- `Int`: Severity level (3=failure, 2=skipped, 1=success) -""" -@inline _severity(status::Symbol) = - (status == :error || status == :test_failed) ? 3 : (status == :skipped ? 2 : 1) - -""" - _color_for_severity(sev::Int) -> String - -Internal helper to map severity level to ANSI color code. - -# Arguments -- `sev::Int`: Severity level (3=failure, 2=skipped, 1=success) - -# Returns -- `String`: ANSI color escape code (red for failure, yellow for skipped, green for success) -""" -@inline function _color_for_severity(sev::Int) - sev >= 3 && return "\e[31m" # red - sev == 2 && return "\e[33m" # yellow - return "\e[32m" # green -end - -""" - _block_char_for_severity(sev::Int) -> String - -Internal helper to map severity level to block character for colorblind-friendly display. - -Uses distinct glyphs to ensure progress bars are readable without color: -- Success: โ–ˆ (solid block) -- Skipped: โ”† (thin vertical line) -- Failure: โ–š (diagonal pattern) - -# Arguments -- `sev::Int`: Severity level (3=failure, 2=skipped, 1=success) - -# Returns -- `String`: Unicode block character representing the severity -""" -@inline function _block_char_for_severity(sev::Int) - sev >= 3 && return "โ–š" # failure (diagonal) - sev == 2 && return "โ”†" # skipped (thin vertical) - return "โ–ˆ" # success -end - -""" -$(TYPEDSIGNATURES) - -Write a styled progress line for a completed test to `io`. - -Uses ANSI colors: green for success, red for errors, yellow for skipped. - -# Arguments -- `io::IO`: Output stream to write to -- `info::TestRunInfo`: Test execution information -- `history::Union{Vector{Int},Nothing}`: Optional history array for per-test coloring (default: `nothing`) -- `cumulative_severity::Union{Int,Nothing}`: Optional cumulative severity for coloring (default: `nothing`) -- `progress_bar_threshold::Int`: Maximum tests for full-resolution progress bar (default: `100`) -- `show_progress_bar::Bool`: Show graphical progress bar `[โ–ˆโ–‘โ–‘โ–‘...]` (default: `true`) - -# Notes -- Format: `[progress_bar] symbol [index/total] spec (time) status` -- Colors: green for success, red for errors, yellow for skipped -- Time is displayed with one decimal place when available -- **Cursor-style display**: In full-resolution mode (total โ‰ค threshold), only the current test position is filled for successes, while failures and skips persist at their positions. This creates a lighter, cursor-like visual where past successes are ephemeral. - -# Example -```julia-repl -julia> using CTBase.TestRunner, IOBuffer - -julia> info = TestRunner.TestRunInfo( - :test_example, - "/path/to/test.jl", - :test_example, - 5, 10, - :post_eval, - nothing, - 1.23 - ); - -julia> buf = IOBuffer(); -julia> TestRunner._format_progress_line(buf, info); -julia> String(take!(buf)) -"[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘] โœ“ [05/10] test_example (1.2s)" -``` -""" -function _format_progress_line( - io::IO, - info::TestRunInfo; - history::Union{Vector{Int},Nothing}=nothing, - cumulative_severity::Union{Int,Nothing}=nothing, - progress_bar_threshold::Int=_PROGRESS_BAR_THRESHOLD, - show_progress_bar::Bool=true, -) - # ANSI codes - reset = "\e[0m" - bold = "\e[1m" - dim = "\e[2m" - green = "\e[32m" - red = "\e[31m" - yellow = "\e[33m" - cyan = "\e[36m" - - bar_width = _bar_width(info.total, progress_bar_threshold) - bar = _progress_bar(info.index, info.total; width=bar_width) - - severity = _severity(info.status) - if severity == 3 - color = red - symbol = "โœ—" - elseif severity == 2 - color = yellow - symbol = "โ—‹" - else - color = green - symbol = "โœ“" - end - - w = ndigits(info.total) - idx_str = "[$(lpad(info.index, w, '0'))/$(info.total)]" - time_str = if info.elapsed !== nothing - " $(dim)($(round(info.elapsed; digits=1))s)$(reset)" - else - "" - end - status_str = if (info.status == :error || info.status == :test_failed) - " $(bold)$(red)FAILED$(reset)$(dim)," - else - "" - end - - function bracket_color_from(sev::Union{Int,Nothing}) - sev === nothing && return green - sev <= 1 && return green - sev == 2 && return yellow - return red - end - - has_history = - history !== nothing && length(history) == info.total && bar_width == info.total - if show_progress_bar && has_history - # Build colored bar per block using history; brackets colored by max severity seen - max_sev = maximum(history) - bracket_color = bracket_color_from(max_sev) - blocks = String[] - for i in 1:info.total - sev = history[i] - if sev <= 0 - push!(blocks, "$(dim)โ–‘$(reset)") - elseif sev >= 2 - # Failures and skips persist at their positions - glyph = _block_char_for_severity(sev) - push!(blocks, "$( _color_for_severity(sev) )$(glyph)$(reset)") - elseif i == info.index - # Current test (success) is shown - glyph = _block_char_for_severity(sev) - push!(blocks, "$( _color_for_severity(sev) )$(glyph)$(reset)") - else - # Past successes are cleared (ephemeral cursor style) - push!(blocks, "$(dim)โ–‘$(reset)") - end - end - # Reapply bracket_color for closing bracket so block-local resets do not strip it - bar = "$(bracket_color)[" * join(blocks) * "$(bracket_color)]$(reset)" - print(io, "$(bar) ") - elseif show_progress_bar && !isempty(bar) - bracket_sev = cumulative_severity === nothing ? severity : cumulative_severity - bracket_color = bracket_color_from(bracket_sev) - # Use single cursor style instead of repeating filled blocks - filled_char = _block_char_for_severity(severity) - w = bar_width - cursor_pos = clamp(round(Int, info.index / info.total * w), 1, w) - # Build bar with โ–‘ everywhere except at cursor position - inner_chars = fill("โ–‘", w) - inner_chars[cursor_pos] = filled_char - inner = join(inner_chars) - print( - io, - "$(bracket_color)[$(reset)$(color)$(inner)$(reset)$(bracket_color)]$(reset) ", - ) - end - print(io, "$(bold)$(color)$(symbol)$(reset) ") - print(io, "$(cyan)$(idx_str)$(reset) ") - print(io, "$(bold)$(info.spec)$(reset)") - println(io, "$(status_str)$(time_str)") - return nothing -end - -""" -$(TYPEDSIGNATURES) - -Create a stateful progress callback for `on_test_done`. Prints to `io`. - -# Arguments -- `io::IO`: Output stream for progress display -- `total::Int`: Total number of tests -- `progress_bar_threshold::Int`: Maximum tests for full-resolution progress bar (default: `100`) -- `show_progress_bar::Bool`: Show graphical progress bar `[โ–ˆโ–‘โ–‘โ–‘...]` (default: `true`) - -# Returns -- `Function`: Callback function that accepts `TestRunInfo` and updates progress display - -# Notes -- This is the default callback used when `show_progress_line=true` and no custom `on_test_done` is provided -- The returned callback maintains state (history, max_severity) across invocations -- Outputs a formatted progress line to `io` with colors and timing information -- When `show_progress_bar=false`, displays minimal output without the graphical bar - -# Example -```julia-repl -julia> using CTBase.TestRunner - -julia> cb = TestRunner._make_default_on_test_done(stdout, 10) - -julia> info = TestRunner.TestRunInfo( - :test_example, - "/path/to/test.jl", - :test_example, - 5, 10, - :post_eval, - nothing, - 1.23 - ); - -julia> cb(info) -[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘] โœ“ [05/10] test_example (1.2s) -``` -""" -function _make_default_on_test_done( - io::IO, - total::Int, - progress_bar_threshold::Int=_PROGRESS_BAR_THRESHOLD, - show_progress_bar::Bool=true, -) - history = total <= progress_bar_threshold ? fill(0, total) : Int[] - max_severity = Ref{Int}(0) - - function update(info::TestRunInfo) - sev = _severity(info.status) - max_severity[] = max(max_severity[], sev) - if !isempty(history) && info.index <= length(history) - history[info.index] = sev - end - _format_progress_line( - io, - info; - history=(!isempty(history) ? history : nothing), - cumulative_severity=max_severity[], - progress_bar_threshold=progress_bar_threshold, - show_progress_bar=show_progress_bar, - ) - return nothing - end - - return update -end - -""" - _default_on_test_done(info::TestRunInfo) - -Backward compatibility shim for the default test completion callback. - -Creates a fresh stateful callback via `_make_default_on_test_done` and invokes it -with the given `info`. This function exists for compatibility with existing code/tests that -expect a stateless callback signature. - -For new code, prefer using `_make_default_on_test_done` directly to create a -persistent callback that maintains test history across multiple invocations. - -# Arguments -- `info::TestRunInfo`: Test execution information - -See also: `_make_default_on_test_done` -""" -function _default_on_test_done(info::TestRunInfo) - cb = _make_default_on_test_done(stdout, info.total) - return cb(info) -end - -end diff --git a/ext/TestRunner/TestRunner.jl b/ext/TestRunner/TestRunner.jl new file mode 100644 index 00000000..d2d86704 --- /dev/null +++ b/ext/TestRunner/TestRunner.jl @@ -0,0 +1,24 @@ +""" +Test runner backend for CTBase. + +This extension implements `CTBase.Extensions.run_tests`, allowing test selection +via command-line arguments (globs) and configurable filename/function-name builders. + +Most functions in this module have side effects (including file inclusion and +running testsets). +""" +module TestRunner + +using CTBase: CTBase +import CTBase.Extensions +import DocStringExtensions: TYPEDEF, TYPEDSIGNATURES +using Test: Test, @testset + +include("types.jl") +include("arg_parsing.jl") +include("test_selection.jl") +include("test_execution.jl") +include("progress.jl") +include("entry_point.jl") + +end diff --git a/ext/TestRunner/arg_parsing.jl b/ext/TestRunner/arg_parsing.jl new file mode 100644 index 00000000..966fb784 --- /dev/null +++ b/ext/TestRunner/arg_parsing.jl @@ -0,0 +1,294 @@ +""" +$(TYPEDSIGNATURES) + +Parse command-line test arguments, filtering out coverage-related flags. + +# Arguments +- `args::Vector{String}`: Raw command-line arguments + +# Returns +- `Tuple{Vector{String}, Bool, Bool}`: `(selections, run_all, dry_run)` where: + - `selections`: selection patterns provided by the user (as strings) + - `run_all`: whether `-a` / `--all` was present + - `dry_run`: whether `-n` / `--dryrun` was present + +# Notes +- Coverage flags (`coverage=true`, `--coverage`, etc.) are automatically filtered out +- Selection patterns starting with `test/` or `test\\` are automatically stripped + so that users can write `test/suite/foo` or `suite/foo` interchangeably + +# Example +```julia-repl +julia> using CTBase.TestRunner + +julia> TestRunner._parse_test_args(["utils", "-a", "--dryrun"]) +(["utils"], true, true) + +julia> TestRunner._parse_test_args(["test/suite", "coverage=true"]) +(["suite"], false, false) +``` +""" +function _parse_test_args(args::Vector{String}) + selections = String[] + run_all = false + dry_run = false + + for arg in args + if arg in ("coverage=true", "coverage", "--coverage", "coverage=false") + continue + elseif arg == "-a" || arg == "--all" + run_all = true + elseif arg == "-n" || arg == "--dryrun" + dry_run = true + else + push!(selections, _strip_test_prefix(arg)) + end + end + return (selections, run_all, dry_run) +end + +""" +$(TYPEDSIGNATURES) + +Strip a leading `test/` or `test\\` prefix from a selection pattern. + +This allows users to type `test/suite/foo` instead of `suite/foo` since +the test directory is already the root for pattern matching. + +# Arguments +- `s::AbstractString`: Selection pattern to process + +# Returns +- `String`: Pattern with `test/` or `test\\` prefix stripped (if present) + +# Example +```julia-repl +julia> using CTBase.TestRunner + +julia> TestRunner._strip_test_prefix("test/suite/foo") +"suite/foo" + +julia> TestRunner._strip_test_prefix("suite/foo") +"suite/foo" + +julia> TestRunner._strip_test_prefix("test\\windows\\path") +"windows\\path" +``` +""" +function _strip_test_prefix(s::AbstractString) + for prefix in ("test/", "test\\") + if startswith(s, prefix) + return String(s[(length(prefix) + 1):end]) + end + end + return String(s) +end + +""" +$(TYPEDSIGNATURES) + +Normalize user-provided selection patterns before glob matching. + +Applied transformations: +- Strip trailing `/` (e.g. `"suite/exceptions/"` โ†’ `"suite/exceptions"`) +- If a selection contains no glob wildcard (`*` or `?`) and matches a directory + prefix of at least one candidate, expand it to `"selection/*"` so that all + files under that directory are selected. + +The original selection is always kept so that exact-name matches still work. + +# Arguments +- `selections::Vector{String}`: User-provided selection patterns +- `candidates::Vector{<:TestSpec}`: Available test candidates + +# Returns +- `Vector{String}`: Normalized selection patterns + +# Example +```julia-repl +julia> using CTBase.TestRunner + +julia> TestRunner._normalize_selections( + ["suite/"], + ["suite/test_a.jl", "suite/test_b.jl"] + ) +2-element Vector{String}: + "suite" + "suite/*" +``` +""" +function _normalize_selections(selections::Vector{String}, candidates::Vector{<:TestSpec}) + candidate_strs = [String(c) for c in candidates] + normalized = String[] + for sel in selections + # Strip trailing slash(es) + s = rstrip(sel, '/') + push!(normalized, s) + # If no glob wildcard, check if it looks like a directory prefix + if !occursin('*', s) && !occursin('?', s) + prefix = s * "/" + is_dir_prefix = any(c -> startswith(c, prefix), candidate_strs) + if is_dir_prefix + push!(normalized, s * "/*") + end + end + end + return unique(normalized) +end + +""" +$(TYPEDSIGNATURES) + +Convert a glob pattern (using `*` and `?`) into a regular expression. + +The returned regex is anchored (matches the full string). + +# Arguments +- `pattern::AbstractString`: Glob pattern to convert + +# Returns +- `Regex`: Anchored regular expression equivalent to the glob pattern + +# Example +```julia-repl +julia> using CTBase.TestRunner + +julia> TestRunner._glob_to_regex("test_*.jl") +r"^test_.*\\.jl\$" + +julia> TestRunner._glob_to_regex("suite/test_?.jl") +r"^suite/test.\\.jl\$" +``` +""" +function _glob_to_regex(pattern::AbstractString) + # Escape special regex characters except * and ? + regex_str = replace( + pattern, + "." => "\\.", + "+" => "\\+", + "(" => "\\(", + ")" => "\\)", + "[" => "\\[", + "]" => "\\]", + "{" => "\\{", + "}" => "\\}", + "^" => "\\^", + "\u0024" => "\\\u0024", + "\\" => "\\\\", + ) + # Convert glob wildcards to regex wildcards + regex_str = replace(regex_str, "*" => ".*", "?" => ".") + # Anchor to full string + return Regex("^" * regex_str * "\u0024") +end + +""" +$(TYPEDSIGNATURES) + +Ensure that a filename ends with `.jl` extension. + +If the filename already ends with `.jl`, returns it unchanged. +Otherwise, appends `.jl` to the filename. + +# Arguments +- `filename::AbstractString`: Base filename with or without `.jl` extension + +# Returns +- `String`: Filename guaranteed to end with `.jl` + +# Example +```julia +julia> TestRunner._ensure_jl("test_utils") +"test_utils.jl" + +julia> TestRunner._ensure_jl("test_utils.jl") +"test_utils.jl" +``` +""" +function _ensure_jl(filename::AbstractString) + return endswith(filename, ".jl") ? filename : filename * ".jl" +end + +""" +$(TYPEDSIGNATURES) + +Convert a Symbol or String to String. + +This helper function ensures that builder function outputs are always +converted to strings for consistent handling. + +# Arguments +- `x`: Symbol or String to convert + +# Returns +- `String`: The string representation of `x` + +# Example +```julia +julia> TestRunner._builder_to_string(:utils) +"utils" + +julia> TestRunner._builder_to_string("utils") +"utils" +``` +""" +function _builder_to_string(x) + return String(x) +end + +""" +$(TYPEDSIGNATURES) + +Normalize and validate the `available_tests` argument. + +Converts the input to a `Vector{TestSpec}` and validates that all entries +are either `Symbol` or `String`. Returns an empty vector if `available_tests` +is `nothing`. + +# Arguments +- `available_tests`: `nothing`, `Vector`, or `Tuple` containing `Symbol` or `String` entries + +# Returns +- `Vector{TestSpec}`: Normalized vector of test specifications + +# Throws +- `ArgumentError`: If `available_tests` is not a Vector/Tuple or contains invalid entries + +# Example +```julia +julia> TestRunner._normalize_available_tests([:utils, "suite/*"]) +2-element Vector{Union{Symbol, String}}: + :utils + "suite/*" + +julia> TestRunner._normalize_available_tests(nothing) +Union{Symbol, String}[] +``` +""" +function _normalize_available_tests(available_tests) + available_tests === nothing && return TestSpec[] + + if !(available_tests isa AbstractVector || available_tests isa Tuple) + throw( + CTBase.Exceptions.IncorrectArgument( + "available_tests must be a Vector or Tuple of Symbol/String"; + got=string(typeof(available_tests)), + ), + ) + end + + out = TestSpec[] + for entry in available_tests + if entry isa Symbol || entry isa String + push!(out, entry) + else + throw( + CTBase.Exceptions.IncorrectArgument( + "available_tests entries must be Symbol or String"; + got=string(typeof(entry)), + ), + ) + end + end + return out +end diff --git a/ext/TestRunner/entry_point.jl b/ext/TestRunner/entry_point.jl new file mode 100644 index 00000000..49c06303 --- /dev/null +++ b/ext/TestRunner/entry_point.jl @@ -0,0 +1,123 @@ +""" +$(TYPEDSIGNATURES) + +Run tests with configurable file/function name builders and optional available tests filter. + +# Arguments +- `::CTBase.Extensions.TestRunnerTag`: Dispatch tag for the TestRunner extension +- `args::AbstractVector{<:AbstractString}`: Command-line arguments (typically `String.(ARGS)`) +- `testset_name::String`: Name of the main testset (default: `"Tests"`) +- `available_tests`: Allowed tests (Symbols, Strings, or glob patterns). Empty = auto-discovery +- `filename_builder::Function`: `name โ†’ filename` mapping (default: `identity`) +- `funcname_builder::Function`: `name โ†’ function_name` mapping (default: `identity`) +- `eval_mode::Bool`: Whether to call the function after include (default: `true`) +- `verbose::Bool`: Verbose `@testset` output (default: `true`) +- `showtiming::Bool`: Show timing in `@testset` output (default: `true`) +- `test_dir::String`: Root directory for test files (default: `joinpath(pwd(), "test")`) +- `on_test_start::Union{Function,Nothing}`: Callback before eval (default: `nothing`) +- `on_test_done::Union{Function,Nothing}`: Callback after eval (default: `nothing`) +- `show_progress_line::Bool`: Show progress line with symbol, index, spec, and time (default: `true`) +- `show_progress_bar::Bool`: Show graphical progress bar `[โ–ˆโ–‘โ–‘โ–‘...]` within the line (default: `true`) +- `progress_bar_threshold::Int`: Maximum tests for full-resolution progress bar (default: `100`) + +# Returns +- `Nothing`: Tests are executed via side effects + +# Notes +- Test selection is driven by `args` (coverage flags are automatically filtered out) +- Selection arguments are interpreted as glob patterns and matched against both test names and filenames +- Arguments starting with `test/` are automatically stripped for convenience +- When `on_test_done` is provided, the built-in progress line is disabled unless `show_progress_line=true` +- When `show_progress_line=true` but `show_progress_bar=false`, displays minimal output: `โœ“ [01/76] suite/test.jl (0.2s)` without the graphical bar + +# Example +```julia-repl +julia> using CTBase.TestRunner + +julia> # Run all tests with default settings +julia> CTBase.Extensions.run_tests() + +julia> # Run specific tests with custom callbacks +julia> CTBase.Extensions.run_tests(; + args=["utils", "core"], + on_test_start = info -> (println("Running: ", info.spec); true), + on_test_done = info -> println("Done: ", info.status) + ) +``` + +See also: [`CTBase.Extensions.run_tests`](@ref), [`CTBase.TestRunner.TestRunInfo`](@ref) +""" +function Extensions.run_tests( + ::CTBase.Extensions.TestRunnerTag; + args::AbstractVector{<:AbstractString}=String[], + testset_name::String="Tests", + available_tests=Symbol[], + filename_builder::Function=identity, + funcname_builder::Function=identity, + eval_mode::Bool=true, + verbose::Bool=true, + showtiming::Bool=true, + test_dir::String=joinpath(pwd(), "test"), + on_test_start::Union{Function,Nothing}=nothing, + on_test_done::Union{Function,Nothing}=nothing, + show_progress_line::Bool=true, + show_progress_bar::Bool=true, + progress_bar_threshold::Int=_PROGRESS_BAR_THRESHOLD, +) + # Guard: a subdirectory named "test" inside test_dir would conflict with + # the automatic `test/` prefix stripping in _parse_test_args. + if isdir(joinpath(test_dir, "test")) + throw( + CTBase.Exceptions.PreconditionError( + "A subdirectory \"test\" exists inside the test directory \"$(test_dir)\""; + reason="selection arguments starting with \"test/\" are automatically stripped (e.g. \"test/suite\" โ†’ \"suite\")", + suggestion="rename the subdirectory to avoid the conflict", + ), + ) + end + + # Parse command-line arguments + (selections, run_all, dry_run) = _parse_test_args(String.(args)) + + available_tests_vec = _normalize_available_tests(available_tests) + + # Get selected tests + selected = _select_tests( + selections, available_tests_vec, run_all, filename_builder; test_dir=test_dir + ) + + if dry_run + println("Dry run: the following tests would be executed:") + println(join(selected, "\n")) + return nothing + end + + total = length(selected) + + # Wire up default progress callback when no custom on_test_done is provided + effective_on_test_done = if on_test_done !== nothing + on_test_done + elseif show_progress_line + _make_default_on_test_done(stdout, total, progress_bar_threshold, show_progress_bar) + else + nothing + end + + Test.@testset verbose = verbose showtiming = showtiming "$testset_name" begin + for (idx, spec) in enumerate(selected) + Test.@testset "$(spec)" begin + _run_single_test( + spec; + filename_builder, + funcname_builder, + eval_mode, + test_dir, + index=idx, + total, + on_test_start, + on_test_done=effective_on_test_done, + ) + end + end + end +end diff --git a/ext/TestRunner/progress.jl b/ext/TestRunner/progress.jl new file mode 100644 index 00000000..b17a5df3 --- /dev/null +++ b/ext/TestRunner/progress.jl @@ -0,0 +1,375 @@ +""" + _PROGRESS_BAR_THRESHOLD + +Internal constant defining the maximum number of tests for full-resolution progress bars. + +When the total number of tests is โ‰ค `_PROGRESS_BAR_THRESHOLD` (100), the progress bar displays +one character per test with cumulative coloring (each test gets its own colored block). +Beyond this threshold, the bar switches to compressed mode with uniform coloring. + +This threshold balances visual clarity with terminal width constraints. +""" +const _PROGRESS_BAR_THRESHOLD = 100 + +""" +$(TYPEDSIGNATURES) + +Compute the progress bar character width based on the number of tests. + +- `total โ‰ค progress_bar_threshold`: width equals `total` (one block per test). +- `total > progress_bar_threshold`: fixed width of `progress_bar_threshold` (some tests skip a block advance). + +# Arguments +- `total::Int`: Total number of tests +- `progress_bar_threshold::Int`: Maximum tests for full-resolution progress bar (default: `100`) + +# Returns +- `Int`: Character width for the progress bar (0 if `total โ‰ค 0`) + +# Example +```julia-repl +julia> using CTBase.TestRunner + +julia> TestRunner._bar_width(10) +10 + +julia> TestRunner._bar_width(25) +25 + +julia> TestRunner._bar_width(0) +0 + +julia> TestRunner._bar_width(100, progress_bar_threshold=30) +30 +``` +""" +function _bar_width(total::Int, progress_bar_threshold::Int=_PROGRESS_BAR_THRESHOLD) + total <= 0 && return 0 + return min(total, progress_bar_threshold) +end + +""" +$(TYPEDSIGNATURES) + +Render a progress bar string like `[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]`. + +When `width` is `nothing` (default), the width is computed automatically +via `_bar_width(total)`. Returns an empty string when the bar is hidden. + +# Arguments +- `index::Int`: current progress (1-based) +- `total::Int`: total number of items +- `width::Union{Int,Nothing}`: character width of the bar (default: auto) + +# Returns +- `String`: Progress bar string, or empty string if hidden + +# Example +```julia-repl +julia> using CTBase.TestRunner + +julia> TestRunner._progress_bar(5, 10) +"[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘]" + +julia> TestRunner._progress_bar(5, 10; width=20) +"[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]" + +julia> TestRunner._progress_bar(0, 10; width=5) +"[โ–‘โ–‘โ–‘โ–‘โ–‘]" +``` +""" +function _progress_bar(index::Int, total::Int; width::Union{Int,Nothing}=nothing) + w = width === nothing ? _bar_width(total) : width + w <= 0 && return "" + total <= 0 && return "[" * repeat("โ–‘", w) * "]" + filled = round(Int, index / total * w) + filled = clamp(filled, 0, w) + return "[" * repeat("โ–ˆ", filled) * repeat("โ–‘", w - filled) * "]" +end + +""" + _severity(status::Symbol) -> Int + +Internal helper to map test status to severity level for display formatting. + +# Arguments +- `status::Symbol`: Test status (`:error`, `:test_failed`, `:skipped`, or success) + +# Returns +- `Int`: Severity level (3=failure, 2=skipped, 1=success) +""" +@inline _severity(status::Symbol) = + (status == :error || status == :test_failed) ? 3 : (status == :skipped ? 2 : 1) + +""" + _color_for_severity(sev::Int) -> String + +Internal helper to map severity level to ANSI color code. + +# Arguments +- `sev::Int`: Severity level (3=failure, 2=skipped, 1=success) + +# Returns +- `String`: ANSI color escape code (red for failure, yellow for skipped, green for success) +""" +@inline function _color_for_severity(sev::Int) + sev >= 3 && return "\e[31m" # red + sev == 2 && return "\e[33m" # yellow + return "\e[32m" # green +end + +""" + _block_char_for_severity(sev::Int) -> String + +Internal helper to map severity level to block character for colorblind-friendly display. + +Uses distinct glyphs to ensure progress bars are readable without color: +- Success: โ–ˆ (solid block) +- Skipped: โ”† (thin vertical line) +- Failure: โ–š (diagonal pattern) + +# Arguments +- `sev::Int`: Severity level (3=failure, 2=skipped, 1=success) + +# Returns +- `String`: Unicode block character representing the severity +""" +@inline function _block_char_for_severity(sev::Int) + sev >= 3 && return "โ–š" # failure (diagonal) + sev == 2 && return "โ”†" # skipped (thin vertical) + return "โ–ˆ" # success +end + +""" +$(TYPEDSIGNATURES) + +Write a styled progress line for a completed test to `io`. + +Uses ANSI colors: green for success, red for errors, yellow for skipped. + +# Arguments +- `io::IO`: Output stream to write to +- `info::TestRunInfo`: Test execution information +- `history::Union{Vector{Int},Nothing}`: Optional history array for per-test coloring (default: `nothing`) +- `cumulative_severity::Union{Int,Nothing}`: Optional cumulative severity for coloring (default: `nothing`) +- `progress_bar_threshold::Int`: Maximum tests for full-resolution progress bar (default: `100`) +- `show_progress_bar::Bool`: Show graphical progress bar `[โ–ˆโ–‘โ–‘โ–‘...]` (default: `true`) + +# Notes +- Format: `[progress_bar] symbol [index/total] spec (time) status` +- Colors: green for success, red for errors, yellow for skipped +- Time is displayed with one decimal place when available +- **Cursor-style display**: In full-resolution mode (total โ‰ค threshold), only the current test position is filled for successes, while failures and skips persist at their positions. This creates a lighter, cursor-like visual where past successes are ephemeral. + +# Example +```julia-repl +julia> using CTBase.TestRunner, IOBuffer + +julia> info = TestRunner.TestRunInfo( + :test_example, + "/path/to/test.jl", + :test_example, + 5, 10, + :post_eval, + nothing, + 1.23 + ); + +julia> buf = IOBuffer(); +julia> TestRunner._format_progress_line(buf, info); +julia> String(take!(buf)) +"[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘] โœ“ [05/10] test_example (1.2s)" +``` +""" +function _format_progress_line( + io::IO, + info::TestRunInfo; + history::Union{Vector{Int},Nothing}=nothing, + cumulative_severity::Union{Int,Nothing}=nothing, + progress_bar_threshold::Int=_PROGRESS_BAR_THRESHOLD, + show_progress_bar::Bool=true, +) + # ANSI codes + reset = "\e[0m" + bold = "\e[1m" + dim = "\e[2m" + green = "\e[32m" + red = "\e[31m" + yellow = "\e[33m" + cyan = "\e[36m" + + bar_width = _bar_width(info.total, progress_bar_threshold) + bar = _progress_bar(info.index, info.total; width=bar_width) + + severity = _severity(info.status) + if severity == 3 + color = red + symbol = "โœ—" + elseif severity == 2 + color = yellow + symbol = "โ—‹" + else + color = green + symbol = "โœ“" + end + + w = ndigits(info.total) + idx_str = "[$(lpad(info.index, w, '0'))/$(info.total)]" + time_str = if info.elapsed !== nothing + " $(dim)($(round(info.elapsed; digits=1))s)$(reset)" + else + "" + end + status_str = if (info.status == :error || info.status == :test_failed) + " $(bold)$(red)FAILED$(reset)$(dim)," + else + "" + end + + function bracket_color_from(sev::Union{Int,Nothing}) + sev === nothing && return green + sev <= 1 && return green + sev == 2 && return yellow + return red + end + + has_history = + history !== nothing && length(history) == info.total && bar_width == info.total + if show_progress_bar && has_history + # Build colored bar per block using history; brackets colored by max severity seen + max_sev = maximum(history) + bracket_color = bracket_color_from(max_sev) + blocks = String[] + for i in 1:info.total + sev = history[i] + if sev <= 0 + push!(blocks, "$(dim)โ–‘$(reset)") + elseif sev >= 2 + # Failures and skips persist at their positions + glyph = _block_char_for_severity(sev) + push!(blocks, "$( _color_for_severity(sev) )$(glyph)$(reset)") + elseif i == info.index + # Current test (success) is shown + glyph = _block_char_for_severity(sev) + push!(blocks, "$( _color_for_severity(sev) )$(glyph)$(reset)") + else + # Past successes are cleared (ephemeral cursor style) + push!(blocks, "$(dim)โ–‘$(reset)") + end + end + # Reapply bracket_color for closing bracket so block-local resets do not strip it + bar = "$(bracket_color)[" * join(blocks) * "$(bracket_color)]$(reset)" + print(io, "$(bar) ") + elseif show_progress_bar && !isempty(bar) + bracket_sev = cumulative_severity === nothing ? severity : cumulative_severity + bracket_color = bracket_color_from(bracket_sev) + # Use single cursor style instead of repeating filled blocks + filled_char = _block_char_for_severity(severity) + w = bar_width + cursor_pos = clamp(round(Int, info.index / info.total * w), 1, w) + # Build bar with โ–‘ everywhere except at cursor position + inner_chars = fill("โ–‘", w) + inner_chars[cursor_pos] = filled_char + inner = join(inner_chars) + print( + io, + "$(bracket_color)[$(reset)$(color)$(inner)$(reset)$(bracket_color)]$(reset) ", + ) + end + print(io, "$(bold)$(color)$(symbol)$(reset) ") + print(io, "$(cyan)$(idx_str)$(reset) ") + print(io, "$(bold)$(info.spec)$(reset)") + println(io, "$(status_str)$(time_str)") + return nothing +end + +""" +$(TYPEDSIGNATURES) + +Create a stateful progress callback for `on_test_done`. Prints to `io`. + +# Arguments +- `io::IO`: Output stream for progress display +- `total::Int`: Total number of tests +- `progress_bar_threshold::Int`: Maximum tests for full-resolution progress bar (default: `100`) +- `show_progress_bar::Bool`: Show graphical progress bar `[โ–ˆโ–‘โ–‘โ–‘...]` (default: `true`) + +# Returns +- `Function`: Callback function that accepts `TestRunInfo` and updates progress display + +# Notes +- This is the default callback used when `show_progress_line=true` and no custom `on_test_done` is provided +- The returned callback maintains state (history, max_severity) across invocations +- Outputs a formatted progress line to `io` with colors and timing information +- When `show_progress_bar=false`, displays minimal output without the graphical bar + +# Example +```julia-repl +julia> using CTBase.TestRunner + +julia> cb = TestRunner._make_default_on_test_done(stdout, 10) + +julia> info = TestRunner.TestRunInfo( + :test_example, + "/path/to/test.jl", + :test_example, + 5, 10, + :post_eval, + nothing, + 1.23 + ); + +julia> cb(info) +[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘] โœ“ [05/10] test_example (1.2s) +``` +""" +function _make_default_on_test_done( + io::IO, + total::Int, + progress_bar_threshold::Int=_PROGRESS_BAR_THRESHOLD, + show_progress_bar::Bool=true, +) + history = total <= progress_bar_threshold ? fill(0, total) : Int[] + max_severity = Ref{Int}(0) + + function update(info::TestRunInfo) + sev = _severity(info.status) + max_severity[] = max(max_severity[], sev) + if !isempty(history) && info.index <= length(history) + history[info.index] = sev + end + _format_progress_line( + io, + info; + history=(!isempty(history) ? history : nothing), + cumulative_severity=max_severity[], + progress_bar_threshold=progress_bar_threshold, + show_progress_bar=show_progress_bar, + ) + return nothing + end + + return update +end + +""" + _default_on_test_done(info::TestRunInfo) + +Backward compatibility shim for the default test completion callback. + +Creates a fresh stateful callback via `_make_default_on_test_done` and invokes it +with the given `info`. This function exists for compatibility with existing code/tests that +expect a stateless callback signature. + +For new code, prefer using `_make_default_on_test_done` directly to create a +persistent callback that maintains test history across multiple invocations. + +# Arguments +- `info::TestRunInfo`: Test execution information + +See also: `_make_default_on_test_done` +""" +function _default_on_test_done(info::TestRunInfo) + cb = _make_default_on_test_done(stdout, info.total) + return cb(info) +end diff --git a/ext/TestRunner/test_execution.jl b/ext/TestRunner/test_execution.jl new file mode 100644 index 00000000..357c2ceb --- /dev/null +++ b/ext/TestRunner/test_execution.jl @@ -0,0 +1,263 @@ +""" +$(TYPEDSIGNATURES) + +Run a single selected test. + +This helper: +- Resolves a test filename via `filename_builder` +- Includes the file into `Main` +- Calls `on_test_start` (if provided) after include, before eval +- Optionally evaluates a function (via `funcname_builder`) when `eval_mode=true` +- Calls `on_test_done` (if provided) after eval, skip, or error + +# Arguments +- `spec::TestSpec`: Test specification to run +- `filename_builder::Function`: Function to map test names to filenames +- `funcname_builder::Function`: Function to map test names to function names +- `eval_mode::Bool`: Whether to evaluate the function after include +- `test_dir::String`: Root directory containing test files +- `index::Int`: 1-based index in the selected list (default: `1`) +- `total::Int`: Total number of selected tests (default: `1`) +- `on_test_start::Union{Function,Nothing}`: Callback before eval (default: `nothing`) +- `on_test_done::Union{Function,Nothing}`: Callback after eval (default: `nothing`) + +# Notes +- This function is not part of the public API +- Use `run_tests` for running multiple tests with proper orchestration +""" +function _run_single_test( + spec::TestSpec; + filename_builder::Function, + funcname_builder::Function, + eval_mode::Bool, + test_dir::String, + index::Int=1, + total::Int=1, + on_test_start::Union{Function,Nothing}=nothing, + on_test_done::Union{Function,Nothing}=nothing, +) + # --- Resolve filename and func_symbol --- + filename, func_symbol = _resolve_test( + spec; filename_builder, funcname_builder, eval_mode, test_dir + ) + + # --- Include the file --- + Base.include(Main, filename) + + # --- Check function exists after include --- + if eval_mode && func_symbol !== nothing && !isdefined(Main, func_symbol) + throw( + CTBase.Exceptions.PreconditionError( + "Function \"$(func_symbol)\" not found after including \"$(filename)\""; + reason="the file does not define a function with this name", + suggestion="make sure the file defines a top-level function named $(func_symbol)", + ), + ) + end + + # --- on_test_start callback --- + if on_test_start !== nothing + info = TestRunInfo( + spec, filename, func_symbol, index, total, :pre_eval, nothing, nothing + ) + should_continue = on_test_start(info) + if should_continue === false + if on_test_done !== nothing + done_info = TestRunInfo( + spec, filename, func_symbol, index, total, :skipped, nothing, nothing + ) + on_test_done(done_info) + end + return nothing + end + end + + # --- Skip eval if not in eval mode --- + if !eval_mode || func_symbol === nothing + if on_test_done !== nothing + done_info = TestRunInfo( + spec, filename, func_symbol, index, total, :skipped, nothing, nothing + ) + on_test_done(done_info) + end + return nothing + end + + # --- Snapshot testset results before eval --- + ts = Test.get_testset() + n_before = (ts isa Test.DefaultTestSet) ? length(ts.results) : nothing + + # --- Eval the function --- + t0 = time() + try + Main.eval(Expr(:call, func_symbol)) + elapsed = time() - t0 + if on_test_done !== nothing + # Detect @test failures by scanning only the new results added during eval + status = if n_before !== nothing + _has_failures_in_results(ts, n_before + 1) ? :test_failed : :post_eval + else + :post_eval + end + done_info = TestRunInfo( + spec, filename, func_symbol, index, total, status, nothing, elapsed + ) + on_test_done(done_info) + end + catch ex + elapsed = time() - t0 + if on_test_done !== nothing + done_info = TestRunInfo( + spec, filename, func_symbol, index, total, :error, ex, elapsed + ) + on_test_done(done_info) + end + rethrow() + end + + return nothing +end + +""" +$(TYPEDSIGNATURES) + +Resolve a test spec into an absolute filename and function symbol. + +Handles both `String` specs (relative paths) and `Symbol` specs (logical names). +Raises errors if the file is not found or if `eval_mode=true` but no function can be determined. + +# Arguments +- `spec::TestSpec`: Test specification to resolve +- `filename_builder::Function`: Function to map test names to filenames +- `funcname_builder::Function`: Function to map test names to function names +- `eval_mode::Bool`: Whether to resolve a function name +- `test_dir::String`: Root directory containing test files + +# Returns +- `Tuple{String, Union{Symbol,Nothing}}`: `(filename, func_symbol)` where: + - `filename`: Absolute path to the test file + - `func_symbol`: Function symbol to call (or `nothing` if `eval_mode=false`) + +# Throws +- `ErrorException`: If the test file is not found +- `ErrorException`: If `eval_mode=true` but no function can be determined + +# Notes +- This function is not part of the public API +- Use `run_tests` for running tests with proper error handling +""" +function _resolve_test( + spec::TestSpec; + filename_builder::Function, + funcname_builder::Function, + eval_mode::Bool, + test_dir::String, +) + if spec isa String + rel = _ensure_jl(spec) + filename = joinpath(test_dir, rel) + if !isfile(filename) + throw( + CTBase.Exceptions.IncorrectArgument( + "Test file \"$(filename)\" not found"; + context="current directory: $(pwd())", + ), + ) + end + + func_symbol = if eval_mode + Symbol(replace(basename(rel), ".jl" => "")) + else + nothing + end + + return (filename, func_symbol) + end + + name = spec + + # Build filename + rel = _find_symbol_test_file_rel(name, filename_builder; test_dir=test_dir) + rel === nothing && throw( + CTBase.Exceptions.IncorrectArgument( + "Test file not found for test \"$(name)\""; + context="current directory: $(pwd())", + ), + ) + + filename = joinpath(test_dir, rel) + + # Check file exists + !isfile(filename) && throw( + CTBase.Exceptions.IncorrectArgument( + "Test file \"$(filename)\" not found for test \"$(name)\""; + context="current directory: $(pwd())", + ), + ) + + # Determine function name + func_symbol = funcname_builder(name) + + # Check consistency: eval_mode=true but funcname_builder returns nothing + if eval_mode && func_symbol === nothing + throw( + CTBase.Exceptions.PreconditionError( + "eval_mode=true but funcname_builder returned nothing for test \"$(name)\""; + reason="funcname_builder must return a Symbol when eval_mode=true", + suggestion="set eval_mode=false, or make funcname_builder return a Symbol", + ), + ) + end + + if !eval_mode || func_symbol === nothing + return (filename, nothing) + end + + func_symbol = func_symbol isa Symbol ? func_symbol : Symbol(String(func_symbol)) + + return (filename, func_symbol) +end + +""" +$(TYPEDSIGNATURES) + +Recursively scan a `DefaultTestSet` results for `Test.Fail` or `Test.Error` entries, +starting at index `from`. + +This is used to detect `@test` failures that occurred during a specific eval by +comparing the results count before and after the eval. The `anynonpass` field is +unreliable because it is only updated when a testset *finishes* (in `Test.finish`). + +# Arguments +- `ts::Test.DefaultTestSet`: TestSet to scan +- `from::Int`: Starting index for scanning (default: `1`) + +# Returns +- `Bool`: `true` if any failures are found, `false` otherwise + +# Example +```julia-repl +julia> using CTBase.TestRunner, Test + +julia> ts = Test.DefaultTestSet("test", []) +julia> Test.@testset "example" begin + Test.@test 1 == 1 + Test.@test 2 == 0 # This will fail + end +Test.DefaultTestSet("example", Any[Test.Pass(1), Test.Fail("false")]) + +julia> TestRunner._has_failures_in_results(ts) +true +``` +""" +function _has_failures_in_results(ts::Test.DefaultTestSet, from::Int=1) + for i in from:length(ts.results) + r = ts.results[i] + if r isa Test.DefaultTestSet + _has_failures_in_results(r) && return true + elseif r isa Test.Fail || r isa Test.Error + return true + end + end + return false +end diff --git a/ext/TestRunner/test_selection.jl b/ext/TestRunner/test_selection.jl new file mode 100644 index 00000000..ec152b4e --- /dev/null +++ b/ext/TestRunner/test_selection.jl @@ -0,0 +1,219 @@ +""" +$(TYPEDSIGNATURES) + +Recursively collect all `.jl` files in `test_dir` (excluding `runtests.jl`). + +Returns relative paths from `test_dir`, sorted alphabetically. + +# Arguments +- `test_dir::AbstractString`: Root directory to search + +# Returns +- `Vector{String}`: Relative paths to all `.jl` files (excluding `runtests.jl`) + +# Example +```julia +# Assuming test_dir contains: +# - test/utils.jl +# - test/core/test_core.jl +# - test/runtests.jl + +julia> TestRunner._collect_test_files_recursive("test") +2-element Vector{String}: + "test/core/test_core.jl" + "test/utils.jl" +``` +""" +function _collect_test_files_recursive(test_dir::AbstractString) + files = String[] + for (root, _, fs) in walkdir(test_dir) + for f in fs + if endswith(f, ".jl") && f != "runtests.jl" + full = joinpath(root, f) + push!(files, relpath(full, test_dir)) + end + end + end + sort!(files) + return files +end + +""" +$(TYPEDSIGNATURES) + +Find the relative path to a test file for a given symbol name. + +Uses the `filename_builder` to construct the expected filename, then searches +for files matching that basename. If multiple matches exist (e.g., files in +different subdirectories), prefers the shallowest path. + +# Arguments +- `name::Symbol`: Test name to resolve +- `filename_builder::Function`: Function that maps test names to filenames +- `test_dir::AbstractString`: Root directory containing test files + +# Returns +- `String`: Relative path to the matching test file +- `nothing`: If no matching file is found + +# Notes +- Searches recursively in `test_dir` +- Excludes `runtests.jl` from consideration +- Prefers shallower paths when multiple matches exist +- Returns the exact relative path if found + +See also: [`TestRunner._collect_test_files_recursive`](@ref), [`TestRunner._ensure_jl`](@ref) +""" +function _find_symbol_test_file_rel( + name::Symbol, filename_builder::Function; test_dir::AbstractString +) + wanted = _ensure_jl(_builder_to_string(filename_builder(name))) + all = _collect_test_files_recursive(test_dir) + matches = filter(f -> basename(f) == wanted, all) + + if isempty(matches) + return nothing + end + if wanted in matches + return wanted + end + + sort!(matches; by=f -> (count(==('/'), f), ncodeunits(f), f)) + return first(matches) +end + +""" +$(TYPEDSIGNATURES) + +Determine which tests to run based on selections, available_tests filter, and file globbing. + +1. Identify potential test files in `test_dir` (default: `test/`). +2. Filter by `available_tests` if provided. +3. Filter by `selections` (interpreted as globs) if present. + +# Arguments +- `selections::Vector{String}`: User-provided selection patterns +- `available_tests::AbstractVector{<:TestSpec}`: Allowed tests (empty = auto-discovery) +- `run_all::Bool`: Whether to run all available tests +- `filename_builder::Function`: Function to map test names to filenames +- `test_dir::String`: Root directory containing test files + +# Returns +- `Vector{TestSpec}`: Selected test specifications + +# Notes +- If `available_tests` is empty, this function falls back to an auto-discovery + heuristic using the filename stem as the candidate test name +- Selection arguments are matched against multiple representations of each candidate +""" +function _select_tests( + selections::Vector{String}, + available_tests::AbstractVector{<:TestSpec}, + run_all::Bool, + filename_builder::Function; + test_dir::String=joinpath(pwd(), "test"), # Default assumption +) + candidates = TestSpec[] + + if isempty(available_tests) + for f in _collect_test_files_recursive(test_dir) + push!(candidates, f) + end + else + # If available_tests IS provided, we only consider these. + # We verify if their files exist. + recursive_files = _collect_test_files_recursive(test_dir) + for entry in available_tests + if entry isa Symbol + rel = _find_symbol_test_file_rel(entry, filename_builder; test_dir=test_dir) + if rel !== nothing + push!(candidates, entry) + end + else + full = joinpath(test_dir, entry) + if isdir(full) + prefix = entry * "/" + for f in recursive_files + if startswith(f, prefix) + push!(candidates, f) + end + end + else + regex = _glob_to_regex(entry) + for f in recursive_files + f_no_ext = replace(f, ".jl" => "") + if !isnothing(match(regex, f)) || !isnothing(match(regex, f_no_ext)) + push!(candidates, f) + end + end + end + end + end + end + + # If run_all is requested or no selections, return all candidates + if run_all || isempty(selections) + return candidates + end + + # 3. Normalize selections: expand bare directory paths to dir/* + selections = _normalize_selections(selections, candidates) + + # 4. Filter candidates by selections (Patterns) + filtered = TestSpec[] + + for candidate in candidates + candidate_str = candidate isa Symbol ? String(candidate) : String(candidate) + # Also check the associated filename? + # If I have candidate :utils -> test_utils.jl + # And user passes "test_u*", it should match "test_utils.jl" OR "utils"? + # User said "Scan test/ directory... ARGS are globs". + # So matching against the FILENAME seems primary. + + # Resolve filename for candidate + if candidate isa String + candidate_filename = _ensure_jl(candidate) + elseif isempty(available_tests) + candidate_filename = "$(candidate).jl" + else + candidate_filename = _ensure_jl(_builder_to_string(filename_builder(candidate))) + end + + # Also match strictly against filename without extension? + candidate_filename_no_ext = replace(candidate_filename, ".jl" => "") + + candidate_basename = basename(candidate_filename) + candidate_basename_no_ext = replace(candidate_basename, ".jl" => "") + candidate_basename_no_test_prefix = + if startswith(candidate_basename_no_ext, "test_") + candidate_basename_no_ext[6:end] + else + candidate_basename_no_ext + end + + matched = false + for sel in selections + regex = _glob_to_regex(sel) + + # Match against: + # 1. Candidate name (e.g. :utils) + # 2. Filename (e.g. test_utils.jl) + # 3. Filename without extension (e.g. test_utils) + if !isnothing(match(regex, candidate_str)) || + !isnothing(match(regex, candidate_filename)) || + !isnothing(match(regex, candidate_filename_no_ext)) || + !isnothing(match(regex, candidate_basename)) || + !isnothing(match(regex, candidate_basename_no_ext)) || + !isnothing(match(regex, candidate_basename_no_test_prefix)) + matched = true + break + end + end + + if matched + push!(filtered, candidate) + end + end + + return filtered +end diff --git a/ext/TestRunner/types.jl b/ext/TestRunner/types.jl new file mode 100644 index 00000000..db800347 --- /dev/null +++ b/ext/TestRunner/types.jl @@ -0,0 +1,71 @@ +""" +$(TYPEDEF) + +Union type representing a test specification. + +A test spec can be either: +- `Symbol`: A logical test name (e.g., `:utils`, `:core`) +- `String`: A relative file path or glob pattern (e.g., `"suite/test_utils.jl"`, `"suite/core/*"`) + +This type is used throughout TestRunner to represent both user-provided selections +and internal test identifiers. + +# Notes +- Symbol specs are resolved via `filename_builder` and `funcname_builder` +- String specs are treated as relative paths from `test_dir` +- Glob patterns are supported for String specs + +See also: [`CTBase.Extensions.run_tests`](@ref) +""" +const TestSpec = Union{Symbol,String} + +""" +$(TYPEDEF) + +Context information passed to test callbacks (`on_test_start`, `on_test_done`). + +Provides details about the current test being executed, including progress +information (`index`, `total`) and execution results (`status`, `error`, `elapsed`). + +# Fields +- `spec::TestSpec`: test identifier (Symbol or relative path String) +- `filename::String`: absolute path of the included test file +- `func_symbol::Union{Symbol,Nothing}`: function to call (`nothing` if `eval_mode=false`) +- `index::Int`: 1-based index of the current test in the selected list +- `total::Int`: total number of selected tests +- `status::Symbol`: one of `:pre_eval`, `:post_eval`, `:skipped`, `:error`, `:test_failed` +- `error::Union{Exception,Nothing}`: captured exception when `status == :error` +- `elapsed::Union{Float64,Nothing}`: wall-clock seconds for the eval phase (only in `on_test_done`) + +# Example +```julia-repl +julia> using CTBase.TestRunner + +julia> info = TestRunner.TestRunInfo( + :utils, + "/path/to/test_utils.jl", + :test_utils, + 3, 10, + :post_eval, + nothing, + 1.23 + ) +TestRunner.TestRunInfo(:utils, "/path/to/test_utils.jl", :test_utils, 3, 10, :post_eval, nothing, 1.23) + +julia> info.status +:post_eval + +julia> info.elapsed +1.23 +``` +""" +struct TestRunInfo + spec::TestSpec + filename::String + func_symbol::Union{Symbol,Nothing} + index::Int + total::Int + status::Symbol + error::Union{Exception,Nothing} + elapsed::Union{Float64,Nothing} +end diff --git a/src/Core/Core.jl b/src/Core/Core.jl index 827b782a..f5faefda 100644 --- a/src/Core/Core.jl +++ b/src/Core/Core.jl @@ -10,49 +10,8 @@ module Core import DocStringExtensions: TYPEDEF, TYPEDSIGNATURES -# -------------------------------------------------------------------------------------------------- -# Type aliases and constants -""" -Type alias for a real number. - -This constant is primarily meant as a short, semantic alias when writing APIs -that accept real-valued quantities. - -# Example - -```julia-repl -julia> using CTBase - -julia> CTBase.ctNumber === Real -true -``` -""" -const ctNumber = Real - -# -------------------------------------------------------------------------------------------------- -# Internal utilities -""" -$(TYPEDSIGNATURES) - -Return the default value of the display flag. - -This internal utility is used to decide whether output should be shown during -execution. - -# Returns - -- `Bool`: The default value `true`, indicating that output is displayed. - -# Example - -```julia-repl -julia> using CTBase - -julia> CTBase.__display() -true -``` -""" -__display()::Bool = true +include("types.jl") +include("utils.jl") # Export public API export ctNumber diff --git a/src/Core/types.jl b/src/Core/types.jl new file mode 100644 index 00000000..65a00d6e --- /dev/null +++ b/src/Core/types.jl @@ -0,0 +1,16 @@ +""" +Type alias for a real number. + +This constant is primarily meant as a short, semantic alias when writing APIs +that accept real-valued quantities. + +# Example + +```julia-repl +julia> using CTBase + +julia> CTBase.ctNumber === Real +true +``` +""" +const ctNumber = Real diff --git a/src/Core/utils.jl b/src/Core/utils.jl new file mode 100644 index 00000000..3e5682ce --- /dev/null +++ b/src/Core/utils.jl @@ -0,0 +1,22 @@ +""" +$(TYPEDSIGNATURES) + +Return the default value of the display flag. + +This internal utility is used to decide whether output should be shown during +execution. + +# Returns + +- `Bool`: The default value `true`, indicating that output is displayed. + +# Example + +```julia-repl +julia> using CTBase + +julia> CTBase.__display() +true +``` +""" +__display()::Bool = true diff --git a/src/Extensions/Extensions.jl b/src/Extensions/Extensions.jl index 78d9d86a..782fbf38 100644 --- a/src/Extensions/Extensions.jl +++ b/src/Extensions/Extensions.jl @@ -12,325 +12,9 @@ module Extensions import DocStringExtensions: TYPEDEF, TYPEDSIGNATURES using ..Exceptions -# -------------------------------------------------------------------------------------------------- -# Documentation extension system -""" -$(TYPEDEF) - -Abstract supertype for tags used to select a particular implementation of -`automatic_reference_documentation`. - -Concrete subtypes identify a specific backend that provides the actual -documentation generation logic. - -# Example - -```julia-repl -julia> using CTBase - -julia> CTBase.DocumenterReferenceTag() isa CTBase.AbstractDocumenterReferenceTag -true -``` -""" -abstract type AbstractDocumenterReferenceTag end - -""" -$(TYPEDEF) - -Concrete tag type used to dispatch to the `DocumenterReference` extension. - -Instances of this type are passed to `automatic_reference_documentation` to -enable the integration with Documenter.jl when the `DocumenterReference` -extension is available. - -# Example - -```julia-repl -julia> using CTBase - -julia> tag = CTBase.DocumenterReferenceTag() -CTBase.DocumenterReferenceTag() -``` -""" -struct DocumenterReferenceTag <: AbstractDocumenterReferenceTag end - -""" -$(TYPEDSIGNATURES) - -Generate API reference documentation pages for one or more modules. - -This method is an **extension point**: the default implementation throws an -`CTBase.Exceptions.ExtensionError` unless a backend extension providing the actual -implementation is loaded (e.g. the `DocumenterReference` extension). - -# Keyword Arguments - -Forwarded to the active backend implementation. - -# Throws - -- `CTBase.Exceptions.ExtensionError`: If no backend extension is loaded. - -# Example - -```julia -using CTBase -# Requires DocumenterReference extension to be active -automatic_reference_documentation( - subdirectory="api", - primary_modules=[MyModule], - title="My API" -) -``` -""" -function automatic_reference_documentation(::AbstractDocumenterReferenceTag; kwargs...) - throw( - Exceptions.ExtensionError( - :Documenter, - :Markdown, - :MarkdownAST; - feature="automatic documentation generation", - context="reference generation", - ), - ) -end - -""" -$(TYPEDSIGNATURES) - -Convenience wrapper for `automatic_reference_documentation` using the -default backend tag. - -# Keyword Arguments - -Forwarded to `automatic_reference_documentation(DocumenterReferenceTag(); kwargs...)`. - -# Throws - -- `CTBase.Exceptions.ExtensionError`: If the required backend extension is not loaded. - -# Example - -```julia -using CTBase -# automatic_reference_documentation(subdirectory="api") -``` -""" -function automatic_reference_documentation(; kwargs...) - return automatic_reference_documentation(DocumenterReferenceTag(); kwargs...) -end - -# -------------------------------------------------------------------------------------------------- -# Coverage extension system -""" -$(TYPEDEF) - -Abstract supertype for tags used to select a particular implementation of -`postprocess_coverage`. - -Concrete subtypes identify a specific backend that provides the actual coverage -post-processing logic. - -# Example - -```julia-repl -julia> using CTBase - -julia> CTBase.CoveragePostprocessingTag() isa CTBase.AbstractCoveragePostprocessingTag -true -``` -""" -abstract type AbstractCoveragePostprocessingTag end - -""" -$(TYPEDEF) - -Concrete tag type used to dispatch to the `CoveragePostprocessing` extension. - -Instances of this type are passed to `postprocess_coverage` to enable -coverage post-processing when the extension is available. - -# Example - -```julia-repl -julia> using CTBase - -julia> CTBase.CoveragePostprocessingTag() isa CTBase.AbstractCoveragePostprocessingTag -true -``` - -See also: [`CTBase.Extensions.AbstractCoveragePostprocessingTag`](@ref) -""" -struct CoveragePostprocessingTag <: AbstractCoveragePostprocessingTag end - -""" -$(TYPEDSIGNATURES) - -Post-process coverage artifacts produced by `Pkg.test(; coverage=true)`. - -This is an **extension point**: the default implementation throws an -`CTBase.Exceptions.ExtensionError` unless a backend extension (e.g. `CoveragePostprocessing`) -is loaded. - -# Keyword Arguments - -- `generate_report::Bool=true`: Whether to generate summary reports. -- `root_dir::String=pwd()`: Project root directory used to locate coverage artifacts. -- `dest_dir::String="coverage"`: Destination directory for coverage artifacts. -- `worst_n_files::Int=20`: Maximum number of lowest-covered files to list in the report. -- `max_uncovered_lines::Int=200`: Maximum number of uncovered lines to display in the report. - -# Throws - -- `CTBase.Exceptions.ExtensionError`: If the coverage post-processing extension is not loaded. - -# Example - -```julia -using CTBase -# postprocess_coverage(generate_report=true) -``` -""" -function postprocess_coverage( - ::AbstractCoveragePostprocessingTag; - generate_report::Bool=true, - root_dir::String=pwd(), - dest_dir::String="coverage", - worst_n_files::Int=20, - max_uncovered_lines::Int=200, -) - throw( - Exceptions.ExtensionError( - :Coverage; - feature="coverage analysis and reporting", - context="coverage postprocessing", - ), - ) -end - -""" -$(TYPEDSIGNATURES) - -Convenience wrapper for `postprocess_coverage` using the default backend tag. - -# Keyword Arguments - -Forwarded to `postprocess_coverage(CoveragePostprocessingTag(); kwargs...)`. - -# Throws - -- `CTBase.Exceptions.ExtensionError`: If the coverage post-processing extension is not loaded. - -# Example - -```julia -using CTBase -# postprocess_coverage() -``` -""" -function postprocess_coverage(; kwargs...) - return postprocess_coverage(CoveragePostprocessingTag(); kwargs...) -end - -# -------------------------------------------------------------------------------------------------- -# Test runner extension system -""" -$(TYPEDEF) - -Abstract supertype for tags used to select a particular implementation of -`run_tests`. - -Concrete subtypes identify a specific backend that provides the actual test -runner logic. - -# Example - -```julia-repl -julia> using CTBase - -julia> CTBase.TestRunnerTag() isa CTBase.AbstractTestRunnerTag -true -``` - -See also: [`CTBase.Extensions.TestRunnerTag`](@ref) -""" -abstract type AbstractTestRunnerTag end - -""" -$(TYPEDEF) - -Concrete tag type used to dispatch to the `TestRunner` extension. - -Instances of this type are passed to `run_tests` to enable the -extension-based test runner when the extension is available. - -# Example - -```julia-repl -julia> using CTBase - -julia> tag = CTBase.TestRunnerTag() -CTBase.TestRunnerTag() -``` - -See also: [`CTBase.Extensions.AbstractTestRunnerTag`](@ref) -""" -struct TestRunnerTag <: AbstractTestRunnerTag end - -""" -$(TYPEDSIGNATURES) - -Run the project test suite using an extension-provided test runner. - -This is an **extension point**: the default implementation throws an -`CTBase.Exceptions.ExtensionError` unless a backend extension is loaded. - -# Keyword Arguments - -Forwarded to the active backend implementation. - -# Throws - -- `CTBase.Exceptions.ExtensionError`: If the test runner extension is not loaded. - -# Example - -```julia -using CTBase -# run_tests() -``` -""" -function run_tests(::AbstractTestRunnerTag; kwargs...) - throw( - Exceptions.ExtensionError( - :Test; feature="test execution and reporting", context="test running" - ), - ) -end - -""" -$(TYPEDSIGNATURES) - -Convenience wrapper for `run_tests` using the default backend tag. - -# Keyword Arguments - -Forwarded to `run_tests(TestRunnerTag(); kwargs...)`. - -# Throws - -- `CTBase.Exceptions.ExtensionError`: If the test runner extension is not loaded. - -# Example - -```julia -using CTBase -# run_tests() -``` -""" -function run_tests(; kwargs...) - return run_tests(TestRunnerTag(); kwargs...) -end +include("documenter_reference.jl") +include("coverage_postprocessing.jl") +include("test_runner.jl") # Export public API export automatic_reference_documentation, postprocess_coverage, run_tests diff --git a/src/Extensions/coverage_postprocessing.jl b/src/Extensions/coverage_postprocessing.jl new file mode 100644 index 00000000..598ad636 --- /dev/null +++ b/src/Extensions/coverage_postprocessing.jl @@ -0,0 +1,109 @@ +""" +$(TYPEDEF) + +Abstract supertype for tags used to select a particular implementation of +`postprocess_coverage`. + +Concrete subtypes identify a specific backend that provides the actual coverage +post-processing logic. + +# Example + +```julia-repl +julia> using CTBase + +julia> CTBase.CoveragePostprocessingTag() isa CTBase.AbstractCoveragePostprocessingTag +true +``` +""" +abstract type AbstractCoveragePostprocessingTag end + +""" +$(TYPEDEF) + +Concrete tag type used to dispatch to the `CoveragePostprocessing` extension. + +Instances of this type are passed to `postprocess_coverage` to enable +coverage post-processing when the extension is available. + +# Example + +```julia-repl +julia> using CTBase + +julia> CTBase.CoveragePostprocessingTag() isa CTBase.AbstractCoveragePostprocessingTag +true +``` + +See also: [`CTBase.Extensions.AbstractCoveragePostprocessingTag`](@ref) +""" +struct CoveragePostprocessingTag <: AbstractCoveragePostprocessingTag end + +""" +$(TYPEDSIGNATURES) + +Post-process coverage artifacts produced by `Pkg.test(; coverage=true)`. + +This is an **extension point**: the default implementation throws an +`CTBase.Exceptions.ExtensionError` unless a backend extension (e.g. `CoveragePostprocessing`) +is loaded. + +# Keyword Arguments + +- `generate_report::Bool=true`: Whether to generate summary reports. +- `root_dir::String=pwd()`: Project root directory used to locate coverage artifacts. +- `dest_dir::String="coverage"`: Destination directory for coverage artifacts. +- `worst_n_files::Int=20`: Maximum number of lowest-covered files to list in the report. +- `max_uncovered_lines::Int=200`: Maximum number of uncovered lines to display in the report. + +# Throws + +- `CTBase.Exceptions.ExtensionError`: If the coverage post-processing extension is not loaded. + +# Example + +```julia +using CTBase +# postprocess_coverage(generate_report=true) +``` +""" +function postprocess_coverage( + ::AbstractCoveragePostprocessingTag; + generate_report::Bool=true, + root_dir::String=pwd(), + dest_dir::String="coverage", + worst_n_files::Int=20, + max_uncovered_lines::Int=200, +) + throw( + Exceptions.ExtensionError( + :Coverage; + feature="coverage analysis and reporting", + context="coverage postprocessing", + ), + ) +end + +""" +$(TYPEDSIGNATURES) + +Convenience wrapper for `postprocess_coverage` using the default backend tag. + +# Keyword Arguments + +Forwarded to `postprocess_coverage(CoveragePostprocessingTag(); kwargs...)`. + +# Throws + +- `CTBase.Exceptions.ExtensionError`: If the coverage post-processing extension is not loaded. + +# Example + +```julia +using CTBase +# postprocess_coverage() +``` +""" +function postprocess_coverage(; kwargs...) + return postprocess_coverage(CoveragePostprocessingTag(); kwargs...) +end diff --git a/src/Extensions/documenter_reference.jl b/src/Extensions/documenter_reference.jl new file mode 100644 index 00000000..620f8639 --- /dev/null +++ b/src/Extensions/documenter_reference.jl @@ -0,0 +1,105 @@ +""" +$(TYPEDEF) + +Abstract supertype for tags used to select a particular implementation of +`automatic_reference_documentation`. + +Concrete subtypes identify a specific backend that provides the actual +documentation generation logic. + +# Example + +```julia-repl +julia> using CTBase + +julia> CTBase.DocumenterReferenceTag() isa CTBase.AbstractDocumenterReferenceTag +true +``` +""" +abstract type AbstractDocumenterReferenceTag end + +""" +$(TYPEDEF) + +Concrete tag type used to dispatch to the `DocumenterReference` extension. + +Instances of this type are passed to `automatic_reference_documentation` to +enable the integration with Documenter.jl when the `DocumenterReference` +extension is available. + +# Example + +```julia-repl +julia> using CTBase + +julia> tag = CTBase.DocumenterReferenceTag() +CTBase.DocumenterReferenceTag() +``` +""" +struct DocumenterReferenceTag <: AbstractDocumenterReferenceTag end + +""" +$(TYPEDSIGNATURES) + +Generate API reference documentation pages for one or more modules. + +This method is an **extension point**: the default implementation throws an +`CTBase.Exceptions.ExtensionError` unless a backend extension providing the actual +implementation is loaded (e.g. the `DocumenterReference` extension). + +# Keyword Arguments + +Forwarded to the active backend implementation. + +# Throws + +- `CTBase.Exceptions.ExtensionError`: If no backend extension is loaded. + +# Example + +```julia +using CTBase +# Requires DocumenterReference extension to be active +automatic_reference_documentation( + subdirectory="api", + primary_modules=[MyModule], + title="My API" +) +``` +""" +function automatic_reference_documentation(::AbstractDocumenterReferenceTag; kwargs...) + throw( + Exceptions.ExtensionError( + :Documenter, + :Markdown, + :MarkdownAST; + feature="automatic documentation generation", + context="reference generation", + ), + ) +end + +""" +$(TYPEDSIGNATURES) + +Convenience wrapper for `automatic_reference_documentation` using the +default backend tag. + +# Keyword Arguments + +Forwarded to `automatic_reference_documentation(DocumenterReferenceTag(); kwargs...)`. + +# Throws + +- `CTBase.Exceptions.ExtensionError`: If the required backend extension is not loaded. + +# Example + +```julia +using CTBase +# automatic_reference_documentation(subdirectory="api") +``` +""" +function automatic_reference_documentation(; kwargs...) + return automatic_reference_documentation(DocumenterReferenceTag(); kwargs...) +end diff --git a/src/Extensions/test_runner.jl b/src/Extensions/test_runner.jl new file mode 100644 index 00000000..761556e4 --- /dev/null +++ b/src/Extensions/test_runner.jl @@ -0,0 +1,97 @@ +""" +$(TYPEDEF) + +Abstract supertype for tags used to select a particular implementation of +`run_tests`. + +Concrete subtypes identify a specific backend that provides the actual test +runner logic. + +# Example + +```julia-repl +julia> using CTBase + +julia> CTBase.TestRunnerTag() isa CTBase.AbstractTestRunnerTag +true +``` + +See also: [`CTBase.Extensions.TestRunnerTag`](@ref) +""" +abstract type AbstractTestRunnerTag end + +""" +$(TYPEDEF) + +Concrete tag type used to dispatch to the `TestRunner` extension. + +Instances of this type are passed to `run_tests` to enable the +extension-based test runner when the extension is available. + +# Example + +```julia-repl +julia> using CTBase + +julia> tag = CTBase.TestRunnerTag() +CTBase.TestRunnerTag() +``` + +See also: [`CTBase.Extensions.AbstractTestRunnerTag`](@ref) +""" +struct TestRunnerTag <: AbstractTestRunnerTag end + +""" +$(TYPEDSIGNATURES) + +Run the project test suite using an extension-provided test runner. + +This is an **extension point**: the default implementation throws an +`CTBase.Exceptions.ExtensionError` unless a backend extension is loaded. + +# Keyword Arguments + +Forwarded to the active backend implementation. + +# Throws + +- `CTBase.Exceptions.ExtensionError`: If the test runner extension is not loaded. + +# Example + +```julia +using CTBase +# run_tests() +``` +""" +function run_tests(::AbstractTestRunnerTag; kwargs...) + throw( + Exceptions.ExtensionError( + :Test; feature="test execution and reporting", context="test running" + ), + ) +end + +""" +$(TYPEDSIGNATURES) + +Convenience wrapper for `run_tests` using the default backend tag. + +# Keyword Arguments + +Forwarded to `run_tests(TestRunnerTag(); kwargs...)`. + +# Throws + +- `CTBase.Exceptions.ExtensionError`: If the test runner extension is not loaded. + +# Example + +```julia +using CTBase +# run_tests() +``` +""" +function run_tests(; kwargs...) + return run_tests(TestRunnerTag(); kwargs...) +end diff --git a/src/Unicode/Unicode.jl b/src/Unicode/Unicode.jl index 4ea50cad..32edc557 100644 --- a/src/Unicode/Unicode.jl +++ b/src/Unicode/Unicode.jl @@ -11,153 +11,8 @@ module Unicode import DocStringExtensions: TYPEDEF, TYPEDSIGNATURES using ..Exceptions -""" -$(TYPEDSIGNATURES) - -Return the integer `i` โˆˆ [0, 9] as a Unicode **subscript character**. - -Throws an `CTBase.Exceptions.IncorrectArgument` exception if `i` is outside this range. - -The Unicode subscript digits start at codepoint U+2080 for '0' and continue sequentially. - -# Example - -```julia-repl -julia> using CTBase - -julia> CTBase.ctindice(3) -'โ‚ƒ': Unicode U+2083 (category No: Number, other) -``` -""" -function ctindice(i::Int)::Char - if i < 0 || i > 9 - throw( - Exceptions.IncorrectArgument( - "the subscript must be between 0 and 9"; - got=string(i), - expected="0-9", - suggestion="Use ctindices() for numbers larger than 9, or check your input value", - context="Unicode subscript generation", - ), - ) - end - # Unicode subscript digits 0-9 are contiguous from U+2080 to U+2089 - return Char(Int('\u2080') + i) -end - -""" -$(TYPEDSIGNATURES) - -Return the integer `i` โ‰ฅ 0 as a string of Unicode **subscript characters**. - -Throws an `CTBase.Exceptions.IncorrectArgument` if `i` is negative. - -# Example - -```julia-repl -julia> using CTBase - -julia> CTBase.ctindices(123) -"โ‚โ‚‚โ‚ƒ" -``` -""" -function ctindices(i::Int)::String - if i < 0 - throw( - Exceptions.IncorrectArgument( - "the subscript must be positive"; - got=string(i), - expected="โ‰ฅ 0", - context="Unicode subscript string generation", - ), - ) - end - s = "" - # digits returns digits from least significant to most significant, - # so build string from right to left by prepending - for d in digits(i) - s = ctindice(d) * s - end - return s -end - -""" -$(TYPEDSIGNATURES) - -Return the integer `i` โˆˆ [0, 9] as a Unicode **superscript (upper) character**. - -Throws an `CTBase.Exceptions.IncorrectArgument` exception if `i` is outside this range. - -Note: Unicode superscripts ยน (U+00B9), ยฒ (U+00B2), and ยณ (U+00B3) are special cases. -The other digits โฐ (U+2070) and โด to โน (U+2074 to U+2079) are mostly contiguous. - -# Example - -```julia-repl -julia> using CTBase - -julia> CTBase.ctupperscript(2) -'ยฒ': Unicode U+00B2 (category No: Number, other) -``` -""" -function ctupperscript(i::Int)::Char - if i < 0 || i > 9 - throw( - Exceptions.IncorrectArgument( - "the superscript must be between 0 and 9"; - got=string(i), - expected="0-9", - suggestion="Use ctupperscripts() for numbers larger than 9, or check your input value", - context="Unicode superscript generation", - ), - ) - elseif i == 0 - return '\u2070' # superscript zero - elseif i == 1 - return '\u00B9' # superscript one - elseif i == 2 - return '\u00B2' # superscript two - elseif i == 3 - return '\u00B3' # superscript three - else #if 4 <= i <= 9 - # Unicode superscript digits 4-9 contiguous starting at U+2074 - return Char(Int('\u2074') + (i - 4)) - end -end - -""" -$(TYPEDSIGNATURES) - -Return the integer `i` โ‰ฅ 0 as a string of Unicode **superscript characters**. - -Throws an `CTBase.Exceptions.IncorrectArgument` if `i` is negative. - -# Example - -```julia-repl -julia> using CTBase - -julia> CTBase.ctupperscripts(123) -"ยนยฒยณ" -``` -""" -function ctupperscripts(i::Int)::String - if i < 0 - throw( - Exceptions.IncorrectArgument( - "the superscript must be positive"; - got=string(i), - expected="โ‰ฅ 0", - context="Unicode superscript string generation", - ), - ) - end - s = "" - for d in digits(i) - s = ctupperscript(d) * s - end - return s -end +include("subscripts.jl") +include("superscripts.jl") # Export public API export ctindice, ctindices, ctupperscript, ctupperscripts diff --git a/src/Unicode/subscripts.jl b/src/Unicode/subscripts.jl new file mode 100644 index 00000000..d08b7a71 --- /dev/null +++ b/src/Unicode/subscripts.jl @@ -0,0 +1,69 @@ +""" +$(TYPEDSIGNATURES) + +Return the integer `i` โˆˆ [0, 9] as a Unicode **subscript character**. + +Throws an `CTBase.Exceptions.IncorrectArgument` exception if `i` is outside this range. + +The Unicode subscript digits start at codepoint U+2080 for '0' and continue sequentially. + +# Example + +```julia-repl +julia> using CTBase + +julia> CTBase.ctindice(3) +'โ‚ƒ': Unicode U+2083 (category No: Number, other) +``` +""" +function ctindice(i::Int)::Char + if i < 0 || i > 9 + throw( + Exceptions.IncorrectArgument( + "the subscript must be between 0 and 9"; + got=string(i), + expected="0-9", + suggestion="Use ctindices() for numbers larger than 9, or check your input value", + context="Unicode subscript generation", + ), + ) + end + # Unicode subscript digits 0-9 are contiguous from U+2080 to U+2089 + return Char(Int('\u2080') + i) +end + +""" +$(TYPEDSIGNATURES) + +Return the integer `i` โ‰ฅ 0 as a string of Unicode **subscript characters**. + +Throws an `CTBase.Exceptions.IncorrectArgument` if `i` is negative. + +# Example + +```julia-repl +julia> using CTBase + +julia> CTBase.ctindices(123) +"โ‚โ‚‚โ‚ƒ" +``` +""" +function ctindices(i::Int)::String + if i < 0 + throw( + Exceptions.IncorrectArgument( + "the subscript must be positive"; + got=string(i), + expected="โ‰ฅ 0", + context="Unicode subscript string generation", + ), + ) + end + s = "" + # digits returns digits from least significant to most significant, + # so build string from right to left by prepending + for d in digits(i) + s = ctindice(d) * s + end + return s +end diff --git a/src/Unicode/superscripts.jl b/src/Unicode/superscripts.jl new file mode 100644 index 00000000..3ef40683 --- /dev/null +++ b/src/Unicode/superscripts.jl @@ -0,0 +1,77 @@ +""" +$(TYPEDSIGNATURES) + +Return the integer `i` โˆˆ [0, 9] as a Unicode **superscript (upper) character**. + +Throws an `CTBase.Exceptions.IncorrectArgument` exception if `i` is outside this range. + +Note: Unicode superscripts ยน (U+00B9), ยฒ (U+00B2), and ยณ (U+00B3) are special cases. +The other digits โฐ (U+2070) and โด to โน (U+2074 to U+2079) are mostly contiguous. + +# Example + +```julia-repl +julia> using CTBase + +julia> CTBase.ctupperscript(2) +'ยฒ': Unicode U+00B2 (category No: Number, other) +``` +""" +function ctupperscript(i::Int)::Char + if i < 0 || i > 9 + throw( + Exceptions.IncorrectArgument( + "the superscript must be between 0 and 9"; + got=string(i), + expected="0-9", + suggestion="Use ctupperscripts() for numbers larger than 9, or check your input value", + context="Unicode superscript generation", + ), + ) + elseif i == 0 + return '\u2070' # superscript zero + elseif i == 1 + return '\u00B9' # superscript one + elseif i == 2 + return '\u00B2' # superscript two + elseif i == 3 + return '\u00B3' # superscript three + else #if 4 <= i <= 9 + # Unicode superscript digits 4-9 contiguous starting at U+2074 + return Char(Int('\u2074') + (i - 4)) + end +end + +""" +$(TYPEDSIGNATURES) + +Return the integer `i` โ‰ฅ 0 as a string of Unicode **superscript characters**. + +Throws an `CTBase.Exceptions.IncorrectArgument` if `i` is negative. + +# Example + +```julia-repl +julia> using CTBase + +julia> CTBase.ctupperscripts(123) +"ยนยฒยณ" +``` +""" +function ctupperscripts(i::Int)::String + if i < 0 + throw( + Exceptions.IncorrectArgument( + "the superscript must be positive"; + got=string(i), + expected="โ‰ฅ 0", + context="Unicode superscript string generation", + ), + ) + end + s = "" + for d in digits(i) + s = ctupperscript(d) * s + end + return s +end diff --git a/test/runtests.jl b/test/runtests.jl index 597669df..0123538a 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -5,22 +5,8 @@ # See test/README.md for usage instructions (running specific tests, coverage, etc.) # # ============================================================================== - -# Test dependencies -using CTBase -using Aqua -using Documenter - -# Trigger loading of optional extensions -using Test -using Markdown -using MarkdownAST -using Coverage - -# Optional extension module access (loaded only when the package defines it). -const DocumenterReference = Base.get_extension(CTBase, :DocumenterReference) -const TestRunner = Base.get_extension(CTBase, :TestRunner) -const CoveragePostprocessing = Base.get_extension(CTBase, :CoveragePostprocessing) +import Test +import CTBase # Controls nested testset output formatting (used by individual test files) module TestOptions @@ -33,11 +19,11 @@ using .TestOptions: VERBOSE, SHOWTIMING macro test_inferred(expr) q = quote try - @inferred $expr - @test true + Test.@inferred $expr + Test.@test true catch e - @test false - println("Error in @inferred: ", e) + Test.@test false + println("Error in Test.@inferred: ", e) end end return esc(q) diff --git a/test/suite/core/test_core.jl b/test/suite/core/test_core.jl index 26a3b8f2..3cbb6b11 100644 --- a/test/suite/core/test_core.jl +++ b/test/suite/core/test_core.jl @@ -1,18 +1,20 @@ module TestCore -using Test -using CTBase -using Main.TestOptions: VERBOSE, SHOWTIMING +import Test +import CTBase.Core + +const VERBOSE = isdefined(Main, :TestOptions) ? Main.TestOptions.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestOptions) ? Main.TestOptions.SHOWTIMING : true function test_core() - @testset verbose = VERBOSE showtiming = SHOWTIMING "Core" begin - @testset "Default value of the display during resolution" begin - @test CTBase.Core.__display() + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "Core" begin + Test.@testset "Default value of the display during resolution" begin + Test.@test Core.__display() end - @testset "Type aliases" begin - @test CTBase.ctNumber === Real - @test CTBase.ctNumber === Real + Test.@testset "Type aliases" begin + Test.@test Core.ctNumber === Real + Test.@test Core.ctNumber === Real end end end diff --git a/test/suite/descriptions/test_catalog.jl b/test/suite/descriptions/test_catalog.jl index d06daa1a..760f9405 100644 --- a/test/suite/descriptions/test_catalog.jl +++ b/test/suite/descriptions/test_catalog.jl @@ -1,154 +1,157 @@ module TestCatalog -using Test -using CTBase -using Main.TestOptions: VERBOSE, SHOWTIMING +import Test +import CTBase.Descriptions +import CTBase.Exceptions + +const VERBOSE = isdefined(Main, :TestOptions) ? Main.TestOptions.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestOptions) ? Main.TestOptions.SHOWTIMING : true function test_catalog() - @testset verbose = VERBOSE showtiming = SHOWTIMING "Catalog Operations" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "Catalog Operations" begin # ==================================================================== # UNIT TESTS - Catalog Add Function # ==================================================================== - @testset "Add to empty catalog" begin + Test.@testset "Add to empty catalog" begin # Initialize empty catalog descriptions = () - @test isempty(descriptions) + Test.@test isempty(descriptions) # Add first description - descriptions = CTBase.add(descriptions, (:a,)) - @test length(descriptions) == 1 - @test descriptions[1] == (:a,) - @test typeof(descriptions) <: Tuple{Vararg{CTBase.Description}} + descriptions = Descriptions.add(descriptions, (:a,)) + Test.@test length(descriptions) == 1 + Test.@test descriptions[1] == (:a,) + Test.@test typeof(descriptions) <: Tuple{Vararg{Descriptions.Description}} # Add single-element description descriptions2 = () - descriptions2 = CTBase.add(descriptions2, (:x,)) - @test descriptions2[1] == (:x,) + descriptions2 = Descriptions.add(descriptions2, (:x,)) + Test.@test descriptions2[1] == (:x,) # Add multi-element description descriptions3 = () - descriptions3 = CTBase.add(descriptions3, (:a, :b, :c)) - @test descriptions3[1] == (:a, :b, :c) + descriptions3 = Descriptions.add(descriptions3, (:a, :b, :c)) + Test.@test descriptions3[1] == (:a, :b, :c) end - @testset "Add to non-empty catalog" begin + Test.@testset "Add to non-empty catalog" begin # Sequential additions descriptions = () - descriptions = CTBase.add(descriptions, (:a,)) - @test descriptions[1] == (:a,) + descriptions = Descriptions.add(descriptions, (:a,)) + Test.@test descriptions[1] == (:a,) - descriptions = CTBase.add(descriptions, (:b,)) - @test descriptions[1] == (:a,) - @test descriptions[2] == (:b,) - @test length(descriptions) == 2 + descriptions = Descriptions.add(descriptions, (:b,)) + Test.@test descriptions[1] == (:a,) + Test.@test descriptions[2] == (:b,) + Test.@test length(descriptions) == 2 # Add third description - descriptions = CTBase.add(descriptions, (:c,)) - @test length(descriptions) == 3 - @test descriptions[3] == (:c,) + descriptions = Descriptions.add(descriptions, (:c,)) + Test.@test length(descriptions) == 3 + Test.@test descriptions[3] == (:c,) # Verify order is preserved - @test descriptions == ((:a,), (:b,), (:c,)) + Test.@test descriptions == ((:a,), (:b,), (:c,)) end - @testset "Add multiple descriptions in sequence" begin + Test.@testset "Add multiple descriptions in sequence" begin descriptions = () - descriptions = CTBase.add(descriptions, (:a, :b)) - descriptions = CTBase.add(descriptions, (:c, :d)) - descriptions = CTBase.add(descriptions, (:e, :f)) - descriptions = CTBase.add(descriptions, (:g, :h)) - - @test length(descriptions) == 4 - @test descriptions[1] == (:a, :b) - @test descriptions[2] == (:c, :d) - @test descriptions[3] == (:e, :f) - @test descriptions[4] == (:g, :h) + descriptions = Descriptions.add(descriptions, (:a, :b)) + descriptions = Descriptions.add(descriptions, (:c, :d)) + descriptions = Descriptions.add(descriptions, (:e, :f)) + descriptions = Descriptions.add(descriptions, (:g, :h)) + + Test.@test length(descriptions) == 4 + Test.@test descriptions[1] == (:a, :b) + Test.@test descriptions[2] == (:c, :d) + Test.@test descriptions[3] == (:e, :f) + Test.@test descriptions[4] == (:g, :h) end - @testset "Add descriptions of varying sizes" begin + Test.@testset "Add descriptions of varying sizes" begin descriptions = () - descriptions = CTBase.add(descriptions, (:a,)) # Size 1 - descriptions = CTBase.add(descriptions, (:b, :c)) # Size 2 - descriptions = CTBase.add(descriptions, (:d, :e, :f)) # Size 3 - descriptions = CTBase.add(descriptions, (:g, :h, :i, :j)) # Size 4 - - @test length(descriptions) == 4 - @test length(descriptions[1]) == 1 - @test length(descriptions[2]) == 2 - @test length(descriptions[3]) == 3 - @test length(descriptions[4]) == 4 + descriptions = Descriptions.add(descriptions, (:a,)) # Size 1 + descriptions = Descriptions.add(descriptions, (:b, :c)) # Size 2 + descriptions = Descriptions.add(descriptions, (:d, :e, :f)) # Size 3 + descriptions = Descriptions.add(descriptions, (:g, :h, :i, :j)) # Size 4 + + Test.@test length(descriptions) == 4 + Test.@test length(descriptions[1]) == 1 + Test.@test length(descriptions[2]) == 2 + Test.@test length(descriptions[3]) == 3 + Test.@test length(descriptions[4]) == 4 end # ==================================================================== # TYPE STABILITY TESTS # ==================================================================== - @testset "Type stability - add function" begin + Test.@testset "Type stability - add function" begin # Add to empty catalog - @test (@inferred CTBase.add((), (:a,))) isa Tuple{Vararg{CTBase.Description}} - @test (@inferred CTBase.add((), (:a, :b))) isa Tuple{Vararg{CTBase.Description}} + Test.@test (Test.@inferred Descriptions.add((), (:a,))) isa Tuple{Vararg{Descriptions.Description}} + Test.@test (Test.@inferred Descriptions.add((), (:a, :b))) isa Tuple{Vararg{Descriptions.Description}} # Add to non-empty catalog descriptions = ((:a,),) - @test (@inferred CTBase.add(descriptions, (:b,))) isa - Tuple{Vararg{CTBase.Description}} + Test.@test (Test.@inferred Descriptions.add(descriptions, (:b,))) isa + Tuple{Vararg{Descriptions.Description}} # Verify return type consistency - result = CTBase.add((), (:x, :y)) - @test result isa Tuple{Vararg{Tuple{Vararg{Symbol}}}} + result = Descriptions.add((), (:x, :y)) + Test.@test result isa Tuple{Vararg{Tuple{Vararg{Symbol}}}} end # ==================================================================== # ERROR TESTS - Exception Quality # ==================================================================== - @testset "Duplicate description error" begin + Test.@testset "Duplicate description error" begin algorithms = () - algorithms = CTBase.add(algorithms, (:a, :b, :c)) + algorithms = Descriptions.add(algorithms, (:a, :b, :c)) # Basic error check - @test_throws CTBase.IncorrectArgument CTBase.add(algorithms, (:a, :b, :c)) + Test.@test_throws Exceptions.IncorrectArgument Descriptions.add(algorithms, (:a, :b, :c)) # Enriched error check - verify all exception fields try - CTBase.add(algorithms, (:a, :b, :c)) - @test false # Should not reach here + Descriptions.add(algorithms, (:a, :b, :c)) + Test.@test false # Should not reach here catch e - @test e isa CTBase.IncorrectArgument - @test occursin("already in", e.msg) - @test e.got == "(:a, :b, :c)" - @test occursin("unique description", e.expected) - @test occursin("Check existing descriptions", e.suggestion) - @test e.context == "description catalog management" + Test.@test e isa Exceptions.IncorrectArgument + Test.@test occursin("already in", e.msg) + Test.@test e.got == "(:a, :b, :c)" + Test.@test occursin("unique description", e.expected) + Test.@test occursin("Check existing descriptions", e.suggestion) + Test.@test e.context == "description catalog management" end end - @testset "Duplicate detection at different positions" begin + Test.@testset "Duplicate detection at different positions" begin descriptions = ((:a,), (:b,), (:c,)) # Try to add duplicate of first - @test_throws CTBase.IncorrectArgument CTBase.add(descriptions, (:a,)) + Test.@test_throws Exceptions.IncorrectArgument Descriptions.add(descriptions, (:a,)) # Try to add duplicate of middle - @test_throws CTBase.IncorrectArgument CTBase.add(descriptions, (:b,)) + Test.@test_throws Exceptions.IncorrectArgument Descriptions.add(descriptions, (:b,)) # Try to add duplicate of last - @test_throws CTBase.IncorrectArgument CTBase.add(descriptions, (:c,)) + Test.@test_throws Exceptions.IncorrectArgument Descriptions.add(descriptions, (:c,)) end - @testset "Return type consistency" begin + Test.@testset "Return type consistency" begin # Verify add always returns correct type descriptions = () - result1 = CTBase.add(descriptions, (:a,)) - @test typeof(result1) <: Tuple{Vararg{CTBase.Description}} + result1 = Descriptions.add(descriptions, (:a,)) + Test.@test typeof(result1) <: Tuple{Vararg{Descriptions.Description}} - result2 = CTBase.add(result1, (:b,)) - @test typeof(result2) <: Tuple{Vararg{CTBase.Description}} + result2 = Descriptions.add(result1, (:b,)) + Test.@test typeof(result2) <: Tuple{Vararg{Descriptions.Description}} # Both are tuples of descriptions (same supertype) - @test typeof(result1) <: Tuple{Vararg{CTBase.Description}} - @test typeof(result2) <: Tuple{Vararg{CTBase.Description}} + Test.@test typeof(result1) <: Tuple{Vararg{Descriptions.Description}} + Test.@test typeof(result2) <: Tuple{Vararg{Descriptions.Description}} end end end diff --git a/test/suite/descriptions/test_complete.jl b/test/suite/descriptions/test_complete.jl index ebfe5fdd..cbb7056a 100644 --- a/test/suite/descriptions/test_complete.jl +++ b/test/suite/descriptions/test_complete.jl @@ -1,188 +1,191 @@ module TestComplete -using Test -using CTBase -using Main.TestOptions: VERBOSE, SHOWTIMING +import Test +import CTBase.Descriptions +import CTBase.Exceptions + +const VERBOSE = isdefined(Main, :TestOptions) ? Main.TestOptions.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestOptions) ? Main.TestOptions.SHOWTIMING : true function test_complete() - @testset verbose = VERBOSE showtiming = SHOWTIMING "Complete Descriptions" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "Complete Descriptions" begin # ==================================================================== # UNIT TESTS - Complete Function Core Logic # ==================================================================== algorithms = () - algorithms = CTBase.add(algorithms, (:descent, :bfgs, :bisection)) - algorithms = CTBase.add(algorithms, (:descent, :bfgs, :backtracking)) - algorithms = CTBase.add(algorithms, (:descent, :bfgs, :fixedstep)) - algorithms = CTBase.add(algorithms, (:descent, :gradient, :bisection)) - algorithms = CTBase.add(algorithms, (:descent, :gradient, :backtracking)) - algorithms = CTBase.add(algorithms, (:descent, :gradient, :fixedstep)) - - @testset "Successful completions" begin - @test CTBase.complete((:descent,); descriptions=algorithms) == + algorithms = Descriptions.add(algorithms, (:descent, :bfgs, :bisection)) + algorithms = Descriptions.add(algorithms, (:descent, :bfgs, :backtracking)) + algorithms = Descriptions.add(algorithms, (:descent, :bfgs, :fixedstep)) + algorithms = Descriptions.add(algorithms, (:descent, :gradient, :bisection)) + algorithms = Descriptions.add(algorithms, (:descent, :gradient, :backtracking)) + algorithms = Descriptions.add(algorithms, (:descent, :gradient, :fixedstep)) + + Test.@testset "Successful completions" begin + Test.@test Descriptions.complete((:descent,); descriptions=algorithms) == (:descent, :bfgs, :bisection) - @test CTBase.complete((:bfgs,); descriptions=algorithms) == + Test.@test Descriptions.complete((:bfgs,); descriptions=algorithms) == (:descent, :bfgs, :bisection) # Tuple overload check - @test CTBase.complete(:descent; descriptions=algorithms) == + Test.@test Descriptions.complete(:descent; descriptions=algorithms) == (:descent, :bfgs, :bisection) end - @testset "Completion with Variable Sized Descriptions" begin + Test.@testset "Completion with Variable Sized Descriptions" begin algorithms = () - algorithms = CTBase.add(algorithms, (:a, :b, :c)) - algorithms = CTBase.add(algorithms, (:a, :b, :c, :d)) - @test CTBase.complete((:a, :b); descriptions=algorithms) == (:a, :b, :c) - @test CTBase.complete((:a, :b, :c, :d); descriptions=algorithms) == + algorithms = Descriptions.add(algorithms, (:a, :b, :c)) + algorithms = Descriptions.add(algorithms, (:a, :b, :c, :d)) + Test.@test Descriptions.complete((:a, :b); descriptions=algorithms) == (:a, :b, :c) + Test.@test Descriptions.complete((:a, :b, :c, :d); descriptions=algorithms) == (:a, :b, :c, :d) end - @testset "Priority handling" begin + Test.@testset "Priority handling" begin # Test priority when ordering of descriptions switched algos_swapped = () - algos_swapped = CTBase.add(algos_swapped, (:a, :b, :c, :d)) - algos_swapped = CTBase.add(algos_swapped, (:a, :b, :c)) - @test CTBase.complete((:a, :b); descriptions=algos_swapped) == (:a, :b, :c, :d) + algos_swapped = Descriptions.add(algos_swapped, (:a, :b, :c, :d)) + algos_swapped = Descriptions.add(algos_swapped, (:a, :b, :c)) + Test.@test Descriptions.complete((:a, :b); descriptions=algos_swapped) == (:a, :b, :c, :d) algos_ordered = () - algos_ordered = CTBase.add(algos_ordered, (:a, :b, :c)) - algos_ordered = CTBase.add(algos_ordered, (:a, :b, :c, :d)) - @test CTBase.complete((:a, :b); descriptions=algos_ordered) == (:a, :b, :c) + algos_ordered = Descriptions.add(algos_ordered, (:a, :b, :c)) + algos_ordered = Descriptions.add(algos_ordered, (:a, :b, :c, :d)) + Test.@test Descriptions.complete((:a, :b); descriptions=algos_ordered) == (:a, :b, :c) end - @testset "Successful completion with exact and partial matches" begin + Test.@testset "Successful completion with exact and partial matches" begin descriptions = ((:a, :b), (:a, :b, :c), (:b, :c)) # Test exact match - result = CTBase.complete(:a, :b; descriptions=descriptions) - @test result == (:a, :b) + result = Descriptions.complete(:a, :b; descriptions=descriptions) + Test.@test result == (:a, :b) # Test partial match - result2 = CTBase.complete(:a; descriptions=descriptions) - @test result2 in [(:a, :b), (:a, :b, :c)] + result2 = Descriptions.complete(:a; descriptions=descriptions) + Test.@test result2 in [(:a, :b), (:a, :b, :c)] end - @testset "Tie-breaking behavior" begin + Test.@testset "Tie-breaking behavior" begin # When multiple descriptions have same intersection size, first wins descriptions = ((:a, :b, :c), (:a, :b, :d), (:a, :b, :e)) - result = CTBase.complete(:a, :b; descriptions=descriptions) - @test result == (:a, :b, :c) # First one wins + result = Descriptions.complete(:a, :b; descriptions=descriptions) + Test.@test result == (:a, :b, :c) # First one wins # Different order descriptions2 = ((:x, :y, :z), (:x, :y, :w), (:x, :y, :v)) - result2 = CTBase.complete(:x, :y; descriptions=descriptions2) - @test result2 == (:x, :y, :z) # First one wins + result2 = Descriptions.complete(:x, :y; descriptions=descriptions2) + Test.@test result2 == (:x, :y, :z) # First one wins # Single symbol query with equal matches descriptions3 = ((:a, :b), (:a, :c), (:a, :d)) - result3 = CTBase.complete(:a; descriptions=descriptions3) - @test result3 == (:a, :b) # First one wins + result3 = Descriptions.complete(:a; descriptions=descriptions3) + Test.@test result3 == (:a, :b) # First one wins end - @testset "Exact match with multiple candidates" begin + Test.@testset "Exact match with multiple candidates" begin # Exact match exists among multiple partial matches descriptions = ((:a, :b, :c), (:a, :b), (:a, :c)) - result = CTBase.complete(:a, :b; descriptions=descriptions) + result = Descriptions.complete(:a, :b; descriptions=descriptions) # Should prefer exact match or first with max intersection - @test result in [(:a, :b, :c), (:a, :b)] + Test.@test result in [(:a, :b, :c), (:a, :b)] # Multiple exact matches - first wins descriptions2 = ((:x, :y), (:x, :y), (:x, :y, :z)) - result2 = CTBase.complete(:x, :y; descriptions=descriptions2) - @test result2 == (:x, :y) # First exact match + result2 = Descriptions.complete(:x, :y; descriptions=descriptions2) + Test.@test result2 == (:x, :y) # First exact match end - @testset "Single vs multi-symbol input" begin + Test.@testset "Single vs multi-symbol input" begin descriptions = ((:a, :b, :c), (:a, :d), (:b, :c)) # Single symbol - result1 = CTBase.complete(:a; descriptions=descriptions) - @test result1 in [(:a, :b, :c), (:a, :d)] + result1 = Descriptions.complete(:a; descriptions=descriptions) + Test.@test result1 in [(:a, :b, :c), (:a, :d)] # Two symbols - result2 = CTBase.complete(:a, :b; descriptions=descriptions) - @test result2 == (:a, :b, :c) + result2 = Descriptions.complete(:a, :b; descriptions=descriptions) + Test.@test result2 == (:a, :b, :c) # Three symbols - result3 = CTBase.complete(:a, :b, :c; descriptions=descriptions) - @test result3 == (:a, :b, :c) + result3 = Descriptions.complete(:a, :b, :c; descriptions=descriptions) + Test.@test result3 == (:a, :b, :c) end - @testset "Tuple overload delegation" begin + Test.@testset "Tuple overload delegation" begin descriptions = ((:a, :b), (:c, :d)) # Test that tuple overload works correctly - result1 = CTBase.complete((:a,); descriptions=descriptions) - result2 = CTBase.complete(:a; descriptions=descriptions) - @test result1 == result2 + result1 = Descriptions.complete((:a,); descriptions=descriptions) + result2 = Descriptions.complete(:a; descriptions=descriptions) + Test.@test result1 == result2 # Multi-element tuple - result3 = CTBase.complete((:a, :b); descriptions=descriptions) - result4 = CTBase.complete(:a, :b; descriptions=descriptions) - @test result3 == result4 + result3 = Descriptions.complete((:a, :b); descriptions=descriptions) + result4 = Descriptions.complete(:a, :b; descriptions=descriptions) + Test.@test result3 == result4 end # ==================================================================== # TYPE STABILITY TESTS # ==================================================================== - @testset "Type stability - complete function" begin + Test.@testset "Type stability - complete function" begin descriptions = ((:a, :b), (:a, :c), (:b, :c)) # Varargs overload - @test (@inferred CTBase.complete(:a; descriptions=descriptions)) isa - CTBase.Description - @test (@inferred CTBase.complete(:a, :b; descriptions=descriptions)) isa - CTBase.Description + Test.@test (Test.@inferred Descriptions.complete(:a; descriptions=descriptions)) isa + Descriptions.Description + Test.@test (Test.@inferred Descriptions.complete(:a, :b; descriptions=descriptions)) isa + Descriptions.Description # Tuple overload - @test (@inferred CTBase.complete((:a,); descriptions=descriptions)) isa - CTBase.Description - @test (@inferred CTBase.complete((:a, :b); descriptions=descriptions)) isa - CTBase.Description + Test.@test (Test.@inferred Descriptions.complete((:a,); descriptions=descriptions)) isa + Descriptions.Description + Test.@test (Test.@inferred Descriptions.complete((:a, :b); descriptions=descriptions)) isa + Descriptions.Description # Verify return type consistency - result = CTBase.complete(:a; descriptions=descriptions) - @test result isa Tuple{Vararg{Symbol}} + result = Descriptions.complete(:a; descriptions=descriptions) + Test.@test result isa Tuple{Vararg{Symbol}} end # ==================================================================== # ERROR TESTS - AmbiguousDescription Quality # ==================================================================== - @testset "Ambiguous/Invalid completions" begin + Test.@testset "Ambiguous/Invalid completions" begin # Basic error check - @test_throws CTBase.AmbiguousDescription CTBase.complete( + Test.@test_throws Exceptions.AmbiguousDescription Descriptions.complete( (:ttt,); descriptions=algorithms ) # Empty catalog - @test_throws CTBase.AmbiguousDescription CTBase.complete(:a; descriptions=()) + Test.@test_throws Exceptions.AmbiguousDescription Descriptions.complete(:a; descriptions=()) # Enriched error checks - rigorous # 1. Empty descriptions check try - CTBase.complete(:a; descriptions=()) + Descriptions.complete(:a; descriptions=()) catch e - @test e isa CTBase.AmbiguousDescription - @test isempty(e.candidates) - @test occursin("No descriptions available", e.suggestion) - @test e.context == "description completion" + Test.@test e isa Exceptions.AmbiguousDescription + Test.@test isempty(e.candidates) + Test.@test occursin("No descriptions available", e.suggestion) + Test.@test e.context == "description completion" end # 2. Description not found with suggestions (subset of existing) descriptions = ((:a, :b), (:c, :d), (:e, :f)) try - CTBase.complete(:x; descriptions=descriptions) + Descriptions.complete(:x; descriptions=descriptions) catch e - @test e isa CTBase.AmbiguousDescription - @test e.description == (:x,) - @test !isempty(e.candidates) - @test length(e.candidates) == 3 - @test "(:a, :b)" in e.candidates - @test occursin( + Test.@test e isa Exceptions.AmbiguousDescription + Test.@test e.description == (:x,) + Test.@test !isempty(e.candidates) + Test.@test length(e.candidates) == 3 + Test.@test "(:a, :b)" in e.candidates + Test.@test occursin( "Choose from the available descriptions listed above", e.suggestion ) end @@ -190,41 +193,41 @@ function test_complete() # 3. Description not found with similar suggestions descriptions_sim = ((:a, :b, :c), (:a, :d, :e), (:x, :y, :z)) try - CTBase.complete(:b, :f; descriptions=descriptions_sim) + Descriptions.complete(:b, :f; descriptions=descriptions_sim) catch e - @test e isa CTBase.AmbiguousDescription - @test !isempty(e.candidates) - @test occursin("closest matches", e.suggestion) + Test.@test e isa Exceptions.AmbiguousDescription + Test.@test !isempty(e.candidates) + Test.@test occursin("closest matches", e.suggestion) # Should suggest descriptions containing :b (which is (:a, :b, :c)) - @test any(occursin("(:a,", candidate) for candidate in e.candidates) + Test.@test any(occursin("(:a,", candidate) for candidate in e.candidates) end end - @testset "Diagnostic field verification" begin + Test.@testset "Diagnostic field verification" begin # Empty catalog - should have diagnostic try - CTBase.complete(:a; descriptions=()) + Descriptions.complete(:a; descriptions=()) catch e - @test e isa CTBase.AmbiguousDescription - @test e.diagnostic == "empty catalog" + Test.@test e isa Exceptions.AmbiguousDescription + Test.@test e.diagnostic == "empty catalog" end # Unknown symbols - should have diagnostic descriptions = ((:a, :b), (:c, :d)) try - CTBase.complete(:x, :y; descriptions=descriptions) + Descriptions.complete(:x, :y; descriptions=descriptions) catch e - @test e isa CTBase.AmbiguousDescription - @test e.diagnostic in ["unknown symbols", "no complete match"] + Test.@test e isa Exceptions.AmbiguousDescription + Test.@test e.diagnostic in ["unknown symbols", "no complete match"] end # Partial match but not complete - should have diagnostic descriptions2 = ((:a, :b, :c), (:d, :e, :f)) try - CTBase.complete(:a, :x; descriptions=descriptions2) + Descriptions.complete(:a, :x; descriptions=descriptions2) catch e - @test e isa CTBase.AmbiguousDescription - @test e.diagnostic == "no complete match" + Test.@test e isa Exceptions.AmbiguousDescription + Test.@test e.diagnostic == "no complete match" end end end diff --git a/test/suite/descriptions/test_description_types.jl b/test/suite/descriptions/test_description_types.jl index 3cf1437c..84a1f9f2 100644 --- a/test/suite/descriptions/test_description_types.jl +++ b/test/suite/descriptions/test_description_types.jl @@ -1,110 +1,112 @@ module TestDescriptionTypes -using Test -using CTBase -using Main.TestOptions: VERBOSE, SHOWTIMING +import Test +import CTBase.Descriptions + +const VERBOSE = isdefined(Main, :TestOptions) ? Main.TestOptions.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestOptions) ? Main.TestOptions.SHOWTIMING : true function test_description_types() - @testset verbose = VERBOSE showtiming = SHOWTIMING "Description Types" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "Description Types" begin # ==================================================================== # UNIT TESTS - Type Definitions # ==================================================================== - @testset "DescVarArg type alias" begin + Test.@testset "DescVarArg type alias" begin # Verify type equality - @test CTBase.DescVarArg == Vararg{Symbol} - @test CTBase.DescVarArg === Vararg{Symbol} + Test.@test Descriptions.DescVarArg == Vararg{Symbol} + Test.@test Descriptions.DescVarArg === Vararg{Symbol} # Verify it's a type alias, not a new type - @test typeof(CTBase.DescVarArg) == typeof(Vararg{Symbol}) + Test.@test typeof(Descriptions.DescVarArg) == typeof(Vararg{Symbol}) # Test that it can be used in function signatures - test_func(args::CTBase.DescVarArg) = length(args) - @test test_func(:a, :b, :c) == 3 - @test test_func(:x) == 1 + test_func(args::Descriptions.DescVarArg) = length(args) + Test.@test test_func(:a, :b, :c) == 3 + Test.@test test_func(:x) == 1 end - @testset "Description type alias" begin + Test.@testset "Description type alias" begin # Verify type equality - @test CTBase.Description == Tuple{Vararg{Symbol}} - @test CTBase.Description === Tuple{Vararg{Symbol}} + Test.@test Descriptions.Description == Tuple{Vararg{Symbol}} + Test.@test Descriptions.Description === Tuple{Vararg{Symbol}} # Verify it's a type alias, not a new type - @test typeof(CTBase.Description) == typeof(Tuple{Vararg{Symbol}}) + Test.@test typeof(Descriptions.Description) == typeof(Tuple{Vararg{Symbol}}) # Test concrete instances - @test (:a, :b) isa CTBase.Description - @test (:x,) isa CTBase.Description - @test (:a, :b, :c, :d) isa CTBase.Description - @test () isa CTBase.Description # Empty tuple is valid + Test.@test (:a, :b) isa Descriptions.Description + Test.@test (:x,) isa Descriptions.Description + Test.@test (:a, :b, :c, :d) isa Descriptions.Description + Test.@test () isa Descriptions.Description # Empty tuple is valid end - @testset "Type properties" begin + Test.@testset "Type properties" begin # Verify Description is a type (DataType or UnionAll depending on Julia version) - @test CTBase.Description isa Type - @test CTBase.Description == Tuple{Vararg{Symbol}} + Test.@test Descriptions.Description isa Type + Test.@test Descriptions.Description == Tuple{Vararg{Symbol}} # Verify concrete tuple instances are of Description type - @test (:a, :b) isa CTBase.Description - @test (:x,) isa CTBase.Description - @test () isa CTBase.Description + Test.@test (:a, :b) isa Descriptions.Description + Test.@test (:x,) isa Descriptions.Description + Test.@test () isa Descriptions.Description # Verify concrete tuple types are subtypes - @test Tuple{Symbol,Symbol} <: Tuple{Vararg{Symbol}} - @test Tuple{Symbol} <: Tuple{Vararg{Symbol}} - @test Tuple{} <: Tuple{Vararg{Symbol}} + Test.@test Tuple{Symbol,Symbol} <: Tuple{Vararg{Symbol}} + Test.@test Tuple{Symbol} <: Tuple{Vararg{Symbol}} + Test.@test Tuple{} <: Tuple{Vararg{Symbol}} # Verify non-Symbol tuples are not of Description type - @test !((1, 2) isa CTBase.Description) - @test !("hello" isa CTBase.Description) - @test !([1, 2, 3] isa CTBase.Description) + Test.@test !((1, 2) isa Descriptions.Description) + Test.@test !("hello" isa Descriptions.Description) + Test.@test !([1, 2, 3] isa Descriptions.Description) end - @testset "Type parameter behavior" begin + Test.@testset "Type parameter behavior" begin # Test that Description accepts any number of Symbols - desc1::CTBase.Description = (:a,) - desc2::CTBase.Description = (:a, :b) - desc3::CTBase.Description = (:a, :b, :c) - desc4::CTBase.Description = () + desc1::Descriptions.Description = (:a,) + desc2::Descriptions.Description = (:a, :b) + desc3::Descriptions.Description = (:a, :b, :c) + desc4::Descriptions.Description = () - @test desc1 isa CTBase.Description - @test desc2 isa CTBase.Description - @test desc3 isa CTBase.Description - @test desc4 isa CTBase.Description + Test.@test desc1 isa Descriptions.Description + Test.@test desc2 isa Descriptions.Description + Test.@test desc3 isa Descriptions.Description + Test.@test desc4 isa Descriptions.Description # Verify length variability - @test length(desc1) == 1 - @test length(desc2) == 2 - @test length(desc3) == 3 - @test length(desc4) == 0 + Test.@test length(desc1) == 1 + Test.@test length(desc2) == 2 + Test.@test length(desc3) == 3 + Test.@test length(desc4) == 0 end - @testset "Type usage in collections" begin + Test.@testset "Type usage in collections" begin # Verify Description can be used in tuples of descriptions - catalog::Tuple{Vararg{CTBase.Description}} = ((:a, :b), (:c, :d)) - @test length(catalog) == 2 - @test catalog[1] isa CTBase.Description - @test catalog[2] isa CTBase.Description + catalog::Tuple{Vararg{Descriptions.Description}} = ((:a, :b), (:c, :d)) + Test.@test length(catalog) == 2 + Test.@test catalog[1] isa Descriptions.Description + Test.@test catalog[2] isa Descriptions.Description # Verify in vectors - vec_catalog::Vector{CTBase.Description} = [(:a, :b), (:c, :d)] - @test length(vec_catalog) == 2 - @test vec_catalog[1] isa CTBase.Description + vec_catalog::Vector{Descriptions.Description} = [(:a, :b), (:c, :d)] + Test.@test length(vec_catalog) == 2 + Test.@test vec_catalog[1] isa Descriptions.Description end - @testset "Type inference with aliases" begin + Test.@testset "Type inference with aliases" begin # Verify type inference works correctly - function create_description(syms::Symbol...)::CTBase.Description + function create_description(syms::Symbol...)::Descriptions.Description return syms end result = create_description(:a, :b, :c) - @test result isa CTBase.Description - @test result == (:a, :b, :c) + Test.@test result isa Descriptions.Description + Test.@test result == (:a, :b, :c) - # Test with @inferred - @test (@inferred create_description(:x, :y)) isa CTBase.Description + # Test with Test.@inferred + Test.@test (Test.@inferred create_description(:x, :y)) isa Descriptions.Description end end end diff --git a/test/suite/descriptions/test_display_description.jl b/test/suite/descriptions/test_display_description.jl index 07a3c3d7..8bdbf573 100644 --- a/test/suite/descriptions/test_display_description.jl +++ b/test/suite/descriptions/test_display_description.jl @@ -1,50 +1,51 @@ module TestDisplayDescription -using Test -using CTBase -using Main.TestOptions: VERBOSE, SHOWTIMING +import Test + +const VERBOSE = isdefined(Main, :TestOptions) ? Main.TestOptions.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestOptions) ? Main.TestOptions.SHOWTIMING : true function test_display_description() - @testset verbose = VERBOSE showtiming = SHOWTIMING "Description Display" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "Description Display" begin # ==================================================================== # UNIT TESTS - Display Function # ==================================================================== - @testset "Basic display" begin + Test.@testset "Basic display" begin io = IOBuffer() descriptions = ((:a, :b), (:b, :c)) show(io, MIME"text/plain"(), descriptions) output = String(take!(io)) expected = "(:a, :b)\n(:b, :c)" - @test output == expected + Test.@test output == expected # Three descriptions io = IOBuffer() descriptions2 = ((:a,), (:b,), (:c,)) show(io, MIME"text/plain"(), descriptions2) output2 = String(take!(io)) - @test output2 == "(:a,)\n(:b,)\n(:c,)" + Test.@test output2 == "(:a,)\n(:b,)\n(:c,)" end - @testset "Edge cases - empty and single" begin + Test.@testset "Edge cases - empty and single" begin # Empty catalog io = IOBuffer() show(io, MIME"text/plain"(), ()) - @test String(take!(io)) == "" + Test.@test String(take!(io)) == "" # Single description io = IOBuffer() show(io, MIME"text/plain"(), ((:a, :b),)) - @test String(take!(io)) == "(:a, :b)" + Test.@test String(take!(io)) == "(:a, :b)" # Single description with one symbol io = IOBuffer() show(io, MIME"text/plain"(), ((:x,),)) - @test String(take!(io)) == "(:x,)" + Test.@test String(take!(io)) == "(:x,)" end - @testset "Large catalogs" begin + Test.@testset "Large catalogs" begin # 10 descriptions descriptions = ( (:a, :b), @@ -62,9 +63,9 @@ function test_display_description() show(io, MIME"text/plain"(), descriptions) output = String(take!(io)) lines = split(output, '\n') - @test length(lines) == 10 - @test lines[1] == "(:a, :b)" - @test lines[10] == "(:s, :t)" + Test.@test length(lines) == 10 + Test.@test lines[1] == "(:a, :b)" + Test.@test lines[10] == "(:s, :t)" # 15 descriptions descriptions2 = ( @@ -88,10 +89,10 @@ function test_display_description() show(io, MIME"text/plain"(), descriptions2) output2 = String(take!(io)) lines2 = split(output2, '\n') - @test length(lines2) == 15 + Test.@test length(lines2) == 15 end - @testset "Complex descriptions" begin + Test.@testset "Complex descriptions" begin # Descriptions with many symbols (5+ each) descriptions = ( (:a, :b, :c, :d, :e), (:f, :g, :h, :i, :j), (:k, :l, :m, :n, :o, :p) @@ -100,10 +101,10 @@ function test_display_description() show(io, MIME"text/plain"(), descriptions) output = String(take!(io)) lines = split(output, '\n') - @test length(lines) == 3 - @test lines[1] == "(:a, :b, :c, :d, :e)" - @test lines[2] == "(:f, :g, :h, :i, :j)" - @test lines[3] == "(:k, :l, :m, :n, :o, :p)" + Test.@test length(lines) == 3 + Test.@test lines[1] == "(:a, :b, :c, :d, :e)" + Test.@test lines[2] == "(:f, :g, :h, :i, :j)" + Test.@test lines[3] == "(:k, :l, :m, :n, :o, :p)" # Mixed sizes descriptions2 = ( @@ -113,44 +114,44 @@ function test_display_description() show(io, MIME"text/plain"(), descriptions2) output2 = String(take!(io)) lines2 = split(output2, '\n') - @test length(lines2) == 5 + Test.@test length(lines2) == 5 end - @testset "Output format consistency" begin + Test.@testset "Output format consistency" begin # Verify no trailing newline on last item descriptions = ((:a, :b), (:c, :d)) io = IOBuffer() show(io, MIME"text/plain"(), descriptions) output = String(take!(io)) - @test !endswith(output, '\n') + Test.@test !endswith(output, '\n') # Verify newlines between items - @test occursin("\n", output) - @test count(c -> c == '\n', output) == 1 # Exactly one newline for 2 items + Test.@test occursin("\n", output) + Test.@test count(c -> c == '\n', output) == 1 # Exactly one newline for 2 items # Three items should have 2 newlines descriptions2 = ((:a,), (:b,), (:c,)) io = IOBuffer() show(io, MIME"text/plain"(), descriptions2) output2 = String(take!(io)) - @test count(c -> c == '\n', output2) == 2 + Test.@test count(c -> c == '\n', output2) == 2 end - @testset "Special symbol names" begin + Test.@testset "Special symbol names" begin # Long symbol names descriptions = ((:very_long_symbol_name, :another_long_name),) io = IOBuffer() show(io, MIME"text/plain"(), descriptions) output = String(take!(io)) - @test occursin("very_long_symbol_name", output) - @test occursin("another_long_name", output) + Test.@test occursin("very_long_symbol_name", output) + Test.@test occursin("another_long_name", output) # Symbols with numbers descriptions2 = ((:x1, :x2, :x3),) io = IOBuffer() show(io, MIME"text/plain"(), descriptions2) output2 = String(take!(io)) - @test output2 == "(:x1, :x2, :x3)" + Test.@test output2 == "(:x1, :x2, :x3)" end end end diff --git a/test/suite/descriptions/test_integration.jl b/test/suite/descriptions/test_integration.jl index e111c272..4a6ff041 100644 --- a/test/suite/descriptions/test_integration.jl +++ b/test/suite/descriptions/test_integration.jl @@ -1,84 +1,101 @@ -struct DummyDocRefTag <: CTBase.Extensions.AbstractDocumenterReferenceTag end +module TestIntegration + +import Test +import CTBase.Descriptions: Descriptions +import CTBase.Extensions: Extensions +import CTBase.Exceptions: Exceptions +import CTBase.Unicode: Unicode + +const VERBOSE = isdefined(Main, :TestOptions) ? Main.TestOptions.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestOptions) ? Main.TestOptions.SHOWTIMING : true + +# TOP-LEVEL: Fake type for extension testing +struct DummyDocRefTag <: Extensions.AbstractDocumenterReferenceTag end function test_integration() # Integration test: description workflow combining add, complete, remove, and exceptions - @testset verbose = VERBOSE showtiming = SHOWTIMING "Integration: description workflow" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "Integration: description workflow" begin algorithms = () - algorithms = CTBase.add(algorithms, (:descent, :bfgs, :bisection)) - algorithms = CTBase.add(algorithms, (:descent, :gradient, :fixedstep)) + algorithms = Descriptions.add(algorithms, (:descent, :bfgs, :bisection)) + algorithms = Descriptions.add(algorithms, (:descent, :gradient, :fixedstep)) # Successful completion from partial descriptions - @test CTBase.complete((:descent,); descriptions=algorithms) == + Test.@test Descriptions.complete((:descent,); descriptions=algorithms) == (:descent, :bfgs, :bisection) - @test CTBase.complete((:gradient, :fixedstep); descriptions=algorithms) == + Test.@test Descriptions.complete((:gradient, :fixedstep); descriptions=algorithms) == (:descent, :gradient, :fixedstep) # Removing known prefix from completed description - full = CTBase.complete((:descent,); descriptions=algorithms) + full = Descriptions.complete((:descent,); descriptions=algorithms) prefix = (:descent, :bfgs) - diff = CTBase.remove(full, prefix) - @test diff == (:bisection,) + diff = Descriptions.remove(full, prefix) + Test.@test diff == (:bisection,) # Ambiguous / invalid descriptions should raise AmbiguousDescription - @test_throws CTBase.AmbiguousDescription CTBase.complete( + Test.@test_throws Exceptions.AmbiguousDescription Descriptions.complete( (:unknown,); descriptions=algorithms ) - @test_throws CTBase.AmbiguousDescription CTBase.complete( + Test.@test_throws Exceptions.AmbiguousDescription Descriptions.complete( (:descent, :unknown); descriptions=algorithms ) end - # Integration test: formatting labels using utils (subscripts/superscripts) with descriptions - @testset verbose = VERBOSE showtiming = SHOWTIMING "Integration: label formatting with utils" begin + # Integration test: formatting labels using Unicode (subscripts/superscripts) with descriptions + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "Integration: label formatting with Unicode" begin base = :x # Single index / power labels - label1 = string(base, CTBase.ctindice(1)) # xโ‚ - label2 = string(base, CTBase.ctupperscript(2)) # xยฒ - @test label1 == "xโ‚" - @test label2 == "xยฒ" + label1 = string(base, Unicode.ctindice(1)) # xโ‚ + label2 = string(base, Unicode.ctupperscript(2)) # xยฒ + Test.@test label1 == "xโ‚" + Test.@test label2 == "xยฒ" # Multi-digit index and power combined - label3 = string(base, CTBase.ctindices(10), CTBase.ctupperscripts(3)) - @test label3 == "xโ‚โ‚€ยณ" + label3 = string(base, Unicode.ctindices(10), Unicode.ctupperscripts(3)) + Test.@test label3 == "xโ‚โ‚€ยณ" # Negative inputs should still throw via the public API - @test_throws CTBase.IncorrectArgument CTBase.ctindices(-5) - @test_throws CTBase.IncorrectArgument CTBase.ctupperscripts(-2) + Test.@test_throws Exceptions.IncorrectArgument Unicode.ctindices(-5) + Test.@test_throws Exceptions.IncorrectArgument Unicode.ctupperscripts(-2) end # Integration test: descriptions and exceptions consistency - @testset verbose = VERBOSE showtiming = SHOWTIMING "Integration: descriptions and exceptions" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "Integration: descriptions and exceptions" begin descs = () - descs = CTBase.add(descs, (:a, :b)) + descs = Descriptions.add(descs, (:a, :b)) # Duplicate description via add should raise IncorrectArgument - @test_throws CTBase.IncorrectArgument CTBase.add(descs, (:a, :b)) + Test.@test_throws Exceptions.IncorrectArgument Descriptions.add(descs, (:a, :b)) # Build a small description set and complete from a prefix full = (:a, :b, :c) all_descs = () - all_descs = CTBase.add(all_descs, full) - completed = CTBase.complete((:a,); descriptions=all_descs) - @test completed == full + all_descs = Descriptions.add(all_descs, full) + completed = Descriptions.complete((:a,); descriptions=all_descs) + Test.@test completed == full # Removing a subset should leave the remaining tail - tail = CTBase.remove(completed, (:a,)) - @test tail == (:b, :c) + tail = Descriptions.remove(completed, (:a,)) + Test.@test tail == (:b, :c) end # Integration test: automatic_reference_documentation fallback when extension is not used - @testset verbose = VERBOSE showtiming = SHOWTIMING "Integration: automatic_reference_documentation fallback" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "Integration: automatic_reference_documentation fallback" begin err = try - CTBase.automatic_reference_documentation(DummyDocRefTag();) + Extensions.automatic_reference_documentation(DummyDocRefTag();) nothing catch e e end - @test err isa CTBase.ExtensionError - @test err.weakdeps == (:Documenter, :Markdown, :MarkdownAST) + Test.@test err isa Exceptions.ExtensionError + Test.@test err.weakdeps == (:Documenter, :Markdown, :MarkdownAST) end return nothing end + +end # module TestIntegration + +# CRITICAL: redefine in outer scope so the test runner can call it +test_integration() = TestIntegration.test_integration() diff --git a/test/suite/descriptions/test_remove.jl b/test/suite/descriptions/test_remove.jl index 4083c516..a2373d8d 100644 --- a/test/suite/descriptions/test_remove.jl +++ b/test/suite/descriptions/test_remove.jl @@ -1,134 +1,136 @@ module TestRemove -using Test -using CTBase -using Main.TestOptions: VERBOSE, SHOWTIMING +import Test +import CTBase.Descriptions + +const VERBOSE = isdefined(Main, :TestOptions) ? Main.TestOptions.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestOptions) ? Main.TestOptions.SHOWTIMING : true function test_remove() - @testset verbose = VERBOSE showtiming = SHOWTIMING "Remove Symbols" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "Remove Symbols" begin # ==================================================================== # UNIT TESTS - Remove Function Core Logic # ==================================================================== - @testset "Basic removal" begin + Test.@testset "Basic removal" begin x = (:a, :b, :c) y = (:b,) - @test CTBase.remove(x, y) == (:a, :c) - @test typeof(CTBase.remove(x, y)) <: CTBase.Description + Test.@test Descriptions.remove(x, y) == (:a, :c) + Test.@test typeof(Descriptions.remove(x, y)) <: Descriptions.Description end - @testset "Multiple symbol removal" begin + Test.@testset "Multiple symbol removal" begin x = (:a, :b, :c, :d) y = (:b, :d) - @test CTBase.remove(x, y) == (:a, :c) + Test.@test Descriptions.remove(x, y) == (:a, :c) # Remove multiple consecutive symbols x2 = (:a, :b, :c, :d, :e) y2 = (:b, :c, :d) - @test CTBase.remove(x2, y2) == (:a, :e) + Test.@test Descriptions.remove(x2, y2) == (:a, :e) end - @testset "Edge cases - empty inputs" begin + Test.@testset "Edge cases - empty inputs" begin # Remove from empty tuple - @test CTBase.remove((), ()) == () - @test CTBase.remove((), (:a,)) == () + Test.@test Descriptions.remove((), ()) == () + Test.@test Descriptions.remove((), (:a,)) == () # Remove empty tuple from description x = (:a, :b, :c) - @test CTBase.remove(x, ()) == (:a, :b, :c) + Test.@test Descriptions.remove(x, ()) == (:a, :b, :c) end - @testset "Edge cases - no overlap" begin + Test.@testset "Edge cases - no overlap" begin # No common symbols x = (:a, :b, :c) y = (:x, :y, :z) - @test CTBase.remove(x, y) == (:a, :b, :c) + Test.@test Descriptions.remove(x, y) == (:a, :b, :c) # Single symbol, no overlap x2 = (:a,) y2 = (:b,) - @test CTBase.remove(x2, y2) == (:a,) + Test.@test Descriptions.remove(x2, y2) == (:a,) end - @testset "Edge cases - complete overlap" begin + Test.@testset "Edge cases - complete overlap" begin # Remove all symbols x = (:a, :b, :c) y = (:a, :b, :c) - @test CTBase.remove(x, y) == () + Test.@test Descriptions.remove(x, y) == () # Single symbol removal x2 = (:a,) y2 = (:a,) - @test CTBase.remove(x2, y2) == () + Test.@test Descriptions.remove(x2, y2) == () end - @testset "Edge cases - partial overlap" begin + Test.@testset "Edge cases - partial overlap" begin # Remove first symbol x = (:a, :b, :c) y = (:a,) - @test CTBase.remove(x, y) == (:b, :c) + Test.@test Descriptions.remove(x, y) == (:b, :c) # Remove last symbol x2 = (:a, :b, :c) y2 = (:c,) - @test CTBase.remove(x2, y2) == (:a, :b) + Test.@test Descriptions.remove(x2, y2) == (:a, :b) # Remove middle symbol x3 = (:a, :b, :c) y3 = (:b,) - @test CTBase.remove(x3, y3) == (:a, :c) + Test.@test Descriptions.remove(x3, y3) == (:a, :c) end - @testset "Order preservation" begin + Test.@testset "Order preservation" begin # Verify order is preserved after removal x = (:z, :y, :x, :w, :v) y = (:y, :w) - result = CTBase.remove(x, y) - @test result == (:z, :x, :v) - @test result[1] == :z - @test result[2] == :x - @test result[3] == :v + result = Descriptions.remove(x, y) + Test.@test result == (:z, :x, :v) + Test.@test result[1] == :z + Test.@test result[2] == :x + Test.@test result[3] == :v end - @testset "Duplicate symbols handling" begin + Test.@testset "Duplicate symbols handling" begin # Note: Descriptions are tuples, can have duplicates # setdiff removes duplicates, so test actual behavior x = (:a, :b, :a, :c) y = (:a,) - result = CTBase.remove(x, y) + result = Descriptions.remove(x, y) # setdiff removes all :a occurrences - @test :a โˆ‰ result - @test :b โˆˆ result - @test :c โˆˆ result + Test.@test :a โˆ‰ result + Test.@test :b โˆˆ result + Test.@test :c โˆˆ result end # ==================================================================== # TYPE STABILITY TESTS # ==================================================================== - @testset "Type stability" begin + Test.@testset "Type stability" begin # Note: Julia's type inference returns concrete tuple types (e.g., Tuple{Symbol, Symbol}) # rather than Tuple{Vararg{Symbol}} for fixed-size results. # This is expected and correct behavior. # Test that remove returns correct results - @test CTBase.remove((:a, :b, :c), (:b,)) == (:a, :c) - @test CTBase.remove((:a,), ()) == (:a,) - @test CTBase.remove((:a, :b), (:a,)) == (:b,) - @test CTBase.remove((), ()) == () - @test CTBase.remove((:a, :b), (:a, :b)) == () + Test.@test Descriptions.remove((:a, :b, :c), (:b,)) == (:a, :c) + Test.@test Descriptions.remove((:a,), ()) == (:a,) + Test.@test Descriptions.remove((:a, :b), (:a,)) == (:b,) + Test.@test Descriptions.remove((), ()) == () + Test.@test Descriptions.remove((:a, :b), (:a, :b)) == () # Verify return types are tuple types with Symbol elements - result1 = CTBase.remove((:a, :b, :c), (:b,)) - @test typeof(result1) <: Tuple{Vararg{Symbol}} - @test result1 isa Tuple - @test all(x -> x isa Symbol, result1) + result1 = Descriptions.remove((:a, :b, :c), (:b,)) + Test.@test typeof(result1) <: Tuple{Vararg{Symbol}} + Test.@test result1 isa Tuple + Test.@test all(x -> x isa Symbol, result1) # Verify type consistency - result2 = CTBase.remove((:x, :y, :z), (:y,)) - @test typeof(result2) <: Tuple{Vararg{Symbol}} - @test typeof(result1) == typeof(result2) # Same structure + result2 = Descriptions.remove((:x, :y, :z), (:y,)) + Test.@test typeof(result2) <: Tuple{Vararg{Symbol}} + Test.@test typeof(result1) == typeof(result2) # Same structure end end end diff --git a/test/suite/descriptions/test_similarity.jl b/test/suite/descriptions/test_similarity.jl index 4c27fb17..97aaec19 100644 --- a/test/suite/descriptions/test_similarity.jl +++ b/test/suite/descriptions/test_similarity.jl @@ -1,158 +1,160 @@ module TestSimilarity -using Test -using CTBase -using Main.TestOptions: VERBOSE, SHOWTIMING +import Test +import CTBase.Descriptions + +const VERBOSE = isdefined(Main, :TestOptions) ? Main.TestOptions.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestOptions) ? Main.TestOptions.SHOWTIMING : true function test_similarity() - @testset verbose = VERBOSE showtiming = SHOWTIMING "Similarity Utilities" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "Similarity Utilities" begin # ==================================================================== # UNIT TESTS - Similarity Computation # ==================================================================== - @testset "compute_similarity - basic cases" begin + Test.@testset "compute_similarity - basic cases" begin # Identical descriptions - @test CTBase.Descriptions._compute_similarity((:a, :b), (:a, :b)) == 1.0 + Test.@test Descriptions._compute_similarity((:a, :b), (:a, :b)) == 1.0 # Partial overlap - @test CTBase.Descriptions._compute_similarity((:a, :b), (:a, :c)) == 1 / 3 - @test CTBase.Descriptions._compute_similarity((:a, :c), (:a, :b, :c)) == 2 / 3 + Test.@test Descriptions._compute_similarity((:a, :b), (:a, :c)) == 1 / 3 + Test.@test Descriptions._compute_similarity((:a, :c), (:a, :b, :c)) == 2 / 3 # No overlap - @test CTBase.Descriptions._compute_similarity((:x, :y), (:a, :b)) == 0.0 + Test.@test Descriptions._compute_similarity((:x, :y), (:a, :b)) == 0.0 end - @testset "compute_similarity - edge cases" begin + Test.@testset "compute_similarity - edge cases" begin # Empty tuples - @test CTBase.Descriptions._compute_similarity((), ()) == 0.0 - @test CTBase.Descriptions._compute_similarity((), (:a,)) == 0.0 - @test CTBase.Descriptions._compute_similarity((:a,), ()) == 0.0 + Test.@test Descriptions._compute_similarity((), ()) == 0.0 + Test.@test Descriptions._compute_similarity((), (:a,)) == 0.0 + Test.@test Descriptions._compute_similarity((:a,), ()) == 0.0 # Single-element tuples - @test CTBase.Descriptions._compute_similarity((:a,), (:a,)) == 1.0 - @test CTBase.Descriptions._compute_similarity((:a,), (:b,)) == 0.0 - @test CTBase.Descriptions._compute_similarity((:a,), (:a, :b)) == 0.5 + Test.@test Descriptions._compute_similarity((:a,), (:a,)) == 1.0 + Test.@test Descriptions._compute_similarity((:a,), (:b,)) == 0.0 + Test.@test Descriptions._compute_similarity((:a,), (:a, :b)) == 0.5 # Large descriptions desc1 = (:a, :b, :c, :d, :e) desc2 = (:a, :b, :c, :d, :e) - @test CTBase.Descriptions._compute_similarity(desc1, desc2) == 1.0 + Test.@test Descriptions._compute_similarity(desc1, desc2) == 1.0 desc3 = (:a, :b, :c) desc4 = (:d, :e, :f) - @test CTBase.Descriptions._compute_similarity(desc3, desc4) == 0.0 + Test.@test Descriptions._compute_similarity(desc3, desc4) == 0.0 end - @testset "compute_similarity - mathematical properties" begin + Test.@testset "compute_similarity - mathematical properties" begin # Symmetry: sim(A, B) == sim(B, A) desc1 = (:a, :b, :c) desc2 = (:b, :c, :d) - @test CTBase.Descriptions._compute_similarity(desc1, desc2) == - CTBase.Descriptions._compute_similarity(desc2, desc1) + Test.@test Descriptions._compute_similarity(desc1, desc2) == + Descriptions._compute_similarity(desc2, desc1) # Reflexivity: sim(A, A) == 1.0 - @test CTBase.Descriptions._compute_similarity(desc1, desc1) == 1.0 - @test CTBase.Descriptions._compute_similarity(desc2, desc2) == 1.0 + Test.@test Descriptions._compute_similarity(desc1, desc1) == 1.0 + Test.@test Descriptions._compute_similarity(desc2, desc2) == 1.0 # Range: 0.0 <= sim(A, B) <= 1.0 desc3 = (:x, :y) desc4 = (:a, :b, :c, :d) - sim = CTBase.Descriptions._compute_similarity(desc3, desc4) - @test 0.0 <= sim <= 1.0 + sim = Descriptions._compute_similarity(desc3, desc4) + Test.@test 0.0 <= sim <= 1.0 end - @testset "Type stability - compute_similarity" begin + Test.@testset "Type stability - compute_similarity" begin # Basic case - @test (@inferred CTBase.Descriptions._compute_similarity((:a, :b), (:a, :c))) isa + Test.@test (Test.@inferred Descriptions._compute_similarity((:a, :b), (:a, :c))) isa Float64 # Edge cases - @test (@inferred CTBase.Descriptions._compute_similarity((), ())) isa Float64 - @test (@inferred CTBase.Descriptions._compute_similarity((:a,), (:b,))) isa + Test.@test (Test.@inferred Descriptions._compute_similarity((), ())) isa Float64 + Test.@test (Test.@inferred Descriptions._compute_similarity((:a,), (:b,))) isa Float64 # Verify always returns Float64 - result = CTBase.Descriptions._compute_similarity((:a, :b, :c), (:b, :c, :d)) - @test result isa Float64 + result = Descriptions._compute_similarity((:a, :b, :c), (:b, :c, :d)) + Test.@test result isa Float64 end # ==================================================================== # UNIT TESTS - Similar Descriptions Finding # ==================================================================== - @testset "find_similar_descriptions - basic" begin + Test.@testset "find_similar_descriptions - basic" begin descriptions = ((:a, :b), (:a, :c), (:x, :y)) target = (:a,) - similar = CTBase.Descriptions._find_similar_descriptions(target, descriptions) - @test length(similar) == 2 - @test "(:a, :b)" in similar - @test "(:a, :c)" in similar - @test !("(:x, :y)" in similar) + similar = Descriptions._find_similar_descriptions(target, descriptions) + Test.@test length(similar) == 2 + Test.@test "(:a, :b)" in similar + Test.@test "(:a, :c)" in similar + Test.@test !("(:x, :y)" in similar) # No similar descriptions - @test isempty( - CTBase.Descriptions._find_similar_descriptions((:z,), descriptions) + Test.@test isempty( + Descriptions._find_similar_descriptions((:z,), descriptions) ) end - @testset "find_similar_descriptions - boundaries" begin + Test.@testset "find_similar_descriptions - boundaries" begin # Test max_results boundary - exactly max_results descriptions = ((:a, :b), (:a, :c), (:a, :d), (:a, :e), (:a, :f)) target = (:a,) - similar = CTBase.Descriptions._find_similar_descriptions( + similar = Descriptions._find_similar_descriptions( target, descriptions; max_results=5 ) - @test length(similar) == 5 + Test.@test length(similar) == 5 # More than max_results available descriptions2 = ((:a, :b), (:a, :c), (:a, :d), (:a, :e), (:a, :f), (:a, :g)) - similar2 = CTBase.Descriptions._find_similar_descriptions( + similar2 = Descriptions._find_similar_descriptions( target, descriptions2; max_results=3 ) - @test length(similar2) == 3 + Test.@test length(similar2) == 3 # Less than max_results available descriptions3 = ((:a, :b), (:a, :c)) - similar3 = CTBase.Descriptions._find_similar_descriptions( + similar3 = Descriptions._find_similar_descriptions( target, descriptions3; max_results=5 ) - @test length(similar3) == 2 + Test.@test length(similar3) == 2 # All zero similarity (should return empty) descriptions4 = ((:x, :y), (:z, :w)) - similar4 = CTBase.Descriptions._find_similar_descriptions((:a,), descriptions4) - @test isempty(similar4) + similar4 = Descriptions._find_similar_descriptions((:a,), descriptions4) + Test.@test isempty(similar4) end - @testset "find_similar_descriptions - edge cases" begin + Test.@testset "find_similar_descriptions - edge cases" begin # Empty descriptions catalog - @test isempty(CTBase.Descriptions._find_similar_descriptions((:a,), ())) + Test.@test isempty(Descriptions._find_similar_descriptions((:a,), ())) # Empty target descriptions = ((:a, :b), (:c, :d)) - @test isempty(CTBase.Descriptions._find_similar_descriptions((), descriptions)) + Test.@test isempty(Descriptions._find_similar_descriptions((), descriptions)) # Single description in catalog descriptions2 = ((:a, :b),) - similar = CTBase.Descriptions._find_similar_descriptions((:a,), descriptions2) - @test length(similar) == 1 - @test "(:a, :b)" in similar + similar = Descriptions._find_similar_descriptions((:a,), descriptions2) + Test.@test length(similar) == 1 + Test.@test "(:a, :b)" in similar end - @testset "Type stability - find_similar_descriptions" begin + Test.@testset "Type stability - find_similar_descriptions" begin descriptions = ((:a, :b), (:a, :c), (:x, :y)) target = (:a,) - @test (@inferred CTBase.Descriptions._find_similar_descriptions( + Test.@test (Test.@inferred Descriptions._find_similar_descriptions( target, descriptions )) isa Vector{String} - @test (@inferred CTBase.Descriptions._find_similar_descriptions( + Test.@test (Test.@inferred Descriptions._find_similar_descriptions( target, descriptions; max_results=3 )) isa Vector{String} # Edge cases - @test (@inferred CTBase.Descriptions._find_similar_descriptions((), ())) isa + Test.@test (Test.@inferred Descriptions._find_similar_descriptions((), ())) isa Vector{String} end @@ -160,75 +162,75 @@ function test_similarity() # UNIT TESTS - Candidate Formatting # ==================================================================== - @testset "format_description_candidates - basic" begin + Test.@testset "format_description_candidates - basic" begin descriptions = ((:a, :b), (:a, :c), (:x, :y), (:p, :q), (:r, :s), (:t, :u)) - formatted = CTBase.Descriptions._format_description_candidates(descriptions) - @test length(formatted) == 5 # default max_show=5 - @test formatted[1] == "(:a, :b)" - @test formatted[5] == "(:r, :s)" + formatted = Descriptions._format_description_candidates(descriptions) + Test.@test length(formatted) == 5 # default max_show=5 + Test.@test formatted[1] == "(:a, :b)" + Test.@test formatted[5] == "(:r, :s)" # Custom max_show - formatted3 = CTBase.Descriptions._format_description_candidates( + formatted3 = Descriptions._format_description_candidates( descriptions; max_show=3 ) - @test length(formatted3) == 3 - @test formatted3[1] == "(:a, :b)" - @test formatted3[3] == "(:x, :y)" + Test.@test length(formatted3) == 3 + Test.@test formatted3[1] == "(:a, :b)" + Test.@test formatted3[3] == "(:x, :y)" end - @testset "format_description_candidates - boundaries" begin + Test.@testset "format_description_candidates - boundaries" begin # Exactly max_show descriptions descriptions = ((:a, :b), (:c, :d), (:e, :f), (:g, :h), (:i, :j)) - formatted = CTBase.Descriptions._format_description_candidates( + formatted = Descriptions._format_description_candidates( descriptions; max_show=5 ) - @test length(formatted) == 5 + Test.@test length(formatted) == 5 # Less than max_show descriptions2 = ((:a, :b), (:c, :d)) - formatted2 = CTBase.Descriptions._format_description_candidates( + formatted2 = Descriptions._format_description_candidates( descriptions2; max_show=5 ) - @test length(formatted2) == 2 + Test.@test length(formatted2) == 2 # More than max_show descriptions3 = ((:a, :b), (:c, :d), (:e, :f), (:g, :h), (:i, :j), (:k, :l)) - formatted3 = CTBase.Descriptions._format_description_candidates( + formatted3 = Descriptions._format_description_candidates( descriptions3; max_show=3 ) - @test length(formatted3) == 3 + Test.@test length(formatted3) == 3 # max_show=1 - formatted4 = CTBase.Descriptions._format_description_candidates( + formatted4 = Descriptions._format_description_candidates( descriptions3; max_show=1 ) - @test length(formatted4) == 1 - @test formatted4[1] == "(:a, :b)" + Test.@test length(formatted4) == 1 + Test.@test formatted4[1] == "(:a, :b)" end - @testset "format_description_candidates - edge cases" begin + Test.@testset "format_description_candidates - edge cases" begin # Empty descriptions - @test isempty(CTBase.Descriptions._format_description_candidates(())) + Test.@test isempty(Descriptions._format_description_candidates(())) # Single description descriptions = ((:a, :b),) - formatted = CTBase.Descriptions._format_description_candidates(descriptions) - @test length(formatted) == 1 - @test formatted[1] == "(:a, :b)" + formatted = Descriptions._format_description_candidates(descriptions) + Test.@test length(formatted) == 1 + Test.@test formatted[1] == "(:a, :b)" end - @testset "Type stability - format_description_candidates" begin + Test.@testset "Type stability - format_description_candidates" begin descriptions = ((:a, :b), (:c, :d), (:e, :f)) - @test (@inferred CTBase.Descriptions._format_description_candidates( + Test.@test (Test.@inferred Descriptions._format_description_candidates( descriptions )) isa Vector{String} - @test (@inferred CTBase.Descriptions._format_description_candidates( + Test.@test (Test.@inferred Descriptions._format_description_candidates( descriptions; max_show=2 )) isa Vector{String} # Edge case - @test (@inferred CTBase.Descriptions._format_description_candidates(())) isa + Test.@test (Test.@inferred Descriptions._format_description_candidates(())) isa Vector{String} end end diff --git a/test/suite/exceptions/test_display.jl b/test/suite/exceptions/test_display.jl deleted file mode 100644 index 12867a9a..00000000 --- a/test/suite/exceptions/test_display.jl +++ /dev/null @@ -1,421 +0,0 @@ -module TestExceptionDisplay - -using Test -using CTBase -using CTBase.Exceptions -const VERBOSE = isdefined(Main, :TestOptions) ? Main.TestOptions.VERBOSE : true -const SHOWTIMING = isdefined(Main, :TestOptions) ? Main.TestOptions.SHOWTIMING : true - -""" -Tests for exception display functions (display.jl) -""" -function test_exception_display() - @testset "Exception Display" verbose = VERBOSE showtiming = SHOWTIMING begin - @testset "IncorrectArgument - User-Friendly Display" begin - io = IOBuffer() - e = IncorrectArgument( - "Test error", - got="value1", - expected="value2", - suggestion="Fix it like this", - context="test function", - ) - - # User-friendly display (default) - # CTBase.set_show_full_stacktrace!(false) - @test_nowarn showerror(io, e) - output = String(take!(io)) - - # Check for key sections in user-friendly display - @test contains(output, "Control Toolbox Error") - @test contains(output, "Test error") - @test contains(output, "Got:") - @test contains(output, "value1") - @test contains(output, "Expected:") - @test contains(output, "value2") - @test contains(output, "Context:") - @test contains(output, "test function") - @test contains(output, "Suggestion:") - @test contains(output, "Fix it like this") - end - - @testset "IncorrectArgument - Full Stacktrace Display" begin - io = IOBuffer() - e = IncorrectArgument("Test error", got="value1", expected="value2") - - # Full stacktrace display - # CTBase.set_show_full_stacktrace!(true) - @test_nowarn showerror(io, e) - output = String(take!(io)) - - # Check for compact format - @test contains(output, "IncorrectArgument") - @test contains(output, "Test error") - @test contains(output, "Got:") - @test contains(output, "value1") - @test contains(output, "Expected:") - @test contains(output, "value2") - - # Reset to default - # CTBase.set_show_full_stacktrace!(false) - end - - @testset "IncorrectArgument - Minimal Display" begin - io = IOBuffer() - e = IncorrectArgument("Simple error") - - # CTBase.set_show_full_stacktrace!(false) - @test_nowarn showerror(io, e) - output = String(take!(io)) - - @test contains(output, "Simple error") - @test !contains(output, "Got:") - @test !contains(output, "Expected:") - @test !contains(output, "Context:") - @test !contains(output, "Suggestion:") - end - - @testset "PreconditionError - User-Friendly Display" begin - io = IOBuffer() - e = PreconditionError( - "State must be set before dynamics", - reason="state has not been defined yet", - suggestion="Call state!(ocp, dimension) before dynamics!", - context="dynamics! function", - ) - - @test_nowarn showerror(io, e) - output = String(take!(io)) - - @test contains(output, "Control Toolbox Error") - @test contains(output, "State must be set before dynamics") - @test contains(output, "Reason:") - @test contains(output, "state has not been defined yet") - @test contains(output, "Suggestion:") - @test contains(output, "Call state!(ocp, dimension)") - end - - @testset "NotImplemented - Display" begin - io = IOBuffer() - e = NotImplemented("Feature not implemented", required_method="MyType") - - # User-friendly - # CTBase.set_show_full_stacktrace!(false) - @test_nowarn showerror(io, e) - output = String(take!(io)) - @test contains(output, "Feature not implemented") - @test contains(output, "Required method:") - @test contains(output, "MyType") - - # CTBase.set_show_full_stacktrace!(false) - end - - @testset "ParsingError - Display" begin - io = IOBuffer() - e = ParsingError("Syntax error", location="line 42") - - # User-friendly - # CTBase.set_show_full_stacktrace!(false) - @test_nowarn showerror(io, e) - output = String(take!(io)) - @test contains(output, "Syntax error") - @test contains(output, "Location:") - @test contains(output, "line 42") - - # CTBase.set_show_full_stacktrace!(false) - end - - @testset "Display - No Crash on Edge Cases" begin - io = IOBuffer() - - # Empty optional fields - e1 = IncorrectArgument("Error") - @test_nowarn showerror(io, e1) - - e3 = NotImplemented("Error") - @test_nowarn showerror(io, e3) - - e4 = ParsingError("Error") - @test_nowarn showerror(io, e4) - - e5 = AmbiguousDescription((:test,)) - @test_nowarn showerror(io, e5) - - e6 = ExtensionError(:TestExt) - @test_nowarn showerror(io, e6) - - e7 = SolverFailure("Error") - @test_nowarn showerror(io, e7) - end - - @testset "AmbiguousDescription - Display" begin - io = IOBuffer() - e = AmbiguousDescription( - (:f,), - candidates=["(:a, :b)", "(:c, :d)"], - suggestion="Use complete description", - context="algorithm selection", - ) - - # User-friendly - # CTBase.set_show_full_stacktrace!(false) - @test_nowarn showerror(io, e) - output = String(take!(io)) - @test contains(output, "AmbiguousDescription") - @test contains(output, "(:f,)") - @test contains(output, "Available descriptions:") - @test contains(output, "(:a, :b)") - @test contains(output, "algorithm selection") - - # CTBase.set_show_full_stacktrace!(false) - end - - @testset "ExtensionError - Display" begin - io = IOBuffer() - e = ExtensionError( - :Plots, - :PlotlyJS, - message="to plot results", - feature="plotting functionality", - context="solve! call", - ) - - # User-friendly - # CTBase.set_show_full_stacktrace!(false) - @test_nowarn showerror(io, e) - output = String(take!(io)) - @test contains(output, "ExtensionError") - @test contains(output, "Missing dependencies:") - @test contains(output, "Plots") - @test contains(output, "PlotlyJS") - # Check for ANSI-formatted julia> using (colors now work in documentation) - @test contains(output, "\e[1;32mjulia>\e[0m\e[35m using \e[0m") - - # CTBase.set_show_full_stacktrace!(false) - end - - @testset "extract_user_frames function" begin - # Test with a mock stacktrace that includes various frame types - # This tests the filtering logic in extract_user_frames - try - # Create an error to generate a real stacktrace - error("test error") - catch e - st = stacktrace(catch_backtrace()) - filtered = CTBase.Exceptions._extract_user_frames(st) - - # Should return some frames (non-empty in normal test environment) - @test filtered isa Vector - # The filtering should work without errors - @test_nowarn CTBase.Exceptions._extract_user_frames(st) - end - end - - @testset "NotImplemented - Missing optional fields" begin - io = IOBuffer() - # Test with only required field (msg) - e = NotImplemented("Not implemented feature") - - # CTBase.set_show_full_stacktrace!(false) - @test_nowarn showerror(io, e) - output = String(take!(io)) - - @test contains(output, "NotImplemented") - @test contains(output, "Not implemented feature") - # Should not contain optional sections that are not provided - @test !contains(output, "Type:") - @test !contains(output, "Context:") - @test !contains(output, "Suggestion:") - end - - @testset "NotImplemented - All optional fields" begin - io = IOBuffer() - e = NotImplemented( - "Not implemented feature"; - required_method="MyType", - context="testing context", - suggestion="use this instead", - ) - - # CTBase.set_show_full_stacktrace!(false) - @test_nowarn showerror(io, e) - output = String(take!(io)) - - @test contains(output, "NotImplemented") - @test contains(output, "Not implemented feature") - @test contains(output, "Required method:") - @test contains(output, "MyType") - @test contains(output, "Context:") - @test contains(output, "testing context") - @test contains(output, "Suggestion:") - @test contains(output, "use this instead") - end - - @testset "ParsingError - Missing optional fields" begin - io = IOBuffer() - # Test with only required field (msg) - e = ParsingError("Parse error") - - # CTBase.set_show_full_stacktrace!(false) - @test_nowarn showerror(io, e) - output = String(take!(io)) - - @test contains(output, "ParsingError") - @test contains(output, "Parse error") - # Should not contain optional sections that are not provided - @test !contains(output, "Location:") - @test !contains(output, "Suggestion:") - end - - @testset "ParsingError - All optional fields" begin - io = IOBuffer() - e = ParsingError("Parse error"; location="line 10", suggestion="check syntax") - - # CTBase.set_show_full_stacktrace!(false) - @test_nowarn showerror(io, e) - output = String(take!(io)) - - @test contains(output, "ParsingError") - @test contains(output, "Parse error") - @test contains(output, "Location:") - @test contains(output, "line 10") - @test contains(output, "Suggestion:") - @test contains(output, "check syntax") - end - - @testset "ExtensionError - Minimal fields" begin - io = IOBuffer() - # Test with only required fields - e = ExtensionError(:TestDep) - - # CTBase.set_show_full_stacktrace!(false) - @test_nowarn showerror(io, e) - output = String(take!(io)) - - @test contains(output, "ExtensionError") - @test contains(output, "Missing dependencies:") - @test contains(output, "TestDep") - # Should not contain optional sections that are not provided - @test !contains(output, "Feature:") - @test !contains(output, "Context:") - @test !contains(output, "Purpose:") - end - - @testset "PreconditionError - Missing optional fields" begin - io = IOBuffer() - e = PreconditionError("Simple error") - @test_nowarn showerror(io, e) - output = String(take!(io)) - - @test contains(output, "Simple error") - @test !contains(output, "Reason:") - @test !contains(output, "Context:") - @test !contains(output, "Suggestion:") - end - - @testset "AmbiguousDescription - Missing optional fields" begin - io = IOBuffer() - e = AmbiguousDescription((:f,)) - @test_nowarn showerror(io, e) - output = String(take!(io)) - - @test contains(output, "AmbiguousDescription") - @test contains(output, "(:f,)") - @test !contains(output, "Available descriptions:") - @test !contains(output, "Context:") - @test !contains(output, "Suggestion:") - end - - @testset "User code location display" begin - io = IOBuffer() - e = IncorrectArgument("Test error for location") - - # CTBase.set_show_full_stacktrace!(false) - - # We must throw and catch the error to have a valid backtrace - try - throw(e) - catch e_caught - @test_nowarn showerror(io, e_caught) - end - - output = String(take!(io)) - - # The output should contain the user code location section - # (this tests the lines 173-187 that were uncovered) - @test contains(output, "Control Toolbox Error") - @test contains(output, "In your code:") - # In a real test environment, this should show user frames - # The exact content depends on the test environment - end - - @testset "SolverFailure - Display" begin - io = IOBuffer() - e = SolverFailure( - "ODE integration failed", - retcode=":Unstable", - suggestion="Reduce time step or check initial conditions", - context="SciML integrator", - ) - - # User-friendly - # CTBase.set_show_full_stacktrace!(false) - @test_nowarn showerror(io, e) - output = String(take!(io)) - @test contains(output, "SolverFailure") - @test contains(output, "ODE integration failed") - @test contains(output, "Return code:") - @test contains(output, ":Unstable") - @test contains(output, "Suggestion:") - @test contains(output, "Reduce time step") - @test contains(output, "Context:") - @test contains(output, "SciML integrator") - - # CTBase.set_show_full_stacktrace!(false) - end - - @testset "SolverFailure - Missing optional fields" begin - io = IOBuffer() - # Test with only required field (msg) - e = SolverFailure("Solver failed") - - # CTBase.set_show_full_stacktrace!(false) - @test_nowarn showerror(io, e) - output = String(take!(io)) - - @test contains(output, "SolverFailure") - @test contains(output, "Solver failed") - # Should not contain optional sections that are not provided - @test !contains(output, "Return code:") - @test !contains(output, "Context:") - @test !contains(output, "Suggestion:") - end - - @testset "SolverFailure - All optional fields" begin - io = IOBuffer() - e = SolverFailure( - "Optimization did not converge"; - retcode=":MaxIterations", - context="IPOPT solver", - suggestion="Increase max iterations", - ) - - # CTBase.set_show_full_stacktrace!(false) - @test_nowarn showerror(io, e) - output = String(take!(io)) - - @test contains(output, "SolverFailure") - @test contains(output, "Optimization did not converge") - @test contains(output, "Return code:") - @test contains(output, ":MaxIterations") - @test contains(output, "Context:") - @test contains(output, "IPOPT solver") - @test contains(output, "Suggestion:") - @test contains(output, "Increase max iterations") - end - end -end - -end # module - -test_display() = TestExceptionDisplay.test_exception_display() diff --git a/test/suite/exceptions/test_exception_display.jl b/test/suite/exceptions/test_exception_display.jl new file mode 100644 index 00000000..e3b9e8a0 --- /dev/null +++ b/test/suite/exceptions/test_exception_display.jl @@ -0,0 +1,421 @@ +module TestExceptionDisplay + +import Test +import CTBase.Exceptions + +const VERBOSE = isdefined(Main, :TestOptions) ? Main.TestOptions.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestOptions) ? Main.TestOptions.SHOWTIMING : true + +""" +Tests for exception display functions (display.jl) +""" +function test_exception_display() + Test.@testset "Exception Display" verbose = VERBOSE showtiming = SHOWTIMING begin + Test.@testset "IncorrectArgument - User-Friendly Display" begin + io = IOBuffer() + e = Exceptions.IncorrectArgument( + "Test error", + got="value1", + expected="value2", + suggestion="Fix it like this", + context="test function", + ) + + # User-friendly display (default) + # CTBase.set_show_full_stacktrace!(false) + Test.@test_nowarn showerror(io, e) + output = String(take!(io)) + + # Check for key sections in user-friendly display + Test.@test contains(output, "Control Toolbox Error") + Test.@test contains(output, "Test error") + Test.@test contains(output, "Got:") + Test.@test contains(output, "value1") + Test.@test contains(output, "Expected:") + Test.@test contains(output, "value2") + Test.@test contains(output, "Context:") + Test.@test contains(output, "test function") + Test.@test contains(output, "Suggestion:") + Test.@test contains(output, "Fix it like this") + end + + Test.@testset "IncorrectArgument - Full Stacktrace Display" begin + io = IOBuffer() + e = Exceptions.IncorrectArgument("Test error", got="value1", expected="value2") + + # Full stacktrace display + # CTBase.set_show_full_stacktrace!(true) + Test.@test_nowarn showerror(io, e) + output = String(take!(io)) + + # Check for compact format + Test.@test contains(output, "IncorrectArgument") + Test.@test contains(output, "Test error") + Test.@test contains(output, "Got:") + Test.@test contains(output, "value1") + Test.@test contains(output, "Expected:") + Test.@test contains(output, "value2") + + # Reset to default + # CTBase.set_show_full_stacktrace!(false) + end + + Test.@testset "IncorrectArgument - Minimal Display" begin + io = IOBuffer() + e = Exceptions.IncorrectArgument("Simple error") + + # CTBase.set_show_full_stacktrace!(false) + Test.@test_nowarn showerror(io, e) + output = String(take!(io)) + + Test.@test contains(output, "Simple error") + Test.@test !contains(output, "Got:") + Test.@test !contains(output, "Expected:") + Test.@test !contains(output, "Context:") + Test.@test !contains(output, "Suggestion:") + end + + Test.@testset "PreconditionError - User-Friendly Display" begin + io = IOBuffer() + e = Exceptions.PreconditionError( + "State must be set before dynamics", + reason="state has not been defined yet", + suggestion="Call state!(ocp, dimension) before dynamics!", + context="dynamics! function", + ) + + Test.@test_nowarn showerror(io, e) + output = String(take!(io)) + + Test.@test contains(output, "Control Toolbox Error") + Test.@test contains(output, "State must be set before dynamics") + Test.@test contains(output, "Reason:") + Test.@test contains(output, "state has not been defined yet") + Test.@test contains(output, "Suggestion:") + Test.@test contains(output, "Call state!(ocp, dimension)") + end + + Test.@testset "NotImplemented - Display" begin + io = IOBuffer() + e = Exceptions.NotImplemented("Feature not implemented", required_method="MyType") + + # User-friendly + # CTBase.set_show_full_stacktrace!(false) + Test.@test_nowarn showerror(io, e) + output = String(take!(io)) + Test.@test contains(output, "Feature not implemented") + Test.@test contains(output, "Required method:") + Test.@test contains(output, "MyType") + + # CTBase.set_show_full_stacktrace!(false) + end + + Test.@testset "ParsingError - Display" begin + io = IOBuffer() + e = Exceptions.ParsingError("Syntax error", location="line 42") + + # User-friendly + # CTBase.set_show_full_stacktrace!(false) + Test.@test_nowarn showerror(io, e) + output = String(take!(io)) + Test.@test contains(output, "Syntax error") + Test.@test contains(output, "Location:") + Test.@test contains(output, "line 42") + + # CTBase.set_show_full_stacktrace!(false) + end + + Test.@testset "Display - No Crash on Edge Cases" begin + io = IOBuffer() + + # Empty optional fields + e1 = Exceptions.IncorrectArgument("Error") + Test.@test_nowarn showerror(io, e1) + + e3 = Exceptions.NotImplemented("Error") + Test.@test_nowarn showerror(io, e3) + + e4 = Exceptions.ParsingError("Error") + Test.@test_nowarn showerror(io, e4) + + e5 = Exceptions.AmbiguousDescription((:test,)) + Test.@test_nowarn showerror(io, e5) + + e6 = Exceptions.ExtensionError(:TestExt) + Test.@test_nowarn showerror(io, e6) + + e7 = Exceptions.SolverFailure("Error") + Test.@test_nowarn showerror(io, e7) + end + + Test.@testset "AmbiguousDescription - Display" begin + io = IOBuffer() + e = Exceptions.AmbiguousDescription( + (:f,), + candidates=["(:a, :b)", "(:c, :d)"], + suggestion="Use complete description", + context="algorithm selection", + ) + + # User-friendly + # CTBase.set_show_full_stacktrace!(false) + Test.@test_nowarn showerror(io, e) + output = String(take!(io)) + Test.@test contains(output, "AmbiguousDescription") + Test.@test contains(output, "(:f,)") + Test.@test contains(output, "Available descriptions:") + Test.@test contains(output, "(:a, :b)") + Test.@test contains(output, "algorithm selection") + + # CTBase.set_show_full_stacktrace!(false) + end + + Test.@testset "ExtensionError - Display" begin + io = IOBuffer() + e = Exceptions.ExtensionError( + :Plots, + :PlotlyJS, + message="to plot results", + feature="plotting functionality", + context="solve! call", + ) + + # User-friendly + # CTBase.set_show_full_stacktrace!(false) + Test.@test_nowarn showerror(io, e) + output = String(take!(io)) + Test.@test contains(output, "ExtensionError") + Test.@test contains(output, "Missing dependencies:") + Test.@test contains(output, "Plots") + Test.@test contains(output, "PlotlyJS") + # Check for ANSI-formatted julia> using (colors now work in documentation) + Test.@test contains(output, "\e[1;32mjulia>\e[0m\e[35m using \e[0m") + + # CTBase.set_show_full_stacktrace!(false) + end + + Test.@testset "extract_user_frames function" begin + # Test with a mock stacktrace that includes various frame types + # This tests the filtering logic in extract_user_frames + try + # Create an error to generate a real stacktrace + error("test error") + catch e + st = stacktrace(catch_backtrace()) + filtered = Exceptions._extract_user_frames(st) + + # Should return some frames (non-empty in normal test environment) + Test.@test filtered isa Vector + # The filtering should work without errors + Test.@test_nowarn Exceptions._extract_user_frames(st) + end + end + + Test.@testset "NotImplemented - Missing optional fields" begin + io = IOBuffer() + # Test with only required field (msg) + e = Exceptions.NotImplemented("Not implemented feature") + + # CTBase.set_show_full_stacktrace!(false) + Test.@test_nowarn showerror(io, e) + output = String(take!(io)) + + Test.@test contains(output, "NotImplemented") + Test.@test contains(output, "Not implemented feature") + # Should not contain optional sections that are not provided + Test.@test !contains(output, "Type:") + Test.@test !contains(output, "Context:") + Test.@test !contains(output, "Suggestion:") + end + + Test.@testset "NotImplemented - All optional fields" begin + io = IOBuffer() + e = Exceptions.NotImplemented( + "Not implemented feature"; + required_method="MyType", + context="testing context", + suggestion="use this instead", + ) + + # CTBase.set_show_full_stacktrace!(false) + Test.@test_nowarn showerror(io, e) + output = String(take!(io)) + + Test.@test contains(output, "NotImplemented") + Test.@test contains(output, "Not implemented feature") + Test.@test contains(output, "Required method:") + Test.@test contains(output, "MyType") + Test.@test contains(output, "Context:") + Test.@test contains(output, "testing context") + Test.@test contains(output, "Suggestion:") + Test.@test contains(output, "use this instead") + end + + Test.@testset "ParsingError - Missing optional fields" begin + io = IOBuffer() + # Test with only required field (msg) + e = Exceptions.ParsingError("Parse error") + + # CTBase.set_show_full_stacktrace!(false) + Test.@test_nowarn showerror(io, e) + output = String(take!(io)) + + Test.@test contains(output, "ParsingError") + Test.@test contains(output, "Parse error") + # Should not contain optional sections that are not provided + Test.@test !contains(output, "Location:") + Test.@test !contains(output, "Suggestion:") + end + + Test.@testset "ParsingError - All optional fields" begin + io = IOBuffer() + e = Exceptions.ParsingError("Parse error"; location="line 10", suggestion="check syntax") + + # CTBase.set_show_full_stacktrace!(false) + Test.@test_nowarn showerror(io, e) + output = String(take!(io)) + + Test.@test contains(output, "ParsingError") + Test.@test contains(output, "Parse error") + Test.@test contains(output, "Location:") + Test.@test contains(output, "line 10") + Test.@test contains(output, "Suggestion:") + Test.@test contains(output, "check syntax") + end + + Test.@testset "ExtensionError - Minimal fields" begin + io = IOBuffer() + # Test with only required fields + e = Exceptions.ExtensionError(:TestDep) + + # CTBase.set_show_full_stacktrace!(false) + Test.@test_nowarn showerror(io, e) + output = String(take!(io)) + + Test.@test contains(output, "ExtensionError") + Test.@test contains(output, "Missing dependencies:") + Test.@test contains(output, "TestDep") + # Should not contain optional sections that are not provided + Test.@test !contains(output, "Feature:") + Test.@test !contains(output, "Context:") + Test.@test !contains(output, "Purpose:") + end + + Test.@testset "PreconditionError - Missing optional fields" begin + io = IOBuffer() + e = Exceptions.PreconditionError("Simple error") + Test.@test_nowarn showerror(io, e) + output = String(take!(io)) + + Test.@test contains(output, "Simple error") + Test.@test !contains(output, "Reason:") + Test.@test !contains(output, "Context:") + Test.@test !contains(output, "Suggestion:") + end + + Test.@testset "AmbiguousDescription - Missing optional fields" begin + io = IOBuffer() + e = Exceptions.AmbiguousDescription((:f,)) + Test.@test_nowarn showerror(io, e) + output = String(take!(io)) + + Test.@test contains(output, "AmbiguousDescription") + Test.@test contains(output, "(:f,)") + Test.@test !contains(output, "Available descriptions:") + Test.@test !contains(output, "Context:") + Test.@test !contains(output, "Suggestion:") + end + + Test.@testset "User code location display" begin + io = IOBuffer() + e = Exceptions.IncorrectArgument("Test error for location") + + # CTBase.set_show_full_stacktrace!(false) + + # We must throw and catch the error to have a valid backtrace + try + throw(e) + catch e_caught + Test.@test_nowarn showerror(io, e_caught) + end + + output = String(take!(io)) + + # The output should contain the user code location section + # (this tests the lines 173-187 that were uncovered) + Test.@test contains(output, "Control Toolbox Error") + Test.@test contains(output, "In your code:") + # In a real test environment, this should show user frames + # The exact content depends on the test environment + end + + Test.@testset "SolverFailure - Display" begin + io = IOBuffer() + e = Exceptions.SolverFailure( + "ODE integration failed", + retcode=":Unstable", + suggestion="Reduce time step or check initial conditions", + context="SciML integrator", + ) + + # User-friendly + # CTBase.set_show_full_stacktrace!(false) + Test.@test_nowarn showerror(io, e) + output = String(take!(io)) + Test.@test contains(output, "SolverFailure") + Test.@test contains(output, "ODE integration failed") + Test.@test contains(output, "Return code:") + Test.@test contains(output, ":Unstable") + Test.@test contains(output, "Suggestion:") + Test.@test contains(output, "Reduce time step") + Test.@test contains(output, "Context:") + Test.@test contains(output, "SciML integrator") + + # CTBase.set_show_full_stacktrace!(false) + end + + Test.@testset "SolverFailure - Missing optional fields" begin + io = IOBuffer() + # Test with only required field (msg) + e = Exceptions.SolverFailure("Solver failed") + + # CTBase.set_show_full_stacktrace!(false) + Test.@test_nowarn showerror(io, e) + output = String(take!(io)) + + Test.@test contains(output, "SolverFailure") + Test.@test contains(output, "Solver failed") + # Should not contain optional sections that are not provided + Test.@test !contains(output, "Return code:") + Test.@test !contains(output, "Context:") + Test.@test !contains(output, "Suggestion:") + end + + Test.@testset "SolverFailure - All optional fields" begin + io = IOBuffer() + e = Exceptions.SolverFailure( + "Optimization did not converge"; + retcode=":MaxIterations", + context="IPOPT solver", + suggestion="Increase max iterations", + ) + + # CTBase.set_show_full_stacktrace!(false) + Test.@test_nowarn showerror(io, e) + output = String(take!(io)) + + Test.@test contains(output, "SolverFailure") + Test.@test contains(output, "Optimization did not converge") + Test.@test contains(output, "Return code:") + Test.@test contains(output, ":MaxIterations") + Test.@test contains(output, "Context:") + Test.@test contains(output, "IPOPT solver") + Test.@test contains(output, "Suggestion:") + Test.@test contains(output, "Increase max iterations") + end + end +end + +end # module + +test_exception_display() = TestExceptionDisplay.test_exception_display() diff --git a/test/suite/exceptions/test_exceptions.jl b/test/suite/exceptions/test_exceptions.jl index e866f7ba..90696407 100644 --- a/test/suite/exceptions/test_exceptions.jl +++ b/test/suite/exceptions/test_exceptions.jl @@ -1,6 +1,6 @@ module TestExceptions -using Test +import Test import CTBase.Exceptions const VERBOSE = isdefined(Main, :TestOptions) ? Main.TestOptions.VERBOSE : true @@ -11,16 +11,16 @@ function test_exceptions() # Test suite for CTException subtypes and their error printing # Test AmbiguousDescription - @testset verbose = VERBOSE showtiming = SHOWTIMING "AmbiguousDescription" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "AmbiguousDescription" begin e = Exceptions.AmbiguousDescription((:e,)) # Check that throwing error(e) produces an ErrorException - @test_throws Exceptions.AmbiguousDescription throw(e) + Test.@test_throws Exceptions.AmbiguousDescription throw(e) # Check that showerror produces a string output output = sprint(showerror, e) - @test typeof(output) == String + Test.@test typeof(output) == String # Check that the output contains the type name styled (red, bold) - @test occursin("AmbiguousDescription", output) - @test occursin("(:e,)", output) + Test.@test occursin("AmbiguousDescription", output) + Test.@test occursin("(:e,)", output) # Test enriched version with candidates and suggestions e_enriched = Exceptions.AmbiguousDescription( @@ -30,23 +30,23 @@ function test_exceptions() context="test context", ) output_enriched = sprint(showerror, e_enriched) - @test occursin("Available descriptions", output_enriched) - @test occursin("(:a, :b)", output_enriched) - @test occursin("(:c, :d)", output_enriched) - @test occursin("Suggestion", output_enriched) - @test occursin("Try one of the available descriptions", output_enriched) - @test occursin("Context", output_enriched) - @test occursin("test context", output_enriched) + Test.@test occursin("Available descriptions", output_enriched) + Test.@test occursin("(:a, :b)", output_enriched) + Test.@test occursin("(:c, :d)", output_enriched) + Test.@test occursin("Suggestion", output_enriched) + Test.@test occursin("Try one of the available descriptions", output_enriched) + Test.@test occursin("Context", output_enriched) + Test.@test occursin("test context", output_enriched) end # Test IncorrectArgument - @testset verbose = VERBOSE showtiming = SHOWTIMING "IncorrectArgument" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "IncorrectArgument" begin e = Exceptions.IncorrectArgument("invalid argument") - @test_throws Exceptions.IncorrectArgument throw(e) + Test.@test_throws Exceptions.IncorrectArgument throw(e) output = sprint(showerror, e) - @test typeof(output) == String - @test occursin("IncorrectArgument", output) - @test occursin("invalid argument", output) + Test.@test typeof(output) == String + Test.@test occursin("IncorrectArgument", output) + Test.@test occursin("invalid argument", output) # Test enriched version with all fields e_enriched = Exceptions.IncorrectArgument( @@ -57,24 +57,24 @@ function test_exceptions() context="initialization", ) output_enriched = sprint(showerror, e_enriched) - @test occursin("Got", output_enriched) - @test occursin("vector of length 3", output_enriched) - @test occursin("Expected", output_enriched) - @test occursin("vector of length 2", output_enriched) - @test occursin("Suggestion", output_enriched) - @test occursin("Resize your vector", output_enriched) - @test occursin("Context", output_enriched) - @test occursin("initialization", output_enriched) + Test.@test occursin("Got", output_enriched) + Test.@test occursin("vector of length 3", output_enriched) + Test.@test occursin("Expected", output_enriched) + Test.@test occursin("vector of length 2", output_enriched) + Test.@test occursin("Suggestion", output_enriched) + Test.@test occursin("Resize your vector", output_enriched) + Test.@test occursin("Context", output_enriched) + Test.@test occursin("initialization", output_enriched) end # Test NotImplemented - @testset verbose = VERBOSE showtiming = SHOWTIMING "NotImplemented" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "NotImplemented" begin e = Exceptions.NotImplemented("feature not ready") - @test_throws Exceptions.NotImplemented throw(e) + Test.@test_throws Exceptions.NotImplemented throw(e) output = sprint(showerror, e) - @test typeof(output) == String - @test occursin("NotImplemented", output) - @test occursin("feature not ready", output) + Test.@test typeof(output) == String + Test.@test occursin("NotImplemented", output) + Test.@test occursin("feature not ready", output) # Test enriched version e_enriched = Exceptions.NotImplemented( @@ -84,22 +84,22 @@ function test_exceptions() context="algorithm execution", ) output_enriched = sprint(showerror, e_enriched) - @test occursin("Type", output_enriched) - @test occursin("MyAbstractType", output_enriched) - @test occursin("Suggestion", output_enriched) - @test occursin("Implement this method", output_enriched) - @test occursin("Context", output_enriched) - @test occursin("algorithm execution", output_enriched) + Test.@test occursin("Type", output_enriched) + Test.@test occursin("MyAbstractType", output_enriched) + Test.@test occursin("Suggestion", output_enriched) + Test.@test occursin("Implement this method", output_enriched) + Test.@test occursin("Context", output_enriched) + Test.@test occursin("algorithm execution", output_enriched) end # Test PreconditionError - @testset verbose = VERBOSE showtiming = SHOWTIMING "PreconditionError" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "PreconditionError" begin e = Exceptions.PreconditionError("state must be set before dynamics") - @test_throws Exceptions.PreconditionError throw(e) + Test.@test_throws Exceptions.PreconditionError throw(e) output = sprint(showerror, e) - @test typeof(output) == String - @test occursin("PreconditionError", output) - @test occursin("state must be set before dynamics", output) + Test.@test typeof(output) == String + Test.@test occursin("PreconditionError", output) + Test.@test occursin("state must be set before dynamics", output) # Test enriched version e_enriched = Exceptions.PreconditionError( @@ -109,22 +109,22 @@ function test_exceptions() context="state definition", ) output_enriched = sprint(showerror, e_enriched) - @test occursin("Reason", output_enriched) - @test occursin("state has already been defined", output_enriched) - @test occursin("Suggestion", output_enriched) - @test occursin("Create a new OCP instance", output_enriched) - @test occursin("Context", output_enriched) - @test occursin("state definition", output_enriched) + Test.@test occursin("Reason", output_enriched) + Test.@test occursin("state has already been defined", output_enriched) + Test.@test occursin("Suggestion", output_enriched) + Test.@test occursin("Create a new OCP instance", output_enriched) + Test.@test occursin("Context", output_enriched) + Test.@test occursin("state definition", output_enriched) end # Test ParsingError - @testset verbose = VERBOSE showtiming = SHOWTIMING "ParsingError" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "ParsingError" begin e = Exceptions.ParsingError("syntax error") - @test_throws Exceptions.ParsingError throw(e) + Test.@test_throws Exceptions.ParsingError throw(e) output = sprint(showerror, e) - @test typeof(output) == String - @test occursin("ParsingError", output) - @test occursin("syntax error", output) + Test.@test typeof(output) == String + Test.@test occursin("ParsingError", output) + Test.@test occursin("syntax error", output) # Test enriched version e_enriched = Exceptions.ParsingError( @@ -133,36 +133,36 @@ function test_exceptions() suggestion="Check syntax balance", ) output_enriched = sprint(showerror, e_enriched) - @test occursin("Location", output_enriched) - @test occursin("line 42, column 15", output_enriched) - @test occursin("Suggestion", output_enriched) - @test occursin("Check syntax balance", output_enriched) + Test.@test occursin("Location", output_enriched) + Test.@test occursin("line 42, column 15", output_enriched) + Test.@test occursin("Suggestion", output_enriched) + Test.@test occursin("Check syntax balance", output_enriched) end # Test ExtensionError - @testset verbose = VERBOSE showtiming = SHOWTIMING "ExtensionError" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "ExtensionError" begin # Test constructor throws if no dependencies provided - @test_throws Exceptions.PreconditionError Exceptions.ExtensionError() + Test.@test_throws Exceptions.PreconditionError Exceptions.ExtensionError() # Create with one weak dependency e = Exceptions.ExtensionError(:MyExt) - @test_throws Exceptions.ExtensionError throw(e) + Test.@test_throws Exceptions.ExtensionError throw(e) output = sprint(showerror, e) - @test typeof(output) == String - @test occursin("ExtensionError", output) - @test occursin("MyExt", output) - @test occursin("using", output) + Test.@test typeof(output) == String + Test.@test occursin("ExtensionError", output) + Test.@test occursin("MyExt", output) + Test.@test occursin("using", output) # Create with multiple weak dependencies e2 = Exceptions.ExtensionError(:Ext1, :Ext2) output2 = sprint(showerror, e2) - @test occursin("Ext1", output2) - @test occursin("Ext2", output2) + Test.@test occursin("Ext1", output2) + Test.@test occursin("Ext2", output2) # Test with optional message e_msg = Exceptions.ExtensionError(:MyExt; message="to enable feature X") output_msg = sprint(showerror, e_msg) - @test occursin("ExtensionError", output_msg) - @test occursin("MyExt", output_msg) - @test occursin("to enable feature X", output_msg) + Test.@test occursin("ExtensionError", output_msg) + Test.@test occursin("MyExt", output_msg) + Test.@test occursin("to enable feature X", output_msg) # Test enriched version with feature and context e_enriched = Exceptions.ExtensionError( @@ -173,20 +173,20 @@ function test_exceptions() context="reference generation", ) output_enriched = sprint(showerror, e_enriched) - @test occursin("Missing dependencies", output_enriched) - @test occursin("Documenter", output_enriched) - @test occursin("Markdown", output_enriched) - @test occursin("to generate documentation", output_enriched) + Test.@test occursin("Missing dependencies", output_enriched) + Test.@test occursin("Documenter", output_enriched) + Test.@test occursin("Markdown", output_enriched) + Test.@test occursin("to generate documentation", output_enriched) end # Test SolverFailure - @testset verbose = VERBOSE showtiming = SHOWTIMING "SolverFailure" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "SolverFailure" begin e = Exceptions.SolverFailure("solver failed") - @test_throws Exceptions.SolverFailure throw(e) + Test.@test_throws Exceptions.SolverFailure throw(e) output = sprint(showerror, e) - @test typeof(output) == String - @test occursin("SolverFailure", output) - @test occursin("solver failed", output) + Test.@test typeof(output) == String + Test.@test occursin("SolverFailure", output) + Test.@test occursin("solver failed", output) # Test enriched version with retcode e_enriched = Exceptions.SolverFailure( @@ -196,17 +196,17 @@ function test_exceptions() context="SciML integrator", ) output_enriched = sprint(showerror, e_enriched) - @test occursin("Return code", output_enriched) - @test occursin(":Unstable", output_enriched) - @test occursin("Suggestion", output_enriched) - @test occursin("Reduce time step", output_enriched) - @test occursin("Context", output_enriched) - @test occursin("SciML integrator", output_enriched) + Test.@test occursin("Return code", output_enriched) + Test.@test occursin(":Unstable", output_enriched) + Test.@test occursin("Suggestion", output_enriched) + Test.@test occursin("Reduce time step", output_enriched) + Test.@test occursin("Context", output_enriched) + Test.@test occursin("SciML integrator", output_enriched) end - @testset verbose = VERBOSE showtiming = SHOWTIMING "CTException supertype catch" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "CTException supertype catch" begin e = Exceptions.IncorrectArgument("msg") - @test_throws Exceptions.IncorrectArgument throw(e) + Test.@test_throws Exceptions.IncorrectArgument throw(e) end return nothing diff --git a/test/suite/exceptions/test_types.jl b/test/suite/exceptions/test_types.jl index 0c3b5ebb..f900ee50 100644 --- a/test/suite/exceptions/test_types.jl +++ b/test/suite/exceptions/test_types.jl @@ -1,7 +1,8 @@ module TestExceptionTypes -using Test -using CTBase.Exceptions +import Test +import CTBase.Exceptions + const VERBOSE = isdefined(Main, :TestOptions) ? Main.TestOptions.VERBOSE : true const SHOWTIMING = isdefined(Main, :TestOptions) ? Main.TestOptions.SHOWTIMING : true @@ -9,252 +10,252 @@ const SHOWTIMING = isdefined(Main, :TestOptions) ? Main.TestOptions.SHOWTIMING : Tests for exception type definitions (types.jl) """ function test_exception_types() - @testset "Exception Types" verbose = VERBOSE showtiming = SHOWTIMING begin - @testset "CTException Hierarchy" begin + Test.@testset "Exception Types" verbose = VERBOSE showtiming = SHOWTIMING begin + Test.@testset "CTException Hierarchy" begin # Test that all exceptions inherit from CTException - @test IncorrectArgument("test") isa CTException - @test PreconditionError("test") isa CTException + Test.@test Exceptions.IncorrectArgument("test") isa Exceptions.CTException + Test.@test Exceptions.PreconditionError("test") isa Exceptions.CTException - @test NotImplemented("test") isa CTException - @test ParsingError("test") isa CTException - @test AmbiguousDescription((:f,)) isa CTException - @test ExtensionError(:MyExt) isa CTException - @test SolverFailure("test") isa CTException + Test.@test Exceptions.NotImplemented("test") isa Exceptions.CTException + Test.@test Exceptions.ParsingError("test") isa Exceptions.CTException + Test.@test Exceptions.AmbiguousDescription((:f,)) isa Exceptions.CTException + Test.@test Exceptions.ExtensionError(:MyExt) isa Exceptions.CTException + Test.@test Exceptions.SolverFailure("test") isa Exceptions.CTException # Test that they are also standard Exceptions - @test IncorrectArgument("test") isa Exception - @test PreconditionError("test") isa Exception - - @test NotImplemented("test") isa Exception - @test ParsingError("test") isa Exception - @test AmbiguousDescription((:f,)) isa Exception - @test ExtensionError(:MyExt) isa Exception - @test SolverFailure("test") isa Exception + Test.@test Exceptions.IncorrectArgument("test") isa Exception + Test.@test Exceptions.PreconditionError("test") isa Exception + + Test.@test Exceptions.NotImplemented("test") isa Exception + Test.@test Exceptions.ParsingError("test") isa Exception + Test.@test Exceptions.AmbiguousDescription((:f,)) isa Exception + Test.@test Exceptions.ExtensionError(:MyExt) isa Exception + Test.@test Exceptions.SolverFailure("test") isa Exception end - @testset "IncorrectArgument - Construction" begin + Test.@testset "IncorrectArgument - Construction" begin # Simple message only - e = IncorrectArgument("Invalid input") - @test e.msg == "Invalid input" - @test isnothing(e.got) - @test isnothing(e.expected) - @test isnothing(e.suggestion) - @test isnothing(e.context) + e = Exceptions.IncorrectArgument("Invalid input") + Test.@test e.msg == "Invalid input" + Test.@test isnothing(e.got) + Test.@test isnothing(e.expected) + Test.@test isnothing(e.suggestion) + Test.@test isnothing(e.context) # With got and expected - e = IncorrectArgument("Invalid value", got="x", expected="y") - @test e.msg == "Invalid value" - @test e.got == "x" - @test e.expected == "y" - @test isnothing(e.suggestion) - @test isnothing(e.context) + e = Exceptions.IncorrectArgument("Invalid value", got="x", expected="y") + Test.@test e.msg == "Invalid value" + Test.@test e.got == "x" + Test.@test e.expected == "y" + Test.@test isnothing(e.suggestion) + Test.@test isnothing(e.context) # With all fields - e = IncorrectArgument( + e = Exceptions.IncorrectArgument( "Invalid criterion", got=":invalid", expected=":min or :max", suggestion="Use objective!(ocp, :min, ...)", context="objective! function", ) - @test e.msg == "Invalid criterion" - @test e.got == ":invalid" - @test e.expected == ":min or :max" - @test e.suggestion == "Use objective!(ocp, :min, ...)" - @test e.context == "objective! function" + Test.@test e.msg == "Invalid criterion" + Test.@test e.got == ":invalid" + Test.@test e.expected == ":min or :max" + Test.@test e.suggestion == "Use objective!(ocp, :min, ...)" + Test.@test e.context == "objective! function" # Test that it can be thrown - @test_throws IncorrectArgument throw(IncorrectArgument("Test error")) + Test.@test_throws Exceptions.IncorrectArgument throw(Exceptions.IncorrectArgument("Test error")) end - @testset "PreconditionError - Construction" begin + Test.@testset "PreconditionError - Construction" begin # Simple message only - e = PreconditionError("State must be set before dynamics") - @test e.msg == "State must be set before dynamics" - @test isnothing(e.reason) - @test isnothing(e.suggestion) - @test isnothing(e.context) + e = Exceptions.PreconditionError("State must be set before dynamics") + Test.@test e.msg == "State must be set before dynamics" + Test.@test isnothing(e.reason) + Test.@test isnothing(e.suggestion) + Test.@test isnothing(e.context) # With reason - e = PreconditionError("Cannot call", reason="precondition not met") - @test e.msg == "Cannot call" - @test e.reason == "precondition not met" - @test isnothing(e.suggestion) + e = Exceptions.PreconditionError("Cannot call", reason="precondition not met") + Test.@test e.msg == "Cannot call" + Test.@test e.reason == "precondition not met" + Test.@test isnothing(e.suggestion) # With all fields - e = PreconditionError( + e = Exceptions.PreconditionError( "Cannot call state! twice", reason="state has already been defined for this OCP", suggestion="Create a new OCP instance", context="state! function", ) - @test e.msg == "Cannot call state! twice" - @test e.reason == "state has already been defined for this OCP" - @test e.suggestion == "Create a new OCP instance" - @test e.context == "state! function" + Test.@test e.msg == "Cannot call state! twice" + Test.@test e.reason == "state has already been defined for this OCP" + Test.@test e.suggestion == "Create a new OCP instance" + Test.@test e.context == "state! function" # Test that it can be thrown - @test_throws PreconditionError throw(PreconditionError("Test error")) + Test.@test_throws Exceptions.PreconditionError throw(Exceptions.PreconditionError("Test error")) end - @testset "NotImplemented - Construction" begin + Test.@testset "NotImplemented - Construction" begin # Simple message only - e = NotImplemented("run! not implemented") - @test e.msg == "run! not implemented" - @test isnothing(e.required_method) - @test isnothing(e.suggestion) - @test isnothing(e.context) + e = Exceptions.NotImplemented("run! not implemented") + Test.@test e.msg == "run! not implemented" + Test.@test isnothing(e.required_method) + Test.@test isnothing(e.suggestion) + Test.@test isnothing(e.context) # With required method - e = NotImplemented( + e = Exceptions.NotImplemented( "run! not implemented", required_method="run!(::MyAlgorithm, state)" ) - @test e.msg == "run! not implemented" - @test e.required_method == "run!(::MyAlgorithm, state)" - @test isnothing(e.suggestion) - @test isnothing(e.context) + Test.@test e.msg == "run! not implemented" + Test.@test e.required_method == "run!(::MyAlgorithm, state)" + Test.@test isnothing(e.suggestion) + Test.@test isnothing(e.context) # With all fields (NEW) - e = NotImplemented( + e = Exceptions.NotImplemented( "Method solve! not implemented", required_method="solve!(::MyStrategy, ...)", context="solve call", suggestion="Import the relevant package (e.g. CTDirect) or implement solve!(::MyStrategy, ...)", ) - @test e.msg == "Method solve! not implemented" - @test e.required_method == "solve!(::MyStrategy, ...)" - @test e.context == "solve call" - @test e.suggestion == + Test.@test e.msg == "Method solve! not implemented" + Test.@test e.required_method == "solve!(::MyStrategy, ...)" + Test.@test e.context == "solve call" + Test.@test e.suggestion == "Import the relevant package (e.g. CTDirect) or implement solve!(::MyStrategy, ...)" # Test that it can be thrown - @test_throws NotImplemented throw(NotImplemented("Test")) + Test.@test_throws Exceptions.NotImplemented throw(Exceptions.NotImplemented("Test")) end - @testset "ParsingError - Construction" begin + Test.@testset "ParsingError - Construction" begin # Simple message only - e = ParsingError("Unexpected token") - @test e.msg == "Unexpected token" - @test isnothing(e.location) - @test isnothing(e.suggestion) + e = Exceptions.ParsingError("Unexpected token") + Test.@test e.msg == "Unexpected token" + Test.@test isnothing(e.location) + Test.@test isnothing(e.suggestion) # With location - e = ParsingError("Unexpected token", location="line 42") - @test e.msg == "Unexpected token" - @test e.location == "line 42" - @test isnothing(e.suggestion) + e = Exceptions.ParsingError("Unexpected token", location="line 42") + Test.@test e.msg == "Unexpected token" + Test.@test e.location == "line 42" + Test.@test isnothing(e.suggestion) # With all fields (NEW) - e = ParsingError( + e = Exceptions.ParsingError( "Unexpected token 'end'", location="line 42, column 15", suggestion="Check syntax balance or remove extra 'end'", ) - @test e.msg == "Unexpected token 'end'" - @test e.location == "line 42, column 15" - @test e.suggestion == "Check syntax balance or remove extra 'end'" + Test.@test e.msg == "Unexpected token 'end'" + Test.@test e.location == "line 42, column 15" + Test.@test e.suggestion == "Check syntax balance or remove extra 'end'" # Test that it can be thrown - @test_throws ParsingError throw(ParsingError("Test")) + Test.@test_throws Exceptions.ParsingError throw(Exceptions.ParsingError("Test")) end - @testset "AmbiguousDescription - Construction" begin + Test.@testset "AmbiguousDescription - Construction" begin # Simple description only - e = AmbiguousDescription((:f,)) - @test e.description == (:f,) - @test contains(e.msg, "cannot find matching description") - @test isnothing(e.candidates) - @test isnothing(e.suggestion) - @test isnothing(e.context) + e = Exceptions.AmbiguousDescription((:f,)) + Test.@test e.description == (:f,) + Test.@test contains(e.msg, "cannot find matching description") + Test.@test isnothing(e.candidates) + Test.@test isnothing(e.suggestion) + Test.@test isnothing(e.context) # With custom message - e = AmbiguousDescription((:x, :y); msg="Custom message") - @test e.description == (:x, :y) - @test e.msg == "Custom message" - @test isnothing(e.candidates) - @test isnothing(e.suggestion) - @test isnothing(e.context) + e = Exceptions.AmbiguousDescription((:x, :y); msg="Custom message") + Test.@test e.description == (:x, :y) + Test.@test e.msg == "Custom message" + Test.@test isnothing(e.candidates) + Test.@test isnothing(e.suggestion) + Test.@test isnothing(e.context) # With all fields - e = AmbiguousDescription( + e = Exceptions.AmbiguousDescription( (:f,), candidates=["(:a, :b)", "(:c, :d)"], suggestion="Use a complete description", context="algorithm selection", ) - @test e.description == (:f,) - @test e.candidates == ["(:a, :b)", "(:c, :d)"] - @test e.suggestion == "Use a complete description" - @test e.context == "algorithm selection" + Test.@test e.description == (:f,) + Test.@test e.candidates == ["(:a, :b)", "(:c, :d)"] + Test.@test e.suggestion == "Use a complete description" + Test.@test e.context == "algorithm selection" # Test that it can be thrown - @test_throws AmbiguousDescription throw(AmbiguousDescription((:test,))) + Test.@test_throws Exceptions.AmbiguousDescription throw(Exceptions.AmbiguousDescription((:test,))) end - @testset "ExtensionError - Construction" begin + Test.@testset "ExtensionError - Construction" begin # Simple dependency only - e = ExtensionError(:MyExt) - @test e.weakdeps == (:MyExt,) - @test e.msg == "missing dependencies" - @test isnothing(e.feature) - @test isnothing(e.context) + e = Exceptions.ExtensionError(:MyExt) + Test.@test e.weakdeps == (:MyExt,) + Test.@test e.msg == "missing dependencies" + Test.@test isnothing(e.feature) + Test.@test isnothing(e.context) # With message - e = ExtensionError(:Plots; message="to plot results") - @test e.weakdeps == (:Plots,) - @test e.msg == "missing dependencies to plot results" - @test isnothing(e.feature) - @test isnothing(e.context) + e = Exceptions.ExtensionError(:Plots; message="to plot results") + Test.@test e.weakdeps == (:Plots,) + Test.@test e.msg == "missing dependencies to plot results" + Test.@test isnothing(e.feature) + Test.@test isnothing(e.context) # With all fields - e = ExtensionError( + e = Exceptions.ExtensionError( :Plots, :PlotlyJS, message="to plot optimization results", feature="plotting functionality", context="solve! call", ) - @test e.weakdeps == (:Plots, :PlotlyJS) - @test e.msg == "missing dependencies to plot optimization results" - @test e.feature == "plotting functionality" - @test e.context == "solve! call" + Test.@test e.weakdeps == (:Plots, :PlotlyJS) + Test.@test e.msg == "missing dependencies to plot optimization results" + Test.@test e.feature == "plotting functionality" + Test.@test e.context == "solve! call" # Test that it can be thrown - @test_throws ExtensionError throw(ExtensionError(:TestExt)) + Test.@test_throws Exceptions.ExtensionError throw(Exceptions.ExtensionError(:TestExt)) # Test error when no dependencies provided - @test_throws PreconditionError ExtensionError() + Test.@test_throws Exceptions.PreconditionError Exceptions.ExtensionError() end - @testset "SolverFailure - Construction" begin + Test.@testset "SolverFailure - Construction" begin # Simple message only - e = SolverFailure("Solver failed") - @test e.msg == "Solver failed" - @test isnothing(e.retcode) - @test isnothing(e.suggestion) - @test isnothing(e.context) + e = Exceptions.SolverFailure("Solver failed") + Test.@test e.msg == "Solver failed" + Test.@test isnothing(e.retcode) + Test.@test isnothing(e.suggestion) + Test.@test isnothing(e.context) # With retcode - e = SolverFailure("Integration failed", retcode=":Unstable") - @test e.msg == "Integration failed" - @test e.retcode == ":Unstable" - @test isnothing(e.suggestion) - @test isnothing(e.context) + e = Exceptions.SolverFailure("Integration failed", retcode=":Unstable") + Test.@test e.msg == "Integration failed" + Test.@test e.retcode == ":Unstable" + Test.@test isnothing(e.suggestion) + Test.@test isnothing(e.context) # With all fields - e = SolverFailure( + e = Exceptions.SolverFailure( "Optimization did not converge", retcode=":MaxIterations", suggestion="Increase max iterations or adjust tolerance", context="IPOPT solver", ) - @test e.msg == "Optimization did not converge" - @test e.retcode == ":MaxIterations" - @test e.suggestion == "Increase max iterations or adjust tolerance" - @test e.context == "IPOPT solver" + Test.@test e.msg == "Optimization did not converge" + Test.@test e.retcode == ":MaxIterations" + Test.@test e.suggestion == "Increase max iterations or adjust tolerance" + Test.@test e.context == "IPOPT solver" # Test that it can be thrown - @test_throws SolverFailure throw(SolverFailure("Test error")) + Test.@test_throws Exceptions.SolverFailure throw(Exceptions.SolverFailure("Test error")) end end end diff --git a/test/suite/extensions/test_coverage_edge_cases.jl b/test/suite/extensions/test_coverage_edge_cases.jl index 59cc8c52..0f8195f2 100644 --- a/test/suite/extensions/test_coverage_edge_cases.jl +++ b/test/suite/extensions/test_coverage_edge_cases.jl @@ -1,9 +1,9 @@ module TestCoverageEdgeCases -using Test -using CTBase -using Documenter -using Coverage +import Test +import CTBase +import Documenter +import Coverage # Access internal modules via get_extension const CP = Base.get_extension(CTBase, :CoveragePostprocessing) @@ -14,7 +14,7 @@ const VERBOSE = isdefined(Main, :TestOptions) ? Main.TestOptions.VERBOSE : true const SHOWTIMING = isdefined(Main, :TestOptions) ? Main.TestOptions.SHOWTIMING : true function test_coverage_edge_cases() - @testset verbose = VERBOSE showtiming = SHOWTIMING "Coverage and Test Edge Cases" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "Coverage and Test Edge Cases" begin # ---------------------------------------------------------------------------------- # CoveragePostprocessing: trigger error at line 92 @@ -22,8 +22,8 @@ function test_coverage_edge_cases() # Strategy: Mocking fails because the function is likely inlined. # We skip this test as the line is unreachable under normal conditions. # ---------------------------------------------------------------------------------- - # @testset "CoveragePostprocessing: clean_stale_cov_files! deletes all" begin - # @test CP !== nothing + # Test.@testset "CoveragePostprocessing: clean_stale_cov_files! deletes all" begin + # Test.@test CP !== nothing # mktempdir() do tmp # cd(tmp) do # mkpath("src") @@ -63,8 +63,8 @@ function test_coverage_edge_cases() # e # end - # @test err isa ErrorException - # @test occursin("no usable .cov files", err.msg) + # Test.@test err isa ErrorException + # Test.@test occursin("no usable .cov files", err.msg) # finally # # Restore original function by defining a method that calls the captured original @@ -84,59 +84,44 @@ function test_coverage_edge_cases() # ---------------------------------------------------------------------------------- # TestRunner: trigger error at line 424 # Error: "Test file ... not found for test ..." inside _run_single_test - # Strategy: Mock _find_symbol_test_file_rel to return a non-existent file. + # Strategy: Create a file that exists during discovery but is deleted before execution. # ---------------------------------------------------------------------------------- - @testset "TestRunner: file exists then disappears" begin - @test TR !== nothing + Test.@testset "TestRunner: file exists then disappears" begin + Test.@test TR !== nothing mktempdir() do tmp - # Redefine _find_symbol_test_file_rel to return a phantom file - original_find = TR._find_symbol_test_file_rel - - try - # Return a filename that definitely does not exist - Base.eval( - TR, :(function _find_symbol_test_file_rel(name, builder; test_dir) - return "phantom.jl" - end) - ) - - err = try - TR._run_single_test( - :phantom_test; - filename_builder=identity, - funcname_builder=identity, - eval_mode=false, - test_dir=tmp, - ) - nothing - catch e - e - end - - @test err isa CTBase.Exceptions.IncorrectArgument - @test occursin("not found", err.msg) - - finally - Base.eval( - TR, - quote - function _find_symbol_test_file_rel(name, builder; test_dir) - return $(original_find)(name, builder; test_dir=test_dir) - end - end, + # Create a test file that will be deleted before execution + test_file = joinpath(tmp, "phantom_test.jl") + touch(test_file) + + # Delete the file before calling _run_single_test + rm(test_file) + + err = try + TR._run_single_test( + :phantom_test; + filename_builder=identity, + funcname_builder=identity, + eval_mode=false, + test_dir=tmp, ) + nothing + catch e + e end + + Test.@test err isa CTBase.Exceptions.IncorrectArgument + Test.@test occursin("not found", err.msg) end end # ---------------------------------------------------------------------------------- # DocumenterReference: Missing coverage # ---------------------------------------------------------------------------------- - @testset "DocumenterReference: Edge cases" begin + Test.@testset "DocumenterReference: Edge cases" begin # Line 327: Documenter.Selectors.order(::Type{APIBuilder}) # Explicit call to ensure coverage - @test Documenter.Selectors.order(DR.APIBuilder) == 0.5 + Test.@test Documenter.Selectors.order(DR.APIBuilder) == 0.5 # Line 539: _exported_symbols getfield failure # Used BrokenExportMod defined at top level @@ -144,7 +129,7 @@ function test_coverage_edge_cases() # This should catch the error and skip the symbol, covering the catch block syms = DR._exported_symbols(BrokenExportMod) # Verify undefined_sym is not in the result - @test !any(p -> first(p) == :undefined_sym, syms.exported) + Test.@test !any(p -> first(p) == :undefined_sym, syms.exported) # Line 607: _get_source_from_docstring # Used HackDocMod defined at top level @@ -168,7 +153,7 @@ function test_coverage_edge_cases() # Should return nothing now src = DR._get_source_from_docstring(HackDocMod, :f) - @test src === nothing + Test.@test src === nothing finally # Restore @@ -202,7 +187,7 @@ function test_coverage_edge_cases() path_plus = DR._get_source_from_methods(+) # valid outcome is either nothing (if all are built-in) or a path (if some are extended). # This at least runs the loop. - @test path_plus === nothing || path_plus isa String + Test.@test path_plus === nothing || path_plus isa String end end end diff --git a/test/suite/extensions/test_coverage_post_process.jl b/test/suite/extensions/test_coverage_post_process.jl index 1152a101..a4d91795 100644 --- a/test/suite/extensions/test_coverage_post_process.jl +++ b/test/suite/extensions/test_coverage_post_process.jl @@ -1,14 +1,25 @@ +module TestCoveragePostProcess + +import Test +import Coverage +import CTBase +import CTBase.Extensions +import CTBase.Exceptions + +const VERBOSE = isdefined(Main, :TestOptions) ? Main.TestOptions.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestOptions) ? Main.TestOptions.SHOWTIMING : true + # TOP-LEVEL: Fake type for stub testing -struct DummyCoverageTag <: CTBase.Extensions.AbstractCoveragePostprocessingTag end +struct DummyCoverageTag <: Extensions.AbstractCoveragePostprocessingTag end function test_coverage_post_process() CP = Base.get_extension(CTBase, :CoveragePostprocessing) - @testset "CoveragePostprocessing extension" begin - @test Base.get_extension(CTBase, :CoveragePostprocessing) !== nothing + Test.@testset "CoveragePostprocessing extension" begin + Test.@test Base.get_extension(CTBase, :CoveragePostprocessing) !== nothing end - @testset "Errors when no .cov files were produced" begin + Test.@testset "Errors when no .cov files were produced" begin mktempdir() do tmp cd(tmp) do mkpath("src") @@ -16,31 +27,39 @@ function test_coverage_post_process() mkpath("ext") err = try - CTBase.postprocess_coverage(; generate_report=false) + redirect_stdout(devnull) do + redirect_stderr(devnull) do + Extensions.postprocess_coverage(; generate_report=false) + end + end nothing catch e e end - @test err isa CTBase.Exceptions.PreconditionError - @test occursin("no .cov files", lowercase(err.msg)) + Test.@test err isa Exceptions.PreconditionError + Test.@test occursin("no .cov files", lowercase(err.msg)) end end end - @testset "Stub dispatch remains available" begin + Test.@testset "Stub dispatch remains available" begin err = try - CTBase.postprocess_coverage(DummyCoverageTag()) + redirect_stdout(devnull) do + redirect_stderr(devnull) do + Extensions.postprocess_coverage(DummyCoverageTag()) + end + end nothing catch e e end - @test err isa CTBase.ExtensionError - @test err.weakdeps == (:Coverage,) + Test.@test err isa Exceptions.ExtensionError + Test.@test err.weakdeps == (:Coverage,) end - @testset "Post-process moves .cov files" begin + Test.@testset "Post-process moves .cov files" begin mktempdir() do tmp cd(tmp) do mkpath("src") @@ -53,21 +72,25 @@ function test_coverage_post_process() touch(joinpath("src", "old.jl.222.cov")) - CTBase.postprocess_coverage(; generate_report=false) + redirect_stdout(devnull) do + redirect_stderr(devnull) do + Extensions.postprocess_coverage(; generate_report=false) + end + end - @test isempty(filter(f -> endswith(f, ".cov"), readdir("src"))) - @test isempty(filter(f -> endswith(f, ".cov"), readdir("test"))) - @test isempty(filter(f -> endswith(f, ".cov"), readdir("ext"))) + Test.@test isempty(filter(f -> endswith(f, ".cov"), readdir("src"))) + Test.@test isempty(filter(f -> endswith(f, ".cov"), readdir("test"))) + Test.@test isempty(filter(f -> endswith(f, ".cov"), readdir("ext"))) - @test isfile(joinpath("coverage", "cov", "a.jl.111.cov")) - @test isfile(joinpath("coverage", "cov", "b.jl.111.cov")) - @test isfile(joinpath("coverage", "cov", "t.jl.111.cov")) - @test !isfile(joinpath("coverage", "cov", "old.jl.222.cov")) + Test.@test isfile(joinpath("coverage", "cov", "a.jl.111.cov")) + Test.@test isfile(joinpath("coverage", "cov", "b.jl.111.cov")) + Test.@test isfile(joinpath("coverage", "cov", "t.jl.111.cov")) + Test.@test !isfile(joinpath("coverage", "cov", "old.jl.222.cov")) end end end - @testset "Generates report when generate_report=true" begin + Test.@testset "Generates report when generate_report=true" begin mktempdir() do tmp cd(tmp) do mkpath("src") @@ -125,19 +148,23 @@ function test_coverage_post_process() # We also need to mock EXT_DIR if we want to test that branch? # The current code checks isdir(EXT_DIR). - CTBase.postprocess_coverage(; generate_report=true) + redirect_stdout(devnull) do + redirect_stderr(devnull) do + Extensions.postprocess_coverage(; generate_report=true) + end + end - @test isfile(joinpath("coverage", "lcov.info")) - @test isfile(joinpath("coverage", "cov_report.md")) + Test.@test isfile(joinpath("coverage", "lcov.info")) + Test.@test isfile(joinpath("coverage", "cov_report.md")) report = read(joinpath("coverage", "cov_report.md"), String) - @test occursin("foo.jl", report) - @test occursin("100.0", report) # 1 line covered out of 1 + Test.@test occursin("foo.jl", report) + Test.@test occursin("100.0", report) # 1 line covered out of 1 end end end - @testset "Internal report generation handles relative source_dirs" begin + Test.@testset "Internal report generation handles relative source_dirs" begin mktempdir() do tmp cd(tmp) do mkpath("src") @@ -152,17 +179,21 @@ function test_coverage_post_process() """, ) - CP._generate_coverage_reports!( - ["src"], joinpath(tmp, "coverage"), tmp, 20, 200 - ) + redirect_stdout(devnull) do + redirect_stderr(devnull) do + CP._generate_coverage_reports!( + ["src"], joinpath(tmp, "coverage"), tmp, 20, 200 + ) + end + end - @test isfile(joinpath("coverage", "lcov.info")) - @test isfile(joinpath("coverage", "cov_report.md")) + Test.@test isfile(joinpath("coverage", "lcov.info")) + Test.@test isfile(joinpath("coverage", "cov_report.md")) end end end - @testset "Internal report generation includes non-root files when filter is empty" begin + Test.@testset "Internal report generation includes non-root files when filter is empty" begin mktempdir() do root mktempdir() do other cd(other) do @@ -178,17 +209,21 @@ function test_coverage_post_process() end mkpath(joinpath(root, "coverage")) - CP._generate_coverage_reports!( - [joinpath(other, "src")], joinpath(root, "coverage"), root, 20, 200 - ) + redirect_stdout(devnull) do + redirect_stderr(devnull) do + CP._generate_coverage_reports!( + [joinpath(other, "src")], joinpath(root, "coverage"), root, 20, 200 + ) + end + end report = read(joinpath(root, "coverage", "cov_report.md"), String) - @test occursin(joinpath(other, "src", "bar.jl"), report) + Test.@test occursin(joinpath(other, "src", "bar.jl"), report) end end end - @testset "Errors when no usable files after cleanup" begin + Test.@testset "Errors when no usable files after cleanup" begin # To trigger this, we need >0 files initially, but 0 after cleanup. # This implies _clean_stale_cov_files! removed everything. # But _clean_stale_cov_files! is designed to keep the majority PID. @@ -215,7 +250,7 @@ function test_coverage_post_process() # I'll skip striving for this branch if it's too defensive. end - @testset "Error when no usable files after cleanup" begin + Test.@testset "Error when no usable files after cleanup" begin # Test the error case at line 92 in CoveragePostprocessing.jl mktempdir() do tmp cd(tmp) do @@ -244,7 +279,7 @@ function test_coverage_post_process() end end - @testset "Report limits - default behavior" begin + Test.@testset "Report limits - default behavior" begin mktempdir() do tmp cd(tmp) do mkpath("src") @@ -276,16 +311,20 @@ function test_coverage_post_process() """, ) - CTBase.postprocess_coverage(; generate_report=true) + redirect_stdout(devnull) do + redirect_stderr(devnull) do + Extensions.postprocess_coverage(; generate_report=true) + end + end report = read(joinpath("coverage", "cov_report.md"), String) - @test occursin("top 20", report) - @test occursin("first 200", report) + Test.@test occursin("top 20", report) + Test.@test occursin("first 200", report) end end end - @testset "Report limits - custom worst_n_files" begin + Test.@testset "Report limits - custom worst_n_files" begin mktempdir() do tmp cd(tmp) do mkpath("src") @@ -317,16 +356,20 @@ function test_coverage_post_process() """, ) - CTBase.postprocess_coverage(; generate_report=true, worst_n_files=1) + redirect_stdout(devnull) do + redirect_stderr(devnull) do + Extensions.postprocess_coverage(; generate_report=true, worst_n_files=1) + end + end report = read(joinpath("coverage", "cov_report.md"), String) - @test occursin("top 1", report) - @test !occursin("top 20", report) + Test.@test occursin("top 1", report) + Test.@test !occursin("top 20", report) end end end - @testset "Report limits - custom max_uncovered_lines" begin + Test.@testset "Report limits - custom max_uncovered_lines" begin mktempdir() do tmp cd(tmp) do mkpath("src") @@ -343,16 +386,20 @@ function test_coverage_post_process() """, ) - CTBase.postprocess_coverage(; generate_report=true, max_uncovered_lines=2) + redirect_stdout(devnull) do + redirect_stderr(devnull) do + Extensions.postprocess_coverage(; generate_report=true, max_uncovered_lines=2) + end + end report = read(joinpath("coverage", "cov_report.md"), String) - @test occursin("first 2", report) - @test !occursin("first 200", report) + Test.@test occursin("first 2", report) + Test.@test !occursin("first 200", report) end end end - @testset "Report limits - invalid options" begin + Test.@testset "Report limits - invalid options" begin mktempdir() do tmp cd(tmp) do mkpath("src") @@ -367,43 +414,64 @@ function test_coverage_post_process() ) err = try - CTBase.postprocess_coverage(; generate_report=false, worst_n_files=0) + redirect_stdout(devnull) do + redirect_stderr(devnull) do + Extensions.postprocess_coverage(; generate_report=false, worst_n_files=0) + end + end nothing catch e e end - @test err isa CTBase.Exceptions.IncorrectArgument - @test occursin("worst_n_files must be > 0", err.msg) + Test.@test err isa Exceptions.IncorrectArgument + Test.@test occursin("worst_n_files must be > 0", err.msg) err = try - CTBase.postprocess_coverage(; generate_report=false, worst_n_files=-5) + redirect_stdout(devnull) do + redirect_stderr(devnull) do + Extensions.postprocess_coverage(; generate_report=false, worst_n_files=-5) + end + end nothing catch e e end - @test err isa CTBase.Exceptions.IncorrectArgument - @test occursin("worst_n_files must be > 0", err.msg) + Test.@test err isa Exceptions.IncorrectArgument + Test.@test occursin("worst_n_files must be > 0", err.msg) err = try - CTBase.postprocess_coverage(; generate_report=false, max_uncovered_lines=0) + redirect_stdout(devnull) do + redirect_stderr(devnull) do + Extensions.postprocess_coverage(; generate_report=false, max_uncovered_lines=0) + end + end nothing catch e e end - @test err isa CTBase.Exceptions.IncorrectArgument - @test occursin("max_uncovered_lines must be > 0", err.msg) + Test.@test err isa Exceptions.IncorrectArgument + Test.@test occursin("max_uncovered_lines must be > 0", err.msg) err = try - CTBase.postprocess_coverage(; - generate_report=false, max_uncovered_lines=-10 - ) + redirect_stdout(devnull) do + redirect_stderr(devnull) do + Extensions.postprocess_coverage(; + generate_report=false, max_uncovered_lines=-10 + ) + end + end nothing catch e e end - @test err isa CTBase.Exceptions.IncorrectArgument - @test occursin("max_uncovered_lines must be > 0", err.msg) + Test.@test err isa Exceptions.IncorrectArgument + Test.@test occursin("max_uncovered_lines must be > 0", err.msg) end end end end + +end # module TestCoveragePostProcess + +# CRITICAL: redefine in outer scope so the test runner can call it +test_coverage_post_process() = TestCoveragePostProcess.test_coverage_post_process() diff --git a/test/suite/extensions/test_documenter_reference.jl b/test/suite/extensions/test_documenter_reference.jl index ba43e54a..c5f567bb 100644 --- a/test/suite/extensions/test_documenter_reference.jl +++ b/test/suite/extensions/test_documenter_reference.jl @@ -1,3 +1,18 @@ +module TestDocumenterReference + +import Test +import CTBase +import CTBase.Exceptions: Exceptions +import CTBase.Extensions: Extensions +import Documenter + +const VERBOSE = isdefined(Main, :TestOptions) ? Main.TestOptions.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestOptions) ? Main.TestOptions.SHOWTIMING : true + +const DocumenterReference = Base.get_extension(CTBase, :DocumenterReference) +const DR = DocumenterReference + +# ------------------------------------------------------------------------------------------ # """ Docstring for the main test module used to validate module-level documentation in the generated API pages. @@ -37,156 +52,169 @@ end const MYCONST = 42 end +using .DocumenterReferenceTestMod +# ------------------------------------------------------------------------------------------ # +# ------------------------------------------------------------------------------------------ # module DRTypeFormatTestMod struct Simple end struct Parametric{T} end struct WithValue{T,N} end end +# ------------------------------------------------------------------------------------------ # +# ------------------------------------------------------------------------------------------ # module DRMethodTestMod f() = 1 f(x::Int) = x g(x::Int, y::String) = x h(xs::Int...) = length(xs) end +# ------------------------------------------------------------------------------------------ # +# ------------------------------------------------------------------------------------------ # module DRExternalTestMod extfun(x::Int) = x extfun(x::String) = length(x) end +# ------------------------------------------------------------------------------------------ # +# ------------------------------------------------------------------------------------------ # module DRNoDocModule module Inner end end +# ------------------------------------------------------------------------------------------ # +# ------------------------------------------------------------------------------------------ # module DRUnionAllTestMod f(x::T) where {T} = x end - -using .DocumenterReferenceTestMod +# ------------------------------------------------------------------------------------------ # function test_documenter_reference() - DR = DocumenterReference - @testset verbose = VERBOSE showtiming = SHOWTIMING "Invalid primary_modules input" begin - @test_throws CTBase.Exceptions.IncorrectArgument CTBase.automatic_reference_documentation( - CTBase.Extensions.DocumenterReferenceTag(); - subdirectory="ref", - primary_modules=["invalid_string"], # String is not Module or Pair - title="My API", - ) + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "Invalid primary_modules input" begin + redirect_stdout(devnull) do + redirect_stderr(devnull) do + Test.@test_throws Extensions.IncorrectArgument Extensions.automatic_reference_documentation( + Extensions.DocumenterReferenceTag(); + subdirectory="ref", + primary_modules=["invalid_string"], # String is not Module or Pair + title="My API", + ) + end + end end - @testset verbose = VERBOSE showtiming = SHOWTIMING "reset_config! clears CONFIG" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "reset_config! clears CONFIG" begin DR.reset_config!() - @test isempty(DR.CONFIG) + Test.@test isempty(DR.CONFIG) end - @testset verbose = VERBOSE showtiming = SHOWTIMING "_default_basename and _build_page_path" begin - @test DR._default_basename("manual", true, true) == "manual" - @test DR._default_basename("", true, true) == "api" - @test DR._default_basename("", true, false) == "public" - @test DR._default_basename("", false, true) == "private" + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "_default_basename and _build_page_path" begin + Test.@test DR._default_basename("manual", true, true) == "manual" + Test.@test DR._default_basename("", true, true) == "api" + Test.@test DR._default_basename("", true, false) == "public" + Test.@test DR._default_basename("", false, true) == "private" - @test DR._build_page_path("api", "public") == "api/public" - @test DR._build_page_path(".", "public") == "public" - @test DR._build_page_path("", "public") == "public" + Test.@test DR._build_page_path("api", "public") == "api/public" + Test.@test DR._build_page_path(".", "public") == "public" + Test.@test DR._build_page_path("", "public") == "public" end - @testset verbose = VERBOSE showtiming = SHOWTIMING "_classify_symbol and _to_string" begin - @test DR._classify_symbol(nothing, "@mymacro") == DR.DOCTYPE_MACRO - @test DR._classify_symbol(DocumenterReferenceTestMod.SubModule, "SubModule") == + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "_classify_symbol and _to_string" begin + Test.@test DR._classify_symbol(nothing, "@mymacro") == DR.DOCTYPE_MACRO + Test.@test DR._classify_symbol(DocumenterReferenceTestMod.SubModule, "SubModule") == DR.DOCTYPE_MODULE - @test DR._classify_symbol(DocumenterReferenceTestMod.AbstractFoo, "AbstractFoo") == + Test.@test DR._classify_symbol(DocumenterReferenceTestMod.AbstractFoo, "AbstractFoo") == DR.DOCTYPE_ABSTRACT_TYPE - @test DR._classify_symbol(DocumenterReferenceTestMod.Foo, "Foo") == + Test.@test DR._classify_symbol(DocumenterReferenceTestMod.Foo, "Foo") == DR.DOCTYPE_STRUCT - @test DR._classify_symbol(DocumenterReferenceTestMod.myfun, "myfun") == + Test.@test DR._classify_symbol(DocumenterReferenceTestMod.myfun, "myfun") == DR.DOCTYPE_FUNCTION - @test DR._classify_symbol(DocumenterReferenceTestMod.MYCONST, "MYCONST") == + Test.@test DR._classify_symbol(DocumenterReferenceTestMod.MYCONST, "MYCONST") == DR.DOCTYPE_CONSTANT - @test DR._to_string(DR.DOCTYPE_ABSTRACT_TYPE) == "abstract type" - @test DR._to_string(DR.DOCTYPE_CONSTANT) == "constant" - @test DR._to_string(DR.DOCTYPE_FUNCTION) == "function" - @test DR._to_string(DR.DOCTYPE_MACRO) == "macro" - @test DR._to_string(DR.DOCTYPE_MODULE) == "module" - @test DR._to_string(DR.DOCTYPE_STRUCT) == "struct" + Test.@test DR._to_string(DR.DOCTYPE_ABSTRACT_TYPE) == "abstract type" + Test.@test DR._to_string(DR.DOCTYPE_CONSTANT) == "constant" + Test.@test DR._to_string(DR.DOCTYPE_FUNCTION) == "function" + Test.@test DR._to_string(DR.DOCTYPE_MACRO) == "macro" + Test.@test DR._to_string(DR.DOCTYPE_MODULE) == "module" + Test.@test DR._to_string(DR.DOCTYPE_STRUCT) == "struct" end - @testset verbose = VERBOSE showtiming = SHOWTIMING "_get_source_file" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "_get_source_file" begin path = DR._get_source_file(DocumenterReferenceTestMod, :myfun, DR.DOCTYPE_FUNCTION) - @test path === abspath(@__FILE__) + Test.@test path === abspath(@__FILE__) # No docstring => should use method-based resolution path2 = DR._get_source_file( DocumenterReferenceTestMod, :no_doc, DR.DOCTYPE_FUNCTION ) - @test path2 === abspath(@__FILE__) + Test.@test path2 === abspath(@__FILE__) # Missing symbol should be caught and return nothing missing_path = DR._get_source_file( DocumenterReferenceTestMod, :__does_not_exist__, DR.DOCTYPE_FUNCTION ) - @test missing_path === nothing + Test.@test missing_path === nothing const_path = DR._get_source_file( DocumenterReferenceTestMod, :MYCONST, DR.DOCTYPE_CONSTANT ) - @test const_path === nothing + Test.@test const_path === nothing end - @testset verbose = VERBOSE showtiming = SHOWTIMING "_get_source_from_methods" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "_get_source_from_methods" begin # For built-in operators like +, source detection behavior may vary by Julia version. # In Julia 1.12+, it may return a source file path even for +. # We just verify the function runs without error and returns String or nothing. result = DR._get_source_from_methods(+) - @test result === nothing || result isa String + Test.@test result === nothing || result isa String end - @testset verbose = VERBOSE showtiming = SHOWTIMING "_parse_primary_modules with Pair" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "_parse_primary_modules with Pair" begin d = DR._parse_primary_modules([DocumenterReferenceTestMod => @__FILE__]) - @test d[DocumenterReferenceTestMod] == [abspath(@__FILE__)] + Test.@test d[DocumenterReferenceTestMod] == [abspath(@__FILE__)] end - @testset verbose = VERBOSE showtiming = SHOWTIMING "_has_documentation: module documented elsewhere" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "_has_documentation: module documented elsewhere" begin modules = Dict(DRNoDocModule.Inner => Any[]) - @test DR._has_documentation(DRNoDocModule, :Inner, DR.DOCTYPE_MODULE, modules) == + Test.@test DR._has_documentation(DRNoDocModule, :Inner, DR.DOCTYPE_MODULE, modules) == true end - @testset verbose = VERBOSE showtiming = SHOWTIMING "Type formatting helpers" begin - @test DR._format_datatype_for_docs(UInt) == "::UInt" + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "Type formatting helpers" begin + Test.@test DR._format_datatype_for_docs(UInt) == "::UInt" # Parametric types without concrete params are UnionAll, use _format_type_for_docs param_result = DR._format_type_for_docs(DRTypeFormatTestMod.Parametric) - @test occursin("Parametric", param_result) + Test.@test occursin("Parametric", param_result) # Concrete params => kept (WithValue{Int,2} is a DataType) concrete_result = DR._format_datatype_for_docs(DRTypeFormatTestMod.WithValue{Int,2}) - @test occursin("WithValue", concrete_result) - @test occursin("Int", concrete_result) - @test occursin("2", concrete_result) + Test.@test occursin("WithValue", concrete_result) + Test.@test occursin("Int", concrete_result) + Test.@test occursin("2", concrete_result) # Fallback branch for non-type inputs - @test DR._format_type_for_docs(1) == "::1" + Test.@test DR._format_type_for_docs(1) == "::1" # TypeVar formatting in _format_type_param - need to unwrap UnionAll first unwrapped = Base.unwrap_unionall(DRTypeFormatTestMod.Parametric) tv = unwrapped.parameters[1] - @test tv isa TypeVar - @test DR._format_type_param(tv) == "T" + Test.@test tv isa TypeVar + Test.@test DR._format_type_param(tv) == "T" end - @testset verbose = VERBOSE showtiming = SHOWTIMING "_method_signature_string handles UnionAll" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "_method_signature_string handles UnionAll" begin m = first(methods(DRUnionAllTestMod.f)) s = DR._method_signature_string(m, DRUnionAllTestMod, :f) - @test occursin("DRUnionAllTestMod.f", s) - @test occursin("T", s) + Test.@test occursin("DRUnionAllTestMod.f", s) + Test.@test occursin("T", s) end - @testset verbose = VERBOSE showtiming = SHOWTIMING "_iterate_over_symbols filtering" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "_iterate_over_symbols filtering" begin current_module = DocumenterReferenceTestMod modules = Dict(current_module => Any[]) exclude = Set{Symbol}([:skip]) @@ -220,16 +248,20 @@ function test_documenter_reference() ] seen = Symbol[] - DR._iterate_over_symbols(config, symbols) do key, type - return push!(seen, key) + redirect_stdout(devnull) do + redirect_stderr(devnull) do + DR._iterate_over_symbols(config, symbols) do key, type + return push!(seen, key) + end + end end - @test :keep in seen - @test :skip โˆ‰ seen - @test :no_doc โˆ‰ seen + Test.@test :keep in seen + Test.@test :skip โˆ‰ seen + Test.@test :no_doc โˆ‰ seen end - @testset verbose = VERBOSE showtiming = SHOWTIMING "_iterate_over_symbols with source_files and include_without_source" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "_iterate_over_symbols with source_files and include_without_source" begin current_module = DocumenterReferenceTestMod modules = Dict(current_module => Any[]) sort_by(x) = x @@ -259,10 +291,14 @@ function test_documenter_reference() symbols1 = [:myfun => DR.DOCTYPE_FUNCTION] seen1 = Symbol[] - DR._iterate_over_symbols(config1, symbols1) do key, type - return push!(seen1, key) + redirect_stdout(devnull) do + redirect_stderr(devnull) do + DR._iterate_over_symbols(config1, symbols1) do key, type + return push!(seen1, key) + end + end end - @test :myfun in seen1 + Test.@test :myfun in seen1 # Case 2: Module symbols without source are kept only when include_without_source=true config2 = DR._Config( @@ -308,98 +344,122 @@ function test_documenter_reference() symbols_module = [:SubModule => DR.DOCTYPE_MODULE] seen2 = Symbol[] - DR._iterate_over_symbols(config2, symbols_module) do key, type - return push!(seen2, key) + redirect_stdout(devnull) do + redirect_stderr(devnull) do + DR._iterate_over_symbols(config2, symbols_module) do key, type + return push!(seen2, key) + end + end end - @test isempty(seen2) + Test.@test isempty(seen2) seen3 = Symbol[] - DR._iterate_over_symbols(config3, symbols_module) do key, type - return push!(seen3, key) + redirect_stdout(devnull) do + redirect_stderr(devnull) do + DR._iterate_over_symbols(config3, symbols_module) do key, type + return push!(seen3, key) + end + end end - @test :SubModule in seen3 + Test.@test :SubModule in seen3 end - @testset verbose = VERBOSE showtiming = SHOWTIMING "automatic_reference_documentation configuration" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "automatic_reference_documentation configuration" begin DR.reset_config!() # Single-module, public-only - pages1 = CTBase.automatic_reference_documentation( - CTBase.Extensions.DocumenterReferenceTag(); - subdirectory="ref", - primary_modules=[DocumenterReferenceTestMod], - public=true, - private=false, - title="My API", - ) + pages1 = redirect_stdout(devnull) do + redirect_stderr(devnull) do + Extensions.automatic_reference_documentation( + Extensions.DocumenterReferenceTag(); + subdirectory="ref", + primary_modules=[DocumenterReferenceTestMod], + public=true, + private=false, + title="My API", + ) + end + end - @testset verbose = VERBOSE showtiming = SHOWTIMING "automatic_reference_documentation default tag" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "automatic_reference_documentation default tag" begin DR.reset_config!() - pages_default = CTBase.automatic_reference_documentation(; - subdirectory="ref", - primary_modules=[DocumenterReferenceTestMod], - public=true, - private=false, - title="My API", - ) + pages_default = redirect_stdout(devnull) do + redirect_stderr(devnull) do + Extensions.automatic_reference_documentation(; + subdirectory="ref", + primary_modules=[DocumenterReferenceTestMod], + public=true, + private=false, + title="My API", + ) + end + end - @test length(DR.CONFIG) == 1 + Test.@test length(DR.CONFIG) == 1 cfg_default = DR.CONFIG[1] - @test cfg_default.current_module === DocumenterReferenceTestMod - @test cfg_default.subdirectory == "ref" - @test cfg_default.public == true - @test cfg_default.private == false - @test cfg_default.filename == "public" - @test pages_default == pages1 + Test.@test cfg_default.current_module === DocumenterReferenceTestMod + Test.@test cfg_default.subdirectory == "ref" + Test.@test cfg_default.public == true + Test.@test cfg_default.private == false + Test.@test cfg_default.filename == "public" + Test.@test pages_default == pages1 end - @test length(DR.CONFIG) == 1 + Test.@test length(DR.CONFIG) == 1 cfg1 = DR.CONFIG[1] - @test cfg1.current_module === DocumenterReferenceTestMod - @test cfg1.subdirectory == "ref" - @test cfg1.public == true - @test cfg1.private == false - @test cfg1.filename == "public" - @test pages1 == ("My API" => "ref/public.md") + Test.@test cfg1.current_module === DocumenterReferenceTestMod + Test.@test cfg1.subdirectory == "ref" + Test.@test cfg1.public == true + Test.@test cfg1.private == false + Test.@test cfg1.filename == "public" + Test.@test pages1 == ("My API" => "ref/public.md") # Both public and private pages DR.reset_config!() - pages2 = CTBase.automatic_reference_documentation( - CTBase.Extensions.DocumenterReferenceTag(); - subdirectory="ref", - primary_modules=[DocumenterReferenceTestMod], - public=true, - private=true, - title="All API", - ) + pages2 = redirect_stdout(devnull) do + redirect_stderr(devnull) do + Extensions.automatic_reference_documentation( + Extensions.DocumenterReferenceTag(); + subdirectory="ref", + primary_modules=[DocumenterReferenceTestMod], + public=true, + private=true, + title="All API", + ) + end + end - @test length(DR.CONFIG) == 1 + Test.@test length(DR.CONFIG) == 1 cfg2 = DR.CONFIG[1] - @test cfg2.filename == "api" - @test cfg2.public == true - @test cfg2.private == true - @test cfg2.title == "All API" - @test pages2 == ( + Test.@test cfg2.filename == "api" + Test.@test cfg2.public == true + Test.@test cfg2.private == true + Test.@test cfg2.title == "All API" + Test.@test pages2 == ( "All API" => ["Public" => "ref/api_public.md", "Private" => "ref/api_private.md"] ) # public=false, private=false should error - @test_throws CTBase.Exceptions.IncorrectArgument CTBase.automatic_reference_documentation( - CTBase.Extensions.DocumenterReferenceTag(); - subdirectory="ref", - primary_modules=[DocumenterReferenceTestMod], - public=false, - private=false, - ) + redirect_stdout(devnull) do + redirect_stderr(devnull) do + Test.@test_throws Extensions.IncorrectArgument Extensions.automatic_reference_documentation( + Extensions.DocumenterReferenceTag(); + subdirectory="ref", + primary_modules=[DocumenterReferenceTestMod], + public=false, + private=false, + ) + end + end end - @testset verbose = VERBOSE showtiming = SHOWTIMING "Documenter.Selectors.order for APIBuilder" begin - @test Documenter.Selectors.order(DR.APIBuilder) == 0.5 + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "Documenter.Selectors.order for APIBuilder" begin + Test.@test Documenter.Selectors.order(DR.APIBuilder) == 0.5 end - @testset verbose = VERBOSE showtiming = SHOWTIMING "_collect_module_docstrings includes module docstring" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "_collect_module_docstrings includes module docstring" begin DR.reset_config!() config = DR._Config( @@ -425,7 +485,7 @@ function test_documenter_reference() symbols = DR._exported_symbols(DocumenterReferenceTestMod).exported docs = DR._collect_module_docstrings(config, symbols) - @test any( + Test.@test any( doc -> occursin("```@docs\n$(DocumenterReferenceTestMod)\n```", doc), docs ) end @@ -434,64 +494,68 @@ function test_documenter_reference() # NEW TESTS: _exported_symbols # ============================================================================ - @testset verbose = VERBOSE showtiming = SHOWTIMING "_exported_symbols classification" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "_exported_symbols classification" begin # Test that _exported_symbols correctly classifies symbols result = DR._exported_symbols(DocumenterReferenceTestMod) # Check structure: should return NamedTuple with exported and private fields - @test haskey(result, :exported) - @test haskey(result, :private) + Test.@test haskey(result, :exported) + Test.@test haskey(result, :private) # Check that exported list contains expected symbols (none exported in test module) - @test result.exported isa Vector + Test.@test result.exported isa Vector # Check that private list contains our test symbols private_symbols = Dict(result.private) - @test haskey(private_symbols, :myfun) - @test private_symbols[:myfun] == DR.DOCTYPE_FUNCTION - @test haskey(private_symbols, :keep) - @test haskey(private_symbols, :skip) - @test haskey(private_symbols, :no_doc) - @test haskey(private_symbols, :SubModule) - @test private_symbols[:SubModule] == DR.DOCTYPE_MODULE - @test haskey(private_symbols, :AbstractFoo) - @test private_symbols[:AbstractFoo] == DR.DOCTYPE_ABSTRACT_TYPE - @test haskey(private_symbols, :Foo) - @test private_symbols[:Foo] == DR.DOCTYPE_STRUCT - @test haskey(private_symbols, :MYCONST) - @test private_symbols[:MYCONST] == DR.DOCTYPE_CONSTANT + Test.@test haskey(private_symbols, :myfun) + Test.@test private_symbols[:myfun] == DR.DOCTYPE_FUNCTION + Test.@test haskey(private_symbols, :keep) + Test.@test haskey(private_symbols, :skip) + Test.@test haskey(private_symbols, :no_doc) + Test.@test haskey(private_symbols, :SubModule) + Test.@test private_symbols[:SubModule] == DR.DOCTYPE_MODULE + Test.@test haskey(private_symbols, :AbstractFoo) + Test.@test private_symbols[:AbstractFoo] == DR.DOCTYPE_ABSTRACT_TYPE + Test.@test haskey(private_symbols, :Foo) + Test.@test private_symbols[:Foo] == DR.DOCTYPE_STRUCT + Test.@test haskey(private_symbols, :MYCONST) + Test.@test private_symbols[:MYCONST] == DR.DOCTYPE_CONSTANT end # ============================================================================ # NEW TESTS: automatic_reference_documentation multi-module # ============================================================================ - @testset verbose = VERBOSE showtiming = SHOWTIMING "automatic_reference_documentation multi-module" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "automatic_reference_documentation multi-module" begin DR.reset_config!() # Create a second test module for multi-module testing mod1 = DocumenterReferenceTestMod # Test multi-module case (using same module twice as a proxy) - pages = CTBase.automatic_reference_documentation( - CTBase.Extensions.DocumenterReferenceTag(); - subdirectory="api", - primary_modules=[mod1, mod1], # Two entries to trigger multi-module path - public=true, - private=true, - title="Multi API", - ) + pages = redirect_stdout(devnull) do + redirect_stderr(devnull) do + Extensions.automatic_reference_documentation( + Extensions.DocumenterReferenceTag(); + subdirectory="api", + primary_modules=[mod1, mod1], # Two entries to trigger multi-module path + public=true, + private=true, + title="Multi API", + ) + end + end # Should return a Pair with title and list of module pages - @test pages isa Pair - @test first(pages) == "Multi API" - @test last(pages) isa Vector + Test.@test pages isa Pair + Test.@test first(pages) == "Multi API" + Test.@test last(pages) isa Vector # CONFIG should have 1 entry (one per unique module) - @test length(DR.CONFIG) == 1 + Test.@test length(DR.CONFIG) == 1 end - @testset verbose = VERBOSE showtiming = SHOWTIMING "automatic_reference_documentation multi-module with filename" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "automatic_reference_documentation multi-module with filename" begin DR.reset_config!() mod1 = DocumenterReferenceTestMod @@ -501,90 +565,94 @@ function test_documenter_reference() # Note: DocumenterReference currently splits public/private if both are true, # ignoring the filename for the split structure. # So we test public-only to verify filename is respected. - pages = CTBase.automatic_reference_documentation( - CTBase.Extensions.DocumenterReferenceTag(); - subdirectory="api", - primary_modules=[mod1, mod2], - public=true, - private=false, - title="Combined Public API", - filename="combined_public", - ) + pages = redirect_stdout(devnull) do + redirect_stderr(devnull) do + Extensions.automatic_reference_documentation( + Extensions.DocumenterReferenceTag(); + subdirectory="api", + primary_modules=[mod1, mod2], + public=true, + private=false, + title="Combined Public API", + filename="combined_public", + ) + end + end # Should return a Pair with title and path to the combined file - @test pages isa Pair - @test first(pages) == "Combined Public API" - @test last(pages) == "api/combined_public.md" + Test.@test pages isa Pair + Test.@test first(pages) == "Combined Public API" + Test.@test last(pages) == "api/combined_public.md" # CONFIG should have 2 entries (one per unique module) - @test length(DR.CONFIG) == 2 - @test count(c -> c.current_module == mod1, DR.CONFIG) == 1 - @test count(c -> c.current_module == mod2, DR.CONFIG) == 1 + Test.@test length(DR.CONFIG) == 2 + Test.@test count(c -> c.current_module == mod1, DR.CONFIG) == 1 + Test.@test count(c -> c.current_module == mod2, DR.CONFIG) == 1 end # ============================================================================ # NEW TESTS: _get_source_file expanded # ============================================================================ - @testset verbose = VERBOSE showtiming = SHOWTIMING "_get_source_file expanded cases" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "_get_source_file expanded cases" begin # Function case (already tested, but verify) func_path = DR._get_source_file( DocumenterReferenceTestMod, :myfun, DR.DOCTYPE_FUNCTION ) - @test func_path !== nothing - @test endswith(func_path, "test_documenter_reference.jl") + Test.@test func_path !== nothing + Test.@test endswith(func_path, "test_documenter_reference.jl") # Struct case - should find source via constructor methods struct_path = DR._get_source_file( DocumenterReferenceTestMod, :Foo, DR.DOCTYPE_STRUCT ) # Structs defined in test file should be found - @test struct_path === nothing || + Test.@test struct_path === nothing || endswith(struct_path, "test_documenter_reference.jl") # Abstract type case - typically returns nothing (no constructor) abstract_path = DR._get_source_file( DocumenterReferenceTestMod, :AbstractFoo, DR.DOCTYPE_ABSTRACT_TYPE ) - @test abstract_path === nothing + Test.@test abstract_path === nothing # Module case - modules cannot reliably determine source mod_path = DR._get_source_file( DocumenterReferenceTestMod, :SubModule, DR.DOCTYPE_MODULE ) - @test mod_path === nothing + Test.@test mod_path === nothing # Constant case (already tested) const_path = DR._get_source_file( DocumenterReferenceTestMod, :MYCONST, DR.DOCTYPE_CONSTANT ) - @test const_path === nothing + Test.@test const_path === nothing end # ============================================================================ # NEW TESTS: Edge cases # ============================================================================ - @testset verbose = VERBOSE showtiming = SHOWTIMING "Edge cases" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "Edge cases" begin # _build_page_path with various inputs - @test DR._build_page_path("", "") == "" - @test DR._build_page_path(".", "") == "" - @test DR._build_page_path("dir", "") == "dir/" - @test DR._build_page_path("a/b/c", "file.md") == "a/b/c/file.md" + Test.@test DR._build_page_path("", "") == "" + Test.@test DR._build_page_path(".", "") == "" + Test.@test DR._build_page_path("dir", "") == "dir/" + Test.@test DR._build_page_path("a/b/c", "file.md") == "a/b/c/file.md" # _default_basename edge cases - @test DR._default_basename("custom", false, false) == "custom" # Custom overrides flags - @test DR._default_basename("", false, false) == "private" # Fallback when both false + Test.@test DR._default_basename("custom", false, false) == "custom" # Custom overrides flags + Test.@test DR._default_basename("", false, false) == "private" # Fallback when both false # _classify_symbol with edge cases - @test DR._classify_symbol(42, "fortytwo") == DR.DOCTYPE_CONSTANT # Integer constant - @test DR._classify_symbol("hello", "greeting") == DR.DOCTYPE_CONSTANT # String constant - @test DR._classify_symbol([1, 2, 3], "myarray") == DR.DOCTYPE_CONSTANT # Array constant + Test.@test DR._classify_symbol(42, "fortytwo") == DR.DOCTYPE_CONSTANT # Integer constant + Test.@test DR._classify_symbol("hello", "greeting") == DR.DOCTYPE_CONSTANT # String constant + Test.@test DR._classify_symbol([1, 2, 3], "myarray") == DR.DOCTYPE_CONSTANT # Array constant # _to_string covers all enum values (verify no error) for dt in instances(DR.DocType) - @test DR._to_string(dt) isa String - @test length(DR._to_string(dt)) > 0 + Test.@test DR._to_string(dt) isa String + Test.@test length(DR._to_string(dt)) > 0 end # _iterate_over_symbols with empty list @@ -608,75 +676,79 @@ function test_documenter_reference() "", ) seen = Symbol[] - DR._iterate_over_symbols(config, Pair{Symbol,DR.DocType}[]) do key, type - return push!(seen, key) + redirect_stdout(devnull) do + redirect_stderr(devnull) do + DR._iterate_over_symbols(config, Pair{Symbol,DR.DocType}[]) do key, type + return push!(seen, key) + end + end end - @test isempty(seen) + Test.@test isempty(seen) # reset_config! is idempotent DR.reset_config!() DR.reset_config!() - @test isempty(DR.CONFIG) + Test.@test isempty(DR.CONFIG) end - @testset verbose = VERBOSE showtiming = SHOWTIMING "_format_type_for_docs and helpers" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "_format_type_for_docs and helpers" begin simple_str = DR._format_type_for_docs(DRTypeFormatTestMod.Simple) - @test startswith(simple_str, "::") - @test occursin("DRTypeFormatTestMod.Simple", simple_str) + Test.@test startswith(simple_str, "::") + Test.@test occursin("DRTypeFormatTestMod.Simple", simple_str) param_type = DRTypeFormatTestMod.Parametric{DRTypeFormatTestMod.Simple} param_str = DR._format_type_for_docs(param_type) - @test occursin("Parametric", param_str) - @test occursin("Simple", param_str) + Test.@test occursin("Parametric", param_str) + Test.@test occursin("Simple", param_str) union_str = DR._format_type_for_docs(Union{DRTypeFormatTestMod.Simple,Nothing}) - @test occursin("Union", union_str) - @test occursin("Simple", union_str) - @test occursin("Nothing", union_str) + Test.@test occursin("Union", union_str) + Test.@test occursin("Simple", union_str) + Test.@test occursin("Nothing", union_str) value_type = DRTypeFormatTestMod.WithValue{Int,3} value_str = DR._format_type_for_docs(value_type) - @test occursin("WithValue", value_str) - @test occursin("3", value_str) + Test.@test occursin("WithValue", value_str) + Test.@test occursin("3", value_str) param_fmt = DR._format_type_param(DRTypeFormatTestMod.Simple) - @test occursin("DRTypeFormatTestMod.Simple", param_fmt) + Test.@test occursin("DRTypeFormatTestMod.Simple", param_fmt) value_param_fmt = DR._format_type_param(3) - @test value_param_fmt == "3" + Test.@test value_param_fmt == "3" end - @testset verbose = VERBOSE showtiming = SHOWTIMING "_method_signature_string and method collection" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "_method_signature_string and method collection" begin methods_f = methods(DRMethodTestMod.f) m0 = first(filter(m -> length(m.sig.parameters) == 1, methods_f)) sig0 = DR._method_signature_string(m0, DRMethodTestMod, :f) - @test endswith(sig0, "DRMethodTestMod.f()") + Test.@test endswith(sig0, "DRMethodTestMod.f()") m1 = first(filter(m -> length(m.sig.parameters) == 2, methods_f)) sig1 = DR._method_signature_string(m1, DRMethodTestMod, :f) - @test occursin("DRMethodTestMod.f", sig1) - @test occursin("Int", sig1) + Test.@test occursin("DRMethodTestMod.f", sig1) + Test.@test occursin("Int", sig1) methods_h = methods(DRMethodTestMod.h) mvar = first(methods_h) sigv = DR._method_signature_string(mvar, DRMethodTestMod, :h) - @test occursin("Vararg", sigv) + Test.@test occursin("Vararg", sigv) source_files = [abspath(@__FILE__)] methods_by_func = DR._collect_methods_from_source_files( DRMethodTestMod, source_files ) - @test :f in keys(methods_by_func) - @test :h in keys(methods_by_func) + Test.@test :f in keys(methods_by_func) + Test.@test :h in keys(methods_by_func) for ms in values(methods_by_func) for m in ms file = String(m.file) - @test abspath(file) in source_files + Test.@test abspath(file) in source_files end end end - @testset verbose = VERBOSE showtiming = SHOWTIMING "Page content builders" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "Page content builders" begin modules_str = "ModA, ModB" module_contents_private = [ (DocumenterReferenceTestMod, String[], ["priv_a"]), @@ -686,11 +758,11 @@ function test_documenter_reference() overview_priv, docs_priv = DR._build_private_page_content( modules_str, module_contents_private, false ) - @test occursin("Private API", overview_priv) - @test occursin("ModA, ModB", overview_priv) - @test !isempty(docs_priv) - @test any(occursin("priv_a", s) for s in docs_priv) - @test any(occursin("priv_b1", s) for s in docs_priv) + Test.@test occursin("Private API", overview_priv) + Test.@test occursin("ModA, ModB", overview_priv) + Test.@test !isempty(docs_priv) + Test.@test any(occursin("priv_a", s) for s in docs_priv) + Test.@test any(occursin("priv_b1", s) for s in docs_priv) module_contents_public = [ (DocumenterReferenceTestMod, ["pub_a"], String[]), @@ -700,23 +772,23 @@ function test_documenter_reference() overview_pub, docs_pub = DR._build_public_page_content( modules_str, module_contents_public, false ) - @test occursin("Public API", overview_pub) - @test occursin("ModA, ModB", overview_pub) - @test !isempty(docs_pub) - @test any(occursin("pub_a", s) for s in docs_pub) + Test.@test occursin("Public API", overview_pub) + Test.@test occursin("ModA, ModB", overview_pub) + Test.@test !isempty(docs_pub) + Test.@test any(occursin("pub_a", s) for s in docs_pub) module_contents_combined = [(DocumenterReferenceTestMod, ["pub_a"], ["priv_a"])] overview_comb, docs_comb = DR._build_combined_page_content( modules_str, module_contents_combined ) - @test occursin("API reference", overview_comb) - @test any(occursin("Public API", s) for s in docs_comb) - @test any(occursin("Private API", s) for s in docs_comb) - @test any(occursin("pub_a", s) for s in docs_comb) - @test any(occursin("priv_a", s) for s in docs_comb) + Test.@test occursin("API reference", overview_comb) + Test.@test any(occursin("Public API", s) for s in docs_comb) + Test.@test any(occursin("Private API", s) for s in docs_comb) + Test.@test any(occursin("pub_a", s) for s in docs_comb) + Test.@test any(occursin("priv_a", s) for s in docs_comb) end - @testset verbose = VERBOSE showtiming = SHOWTIMING "external_modules_to_document" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "external_modules_to_document" begin current_module = DocumenterReferenceTestMod modules = Dict(current_module => String[]) sort_by(x) = x @@ -743,89 +815,101 @@ function test_documenter_reference() ) private_docs = DR._collect_private_docstrings(config, Pair{Symbol,DR.DocType}[]) - @test !isempty(private_docs) - @test any(occursin("DRExternalTestMod.extfun", s) for s in private_docs) + Test.@test !isempty(private_docs) + Test.@test any(occursin("DRExternalTestMod.extfun", s) for s in private_docs) end - @testset verbose = VERBOSE showtiming = SHOWTIMING "APIBuilder runner integration" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "APIBuilder runner integration" begin DR.reset_config!() - pages = CTBase.automatic_reference_documentation( - CTBase.Extensions.DocumenterReferenceTag(); - subdirectory="api_integration", - primary_modules=[DocumenterReferenceTestMod], - public=true, - private=true, - title="Integration API", - ) + pages = redirect_stdout(devnull) do + redirect_stderr(devnull) do + Extensions.automatic_reference_documentation( + Extensions.DocumenterReferenceTag(); + subdirectory="api_integration", + primary_modules=[DocumenterReferenceTestMod], + public=true, + private=true, + title="Integration API", + ) + end + end - @test !isempty(DR.CONFIG) + Test.@test !isempty(DR.CONFIG) - doc = Documenter.Document(; - root=pwd(), source="_test_docs_src", build="_test_docs_build", remotes=nothing - ) + doc = redirect_stdout(devnull) do + redirect_stderr(devnull) do + Documenter.Document(; + root=pwd(), source="_test_docs_src", build="_test_docs_build", remotes=nothing + ) + end + end - Documenter.Selectors.runner(DR.APIBuilder, doc) + redirect_stdout(devnull) do + redirect_stderr(devnull) do + Documenter.Selectors.runner(DR.APIBuilder, doc) + end + end - @test !isempty(doc.blueprint.pages) - @test any(endswith(k, "api_private.md") for k in keys(doc.blueprint.pages)) + Test.@test !isempty(doc.blueprint.pages) + Test.@test any(endswith(k, "api_private.md") for k in keys(doc.blueprint.pages)) end # ============================================================================ # Edge Case Tests: Type Formatting Functions # ============================================================================ - @testset verbose = VERBOSE showtiming = SHOWTIMING "_format_type_for_docs edge cases" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "_format_type_for_docs edge cases" begin # Test Union types formatting (covers L775-782) union_result = DR._format_type_for_docs(Union{Int,String}) - @test occursin("Union", union_result) - @test occursin("Int", union_result) - @test occursin("String", union_result) + Test.@test occursin("Union", union_result) + Test.@test occursin("Int", union_result) + Test.@test occursin("String", union_result) # Test simple non-DataType fallback (covers L782) - @test DR._format_type_for_docs(Any) == "::Any" + Test.@test DR._format_type_for_docs(Any) == "::Any" end - @testset verbose = VERBOSE showtiming = SHOWTIMING "_format_datatype_for_docs with TypeVar" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "_format_datatype_for_docs with TypeVar" begin # Test parametric type with concrete parameters concrete_result = DR._format_datatype_for_docs(Vector{Int}) - @test occursin("Vector", concrete_result) || occursin("Array", concrete_result) - @test occursin("Int", concrete_result) + Test.@test occursin("Vector", concrete_result) || occursin("Array", concrete_result) + Test.@test occursin("Int", concrete_result) # Test parametric type - the function strips parameters when TypeVar present # Array{T,1} where T has a TypeVar, so it gets stripped - @test DR._format_datatype_for_docs(Int) == "::Int" - @test DR._format_datatype_for_docs(String) == "::String" + Test.@test DR._format_datatype_for_docs(Int) == "::Int" + Test.@test DR._format_datatype_for_docs(String) == "::String" end - @testset verbose = VERBOSE showtiming = SHOWTIMING "_format_type_param edge cases" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "_format_type_param edge cases" begin # Test Type parameter (covers L824-826) type_result = DR._format_type_param(Int) - @test type_result == "Int" # Should strip leading :: + Test.@test type_result == "Int" # Should strip leading :: # Test value parameter (covers L830) - e.g., for sized arrays - @test DR._format_type_param(3) == "3" - @test DR._format_type_param(42) == "42" + Test.@test DR._format_type_param(3) == "3" + Test.@test DR._format_type_param(42) == "42" end - @testset verbose = VERBOSE showtiming = SHOWTIMING "_method_signature_string edge cases" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "_method_signature_string edge cases" begin # Test method signature generation for DRMethodTestMod m = first(methods(DRMethodTestMod.f)) sig = DR._method_signature_string(m, DRMethodTestMod, :f) - @test occursin("DRMethodTestMod", sig) - @test occursin("f", sig) + Test.@test occursin("DRMethodTestMod", sig) + Test.@test occursin("f", sig) # Test with multiple arguments m2 = first(methods(DRMethodTestMod.g)) sig2 = DR._method_signature_string(m2, DRMethodTestMod, :g) - @test occursin("g", sig2) + Test.@test occursin("g", sig2) end # ============================================================================ # NEW TESTS: Title System with is_split Parameter # ============================================================================ - @testset verbose = VERBOSE showtiming = SHOWTIMING "Page content builders with is_split parameter" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "Page content builders with is_split parameter" begin modules_str = "TestModule" # Test private page with is_split=false (single page) @@ -834,16 +918,16 @@ function test_documenter_reference() overview_priv_single, docs_priv_single = DR._build_private_page_content( modules_str, module_contents_private, false ) - @test occursin("# Private API", overview_priv_single) - @test !occursin("# Private\n", overview_priv_single) - @test occursin("non-exported", overview_priv_single) + Test.@test occursin("# Private API", overview_priv_single) + Test.@test !occursin("# Private\n", overview_priv_single) + Test.@test occursin("non-exported", overview_priv_single) # Test private page with is_split=true (split page) overview_priv_split, docs_priv_split = DR._build_private_page_content( modules_str, module_contents_private, true ) - @test occursin("# Private API", overview_priv_split) - @test occursin("non-exported", overview_priv_split) + Test.@test occursin("# Private API", overview_priv_split) + Test.@test occursin("non-exported", overview_priv_split) # Test public page with is_split=false (single page) module_contents_public = [(DocumenterReferenceTestMod, ["pub_doc"], String[])] @@ -851,18 +935,18 @@ function test_documenter_reference() overview_pub_single, docs_pub_single = DR._build_public_page_content( modules_str, module_contents_public, false ) - @test occursin("# Public API", overview_pub_single) - @test occursin("exported", overview_pub_single) + Test.@test occursin("# Public API", overview_pub_single) + Test.@test occursin("exported", overview_pub_single) # Test public page with is_split=true (split page) overview_pub_split, docs_pub_split = DR._build_public_page_content( modules_str, module_contents_public, true ) - @test occursin("# Public API", overview_pub_split) - @test occursin("exported", overview_pub_split) + Test.@test occursin("# Public API", overview_pub_split) + Test.@test occursin("exported", overview_pub_split) end - @testset verbose = VERBOSE showtiming = SHOWTIMING "Title consistency across different page types" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "Title consistency across different page types" begin modules_str = "MyModule" module_contents = [(DocumenterReferenceTestMod, ["pub"], ["priv"])] @@ -870,34 +954,34 @@ function test_documenter_reference() overview_priv, _ = DR._build_private_page_content( modules_str, module_contents, false ) - @test occursin("# Private API", overview_priv) + Test.@test occursin("# Private API", overview_priv) # Single public page should have "Public API" title overview_pub, _ = DR._build_public_page_content(modules_str, module_contents, false) - @test occursin("# Public API", overview_pub) + Test.@test occursin("# Public API", overview_pub) # Split private page should have "Private API" title overview_priv_split, _ = DR._build_private_page_content( modules_str, module_contents, true ) - @test occursin("# Private API", overview_priv_split) + Test.@test occursin("# Private API", overview_priv_split) # Split public page should have "Public API" title overview_pub_split, _ = DR._build_public_page_content( modules_str, module_contents, true ) - @test occursin("# Public API", overview_pub_split) + Test.@test occursin("# Public API", overview_pub_split) # Combined page should have "API reference" title overview_comb, _ = DR._build_combined_page_content(modules_str, module_contents) - @test occursin("# API reference", overview_comb) + Test.@test occursin("# API reference", overview_comb) end # ============================================================================ # NEW TESTS: Customization Parameters # ============================================================================ - @testset verbose = VERBOSE showtiming = SHOWTIMING "Custom titles for API pages" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "Custom titles for API pages" begin modules_str = "TestModule" module_contents = [(DocumenterReferenceTestMod, ["pub_doc"], ["priv_doc"])] @@ -905,32 +989,32 @@ function test_documenter_reference() overview_priv_custom, _ = DR._build_private_page_content( modules_str, module_contents, false; custom_title="Internal API" ) - @test occursin("# Internal API", overview_priv_custom) - @test !occursin("# Private API", overview_priv_custom) + Test.@test occursin("# Internal API", overview_priv_custom) + Test.@test !occursin("# Private API", overview_priv_custom) # Test custom title for public page (single) overview_pub_custom, _ = DR._build_public_page_content( modules_str, module_contents, false; custom_title="Exported API" ) - @test occursin("# Exported API", overview_pub_custom) - @test !occursin("# Public API", overview_pub_custom) + Test.@test occursin("# Exported API", overview_pub_custom) + Test.@test !occursin("# Public API", overview_pub_custom) # Test custom title for private page (split) overview_priv_split_custom, _ = DR._build_private_page_content( modules_str, module_contents, true; custom_title="Internal" ) - @test occursin("# Internal", overview_priv_split_custom) - @test !occursin("# Private API", overview_priv_split_custom) + Test.@test occursin("# Internal", overview_priv_split_custom) + Test.@test !occursin("# Private API", overview_priv_split_custom) # Test custom title for public page (split) overview_pub_split_custom, _ = DR._build_public_page_content( modules_str, module_contents, true; custom_title="Exported" ) - @test occursin("# Exported", overview_pub_split_custom) - @test !occursin("# Public API", overview_pub_split_custom) + Test.@test occursin("# Exported", overview_pub_split_custom) + Test.@test !occursin("# Public API", overview_pub_split_custom) end - @testset verbose = VERBOSE showtiming = SHOWTIMING "Custom descriptions for API pages" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "Custom descriptions for API pages" begin modules_str = "TestModule" module_contents = [(DocumenterReferenceTestMod, ["pub_doc"], ["priv_doc"])] @@ -939,20 +1023,20 @@ function test_documenter_reference() overview_priv_desc, _ = DR._build_private_page_content( modules_str, module_contents, false; custom_description=custom_desc_priv ) - @test occursin(custom_desc_priv, overview_priv_desc) - @test !occursin("non-exported", overview_priv_desc) + Test.@test occursin(custom_desc_priv, overview_priv_desc) + Test.@test !occursin("non-exported", overview_priv_desc) # Test custom description for public page custom_desc_pub = "This page documents the public interface for end users." overview_pub_desc, _ = DR._build_public_page_content( modules_str, module_contents, false; custom_description=custom_desc_pub ) - @test occursin(custom_desc_pub, overview_pub_desc) - @test !occursin("exported", overview_pub_desc) || + Test.@test occursin(custom_desc_pub, overview_pub_desc) + Test.@test !occursin("exported", overview_pub_desc) || occursin(custom_desc_pub, overview_pub_desc) end - @testset verbose = VERBOSE showtiming = SHOWTIMING "Combined custom title and description" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "Combined custom title and description" begin modules_str = "TestModule" module_contents = [(DocumenterReferenceTestMod, ["pub_doc"], ["priv_doc"])] @@ -968,13 +1052,13 @@ function test_documenter_reference() custom_description=custom_desc, ) - @test occursin("# Developer Reference", overview) - @test occursin(custom_desc, overview) - @test !occursin("# Private API", overview) - @test !occursin("non-exported", overview) + Test.@test occursin("# Developer Reference", overview) + Test.@test occursin(custom_desc, overview) + Test.@test !occursin("# Private API", overview) + Test.@test !occursin("non-exported", overview) end - @testset verbose = VERBOSE showtiming = SHOWTIMING "Empty customization uses defaults" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "Empty customization uses defaults" begin modules_str = "TestModule" module_contents = [(DocumenterReferenceTestMod, ["pub_doc"], ["priv_doc"])] @@ -982,13 +1066,18 @@ function test_documenter_reference() overview_priv_empty, _ = DR._build_private_page_content( modules_str, module_contents, false; custom_title="", custom_description="" ) - @test occursin("# Private API", overview_priv_empty) - @test occursin("non-exported", overview_priv_empty) + Test.@test occursin("# Private API", overview_priv_empty) + Test.@test occursin("non-exported", overview_priv_empty) overview_pub_empty, _ = DR._build_public_page_content( modules_str, module_contents, false; custom_title="", custom_description="" ) - @test occursin("# Public API", overview_pub_empty) - @test occursin("exported", overview_pub_empty) + Test.@test occursin("# Public API", overview_pub_empty) + Test.@test occursin("exported", overview_pub_empty) end end + +end # module TestDocumenterReference + +# CRITICAL: redefine in outer scope so the test runner can call it +test_documenter_reference() = TestDocumenterReference.test_documenter_reference() diff --git a/test/suite/extensions/test_extensions_enriched.jl b/test/suite/extensions/test_extensions_enriched.jl index 11dc7907..492f08ef 100644 --- a/test/suite/extensions/test_extensions_enriched.jl +++ b/test/suite/extensions/test_extensions_enriched.jl @@ -1,29 +1,32 @@ module TestExtensionsEnriched -using Test -using CTBase -using Main.TestOptions: VERBOSE, SHOWTIMING +import Test +import CTBase.Extensions +import CTBase.Exceptions + +const VERBOSE = isdefined(Main, :TestOptions) ? Main.TestOptions.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestOptions) ? Main.TestOptions.SHOWTIMING : true # Fake tag types for extension stub testing (testing-creation.md ยง6). # These subtype the abstract tags but are unknown to any extension, # so the stubs always throw ExtensionError regardless of which extensions are loaded. -struct FakeDocumenterReferenceTag <: CTBase.AbstractDocumenterReferenceTag end -struct FakeCoveragePostprocessingTag <: CTBase.AbstractCoveragePostprocessingTag end -struct FakeTestRunnerTag <: CTBase.AbstractTestRunnerTag end +struct FakeDocumenterReferenceTag <: Extensions.AbstractDocumenterReferenceTag end +struct FakeCoveragePostprocessingTag <: Extensions.AbstractCoveragePostprocessingTag end +struct FakeTestRunnerTag <: Extensions.AbstractTestRunnerTag end function test_extensions_enriched() - @testset verbose = VERBOSE showtiming = SHOWTIMING "Enriched Extension Errors" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "Enriched Extension Errors" begin # ==================================================================== # UNIT TESTS - Extension Error Contract # ==================================================================== - @testset "ExtensionError Contract Implementation" begin + Test.@testset "ExtensionError Contract Implementation" begin # Test constructor throws if no dependencies provided - @test_throws CTBase.PreconditionError CTBase.ExtensionError() + Test.@test_throws Exceptions.PreconditionError Exceptions.ExtensionError() # Test enriched ExtensionError creation - e = CTBase.ExtensionError( + e = Exceptions.ExtensionError( :Documenter, :Markdown; message="to generate documentation", @@ -31,35 +34,35 @@ function test_extensions_enriched() context="reference generation", ) - @test e isa CTBase.ExtensionError - @test e.weakdeps == (:Documenter, :Markdown) - @test e.msg == "missing dependencies to generate documentation" - @test e.feature == "automatic documentation" - @test e.context == "reference generation" + Test.@test e isa Exceptions.ExtensionError + Test.@test e.weakdeps == (:Documenter, :Markdown) + Test.@test e.msg == "missing dependencies to generate documentation" + Test.@test e.feature == "automatic documentation" + Test.@test e.context == "reference generation" end # ==================================================================== # INTEGRATION TESTS - Extension Functions # ==================================================================== - @testset "Extension Function Error Handling" begin + Test.@testset "Extension Function Error Handling" begin # Test automatic_reference_documentation error - @testset "automatic_reference_documentation" begin - @test_throws CTBase.ExtensionError CTBase.automatic_reference_documentation( + Test.@testset "automatic_reference_documentation" begin + Test.@test_throws Exceptions.ExtensionError Extensions.automatic_reference_documentation( FakeDocumenterReferenceTag() ) end # Test postprocess_coverage error - @testset "postprocess_coverage" begin - @test_throws CTBase.ExtensionError CTBase.postprocess_coverage( + Test.@testset "postprocess_coverage" begin + Test.@test_throws Exceptions.ExtensionError Extensions.postprocess_coverage( FakeCoveragePostprocessingTag() ) end # Test run_tests error - @testset "run_tests" begin - @test_throws CTBase.ExtensionError CTBase.run_tests(FakeTestRunnerTag()) + Test.@testset "run_tests" begin + Test.@test_throws Exceptions.ExtensionError Extensions.run_tests(FakeTestRunnerTag()) end end @@ -67,32 +70,32 @@ function test_extensions_enriched() # ERROR TESTS - Exception Quality # ==================================================================== - @testset "ExtensionError Constructor Validation" begin - @testset "No dependencies provided" begin - @test_throws CTBase.PreconditionError CTBase.ExtensionError() + Test.@testset "ExtensionError Constructor Validation" begin + Test.@testset "No dependencies provided" begin + Test.@test_throws Exceptions.PreconditionError Exceptions.ExtensionError() try - CTBase.ExtensionError() - @test false # Should not reach here + Exceptions.ExtensionError() + Test.@test false # Should not reach here catch e - @test e isa CTBase.PreconditionError - @test occursin("weak dependence", e.msg) - @test occursin("ExtensionError called without dependencies", e.reason) + Test.@test e isa Exceptions.PreconditionError + Test.@test occursin("weak dependence", e.msg) + Test.@test occursin("ExtensionError called without dependencies", e.reason) end end - @testset "Single dependency" begin - e = CTBase.ExtensionError(:MyExt) - @test e isa CTBase.ExtensionError - @test e.weakdeps == (:MyExt,) - @test e.msg == "missing dependencies" + Test.@testset "Single dependency" begin + e = Exceptions.ExtensionError(:MyExt) + Test.@test e isa Exceptions.ExtensionError + Test.@test e.weakdeps == (:MyExt,) + Test.@test e.msg == "missing dependencies" end - @testset "Multiple dependencies with message" begin - e = CTBase.ExtensionError(:Ext1, :Ext2; message="to enable feature X") - @test e isa CTBase.ExtensionError - @test e.weakdeps == (:Ext1, :Ext2) - @test e.msg == "missing dependencies to enable feature X" + Test.@testset "Multiple dependencies with message" begin + e = Exceptions.ExtensionError(:Ext1, :Ext2; message="to enable feature X") + Test.@test e isa Exceptions.ExtensionError + Test.@test e.weakdeps == (:Ext1, :Ext2) + Test.@test e.msg == "missing dependencies to enable feature X" end end end diff --git a/test/suite/extensions/test_testrunner.jl b/test/suite/extensions/test_testrunner.jl index c9fce312..df139ced 100644 --- a/test/suite/extensions/test_testrunner.jl +++ b/test/suite/extensions/test_testrunner.jl @@ -1,125 +1,127 @@ module TestTestRunner -using Test -using CTBase +import Test +import CTBase +import CTBase.Extensions +import CTBase.Exceptions const TestRunner = Base.get_extension(CTBase, :TestRunner) const VERBOSE = isdefined(Main, :TestOptions) ? Main.TestOptions.VERBOSE : true const SHOWTIMING = isdefined(Main, :TestOptions) ? Main.TestOptions.SHOWTIMING : true -struct DummyTestRunnerTag <: CTBase.Extensions.AbstractTestRunnerTag end +struct DummyTestRunnerTag <: Extensions.AbstractTestRunnerTag end function test_testrunner() # ============================================================================ # UNIT TESTS - TestRunner extension # ============================================================================ - @testset verbose = VERBOSE showtiming = SHOWTIMING "TestRunner extension" begin - @test Base.get_extension(CTBase, :TestRunner) !== nothing + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "TestRunner extension" begin + Test.@test Base.get_extension(CTBase, :TestRunner) !== nothing end - @testset verbose = VERBOSE showtiming = SHOWTIMING "TestRunner stub dispatch" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "TestRunner stub dispatch" begin err = try - CTBase.run_tests(DummyTestRunnerTag()) + Extensions.run_tests(DummyTestRunnerTag()) nothing catch e e end - @test err isa CTBase.ExtensionError - @test err.weakdeps == (:Test,) + Test.@test err isa Exceptions.ExtensionError + Test.@test err.weakdeps == (:Test,) end # ============================================================================ # UNIT TESTS - _parse_test_args # ============================================================================ - @testset verbose = VERBOSE showtiming = SHOWTIMING "_parse_test_args" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "_parse_test_args" begin # Access the private function via the loaded extension module parse_args = TestRunner._parse_test_args - @testset verbose = VERBOSE showtiming = SHOWTIMING "empty args with defaults" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "empty args with defaults" begin (sel, all_flag, dry_flag) = parse_args(String[]) - @test sel == String[] - @test all_flag == false + Test.@test sel == String[] + Test.@test all_flag == false - @test dry_flag == false + Test.@test dry_flag == false end - @testset verbose = VERBOSE showtiming = SHOWTIMING "single test name" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "single test name" begin (sel, _, _) = parse_args(["utils"]) - @test sel == ["utils"] + Test.@test sel == ["utils"] end - @testset verbose = VERBOSE showtiming = SHOWTIMING "multiple test names" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "multiple test names" begin (sel, _, _) = parse_args(["utils", "default"]) - @test sel == ["utils", "default"] + Test.@test sel == ["utils", "default"] end - @testset verbose = VERBOSE showtiming = SHOWTIMING "coverage flags are filtered out" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "coverage flags are filtered out" begin (sel, _, _) = parse_args(["coverage=true"]) - @test sel == String[] + Test.@test sel == String[] end - @testset verbose = VERBOSE showtiming = SHOWTIMING "CLI flags -a / --all" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "CLI flags -a / --all" begin # -a should set run_all=true (_, run_all, _) = parse_args(["-a"]) - @test run_all == true + Test.@test run_all == true (_, run_all, _) = parse_args(["--all"]) - @test run_all == true + Test.@test run_all == true end - @testset verbose = VERBOSE showtiming = SHOWTIMING "CLI flags -n / --dryrun" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "CLI flags -n / --dryrun" begin # -n should set dry_run=true (_, _, dry_run) = parse_args(["-n"]) - @test dry_run == true + Test.@test dry_run == true (_, _, dry_run) = parse_args(["--dryrun"]) - @test dry_run == true + Test.@test dry_run == true end - @testset verbose = VERBOSE showtiming = SHOWTIMING "mixed flags and tests" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "mixed flags and tests" begin (sel, run_all, dry_run) = parse_args(["utils", "-n", "--all"]) - @test sel == ["utils"] - @test run_all == true - @test dry_run == true + Test.@test sel == ["utils"] + Test.@test run_all == true + Test.@test dry_run == true end - @testset verbose = VERBOSE showtiming = SHOWTIMING "test/ prefix is stripped" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "test/ prefix is stripped" begin (sel, _, _) = parse_args(["test/suite/foo"]) - @test sel == ["suite/foo"] + Test.@test sel == ["suite/foo"] (sel, _, _) = parse_args(["test/suite"]) - @test sel == ["suite"] + Test.@test sel == ["suite"] # No prefix โ†’ unchanged (sel, _, _) = parse_args(["suite/foo"]) - @test sel == ["suite/foo"] + Test.@test sel == ["suite/foo"] # Exact "test" (no slash) โ†’ unchanged (sel, _, _) = parse_args(["test"]) - @test sel == ["test"] + Test.@test sel == ["test"] # Multiple args with mixed prefixes (sel, _, _) = parse_args(["test/suite/a", "suite/b"]) - @test sel == ["suite/a", "suite/b"] + Test.@test sel == ["suite/a", "suite/b"] end end - @testset verbose = VERBOSE showtiming = SHOWTIMING "_strip_test_prefix" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "_strip_test_prefix" begin strip_prefix = TestRunner._strip_test_prefix - @test strip_prefix("test/suite/foo") == "suite/foo" - @test strip_prefix("test/a") == "a" - @test strip_prefix("suite/foo") == "suite/foo" - @test strip_prefix("test") == "test" - @test strip_prefix("testing/foo") == "testing/foo" - @test strip_prefix("test/") == "" + Test.@test strip_prefix("test/suite/foo") == "suite/foo" + Test.@test strip_prefix("test/a") == "a" + Test.@test strip_prefix("suite/foo") == "suite/foo" + Test.@test strip_prefix("test") == "test" + Test.@test strip_prefix("testing/foo") == "testing/foo" + Test.@test strip_prefix("test/") == "" end - @testset verbose = VERBOSE showtiming = SHOWTIMING "run_tests (dry run)" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "run_tests (dry run)" begin mktempdir() do temp_dir touch(joinpath(temp_dir, "test_dummy.jl")) @@ -127,7 +129,7 @@ function test_testrunner() old_stdout = stdout rd, wr = redirect_stdout() try - CTBase.run_tests(; + Extensions.run_tests(; args=["--dryrun", "test_dummy.jl"], testset_name="DryRun", available_tests=("*.jl",), @@ -144,8 +146,8 @@ function test_testrunner() end s = read(rd, String) - @test occursin("Dry run", s) - @test occursin("test_dummy.jl", s) + Test.@test occursin("Dry run", s) + Test.@test occursin("test_dummy.jl", s) end end @@ -153,7 +155,7 @@ function test_testrunner() # UNIT TESTS - _select_tests # ============================================================================ - @testset verbose = VERBOSE showtiming = SHOWTIMING "_select_tests" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "_select_tests" begin select_tests = TestRunner._select_tests # Mock filename builder for testing @@ -174,45 +176,45 @@ function test_testrunner() # ========================================================== # Scenario 1: Auto-discovery (available_tests empty) # ========================================================== - @testset verbose = VERBOSE showtiming = SHOWTIMING "Auto-discovery (empty available_tests)" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "Auto-discovery (empty available_tests)" begin # Empty args -> run all .jl files (excluding runtests.jl) # Names derived as basenames sel = select_tests(String[], Symbol[], false, identity; test_dir=temp_dir) - @test sort(sel) == ["test_core.jl", "test_utils.jl"] + Test.@test sort(sel) == ["test_core.jl", "test_utils.jl"] # Run all (-a) -> same result sel = select_tests(String[], Symbol[], true, identity; test_dir=temp_dir) - @test sort(sel) == ["test_core.jl", "test_utils.jl"] + Test.@test sort(sel) == ["test_core.jl", "test_utils.jl"] # Globbing: select only utils sel = select_tests( ["test_utils"], Symbol[], false, identity; test_dir=temp_dir ) - @test sel == ["test_utils.jl"] + Test.@test sel == ["test_utils.jl"] # Globbing: pattern matching sel = select_tests( ["test_c*"], Symbol[], false, identity; test_dir=temp_dir ) - @test sel == ["test_core.jl"] + Test.@test sel == ["test_core.jl"] # Globbing: match filename? sel = select_tests( ["test_core_jl"], Symbol[], false, identity; test_dir=temp_dir ) # "^test_core_jl$" doesn't match "test_core.jl" or "test_core" - @test isempty(sel) + Test.@test isempty(sel) sel = select_tests( ["test_core.jl"], Symbol[], false, identity; test_dir=temp_dir ) - @test sel == ["test_core.jl"] + Test.@test sel == ["test_core.jl"] end # ========================================================== # Scenario 2: With available_tests # ========================================================== - @testset verbose = VERBOSE showtiming = SHOWTIMING "With available_tests" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "With available_tests" begin available = [:utils, :core] # Note: TestRunner checks if file exists via builder @@ -223,36 +225,36 @@ function test_testrunner() sel = select_tests( String[], available, false, test_builder_sym; test_dir=temp_dir ) - @test sort(sel) == [:core, :utils] + Test.@test sort(sel) == [:core, :utils] # Selection sel = select_tests( ["utils"], available, false, test_builder_sym; test_dir=temp_dir ) - @test sel == [:utils] + Test.@test sel == [:utils] # Globbing over available names # available test :core, file: test_core.jl sel = select_tests( ["c*"], available, false, test_builder_sym; test_dir=temp_dir ) - @test sel == [:core] + Test.@test sel == [:core] # Globbing over filename without extension # available test :core, file: test_core.jl sel = select_tests( ["test_core"], available, false, test_builder_sym; test_dir=temp_dir ) - @test sel == [:core] + Test.@test sel == [:core] # Builder may return String sel = select_tests( ["core"], available, false, test_builder_str; test_dir=temp_dir ) - @test sel == [:core] + Test.@test sel == [:core] end - @testset verbose = VERBOSE showtiming = SHOWTIMING "symbol in subdirectory" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "symbol in subdirectory" begin mkpath(joinpath(temp_dir, "suite_src")) touch(joinpath(temp_dir, "suite_src", "test_utils.jl")) @@ -261,35 +263,35 @@ function test_testrunner() sel = select_tests( String[], available, false, test_builder_sym; test_dir=temp_dir ) - @test sel == [:utils] + Test.@test sel == [:utils] end - @testset verbose = VERBOSE showtiming = SHOWTIMING "available_tests may be directories" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "available_tests may be directories" begin mkpath(joinpath(temp_dir, "suite")) touch(joinpath(temp_dir, "suite", "test_a.jl")) touch(joinpath(temp_dir, "suite", "test_b.jl")) available = TestRunner.TestSpec["suite"] sel = select_tests(String[], available, false, identity; test_dir=temp_dir) - @test sel == ["suite/test_a.jl", "suite/test_b.jl"] + Test.@test sel == ["suite/test_a.jl", "suite/test_b.jl"] sel = select_tests( ["suite/test_a"], available, false, identity; test_dir=temp_dir ) - @test sel == ["suite/test_a.jl"] + Test.@test sel == ["suite/test_a.jl"] end - @testset verbose = VERBOSE showtiming = SHOWTIMING "glob: match basename without test_ prefix" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "glob: match basename without test_ prefix" begin mkpath(joinpath(temp_dir, "suite_src")) touch(joinpath(temp_dir, "suite_src", "test_utils.jl")) available = TestRunner.TestSpec["suite_src/*"] sel = select_tests(["utils"], available, false, identity; test_dir=temp_dir) - @test sel == ["suite_src/test_utils.jl"] + Test.@test sel == ["suite_src/test_utils.jl"] end - @testset verbose = VERBOSE showtiming = SHOWTIMING "bare directory selection" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "bare directory selection" begin mkpath(joinpath(temp_dir, "suite_norm")) touch(joinpath(temp_dir, "suite_norm", "test_x.jl")) touch(joinpath(temp_dir, "suite_norm", "test_y.jl")) @@ -300,22 +302,22 @@ function test_testrunner() sel = select_tests( ["suite_norm"], available, false, identity; test_dir=temp_dir ) - @test sort(sel) == ["suite_norm/test_x.jl", "suite_norm/test_y.jl"] + Test.@test sort(sel) == ["suite_norm/test_x.jl", "suite_norm/test_y.jl"] # Trailing slash should also work sel = select_tests( ["suite_norm/"], available, false, identity; test_dir=temp_dir ) - @test sort(sel) == ["suite_norm/test_x.jl", "suite_norm/test_y.jl"] + Test.@test sort(sel) == ["suite_norm/test_x.jl", "suite_norm/test_y.jl"] # Explicit wildcard still works sel = select_tests( ["suite_norm/*"], available, false, identity; test_dir=temp_dir ) - @test sort(sel) == ["suite_norm/test_x.jl", "suite_norm/test_y.jl"] + Test.@test sort(sel) == ["suite_norm/test_x.jl", "suite_norm/test_y.jl"] end - @testset verbose = VERBOSE showtiming = SHOWTIMING "bare nested directory selection" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "bare nested directory selection" begin mkpath(joinpath(temp_dir, "suite_deep", "sub")) touch(joinpath(temp_dir, "suite_deep", "sub", "test_z.jl")) @@ -325,7 +327,7 @@ function test_testrunner() sel = select_tests( ["suite_deep/sub"], available, false, identity; test_dir=temp_dir ) - @test sel == ["suite_deep/sub/test_z.jl"] + Test.@test sel == ["suite_deep/sub/test_z.jl"] end end end @@ -334,7 +336,7 @@ function test_testrunner() # UNIT TESTS - _normalize_selections # ============================================================================ - @testset verbose = VERBOSE showtiming = SHOWTIMING "_normalize_selections" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "_normalize_selections" begin normalize = TestRunner._normalize_selections candidates = TestRunner.TestSpec[ @@ -343,38 +345,38 @@ function test_testrunner() "suite/core/test_core.jl", ] - @testset "bare directory expands to dir/*" begin + Test.@testset "bare directory expands to dir/*" begin result = normalize(["suite/exceptions"], candidates) - @test "suite/exceptions" in result - @test "suite/exceptions/*" in result + Test.@test "suite/exceptions" in result + Test.@test "suite/exceptions/*" in result end - @testset "trailing slash is stripped and expanded" begin + Test.@testset "trailing slash is stripped and expanded" begin result = normalize(["suite/exceptions/"], candidates) - @test "suite/exceptions" in result - @test "suite/exceptions/*" in result + Test.@test "suite/exceptions" in result + Test.@test "suite/exceptions/*" in result end - @testset "pattern with wildcard is kept as-is" begin + Test.@testset "pattern with wildcard is kept as-is" begin result = normalize(["suite/exceptions/*"], candidates) - @test result == ["suite/exceptions/*"] + Test.@test result == ["suite/exceptions/*"] end - @testset "non-matching directory is not expanded" begin + Test.@testset "non-matching directory is not expanded" begin result = normalize(["nonexistent"], candidates) - @test result == ["nonexistent"] + Test.@test result == ["nonexistent"] end - @testset "parent directory expands" begin + Test.@testset "parent directory expands" begin result = normalize(["suite"], candidates) - @test "suite" in result - @test "suite/*" in result + Test.@test "suite" in result + Test.@test "suite/*" in result end - @testset "no duplicates" begin + Test.@testset "no duplicates" begin result = normalize(["suite/exceptions", "suite/exceptions/"], candidates) - @test count(==("suite/exceptions"), result) == 1 - @test count(==("suite/exceptions/*"), result) == 1 + Test.@test count(==("suite/exceptions"), result) == 1 + Test.@test count(==("suite/exceptions/*"), result) == 1 end end @@ -382,82 +384,82 @@ function test_testrunner() # UNIT TESTS - Progress display # ============================================================================ - @testset verbose = VERBOSE showtiming = SHOWTIMING "Progress display" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "Progress display" begin progress_bar = TestRunner._progress_bar bar_width = TestRunner._bar_width default_on_test_done = TestRunner._default_on_test_done - @testset "_bar_width adaptive" begin - @test bar_width(1) == 1 - @test bar_width(10) == 10 - @test bar_width(20) == 20 - @test bar_width(21) == 21 - @test bar_width(30) == 30 - @test bar_width(50) == 50 - @test bar_width(100) == 100 - @test bar_width(101) == 100 - @test bar_width(500) == 100 - @test bar_width(1000) == 100 - @test bar_width(0) == 0 + Test.@testset "_bar_width adaptive" begin + Test.@test bar_width(1) == 1 + Test.@test bar_width(10) == 10 + Test.@test bar_width(20) == 20 + Test.@test bar_width(21) == 21 + Test.@test bar_width(30) == 30 + Test.@test bar_width(50) == 50 + Test.@test bar_width(100) == 100 + Test.@test bar_width(101) == 100 + Test.@test bar_width(500) == 100 + Test.@test bar_width(1000) == 100 + Test.@test bar_width(0) == 0 end - @testset "_bar_width with custom progress_bar_threshold" begin + Test.@testset "_bar_width with custom progress_bar_threshold" begin # Custom threshold of 30 - @test bar_width(1, 30) == 1 - @test bar_width(10, 30) == 10 - @test bar_width(30, 30) == 30 - @test bar_width(31, 30) == 30 - @test bar_width(100, 30) == 30 + Test.@test bar_width(1, 30) == 1 + Test.@test bar_width(10, 30) == 10 + Test.@test bar_width(30, 30) == 30 + Test.@test bar_width(31, 30) == 30 + Test.@test bar_width(100, 30) == 30 # Custom threshold of 100 - @test bar_width(1, 100) == 1 - @test bar_width(50, 100) == 50 - @test bar_width(100, 100) == 100 - @test bar_width(101, 100) == 100 - @test bar_width(500, 100) == 100 + Test.@test bar_width(1, 100) == 1 + Test.@test bar_width(50, 100) == 50 + Test.@test bar_width(100, 100) == 100 + Test.@test bar_width(101, 100) == 100 + Test.@test bar_width(500, 100) == 100 # Custom threshold of 10 - @test bar_width(1, 10) == 1 - @test bar_width(10, 10) == 10 - @test bar_width(11, 10) == 10 - @test bar_width(100, 10) == 10 + Test.@test bar_width(1, 10) == 1 + Test.@test bar_width(10, 10) == 10 + Test.@test bar_width(11, 10) == 10 + Test.@test bar_width(100, 10) == 10 end - @testset "_progress_bar rendering" begin + Test.@testset "_progress_bar rendering" begin # Full bar (explicit width) bar = progress_bar(10, 10; width=10) - @test bar == "[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ]" + Test.@test bar == "[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ]" # Empty bar bar = progress_bar(0, 10; width=10) - @test bar == "[โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]" + Test.@test bar == "[โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]" # Half bar bar = progress_bar(5, 10; width=10) - @test bar == "[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘]" + Test.@test bar == "[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘]" # Auto width: 2 tests โ†’ width=2 bar = progress_bar(1, 2) - @test length(bar) == 4 # 2 chars + 2 brackets + Test.@test length(bar) == 4 # 2 chars + 2 brackets # Auto width: 50 tests โ†’ width=50 bar = progress_bar(25, 50) - @test length(bar) == 52 # 50 chars + 2 brackets + Test.@test length(bar) == 52 # 50 chars + 2 brackets # Auto width: 100 tests โ†’ width=100 bar = progress_bar(50, 100) - @test length(bar) == 102 # 100 chars + 2 brackets + Test.@test length(bar) == 102 # 100 chars + 2 brackets # Auto width: 500 tests โ†’ width=100 bar = progress_bar(250, 500) - @test length(bar) == 102 # 100 chars + 2 brackets + Test.@test length(bar) == 102 # 100 chars + 2 brackets # Edge case: total=0 bar = progress_bar(0, 0; width=10) - @test bar == "[โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]" + Test.@test bar == "[โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]" end - @testset "_format_progress_line output" begin + Test.@testset "_format_progress_line output" begin fmt = TestRunner._format_progress_line info = TestRunner.TestRunInfo( @@ -473,11 +475,11 @@ function test_testrunner() buf = IOBuffer() fmt(buf, info) output = String(take!(buf)) - @test contains(output, "โœ“") - @test contains(output, "[03/10]") - @test contains(output, "my_test") - @test contains(output, "1.2s") - @test contains(output, "โ–ˆ") + Test.@test contains(output, "โœ“") + Test.@test contains(output, "[03/10]") + Test.@test contains(output, "my_test") + Test.@test contains(output, "1.2s") + Test.@test contains(output, "โ–ˆ") # Error status info_err = TestRunner.TestRunInfo( @@ -493,11 +495,11 @@ function test_testrunner() buf_err = IOBuffer() fmt(buf_err, info_err) output_err = String(take!(buf_err)) - @test contains(output_err, "โœ—") - @test contains(output_err, "FAILED") - @test contains(output_err, "[05/10]") + Test.@test contains(output_err, "โœ—") + Test.@test contains(output_err, "FAILED") + Test.@test contains(output_err, "[05/10]") - # test_failed status (from @test failures) + # test_failed status (from Test.@test failures) info_tf = TestRunner.TestRunInfo( :my_test, "/tmp/test_my_test.jl", @@ -511,8 +513,8 @@ function test_testrunner() buf_tf = IOBuffer() fmt(buf_tf, info_tf) output_tf = String(take!(buf_tf)) - @test contains(output_tf, "โœ—") - @test contains(output_tf, "FAILED") + Test.@test contains(output_tf, "โœ—") + Test.@test contains(output_tf, "FAILED") # Skipped status info_skip = TestRunner.TestRunInfo( @@ -528,8 +530,8 @@ function test_testrunner() buf_skip = IOBuffer() fmt(buf_skip, info_skip) output_skip = String(take!(buf_skip)) - @test contains(output_skip, "โ—‹") - @test !contains(output_skip, "FAILED") + Test.@test contains(output_skip, "โ—‹") + Test.@test !contains(output_skip, "FAILED") # Fixed bar of 20 chars even for >400 tests (empty at start) info_large = TestRunner.TestRunInfo( @@ -538,8 +540,8 @@ function test_testrunner() buf_large = IOBuffer() fmt(buf_large, info_large) output_large = String(take!(buf_large)) - @test contains(output_large, "โœ“") - @test contains(output_large, "โ–‘") + Test.@test contains(output_large, "โœ“") + Test.@test contains(output_large, "โ–‘") # Test with some progress (25%) info_large_progress = TestRunner.TestRunInfo( @@ -548,12 +550,12 @@ function test_testrunner() buf_large_progress = IOBuffer() fmt(buf_large_progress, info_large_progress) output_large_progress = String(take!(buf_large_progress)) - @test contains(output_large_progress, "โœ“") - @test contains(output_large_progress, "โ–ˆ") - @test contains(output_large_progress, "โ–‘") + Test.@test contains(output_large_progress, "โœ“") + Test.@test contains(output_large_progress, "โ–ˆ") + Test.@test contains(output_large_progress, "โ–‘") end - @testset "_format_progress_line cumulative coloring (full width)" begin + Test.@testset "_format_progress_line cumulative coloring (full width)" begin fmt = TestRunner._format_progress_line history = [1, 1, 2, 3, 1] # green, green, yellow, red, green info = TestRunner.TestRunInfo( @@ -562,12 +564,12 @@ function test_testrunner() buf = IOBuffer() fmt(buf, info; history=history, cumulative_severity=maximum(history)) output = String(take!(buf)) - @test contains(output, "[") - @test occursin("\e[31m[", output) # brackets red because of failure - @test occursin("\e[33mโ”†", output) # yellow block present (skip glyph) + Test.@test contains(output, "[") + Test.@test occursin("\e[31m[", output) # brackets red because of failure + Test.@test occursin("\e[33mโ”†", output) # yellow block present (skip glyph) end - @testset "_format_progress_line compressed coloring" begin + Test.@testset "_format_progress_line compressed coloring" begin fmt = TestRunner._format_progress_line info = TestRunner.TestRunInfo( :compressed, @@ -582,11 +584,11 @@ function test_testrunner() buf = IOBuffer() fmt(buf, info; cumulative_severity=2) output = String(take!(buf)) - @test occursin("\e[33m[", output) || + Test.@test occursin("\e[33m[", output) || occursin("\e[33m[", replace(output, "\e[0m"=>"")) end - @testset "_format_progress_line show_progress_bar=false" begin + Test.@testset "_format_progress_line show_progress_bar=false" begin fmt = TestRunner._format_progress_line info = TestRunner.TestRunInfo( :test_example, @@ -602,16 +604,16 @@ function test_testrunner() fmt(buf, info; show_progress_bar=false) output = String(take!(buf)) # Bar glyphs should not be present - @test !occursin("โ–ˆ", output) - @test !occursin("โ–‘", output) + Test.@test !occursin("โ–ˆ", output) + Test.@test !occursin("โ–‘", output) # No bar pattern like [โ–ˆ or [โ–‘ - @test !occursin("[โ–ˆ", output) - @test !occursin("[โ–‘", output) + Test.@test !occursin("[โ–ˆ", output) + Test.@test !occursin("[โ–‘", output) # Rest of line should be present - @test contains(output, "โœ“") - @test contains(output, "[05/10]") - @test contains(output, "test_example") - @test contains(output, "(1.2s)") + Test.@test contains(output, "โœ“") + Test.@test contains(output, "[05/10]") + Test.@test contains(output, "test_example") + Test.@test contains(output, "(1.2s)") end end @@ -619,34 +621,34 @@ function test_testrunner() # EDGE CASES # ============================================================================ - @testset verbose = VERBOSE showtiming = SHOWTIMING "Edge cases" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "Edge cases" begin parse_args = TestRunner._parse_test_args select_tests = TestRunner._select_tests normalize_available = TestRunner._normalize_available_tests find_symbol_file = TestRunner._find_symbol_test_file_rel - @testset verbose = VERBOSE showtiming = SHOWTIMING "mixed case & special chars" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "mixed case & special chars" begin # Test names are converted to Symbols, preserving case (sel, _, _) = parse_args(["MyTest", "test_123"]) - @test sel == ["MyTest", "test_123"] + Test.@test sel == ["MyTest", "test_123"] end - @testset verbose = VERBOSE showtiming = SHOWTIMING "available_tests may be a Tuple" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "available_tests may be a Tuple" begin out = normalize_available((:a, "suite_ext/*")) - @test out == TestRunner.TestSpec[:a, "suite_ext/*"] + Test.@test out == TestRunner.TestSpec[:a, "suite_ext/*"] end - @testset verbose = VERBOSE showtiming = SHOWTIMING "available_tests may be Vector{Any}" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "available_tests may be Vector{Any}" begin out = normalize_available(Any[:a, "suite_ext/*"]) - @test out == TestRunner.TestSpec[:a, "suite_ext/*"] + Test.@test out == TestRunner.TestSpec[:a, "suite_ext/*"] end - @testset verbose = VERBOSE showtiming = SHOWTIMING "available_tests input validation" begin - @test_throws CTBase.Exceptions.IncorrectArgument normalize_available(123) - @test_throws CTBase.Exceptions.IncorrectArgument normalize_available(Any[1]) + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "available_tests input validation" begin + Test.@test_throws Exceptions.IncorrectArgument normalize_available(123) + Test.@test_throws Exceptions.IncorrectArgument normalize_available(Any[1]) end - @testset verbose = VERBOSE showtiming = SHOWTIMING "symbol resolution: shallowest match" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "symbol resolution: shallowest match" begin mktempdir() do temp_dir mkpath(joinpath(temp_dir, "a")) mkpath(joinpath(temp_dir, "a", "b")) @@ -654,14 +656,14 @@ function test_testrunner() touch(joinpath(temp_dir, "a", "b", "test_x.jl")) rel = find_symbol_file(:x, n -> "test_" * String(n); test_dir=temp_dir) - @test rel == joinpath("a", "test_x.jl") + Test.@test rel == joinpath("a", "test_x.jl") end end - @testset verbose = VERBOSE showtiming = SHOWTIMING "order preservation" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "order preservation" begin # Selections should preserve order in ARGS parsing (sel, _, _) = parse_args(["z", "a", "m"]) - @test sel == ["z", "a", "m"] + Test.@test sel == ["z", "a", "m"] # Using custom select_tests to verify preservation behavior mktempdir() do temp_dir @@ -674,19 +676,19 @@ function test_testrunner() sel = select_tests( ["z", "a"], [:a, :b, :z], false, identity; test_dir=temp_dir ) - @test sel == [:a, :z] # candidates order candidate list order + Test.@test sel == [:a, :z] # candidates order candidate list order # Case 2: Auto-discovery order (filesystem order, filtered) # We can't guarantee FS order, so checking set equality sel = select_tests(["z", "a"], Symbol[], false, identity; test_dir=temp_dir) - @test Set(sel) == Set(["a.jl", "z.jl"]) + Test.@test Set(sel) == Set(["a.jl", "z.jl"]) end end - @testset verbose = VERBOSE showtiming = SHOWTIMING "duplicate selections preserved" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "duplicate selections preserved" begin # Duplicates are not filtered (caller's responsibility) (sel, _, _) = parse_args(["utils", "utils"]) - @test sel == ["utils", "utils"] + Test.@test sel == ["utils", "utils"] end end @@ -694,10 +696,10 @@ function test_testrunner() # UNIT TESTS - _run_single_test error branches # ============================================================================ - @testset verbose = VERBOSE showtiming = SHOWTIMING "_run_single_test" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "_run_single_test" begin run_single = TestRunner._run_single_test - @testset verbose = VERBOSE showtiming = SHOWTIMING "error when file not found" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "error when file not found" begin # L112: error branch when test file doesn't exist err = try run_single( @@ -711,11 +713,11 @@ function test_testrunner() catch e e end - @test err isa CTBase.Exceptions.IncorrectArgument - @test occursin("not found", err.msg) + Test.@test err isa Exceptions.IncorrectArgument + Test.@test occursin("not found", err.msg) end - @testset verbose = VERBOSE showtiming = SHOWTIMING "string spec: file not found" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "string spec: file not found" begin err = try run_single( "does_not_exist_abc123.jl"; @@ -728,11 +730,11 @@ function test_testrunner() catch e e end - @test err isa CTBase.Exceptions.IncorrectArgument - @test occursin("not found", err.msg) + Test.@test err isa Exceptions.IncorrectArgument + Test.@test occursin("not found", err.msg) end - @testset verbose = VERBOSE showtiming = SHOWTIMING "string spec: eval_mode=false" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "string spec: eval_mode=false" begin mktempdir() do temp_dir stem = "testrunner_evalmode_false" file = joinpath(temp_dir, stem * ".jl") @@ -754,11 +756,11 @@ function test_testrunner() ) # Use invokelatest to avoid world age warning in Julia 1.12+ flag_value = Base.invokelatest(getfield, Main, :__ctbase_tr_flag__) - @test flag_value[] == false + Test.@test flag_value[] == false end end - @testset verbose = VERBOSE showtiming = SHOWTIMING "symbol spec: eval_mode=false" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "symbol spec: eval_mode=false" begin mktempdir() do temp_dir touch(joinpath(temp_dir, "test_sym_noeval.jl")) write( @@ -779,11 +781,11 @@ function test_testrunner() ) flag_value = Base.invokelatest(getfield, Main, :__ctbase_sym_tr_flag__) - @test flag_value[] == false + Test.@test flag_value[] == false end end - @testset verbose = VERBOSE showtiming = SHOWTIMING "string spec: missing function" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "string spec: missing function" begin mktempdir() do temp_dir stem = "testrunner_missing_func" file = joinpath(temp_dir, stem * ".jl") @@ -802,12 +804,12 @@ function test_testrunner() e end - @test err isa CTBase.Exceptions.PreconditionError - @test occursin("not found", err.msg) + Test.@test err isa Exceptions.PreconditionError + Test.@test occursin("not found", err.msg) end end - @testset verbose = VERBOSE showtiming = SHOWTIMING "symbol spec: funcname_builder returns nothing" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "symbol spec: funcname_builder returns nothing" begin mktempdir() do temp_dir touch(joinpath(temp_dir, "test_foo.jl")) @@ -824,12 +826,12 @@ function test_testrunner() e end - @test err isa CTBase.Exceptions.PreconditionError - @test occursin("eval_mode=true", err.msg) + Test.@test err isa Exceptions.PreconditionError + Test.@test occursin("eval_mode=true", err.msg) end end - @testset verbose = VERBOSE showtiming = SHOWTIMING "symbol spec: built function missing" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "symbol spec: built function missing" begin mktempdir() do temp_dir write(joinpath(temp_dir, "test_bar.jl"), "x = 1\n") @@ -846,22 +848,26 @@ function test_testrunner() e end - @test err isa CTBase.Exceptions.PreconditionError - @test occursin("not found", err.msg) + Test.@test err isa Exceptions.PreconditionError + Test.@test occursin("not found", err.msg) end end - @testset verbose = VERBOSE showtiming = SHOWTIMING "funcname_builder: String or Symbol" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "funcname_builder: String or Symbol" begin mktempdir() do temp_dir - # Create a test file with a function + # Create two test files with different names to avoid redefinition warning write( - joinpath(temp_dir, "test_baz.jl"), - "function test_baz()\n @test true\nend\n", + joinpath(temp_dir, "test_baz_string.jl"), + "function test_baz_string()\n Test.@test true\nend\n", + ) + write( + joinpath(temp_dir, "test_baz_symbol.jl"), + "function test_baz_symbol()\n Test.@test true\nend\n", ) # Test with funcname_builder returning String run_single( - :baz; + :baz_string; filename_builder=n -> "test_" * String(n), funcname_builder=n -> "test_" * String(n), # Returns String eval_mode=true, @@ -871,7 +877,7 @@ function test_testrunner() # Test with funcname_builder returning Symbol run_single( - :baz; + :baz_symbol; filename_builder=n -> "test_" * String(n), funcname_builder=n -> Symbol(:test_, n), # Returns Symbol eval_mode=true, @@ -879,7 +885,7 @@ function test_testrunner() ) # If no error, the test passed - @test true # Explicit pass if both succeeded + Test.@test true # Explicit pass if both succeeded end end @@ -892,8 +898,8 @@ function test_testrunner() # UNIT TESTS - TestRunInfo struct # ============================================================================ - @testset verbose = VERBOSE showtiming = SHOWTIMING "TestRunInfo" begin - @testset verbose = VERBOSE showtiming = SHOWTIMING "construction with all fields" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "TestRunInfo" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "construction with all fields" begin info = TestRunner.TestRunInfo( :my_test, "/tmp/test_my_test.jl", @@ -904,17 +910,17 @@ function test_testrunner() nothing, nothing, ) - @test info.spec === :my_test - @test info.filename == "/tmp/test_my_test.jl" - @test info.func_symbol === :test_my_test - @test info.index == 1 - @test info.total == 5 - @test info.status === :pre_eval - @test info.error === nothing - @test info.elapsed === nothing + Test.@test info.spec === :my_test + Test.@test info.filename == "/tmp/test_my_test.jl" + Test.@test info.func_symbol === :test_my_test + Test.@test info.index == 1 + Test.@test info.total == 5 + Test.@test info.status === :pre_eval + Test.@test info.error === nothing + Test.@test info.elapsed === nothing end - @testset verbose = VERBOSE showtiming = SHOWTIMING "construction with String spec" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "construction with String spec" begin info = TestRunner.TestRunInfo( "suite/test_a.jl", "/tmp/suite/test_a.jl", @@ -925,18 +931,18 @@ function test_testrunner() nothing, 1.5, ) - @test info.spec == "suite/test_a.jl" - @test info.elapsed == 1.5 + Test.@test info.spec == "suite/test_a.jl" + Test.@test info.elapsed == 1.5 end - @testset verbose = VERBOSE showtiming = SHOWTIMING "construction with error" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "construction with error" begin ex = ErrorException("boom") info = TestRunner.TestRunInfo( :failing, "/tmp/test_failing.jl", :test_failing, 2, 3, :error, ex, 0.1 ) - @test info.status === :error - @test info.error === ex - @test info.elapsed == 0.1 + Test.@test info.status === :error + Test.@test info.error === ex + Test.@test info.elapsed == 0.1 end end @@ -944,14 +950,14 @@ function test_testrunner() # UNIT TESTS - Callbacks via _run_single_test # ============================================================================ - @testset verbose = VERBOSE showtiming = SHOWTIMING "Callbacks" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "Callbacks" begin run_single = TestRunner._run_single_test - @testset verbose = VERBOSE showtiming = SHOWTIMING "on_test_start receives :pre_eval" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "on_test_start receives :pre_eval" begin mktempdir() do temp_dir write( joinpath(temp_dir, "test_cb_start.jl"), - "function test_cb_start()\n @test true\nend\n", + "function test_cb_start()\n Test.@test true\nend\n", ) captured = Ref{Any}(nothing) @@ -968,22 +974,22 @@ function test_testrunner() ) info = captured[] - @test info isa TestRunner.TestRunInfo - @test info.spec === :cb_start - @test info.func_symbol === :test_cb_start - @test info.index == 2 - @test info.total == 7 - @test info.status === :pre_eval - @test info.error === nothing - @test info.elapsed === nothing + Test.@test info isa TestRunner.TestRunInfo + Test.@test info.spec === :cb_start + Test.@test info.func_symbol === :test_cb_start + Test.@test info.index == 2 + Test.@test info.total == 7 + Test.@test info.status === :pre_eval + Test.@test info.error === nothing + Test.@test info.elapsed === nothing end end - @testset verbose = VERBOSE showtiming = SHOWTIMING "on_test_done receives :post_eval" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "on_test_done receives :post_eval" begin mktempdir() do temp_dir write( joinpath(temp_dir, "test_cb_done.jl"), - "function test_cb_done()\n @test true\nend\n", + "function test_cb_done()\n Test.@test true\nend\n", ) captured = Ref{Any}(nothing) @@ -1000,16 +1006,16 @@ function test_testrunner() ) info = captured[] - @test info isa TestRunner.TestRunInfo - @test info.spec === :cb_done - @test info.status === :post_eval - @test info.error === nothing - @test info.elapsed isa Float64 - @test info.elapsed >= 0.0 + Test.@test info isa TestRunner.TestRunInfo + Test.@test info.spec === :cb_done + Test.@test info.status === :post_eval + Test.@test info.error === nothing + Test.@test info.elapsed isa Float64 + Test.@test info.elapsed >= 0.0 end end - @testset verbose = VERBOSE showtiming = SHOWTIMING "on_test_start false skips eval" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "on_test_start false skips eval" begin mktempdir() do temp_dir write( joinpath(temp_dir, "test_cb_skip.jl"), @@ -1035,17 +1041,17 @@ function test_testrunner() # Function was NOT called flag_value = Base.invokelatest(getfield, Main, :__ctbase_cb_skip_flag__) - @test flag_value[] == false + Test.@test flag_value[] == false # on_test_done was called with :skipped info = done_captured[] - @test info isa TestRunner.TestRunInfo - @test info.status === :skipped - @test info.elapsed === nothing + Test.@test info isa TestRunner.TestRunInfo + Test.@test info.status === :skipped + Test.@test info.elapsed === nothing end end - @testset verbose = VERBOSE showtiming = SHOWTIMING "on_test_done receives :skipped" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "on_test_done receives :skipped" begin mktempdir() do temp_dir write(joinpath(temp_dir, "test_cb_noeval.jl"), "x = 1\n") @@ -1063,13 +1069,13 @@ function test_testrunner() ) info = done_captured[] - @test info isa TestRunner.TestRunInfo - @test info.status === :skipped - @test info.func_symbol === nothing + Test.@test info isa TestRunner.TestRunInfo + Test.@test info.status === :skipped + Test.@test info.func_symbol === nothing end end - @testset verbose = VERBOSE showtiming = SHOWTIMING "on_test_done receives :error on eval failure" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "on_test_done receives :error on eval failure" begin mktempdir() do temp_dir write( joinpath(temp_dir, "test_cb_err.jl"), @@ -1095,23 +1101,23 @@ function test_testrunner() end # Error is rethrown - @test err isa ErrorException - @test occursin("intentional failure", err.msg) + Test.@test err isa ErrorException + Test.@test occursin("intentional failure", err.msg) # on_test_done was called with :error info = done_captured[] - @test info isa TestRunner.TestRunInfo - @test info.status === :error - @test info.error isa ErrorException - @test info.elapsed isa Float64 + Test.@test info isa TestRunner.TestRunInfo + Test.@test info.status === :error + Test.@test info.error isa ErrorException + Test.@test info.elapsed isa Float64 end end - @testset verbose = VERBOSE showtiming = SHOWTIMING "both callbacks work together" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "both callbacks work together" begin mktempdir() do temp_dir write( joinpath(temp_dir, "test_cb_both.jl"), - "function test_cb_both()\n @test true\nend\n", + "function test_cb_both()\n Test.@test true\nend\n", ) start_captured = Ref{Any}(nothing) @@ -1128,20 +1134,20 @@ function test_testrunner() on_test_done=info -> (done_captured[] = info), ) - @test start_captured[].status === :pre_eval - @test start_captured[].index == 3 - @test start_captured[].total == 5 - @test done_captured[].status === :post_eval - @test done_captured[].index == 3 - @test done_captured[].total == 5 + Test.@test start_captured[].status === :pre_eval + Test.@test start_captured[].index == 3 + Test.@test start_captured[].total == 5 + Test.@test done_captured[].status === :post_eval + Test.@test done_captured[].index == 3 + Test.@test done_captured[].total == 5 end end - @testset verbose = VERBOSE showtiming = SHOWTIMING "callbacks with String spec" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "callbacks with String spec" begin mktempdir() do temp_dir write( joinpath(temp_dir, "test_cb_str.jl"), - "function test_cb_str()\n @test true\nend\n", + "function test_cb_str()\n Test.@test true\nend\n", ) start_captured = Ref{Any}(nothing) @@ -1158,18 +1164,18 @@ function test_testrunner() on_test_done=info -> (done_captured[] = info), ) - @test start_captured[].spec == "test_cb_str.jl" - @test start_captured[].status === :pre_eval - @test done_captured[].spec == "test_cb_str.jl" - @test done_captured[].status === :post_eval + Test.@test start_captured[].spec == "test_cb_str.jl" + Test.@test start_captured[].status === :pre_eval + Test.@test done_captured[].spec == "test_cb_str.jl" + Test.@test done_captured[].status === :post_eval end end - @testset verbose = VERBOSE showtiming = SHOWTIMING "no callbacks (nothing) preserves original behavior" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "no callbacks (nothing) preserves original behavior" begin mktempdir() do temp_dir write( joinpath(temp_dir, "test_cb_none.jl"), - "function test_cb_none()\n @test true\nend\n", + "function test_cb_none()\n Test.@test true\nend\n", ) # Should not error when callbacks are nothing @@ -1182,7 +1188,7 @@ function test_testrunner() on_test_start=nothing, on_test_done=nothing, ) - @test true + Test.@test true end end end @@ -1191,22 +1197,22 @@ function test_testrunner() # INTEGRATION TESTS - Callbacks via run_tests # ============================================================================ - @testset verbose = VERBOSE showtiming = SHOWTIMING "Callbacks via run_tests" begin - @testset verbose = VERBOSE showtiming = SHOWTIMING "on_test_start and on_test_done called for each test" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "Callbacks via run_tests" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "on_test_start and on_test_done called for each test" begin mktempdir() do temp_dir write( joinpath(temp_dir, "test_int_a.jl"), - "function test_int_a()\n @test true\nend\n", + "function test_int_a()\n Test.@test true\nend\n", ) write( joinpath(temp_dir, "test_int_b.jl"), - "function test_int_b()\n @test true\nend\n", + "function test_int_b()\n Test.@test true\nend\n", ) start_log = TestRunner.TestRunInfo[] done_log = TestRunner.TestRunInfo[] - CTBase.run_tests(; + Extensions.run_tests(; args=String[], testset_name="CallbackIntegration", available_tests=(:int_a, :int_b), @@ -1220,21 +1226,21 @@ function test_testrunner() on_test_done=info -> push!(done_log, info), ) - @test length(start_log) == 2 - @test length(done_log) == 2 + Test.@test length(start_log) == 2 + Test.@test length(done_log) == 2 # Check index/total progression - @test start_log[1].index == 1 - @test start_log[1].total == 2 - @test start_log[2].index == 2 - @test start_log[2].total == 2 + Test.@test start_log[1].index == 1 + Test.@test start_log[1].total == 2 + Test.@test start_log[2].index == 2 + Test.@test start_log[2].total == 2 # Check statuses - @test all(info -> info.status === :pre_eval, start_log) - @test all(info -> info.status === :post_eval, done_log) + Test.@test all(info -> info.status === :pre_eval, start_log) + Test.@test all(info -> info.status === :post_eval, done_log) # Check elapsed is populated - @test all(info -> info.elapsed isa Float64, done_log) + Test.@test all(info -> info.elapsed isa Float64, done_log) end end end diff --git a/test/suite/meta/test_code_quality.jl b/test/suite/meta/test_code_quality.jl index 78177885..13cf3d14 100644 --- a/test/suite/meta/test_code_quality.jl +++ b/test/suite/meta/test_code_quality.jl @@ -1,6 +1,15 @@ +module TestCodeQuality + +import Test +import Aqua +import CTBase + +const VERBOSE = isdefined(Main, :TestOptions) ? Main.TestOptions.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestOptions) ? Main.TestOptions.SHOWTIMING : true + function test_code_quality() - @testset verbose = VERBOSE showtiming = SHOWTIMING "Code quality" begin - @testset verbose = VERBOSE showtiming = SHOWTIMING "Aqua" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "Code quality" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "Aqua" begin Aqua.test_all( CTBase; ambiguities=false, @@ -12,18 +21,23 @@ function test_code_quality() Aqua.test_ambiguities(CTBase) end - # @testset "JET" begin + # Test.@testset "JET" begin # JET.test_package(CTBase; target_defined_modules=true) # end - # @testset "JuliaFormatter" begin - # @test JuliaFormatter.format(CTBase; verbose=true, overwrite=false) + # Test.@testset "JuliaFormatter" begin + # Test.@test JuliaFormatter.format(CTBase; verbose=true, overwrite=false) # end - # @testset "Doctests" begin + # Test.@testset "Doctests" begin # Documenter.doctest(CTBase) # end end return nothing end + +end # module TestCodeQuality + +# CRITICAL: redefine in outer scope so the test runner can call it +test_code_quality() = TestCodeQuality.test_code_quality() diff --git a/test/suite/unicode/test_unicode_enriched.jl b/test/suite/unicode/test_unicode_enriched.jl index c5e2d4e1..e1c7f1fa 100644 --- a/test/suite/unicode/test_unicode_enriched.jl +++ b/test/suite/unicode/test_unicode_enriched.jl @@ -1,107 +1,110 @@ module TestUnicodeEnriched -using Test -using CTBase -using Main.TestOptions: VERBOSE, SHOWTIMING +import Test +import CTBase.Unicode +import CTBase.Exceptions + +const VERBOSE = isdefined(Main, :TestOptions) ? Main.TestOptions.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestOptions) ? Main.TestOptions.SHOWTIMING : true function test_unicode_enriched() - @testset verbose = VERBOSE showtiming = SHOWTIMING "Enriched Unicode Errors" begin + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "Enriched Unicode Errors" begin # ==================================================================== # ERROR TESTS - Unicode Functions Exception Quality # ==================================================================== - @testset "ctindice enriched errors" begin + Test.@testset "ctindice enriched errors" begin # Test negative value - @test_throws CTBase.IncorrectArgument CTBase.ctindice(-1) + Test.@test_throws Exceptions.IncorrectArgument Unicode.ctindice(-1) try - CTBase.ctindice(-1) - @test false # Should not reach here + Unicode.ctindice(-1) + Test.@test false # Should not reach here catch e - @test e isa CTBase.IncorrectArgument - @test e.got == "-1" - @test e.expected == "0-9" - @test occursin("subscript must be between 0 and 9", e.msg) - @test occursin("ctindices()", e.suggestion) - @test e.context == "Unicode subscript generation" + Test.@test e isa Exceptions.IncorrectArgument + Test.@test e.got == "-1" + Test.@test e.expected == "0-9" + Test.@test occursin("subscript must be between 0 and 9", e.msg) + Test.@test occursin("ctindices()", e.suggestion) + Test.@test e.context == "Unicode subscript generation" end # Test value too large - @test_throws CTBase.IncorrectArgument CTBase.ctindice(15) + Test.@test_throws Exceptions.IncorrectArgument Unicode.ctindice(15) try - CTBase.ctindice(15) - @test false # Should not reach here + Unicode.ctindice(15) + Test.@test false # Should not reach here catch e - @test e isa CTBase.IncorrectArgument - @test e.got == "15" - @test e.expected == "0-9" - @test occursin("ctindices()", e.suggestion) - @test e.context == "Unicode subscript generation" + Test.@test e isa Exceptions.IncorrectArgument + Test.@test e.got == "15" + Test.@test e.expected == "0-9" + Test.@test occursin("ctindices()", e.suggestion) + Test.@test e.context == "Unicode subscript generation" end end - @testset "ctindices enriched errors" begin + Test.@testset "ctindices enriched errors" begin # Test negative value - @test_throws CTBase.IncorrectArgument CTBase.ctindices(-5) + Test.@test_throws Exceptions.IncorrectArgument Unicode.ctindices(-5) try - CTBase.ctindices(-5) - @test false # Should not reach here + Unicode.ctindices(-5) + Test.@test false # Should not reach here catch e - @test e isa CTBase.IncorrectArgument - @test e.got == "-5" - @test e.expected == "โ‰ฅ 0" - @test occursin("subscript must be positive", e.msg) - @test e.context == "Unicode subscript string generation" + Test.@test e isa Exceptions.IncorrectArgument + Test.@test e.got == "-5" + Test.@test e.expected == "โ‰ฅ 0" + Test.@test occursin("subscript must be positive", e.msg) + Test.@test e.context == "Unicode subscript string generation" end end - @testset "ctupperscript enriched errors" begin + Test.@testset "ctupperscript enriched errors" begin # Test negative value - @test_throws CTBase.IncorrectArgument CTBase.ctupperscript(-1) + Test.@test_throws Exceptions.IncorrectArgument Unicode.ctupperscript(-1) try - CTBase.ctupperscript(-1) - @test false # Should not reach here + Unicode.ctupperscript(-1) + Test.@test false # Should not reach here catch e - @test e isa CTBase.IncorrectArgument - @test e.got == "-1" - @test e.expected == "0-9" - @test occursin("superscript must be between 0 and 9", e.msg) - @test occursin("ctupperscripts()", e.suggestion) - @test e.context == "Unicode superscript generation" + Test.@test e isa Exceptions.IncorrectArgument + Test.@test e.got == "-1" + Test.@test e.expected == "0-9" + Test.@test occursin("superscript must be between 0 and 9", e.msg) + Test.@test occursin("ctupperscripts()", e.suggestion) + Test.@test e.context == "Unicode superscript generation" end # Test value too large - @test_throws CTBase.IncorrectArgument CTBase.ctupperscript(12) + Test.@test_throws Exceptions.IncorrectArgument Unicode.ctupperscript(12) try - CTBase.ctupperscript(12) - @test false # Should not reach here + Unicode.ctupperscript(12) + Test.@test false # Should not reach here catch e - @test e isa CTBase.IncorrectArgument - @test e.got == "12" - @test e.expected == "0-9" - @test occursin("ctupperscripts()", e.suggestion) - @test e.context == "Unicode superscript generation" + Test.@test e isa Exceptions.IncorrectArgument + Test.@test e.got == "12" + Test.@test e.expected == "0-9" + Test.@test occursin("ctupperscripts()", e.suggestion) + Test.@test e.context == "Unicode superscript generation" end end - @testset "ctupperscripts enriched errors" begin + Test.@testset "ctupperscripts enriched errors" begin # Test negative value - @test_throws CTBase.IncorrectArgument CTBase.ctupperscripts(-3) + Test.@test_throws Exceptions.IncorrectArgument Unicode.ctupperscripts(-3) try - CTBase.ctupperscripts(-3) - @test false # Should not reach here + Unicode.ctupperscripts(-3) + Test.@test false # Should not reach here catch e - @test e isa CTBase.IncorrectArgument - @test e.got == "-3" - @test e.expected == "โ‰ฅ 0" - @test occursin("superscript must be positive", e.msg) - @test e.context == "Unicode superscript string generation" + Test.@test e isa Exceptions.IncorrectArgument + Test.@test e.got == "-3" + Test.@test e.expected == "โ‰ฅ 0" + Test.@test occursin("superscript must be positive", e.msg) + Test.@test e.context == "Unicode superscript string generation" end end @@ -109,26 +112,26 @@ function test_unicode_enriched() # UNIT TESTS - Successful Operations # ==================================================================== - @testset "Successful Unicode operations" begin + Test.@testset "Successful Unicode operations" begin # Test ctindice - @test CTBase.ctindice(0) == '\u2080' - @test CTBase.ctindice(5) == '\u2085' - @test CTBase.ctindice(9) == '\u2089' + Test.@test Unicode.ctindice(0) == '\u2080' + Test.@test Unicode.ctindice(5) == '\u2085' + Test.@test Unicode.ctindice(9) == '\u2089' # Test ctindices - @test CTBase.ctindices(0) == "\u2080" - @test CTBase.ctindices(123) == "\u2081\u2082\u2083" + Test.@test Unicode.ctindices(0) == "\u2080" + Test.@test Unicode.ctindices(123) == "\u2081\u2082\u2083" # Test ctupperscript - @test CTBase.ctupperscript(0) == '\u2070' - @test CTBase.ctupperscript(1) == '\u00B9' - @test CTBase.ctupperscript(2) == '\u00B2' - @test CTBase.ctupperscript(3) == '\u00B3' - @test CTBase.ctupperscript(5) == '\u2075' + Test.@test Unicode.ctupperscript(0) == '\u2070' + Test.@test Unicode.ctupperscript(1) == '\u00B9' + Test.@test Unicode.ctupperscript(2) == '\u00B2' + Test.@test Unicode.ctupperscript(3) == '\u00B3' + Test.@test Unicode.ctupperscript(5) == '\u2075' # Test ctupperscripts - @test CTBase.ctupperscripts(0) == "\u2070" - @test CTBase.ctupperscripts(123) == "\u00B9\u00B2\u00B3" + Test.@test Unicode.ctupperscripts(0) == "\u2070" + Test.@test Unicode.ctupperscripts(123) == "\u00B9\u00B2\u00B3" end end diff --git a/test/suite/unicode/test_unicode_utils.jl b/test/suite/unicode/test_unicode_utils.jl new file mode 100644 index 00000000..245e2713 --- /dev/null +++ b/test/suite/unicode/test_unicode_utils.jl @@ -0,0 +1,85 @@ +module TestUnicodeUtils + +import Test +import CTBase.Exceptions: Exceptions +import CTBase.Unicode: Unicode + +const VERBOSE = isdefined(Main, :TestOptions) ? Main.TestOptions.VERBOSE : true +const SHOWTIMING = isdefined(Main, :TestOptions) ? Main.TestOptions.SHOWTIMING : true + +function test_unicode_utils() + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "ctindice (single subscript char)" begin + # Test exception thrown for out-of-range inputs + Test.@test_throws Exceptions.IncorrectArgument Unicode.ctindice(-1) + Test.@test_throws Exceptions.IncorrectArgument Unicode.ctindice(10) + + # Test all valid subscripts [0..9] + Test.@test Unicode.ctindice(0) == 'โ‚€' + Test.@test Unicode.ctindice(1) == 'โ‚' + Test.@test Unicode.ctindice(2) == 'โ‚‚' + Test.@test Unicode.ctindice(3) == 'โ‚ƒ' + Test.@test Unicode.ctindice(4) == 'โ‚„' + Test.@test Unicode.ctindice(5) == 'โ‚…' + Test.@test Unicode.ctindice(6) == 'โ‚†' + Test.@test Unicode.ctindice(7) == 'โ‚‡' + Test.@test Unicode.ctindice(8) == 'โ‚ˆ' + Test.@test Unicode.ctindice(9) == 'โ‚‰' + end + + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "ctindices (multi-digit subscript string)" begin + # Exception for negative input + Test.@test_throws Exceptions.IncorrectArgument Unicode.ctindices(-1) + + # Test zero (single digit) and multiple digit inputs + Test.@test Unicode.ctindices(0) == "โ‚€" + Test.@test Unicode.ctindices(19) == "โ‚โ‚‰" # decimal 19, no leading zero to avoid confusion + Test.@test Unicode.ctindices(314) == "โ‚ƒโ‚โ‚„" + end + + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "ctupperscript (single superscript char)" begin + # Exception for out-of-range + Test.@test_throws Exceptions.IncorrectArgument Unicode.ctupperscript(-1) + Test.@test_throws Exceptions.IncorrectArgument Unicode.ctupperscript(10) + + # Test all valid superscripts [0..9] + Test.@test Unicode.ctupperscript(0) == 'โฐ' + Test.@test Unicode.ctupperscript(1) == 'ยน' + Test.@test Unicode.ctupperscript(2) == 'ยฒ' + Test.@test Unicode.ctupperscript(3) == 'ยณ' + Test.@test Unicode.ctupperscript(4) == 'โด' + Test.@test Unicode.ctupperscript(5) == 'โต' + Test.@test Unicode.ctupperscript(6) == 'โถ' + Test.@test Unicode.ctupperscript(7) == 'โท' + Test.@test Unicode.ctupperscript(8) == 'โธ' + Test.@test Unicode.ctupperscript(9) == 'โน' + end + + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "ctupperscripts (multi-digit superscript string)" begin + # Exception for negative input + Test.@test_throws Exceptions.IncorrectArgument Unicode.ctupperscripts(-1) + + # Test zero and multi-digit inputs + Test.@test Unicode.ctupperscripts(0) == "โฐ" + Test.@test Unicode.ctupperscripts(19) == "ยนโน" # no leading zero + Test.@test Unicode.ctupperscripts(109) == "ยนโฐโน" + end + + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "ctindices / ctindice consistency" begin + for d in 0:9 + Test.@test Unicode.ctindices(d) == string(Unicode.ctindice(d)) + end + end + + Test.@testset verbose = VERBOSE showtiming = SHOWTIMING "ctupperscripts / ctupperscript consistency" begin + for d in 0:9 + Test.@test Unicode.ctupperscripts(d) == string(Unicode.ctupperscript(d)) + end + end + + return nothing +end + +end # module TestUnicodeUtils + +# CRITICAL: redefine in outer scope so the test runner can call it +test_unicode_utils() = TestUnicodeUtils.test_unicode_utils() diff --git a/test/suite/unicode/test_utils.jl b/test/suite/unicode/test_utils.jl deleted file mode 100644 index cd707642..00000000 --- a/test/suite/unicode/test_utils.jl +++ /dev/null @@ -1,73 +0,0 @@ -using Test - -function test_utils() - @testset verbose = VERBOSE showtiming = SHOWTIMING "ctindice (single subscript char)" begin - # Test exception thrown for out-of-range inputs - @test_throws CTBase.IncorrectArgument CTBase.ctindice(-1) - @test_throws CTBase.IncorrectArgument CTBase.ctindice(10) - - # Test all valid subscripts [0..9] - @test CTBase.ctindice(0) == 'โ‚€' - @test CTBase.ctindice(1) == 'โ‚' - @test CTBase.ctindice(2) == 'โ‚‚' - @test CTBase.ctindice(3) == 'โ‚ƒ' - @test CTBase.ctindice(4) == 'โ‚„' - @test CTBase.ctindice(5) == 'โ‚…' - @test CTBase.ctindice(6) == 'โ‚†' - @test CTBase.ctindice(7) == 'โ‚‡' - @test CTBase.ctindice(8) == 'โ‚ˆ' - @test CTBase.ctindice(9) == 'โ‚‰' - end - - @testset verbose = VERBOSE showtiming = SHOWTIMING "ctindices (multi-digit subscript string)" begin - # Exception for negative input - @test_throws CTBase.IncorrectArgument CTBase.ctindices(-1) - - # Test zero (single digit) and multiple digit inputs - @test CTBase.ctindices(0) == "โ‚€" - @test CTBase.ctindices(19) == "โ‚โ‚‰" # decimal 19, no leading zero to avoid confusion - @test CTBase.ctindices(314) == "โ‚ƒโ‚โ‚„" - end - - @testset verbose = VERBOSE showtiming = SHOWTIMING "ctupperscript (single superscript char)" begin - # Exception for out-of-range - @test_throws CTBase.IncorrectArgument CTBase.ctupperscript(-1) - @test_throws CTBase.IncorrectArgument CTBase.ctupperscript(10) - - # Test all valid superscripts [0..9] - @test CTBase.ctupperscript(0) == 'โฐ' - @test CTBase.ctupperscript(1) == 'ยน' - @test CTBase.ctupperscript(2) == 'ยฒ' - @test CTBase.ctupperscript(3) == 'ยณ' - @test CTBase.ctupperscript(4) == 'โด' - @test CTBase.ctupperscript(5) == 'โต' - @test CTBase.ctupperscript(6) == 'โถ' - @test CTBase.ctupperscript(7) == 'โท' - @test CTBase.ctupperscript(8) == 'โธ' - @test CTBase.ctupperscript(9) == 'โน' - end - - @testset verbose = VERBOSE showtiming = SHOWTIMING "ctupperscripts (multi-digit superscript string)" begin - # Exception for negative input - @test_throws CTBase.IncorrectArgument CTBase.ctupperscripts(-1) - - # Test zero and multi-digit inputs - @test CTBase.ctupperscripts(0) == "โฐ" - @test CTBase.ctupperscripts(19) == "ยนโน" # no leading zero - @test CTBase.ctupperscripts(109) == "ยนโฐโน" - end - - @testset verbose = VERBOSE showtiming = SHOWTIMING "ctindices / ctindice consistency" begin - for d in 0:9 - @test CTBase.ctindices(d) == string(CTBase.ctindice(d)) - end - end - - @testset verbose = VERBOSE showtiming = SHOWTIMING "ctupperscripts / ctupperscript consistency" begin - for d in 0:9 - @test CTBase.ctupperscripts(d) == string(CTBase.ctupperscript(d)) - end - end - - return nothing -end