diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b1f80c5..7f743574 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -320,10 +320,45 @@ jobs: if-no-files-found: error retention-days: 30 + # A failing assertion must not discard the coverage earned by every test + # that did run. Keep the report available for diagnosis, but only publish + # a complete passing run to Codecov. The test step's exit status still + # controls the job; these informational reports cannot make it green. - name: Process coverage + if: ${{ !cancelled() && hashFiles('**/*.cov') != '' }} uses: julia-actions/julia-processcoverage@03114f09f119417c3242a9fb6e0b722676aedf38 # v1.2.2 + - name: Report descriptive-statistics coverage gaps + if: ${{ !cancelled() && hashFiles('lcov.info') != '' }} + shell: bash + run: | + set -euo pipefail + { + echo '## Descriptive-statistics coverage gaps' + echo + echo 'Informational only; the Julia test result remains the gate.' + echo + echo '| Source | Uncovered line |' + echo '| --- | ---: |' + } >> "$GITHUB_STEP_SUMMARY" + awk -F'[:,]' ' + /^SF:src\/(canonical|server\/routes\/validated_statistics|analysis\/(derived_analyses|exact_summaries|high_precision|numeric_policy|method_catalogue))\.jl$/ { + source = substr($0, 4); next + } + source != "" && $1 == "DA" && $3 == 0 { + printf "::notice file=%s,line=%s::Julia coverage gap on this descriptive-statistics source line\n", source, $2 + printf "| `%s` | %s |\n", source, $2 >> summary + gaps++ + } + /^end_of_record$/ { source = "" } + END { + if (!gaps) print "| No uncovered lines in the descriptive-statistics sources | — |" >> summary + printf "Descriptive-statistics source lines without coverage: %d\n", gaps + } + ' summary="$GITHUB_STEP_SUMMARY" lcov.info + - name: Retain raw Julia coverage + if: ${{ !cancelled() && hashFiles('lcov.info') != '' }} uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: julia-coverage @@ -332,6 +367,7 @@ jobs: retention-days: 30 - name: Upload coverage to Codecov + if: ${{ steps.julia_tests.outcome == 'success' }} uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v6.0.2 with: files: lcov.info diff --git a/docs/statistics/validation-evidence.md b/docs/statistics/validation-evidence.md index c558c17f..1eaa7478 100644 --- a/docs/statistics/validation-evidence.md +++ b/docs/statistics/validation-evidence.md @@ -76,7 +76,17 @@ names colliding with projected feature labels, and subgroup names such as `A` and `AB`. Reserved integer names such as `total` are explicitly rejected when their count/metadata role is ambiguous. Subgroups use the same underscore-delimited prefix contract as the existing analysis routes. Tests check the persisted results and selected -sample columns as well as the response. +sample columns as well as the response. Quoted column identifiers are exercised +through executed text/include/exclude predicates and checked against the exact +selected counts; numeric filters on count columns remain explicitly refused +before the shared floating-point SQL predicate can be used. BIGNUM columns +cannot be silently omitted, and grouped runs persist and retrieve the same +resolved group in their provenance. + +CI retains raw Julia coverage even when a test assertion fails, and lists +uncovered descriptive-statistics source lines in the job summary and check +annotations. These reports are diagnostic, not a replacement for a passing +suite; only a passing test run publishes its coverage to Codecov. `frontend/src/components/AnalysisWorkspace.test.ts` renders the workspace and descriptive panel, toggles aggregation, and checks the submitted scope, diff --git a/test/unit/test_derived_analyses.jl b/test/unit/test_derived_analyses.jl index 16cd8ae2..94d477ed 100644 --- a/test/unit/test_derived_analyses.jl +++ b/test/unit/test_derived_analyses.jl @@ -25,6 +25,40 @@ using MetaManifold.NumericPolicy end end +@testset "Derived-record I/O and storage limits fail explicitly" begin + DA = MetaManifold.DerivedAnalyses + mktempdir() do root + # Reading a directory raises an I/O error, independently of filesystem + # permissions (the test must also work when CI runs as root). + unreadable = joinpath(root, "not-a-file") + mkdir(unreadable) + error = try + DA._existing(unreadable, repeat("0", 64)) + nothing + catch err + err + end + @test error isa DerivedAnalysisError + @test error.code == "conflict" + @test occursin("cannot read existing derived analysis", error.reason) + + # The limit applies to the complete canonical record, not just payload. + # A payload at the limit necessarily makes the enclosing record exceed it. + directory = joinpath(root, "oversized") + error = try + persist_derived_analysis(directory; + source=Dict("run" => "r"), config=Dict("mode" => "exact_counts"), + result=Dict("payload" => repeat("x", MAX_DERIVED_BYTES))) + nothing + catch err + err + end + @test error isa DerivedAnalysisError + @test error.code == "resource_limit" + @test !isdir(directory) # refuse before allocating a publication directory + end +end + @testset "Immutable content-addressed derived analyses" begin mktempdir() do root directory = joinpath(root, "derived") diff --git a/test/unit/test_validated_statistics.jl b/test/unit/test_validated_statistics.jl index b2742beb..9724d117 100644 --- a/test/unit/test_validated_statistics.jl +++ b/test/unit/test_validated_statistics.jl @@ -198,11 +198,11 @@ function _vs_make_run(root::String) DBInterface.execute(con, "CREATE TABLE \"$(saved_name)\" AS SELECT * FROM merged") end DBInterface.execute(con, """CREATE TABLE merged_aliases ( - SeqName VARCHAR, Genus VARCHAR, feature BIGINT, + SeqName VARCHAR, Genus VARCHAR, Pident DOUBLE, feature BIGINT, __VALIDATED_FEATURE BIGINT, __validated_feature_ BIGINT, "odd""sample" HUGEINT)""") DBInterface.execute(con, """INSERT INTO merged_aliases VALUES - ('seq2', 'G', 2, 20, 200, 9007199254740993), - ('seq1', 'G', 1, 10, 100, 1), ('seq3', 'H', 4, 40, 400, 3)""") + ('seq2', 'G', 99.0, 2, 20, 200, 9007199254740993), + ('seq1', 'G', 98.0, 1, 10, 100, 1), ('seq3', 'H', 97.0, 4, 40, 400, 3)""") # An unbounded integer is an integer the detector cannot name, so it # must be refused rather than silently dropped. DBInterface.execute(con, "CREATE TABLE merged_bignum (SeqName VARCHAR, Genus VARCHAR, s1 BIGINT, counted BIGNUM)") @@ -265,7 +265,12 @@ end invalid_response = _vs_post(path, merge(request, Dict("table" => invalid))) @test invalid_response.status == 400 end - @test _vs_post(path, request).status == 200 # invalid names did not mutate the source + # A safe name can still name a table that does not exist. Preserve the + # shared opener's 404 rather than treating its response as an analysis. + missing = _vs_post(path, merge(request, Dict("table" => "absent"))) + @test missing.status == 404 + @test JSON3.read(String(missing.body)).error == "table_not_found" + @test _vs_post(path, request).status == 200 # invalid/missing names did not mutate the source sequence_request = filter(pair -> first(pair) != "taxonomic_level", request) sequence_response = _vs_post(path, merge(sequence_request, Dict("time_limit_seconds" => 30))) @test sequence_response.status == 200 @@ -469,14 +474,33 @@ end response = _vs_post(path, merge(request, filters)) @test response.status == 200 end - # The quoted identifier itself also builds valid SQL. `min` on a sample - # column is refused by the exact-integer count-filter guard, which runs - # after the predicate is built, so reaching that refusal -- and not a - # 500 from malformed SQL -- is the regression under test. - quoted = _vs_post(path, merge(request, - Dict("colFilters" => Dict("odd\"sample" => Dict("min" => 1))))) - @test quoted.status == 422 - @test occursin("count-filter", String(quoted.body)) + # Execute text, include and exclude predicates against the quoted + # identifier itself. Each selects only seq2, so a skipped or malformed + # predicate cannot pass by returning the wrong rows. + for criterion in (Dict("text" => "9007199254740993"), + Dict("include" => ["9007199254740993"]), + Dict("exclude" => ["1", "3"])) + response = _vs_post(path, merge(request, + Dict("colFilters" => Dict("odd\"sample" => criterion)))) + @test response.status == 200 + result = JSON3.read(String(response.body)) + @test collect(result.samples[1].features) == ["G"] + @test collect(result.samples[1].counts) == [2, 20, 200, "9007199254740993"] + @test result.samples[1].total == "9007199254741215" + loaded_response = _vs_get("/api/v1/studies/studyV/runs/run1/statistics/derived/$(result.analysis_id)") + @test loaded_response.status == 200 + loaded = JSON3.read(String(loaded_response.body)) + @test loaded.source.table == "merged_aliases" + @test loaded.result.samples == result.samples + end + # Numeric count filters are deliberately refused before SQL construction: + # the shared min/max predicate would cast exact counts to Float64. + for criterion in (Dict("min" => 1), Dict("max" => 9007199254740993)) + quoted = _vs_post(path, merge(request, + Dict("colFilters" => Dict("odd\"sample" => criterion)))) + @test quoted.status == 422 + @test occursin("count-filter", String(quoted.body)) + end end end