Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
12 changes: 11 additions & 1 deletion docs/statistics/validation-evidence.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
34 changes: 34 additions & 0 deletions test/unit/test_derived_analyses.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
48 changes: 36 additions & 12 deletions test/unit/test_validated_statistics.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down