Skip to content

test: измерване на coverage и ratchet праг в CI (#93) - #216

Merged
todorkolev merged 3 commits into
midt-bg:mainfrom
ydimitrof:feat/coverage-ratchet
Jul 30, 2026
Merged

todorkolev merged 3 commits into
midt-bg:mainfrom
ydimitrof:feat/coverage-ratchet

Conversation

@ydimitrof

Copy link
Copy Markdown
Contributor

Какво и защо

В monorepo-то нямаше измерване на test coverage — нито @vitest/coverage-*, нито праг, а CI пускаше pnpm test без --coverage. Този PR добавя харнеса от #93: измерване per workspace, ratchet праг който fail-ва CI при спад, и coverage делта видима в PR-а. Само харнесът — новите тестове бяха отделен обхват (#74, вече merged).

  • Измерване: @vitest/coverage-v8 (root devDep) през общ preset vitest.shared.ts; vitest.config.ts за всичките 6 workspace-а с тестове, с експлицитен include — иначе v8 provider-ът брои само файловете, заредени от тестове, и нов непокрит модул би бил невидим за ratchet-а.
  • Ratchet: комитнат coverage-baseline.json (seed-нат от реалните числа) + scripts/check-coverage.mjs — lines% и branches% на всеки workspace не може да падне >0.5pp под baseline-а. При покачване >1pp скриптът подканя --update (локално; никога в CI). Умишлен спад = сваляне на числото в baseline-а в същия PR, видимо на ревю. Общият сбор е информативен, смятан от сумирани covered/total бройки, не осреднени проценти.
  • CI: pnpm test -- --coverage (тестовете се пускат веднъж), ratchet gate със self-test първо (по модела на docs проверката), таблицата с делтите в step summary + artifact coverage-report. Sticky PR коментар само за same-repo PR-и — fork PR-ите получават read-only token независимо от permissions:, затова коментарният job е отделен, гейтнат по head.repo.full_name и continue-on-error; check job-ът остава с read-only token.
  • turbo: test task-ът декларира outputs: ["coverage/**"], така че cache replay възстановява отчетите (проверено: FULL TURBO + наличен coverage-summary.json).
  • Конвенцията е документирана в docs/review-testing.md.

Бележка: apps/web/tsconfig.node.json добавя ../../vitest.shared.ts в include (composite проектът изисква изброени файлове). packages/api-contract е извън baseline-а, докато няма тестове. Sticky-коментарният job може да се провери реално чак на този PR — всичко останало е проверено локално.

Свързан issue

Closes #93.

Вид промяна

  • test / ci / build / chore — поддръжка

Как е тествано

  • pnpm check:coverage:test — 12/12 (node:test, адверсариални случаи: спад отвъд толеранса, спад в толеранса, независим branches ratchet, липсващ отчет/метрика, merge на бройки vs осредняване).
  • pnpm test -- --coverage — 6/6 workspace-а зелени, всеки произвежда coverage/coverage-summary.json.
  • Мутационен тест: baseline на packages/shared +2pp → exit 1 с actionable съобщение; +0.3pp → exit 0 (толеранс).
  • Cache: второ пускане е FULL TURBO и отчетите пак са налични (replay на outputs).
  • pnpm typecheck, pnpm lint, pnpm check:docs — зелени.

Чеклист

  • Комитите следват conventional commits и нямат Co-Authored-By: trailer
  • PR-ът е с един логически обхват и е от форк към midt-bg/sigma:main
  • pnpm typecheck минава
  • pnpm test (поне за засегнатите пакети) минава
  • pnpm lint е чисто
  • Няма комитнати тайни, .env* или .dev.vars
  • Документацията в docs/ е обновена, ако промяната го налага

@lyubomir-bozhinov

Copy link
Copy Markdown
Collaborator

Прегледах #216 стриктно (харнесът за #93).

Сигурност — издържана: trigger е pull_request (не pull_request_target), default token contents: read, а коментарният job с pull-requests: write е гейтнат само за same-repo PR-и (head.repo.full_name == github.repository) + continue-on-error — fork код никога не се изпълнява с write token.

Ratchet — коректен и fail-closed: липсващ отчет = твърда грешка, липсваща метрика = грешка, спад > tolerance = fail; sharedCoverage(include) с експлицитен include (src/**; за web app/**+workers/** — worker-ът е покрит) прави ratchet-а реален, а не фасада; merged total-ът сумира covered/total, не осреднява проценти. Self-тестовете хващат точно спада отвъд толеранса (79% vs 80% baseline → exit 1). Внимателна работа. Одобрявам харнеса.

Две точни бележки — за да не се чете покритието погрешно (не блокер за merge):

1) Derive слоят на ETL е ИЗВЪН обхвата на покритието — по конструкция. apps/etl (18.7%) мери само eop.ts/index.ts (feed client + entry), НЕ деривациите. Accuracy логиката е в scripts/ — normalize-raw.sql/precompute.sql/refresh-slice.sql/derive-health.sql + import.mjs/ship-domain.mjs — а scripts/ не е coverage workspace (и v8 не инструментира SQL). Значи ratchet-ът нито мери, нито пази derive слоя. Реалният му guard са D1 интеграционните тестове (packages/db/src/{refresh-slice,ship-domain}.test.ts срещу реален sqlite); тях третирайте като ETL-accuracy гейта, и добавете ред в review-testing.md, че coverage% не говори за коректността на derive-а (да не се чете зелено като „ETL е тестван").

2) Сред измерените workspace-и, branch подовете на accuracy слоевете са ниски. packages/db branches 65.5% и packages/config branches 58.3% — там живеят value_flag / деление-гардовете / праговете. Regression-only ratchet ги узаконява; за #98/#99 (гаранция за стойностната база) искайте цел за ПОКАЧВАНЕ на db/config branches, не само под.

Иначе е точно каквото #93 иска и е сигурно направено. Approve; двете бележки са за roadmap-а на покритието.

ydimitrof added a commit to ydimitrof/sigma that referenced this pull request Jul 7, 2026
…ар fix

Адресира ревю находките по PR midt-bg#216:
- validateBaseline: празен/невалиден workspaces обект, изтрит или забравен
  ключ за workspace с test script, stale ключ и traversal/__proto__ ключове
  вече fail-ват gate-а вместо тихо да го изключват
- --update печата per-workspace делти и предупреждава шумно при спад, за да
  не може реална регресия тихо да влезе в нов baseline
- coverage exclude покрива и .test.tsx/.spec.* варианти
- sticky comment: gh api | head пренаписан на два стъпки (pipefail SIGPIPE)
- по-точно съобщение при липсващ отчет след crash-нал vitest процес
@lyubomir-bozhinov

Copy link
Copy Markdown
Collaborator

Прегледах новия връх d16f32c. Хардънингът е стабилен:

  • validateBaseline вече пада затворено при празен/непълен baseline — празни workspaces → грешка вместо тих pass; workspace с test script, но без baseline entry → грешка; и обратно за stale entry. Затваря реалната fail-open дупка (иначе празен baseline = ratchet no-op).
  • pipefail: gh api | head е разделено на две стъпки заради SIGPIPE (141) под default-ния pipefail на runner-а — коректно; pnpm test -- --coverage е директен run, кодът му на изход не се маскира (тестова грешка си пада CI).

Approve остава. Двете ми roadmap бележки (ETL derive извън обхвата на coverage; ниски branch подове на db/config) са проследени в #217 (priority: high) — не блокират този PR.

@lyubomir-bozhinov lyubomir-bozhinov left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ratchet-ът е реален: check-coverage.mjs прави process.exit(1) при спад, self-test-ът върви пръв, а validateBaseline е fail-closed (хваща и занулен workspaces).

Една дупка за проверка: findTestWorkspaces (:239) открива workspace само по наличие на scripts.test. apps/web има test (vitest run --config vitest.config.ts), но интеграционният lane от #177 върви под отделен config/test:integration — ако той не е включен в vitest.config.ts, ratchet-ът няма да мери точно покритието, което #177 добавя. Увери се, че umbrella test-ът покрива и двата runner-а, иначе новото integration coverage остава невидимо за gate-а.

ydimitrof added a commit to ydimitrof/sigma that referenced this pull request Jul 13, 2026
…ар fix

Адресира ревю находките по PR midt-bg#216:
- validateBaseline: празен/невалиден workspaces обект, изтрит или забравен
  ключ за workspace с test script, stale ключ и traversal/__proto__ ключове
  вече fail-ват gate-а вместо тихо да го изключват
- --update печата per-workspace делти и предупреждава шумно при спад, за да
  не може реална регресия тихо да влезе в нов baseline
- coverage exclude покрива и .test.tsx/.spec.* варианти
- sticky comment: gh api | head пренаписан на два стъпки (pipefail SIGPIPE)
- по-точно съобщение при липсващ отчет след crash-нал vitest процес
@ydimitrof
ydimitrof force-pushed the feat/coverage-ratchet branch from d16f32c to 4a982eb Compare July 13, 2026 08:07
- @vitest/coverage-v8 през общ preset (vitest.shared.ts); vitest.config.ts
  за всичките 6 workspace-а с тестове, с експлицитен include за да не са
  невидими непокритите модули
- scripts/check-coverage.mjs: per-workspace ratchet (lines/branches срещу
  комитнат coverage-baseline.json, 0.5pp толеранс), markdown таблица с
  делти + информативен общ сбор; --update вдига baseline-а локално
- CI: pnpm test -- --coverage + ratchet gate (self-test първо), artifact
  с отчета, step summary, sticky PR коментар само за same-repo PR-и
  (fork token-ът е read-only); turbo test task пази coverage/** outputs
- документирано в docs/review-testing.md
…ар fix

Адресира ревю находките по PR midt-bg#216:
- validateBaseline: празен/невалиден workspaces обект, изтрит или забравен
  ключ за workspace с test script, stale ключ и traversal/__proto__ ключове
  вече fail-ват gate-а вместо тихо да го изключват
- --update печата per-workspace делти и предупреждава шумно при спад, за да
  не може реална регресия тихо да влезе в нов baseline
- coverage exclude покрива и .test.tsx/.spec.* варианти
- sticky comment: gh api | head пренаписан на два стъпки (pipefail SIGPIPE)
- по-точно съобщение при липсващ отчет след crash-нал vitest процес
@ydimitrof
ydimitrof force-pushed the feat/coverage-ratchet branch from 4a982eb to 924b276 Compare July 16, 2026 09:06

@lyubomir-bozhinov lyubomir-bozhinov left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Прегледах стриктно механизма на връх 924b276. Силен ratchet — реален gate, не просто измерване.

Проверих:

  • scripts/check-coverage.mjs fail-ва CI при pct < base − tolerance (0.5pp) — истински gate, process.exit(1). CI го вика blocking (pnpm check:coverage, без || true / continue-on-error), и self-тества checker-а преди gate (check:coverage:test) — добра дисциплина.
  • Anti-gaming-ът е точно правилен: explicit include glob в vitest.shared.ts — коментарът го казва изрично: без него v8 репортва само файловете, заредени от тестове, тъй че нов нетестван модул би бил невидим за ratchet-а. С explicit include нетестван source дърпа coverage-а надолу вместо да се скрие — най-честият начин ratchet-и да се заобикалят е затворен. Exclude листата е само test/dist/build/node_modules — нищо source не се крие.
  • Baseline-ите в coverage-baseline.json са реални (apps/web 89.3/81.3, shared 94.8/78.1, db 82/65.5), не 0%. Умишлено намаление се изразява със свалянето на baseline-а в същия PR (reviewable).

Една бележка (strict):

  • apps/etl baseline е 18.7% lines / 19.4% branches — а ETL-ът е точно там, където живеят accuracy бъговете на СИГМА (#154/#158 FX, #194/#195 канонични имена/ЕИК). Ratchet-ът замразява най-рисковия код на най-слабия под. Механизмът е коректен (записва текущото състояние, не е дефект в PR-а), но предлагам follow-up (вържете с #217) да вдигне etl floor-а — ratchet на 18.7% там е слаба защита. Branch-покритията (config 58.3, db 65.5) също са скромни — edge-бъговете се крият в клоните.

Одобрявам ratchet механизма; etl baseline-ът е за follow-up (#217), не блокер за този PR.

Resolve the add/add conflict in apps/etl/vitest.config.ts by keeping BOTH
sides: main's sql-text-module plugin, the cloudflare:workers/workflows aliases
and the 120s timeout (without them the Workflow suite cannot load), plus this
branch's sharedCoverage(['src/**']) reporter config.

Refresh coverage-baseline.json from the current corpus. The committed baseline
predates the tests main gained since the branch was cut, so it no longer
described reality - apps/etl alone reads 74.0% lines against a 18.7% floor. A
ratchet 55pp below the real level cannot catch a regression, which is the whole
point of the gate. Measured on this merge: etl 74.0/58.2, web 89.7/81.8,
config 92.8/72.2, db 94.2/79.0, ingest 85.8/80.0, shared 95.4/80.0.

@todorkolev todorkolev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Одобрявам. Ratchet върху реалното текущо покритие, с честни числа вместо кръгли амбиции - включително ниските за ETL - е правилният подход: пази от регресия, без да принуждава хората да пишат тестове за показност. Само-тестващият се гейт (check:coverage:test преди check:coverage) също е хубав детайл.

Две неща поправих в клона:

Конфликтът add/add в apps/etl/vitest.config.ts - запазих и двете страни. Без плъгина за .sql модули и alias-ите към cloudflare:workers/workflows от main, наборът на Workflow-а изобщо не се зарежда; към тях добавих sharedCoverage от този клон и 120-секундния timeout.

Обнових coverage-baseline.json. Записаната базова линия беше отпреди тестовете, които main получи междувременно, тоест вече не описваше реалността - apps/etl мери 74,0% редове срещу праг 18,7%. Праг с 55 пункта под истинското ниво не може да хване регресия, което е целият смисъл на гейта. Измерено върху този merge: etl 74,0/58,2, web 89,7/81,8, config 92,8/72,2, db 94,2/79,0, ingest 85,8/80,0, shared 95,4/80,0.

За протокола: #254 предлага същия механизъм с прагове 96-100%; предпочитам този.

@todorkolev
todorkolev merged commit 6940f19 into midt-bg:main Jul 30, 2026
3 checks passed
lyubomir-bozhinov added a commit to lyubomir-bozhinov/sigma that referenced this pull request Aug 3, 2026
midt-bg#216 (coverage harness) merged to main, so the vendored harness reconciles to
the canonical one. Also folds in the feature work that landed since the branch
base — midt-bg#263 (worker-native FX load, rewritten refresh Workflow), midt-bg#252 (Bulstat
EIK control code), midt-bg#210 (similar-contracts cohort) — and re-validates the whole
suite against the moved source.

Conflict resolutions:
- apps/etl/vitest.config.ts: take upstream (needs the SQL text-module plugin +
  cloudflare:workers alias for midt-bg#263's real-Workflow index.ts).
- vitest.shared.ts: keep our fixtures/json/md/d.ts excludes; add **/src/test/**
  (SQLite/workers stubs are test scaffolding, not product code).
- coverage-baseline.json: keep our floors (baseline reconciliation to the merged
  tree's actuals follows in a separate commit).
- apps/etl/src/index.test.ts: take upstream's FX integration test; restore the
  orchestration coverage it does not cover in a new mock-based control-flow test.
- packages/db/src/queries/home.test.ts: union both added fakeDb params
  (singleOffer + capture).

Post-merge fixes for source drift:
- search.suggest.test.tsx: stub getDb (the route now wraps env in getDb()).
- index.control-flow.test.ts (new): capped-window, zero-ingest, FX-uncovered,
  integrity-gate logger + failure (Error and non-Error), scheduled — restoring
  the run()/scheduled() branch coverage displaced by taking upstream's test.

Full suite green: config 27, shared 56, ingest 116, etl 40, db 458, web 469.
todorkolev added a commit that referenced this pull request Sep 2, 2026
* test: coverage measurement + ratchet gate in CI

Vendored from #216 (feat/coverage-ratchet) to put the coverage machinery
in place ahead of #217, while #216 is pending merge upstream. Squashes
ydimitrof's two harness commits into one; original authorship preserved
via the commit author.

@vitest/coverage-v8 through a shared vitest preset, per-workspace coverage
configs with explicit include, committed coverage-baseline.json, the
scripts/check-coverage.mjs ratchet gate (+ node:test self-test), and the CI
wiring.

Ref #93, #216.

* test(config): cover regionByName + taxonomy integrity to 100%

regionByName had zero tests (both lines uncovered). Adds its full branch
matrix (valid, whitespace-trim, null/undefined/empty/unknown, verbatim
round-trip of all 28 regions) plus edge inputs for categoryForDivision and
procedureGroup, and taxonomy-integrity invariants (unique CPV codes, single
procedure-type ownership, classified = competitive∪non-competitive,
28 unique NUTS3 regions). config: 92.85/72.22 -> 100/100 lines/branches.

* test(shared): cover format.ts edge branches (eik/unp, date fallbacks, periodRange)

Adds eik/unp passthrough, one-sided and empty periodRange, date/monthYear/
longDate no-match + datetime-prefix + out-of-range-month fallbacks, count
sign/absence, pct/signedPct dp + non-finite, entityName non-collapsing paths,
cleanName unbalanced-quote drop, ЕТ/ET latin detection. shared branches
78.1 -> 98.3; residual is signedPct's provably-unreachable defensive return.

Caught: count(-0.4) emits '−0' (missing money()'s rounded-zero sign guard);
latent only (count takes non-negative integers), logged not patched.

* test(ingest): cover staging, refresh, and base/ocds edge branches

New staging.test (0%->100%): scoped DELETE, chunked INSERT at CHUNK=100
boundaries (100/101/250 rows), null-fill of absent columns, table+column
routing per target. New refresh.test: SQL splitter (escaped '' in literals,
-- comments inside/outside literals, trailing statement), @refresh-batch
grouping, transient-table drop order, D1 orchestration. base/ocds additions:
toBool, Date.parse date fallback, annexes mapping, baseSqlLiteral numeric/
text/null branches, secured_inverse/variants coercions, party/lot/amendment
and catch-up-window branches. ingest lines 83.8->100, branches 78.9->95.1.

* test(db): close branch gaps in identity, keyset, home, regions, methodology, flows

identity slug fallbacks + undecodable name slug; keyset decodeCursor
malformed/oversized/bad-type-guard paths; home zero single-offer aggregate;
regions year + EU/national funding predicates; methodology absent-count
coalesce with positive total; flows sankey sort tiebreak on shared authority.

* test(db): cover sitemap streaming end to end

streamAuthority/Company/Contract sitemaps via a paginating fake D1: XML
escaping + C0 stripping, lastmod fallbacks (row date -> as_of -> none),
natural-person filtering, empty-chunk skip loop, CHUNK-boundary pagination,
contract page rowid windowing, and contractSitemapPages math. sitemaps
branch 34.9 -> 95.3; residual is the defensive post-close pull guard.

* test(db): cover getCompany, getAuthority, and getContract derivations

getCompany + getAuthority were entirely untested. Adds DTO assembly, share
math (won/spent denominators, zero-guards), avg-bids rounding, consortium
membership (list->participants, prose->note), hasEik, sector top6+tail rollup,
and getContract subcontractor (EUR/BGN/null/blank), framework call-off
detection, eurFromNative currency paths (EUR/BGN peg/FX/no-rate), deltaPct
suspect+zero-base guards, lot dedup/totals, and not-found. details branch
43.6 -> 88.5, lines 53 -> 100.

* test(db): cover company-centred network, defaults, and hop-2 reduction

Adds the company-centre direction, null-param default (top authority) + its
empty fallback, includeCenterOptions=false, loadCenter sample-name fallback
(authority + company), hop-2 top-1-per-neighbour dedup, centre self-skip, and
edgeless-node weighting. network branch 50 -> 88, lines 86.7 -> 100.

* test(db): cover search empty-query + trend zero-year YoY and coverage guards

search: empty/punctuation query -> empty shape, searchMoreHref unknown-kind
fallback. trend: YoY guarded against a zero prior year, coverage pct when
nothing is dated (no divide-by-zero).

* test(db): cover authorities query branches to 96%

Add coverage for the base-aggregation source (year/EU/single- vs multi-sector
primary_sector), the entity WHERE type/text filters, sort normalization, facet
label fallback and sort, page overflow, and the CSV stream body across the
CHUNK boundary. Branch 55%->96%, lines 100%.

* test(db): cover companies query branches to 95%

Add coverage for sort normalization, the base-aggregation source (year/EU/
single- vs multi-sector), the kind/text entity WHERE, facet kind mapping and
sector sort, page overflow, missing total row, and the CSV body across the
CHUNK boundary. Branch 61%->95%, lines 100%.

* test(db): cover competition query branches to 95%

Add coverage for the authority-detail wrappers (getAuthoritySingleOffer,
getAuthorityProcedureCompetition), getCompetitionSummary (both the qualifying
and null-topConcentration paths), the MAX_TOP cap, EU/national funding scope,
and a degenerate corpus exercising the zero-guard fallbacks. Branch 70%->95%.

* test(db): cover contracts query branches to 96%

Add coverage for buildFilters (every year/sector/procedure/value-bucket/EU/
bids/authority/bidder/text predicate), summary override, page overflow,
contractsSummary null row, listSingleOfferContracts modes, facet procedure
folding / sector sort / year ordering, and the streamed CSV body across the
CHUNK boundary. Branch 66%->96%, lines 100%.

* test(db): cover details query fallback branches to 98%

Add degenerate-input coverage for getCompany (absent metadata/bids/suspect
rows, null primary sector and procedure value), getAuthority (spent-nothing
authority with an unknown CPV division, zeroed tail share, null bids/suspect),
and a getContract lot defaulting to the BGN peg. Branch 88%->98%, lines 100%.

* test(db): cover network query branches to 98%

Add coverage for an unresolvable company centre (null name → empty network),
the company-kind fallback when neither rollup nor sample carries a kind, the
empty-default path with includeCenterOptions off, and deduping a hop-1
neighbour that appears twice. Branch 88%->98%, lines 100%.

* test(db): cover flows, search, and trend branches to 95%+

flows: EU/national/all funding scope + long-label truncation (branch 89->100).
search: nullish raw-query coalescing (branch ->97). trend: EU/national funding,
includeSectors=false, and an empty series with an absent coverage row
(branch 89->100).

* test(etl): cover the EOP ingest worker and bucket pipeline to 98%

index.ts: full RefreshWorkflow.run coverage (staging lifecycle, capped and
zero-ingest branches, derive-slice loop, finally-drop on success and error) and
the scheduled cron entrypoint, via mocked platform/ingest/eop seams. eop.ts:
bucket-key parse/classify, catch-up planning (uncapped/capped/default), bucket
listing status + redirect guard, OCDS/base staging, and the window walk.
Workspace 19%->98% branch, lines 100%.

* test(web): cover filters URL-state helpers to 97%

Add coverage for singleSelectFilters (unknown sector/year flags, funding/top
defaults), buildSectorGroup (category grouping, summed vs absent counts,
uncategorised skip), sortHref, and the withParams/pageNav null-override, array,
empty-result, and page-default branches. filters.ts branch 47%->97%.

* test(web): cover assistant agent, report binder, and tool registry

agent.ts: SDK-wiring coverage (model/base-URL resolution, tool-set assembly,
stream Response + onError) via mocked ai/@ai-sdk. report-schema.ts: flows block,
facts sub-line, unknown-handle, empty title, 0-row and null chart edges
(branch 78->94). tools.ts: run_sql AST-reject/error/meta-less paths, semantic
hits, eop_fetch, source_link (branch 57->95). Also exclude test fixtures and
type-only declarations from coverage (permanent 0% data files, not code).

* test(web): cover assistant format, results, eop-fetch, rag, emit-shape

render-format null-date; tool-results missing-cell + truncation flag; eop-fetch
null-date/non-array/invalid-JSON/thrown-fetch; rag embed mismatch + metadata
mapping + empty-vector guards; validateEmitShape callout/flows/timeseries and
the object/question/items/columns negatives.

* test(web): cover CSV export ranges, freshness, and multipart edges

Add coverage for the v0 freshness fallback, non-string/null q classification,
empty and empty-chunk multipart bodies, the abort-on-part-failure path, and
suffix (bytes=-N) / open-ended (bytes=A-) R2 range shapes (the fake now emits
R2-native range objects). csv-export lines 87->95, branch 76->90.

* build: exclude markdown files from coverage instrumentation

The v8 provider instruments every file matched by a workspace's include globs.
Markdown docs colocated in src (e.g. the assistant README) carry no coverable
statements, report a permanent 0%, and — being non-JS — make the reporter's
remap step throw a parse error. Exclude **/*.md alongside the existing JSON and
type-only exclusions so the ratchet total reflects executable code only.

* test(web): raise branch coverage to the 95 floor

Cover the remaining thin spots in the web workspace with real behavioural tests,
no code changes:

- cache.publicCache: default and explicit stale-while-revalidate windows
- eopSource: missing/malformed dates, DD.MM.YYYY key shape, OCDS cutoff boundary
- search.suggest: trimGroup cap + loader query/trim/headers, empty-q default
- app.ts hardening: nonce re-read path, OPTIONS short-circuit, no-Content-Type
- retry: non-Error rejection logging + default backoff past the table
- security: nonce-less headers omit the CSP outside production
- riskLogic: unknown bid count (null) and missing bidsRejected fallback
- ScrollToTop: rAF coalescing of a scroll burst

Web branch total 81.3% -> 95.47%, lines 89.3% -> 99.25%.

* style(config): wrap the curated-sector assertion per prettier

* chore: ratchet coverage floors to >=95 for every workspace

With the new tests in place, every workspace clears 95% on both lines and
branches. Regenerate the ratchet baseline from current coverage so the gate
now enforces the 95 floor going forward:

  etl      18.7/19.4  -> 100/98.5
  web      89.3/81.3  -> 99.2/95.4
  config   88.2/58.3  -> 100/100
  db       82/65.5    -> 100/97.3
  ingest   83.8/78.9  -> 100/95
  shared   94.8/78.1  -> 98.8/98.3

* test(web): assert non-null categories in buildSectorGroup tests

buildSectorGroup always returns categories, but the group type marks it
optional. vitest transpiles without type-checking so this passed locally;
tsc --noEmit under noUncheckedIndexedAccess (CI typecheck) rejected the
possibly-undefined access. Assert non-null at the three call sites.

* test(ingest): cover sparse-release OCDS nullish branches

Add branch-completion tests for releaseToContracts/Amendments/Lots on
releases with absent optional fields: missing tag/contracts keys, id-less and
identifier-less parties, a scheme-less CPV classification, an empty-string
value amount, a blank date, and an ocid/tender-id-less lot. ocds.ts branch
92.17% -> 98.26%, ingest workspace 95.06% -> 98.7%.

Residual uncovered branches are unreachable defensive code: the validDateOnly
regex reject (day is always pre-normalized to YYYY-MM-DD) and the
`rel.contracts ?? []` / `c.id ?? null` right-sides the length/id guards above
them make impossible.

* test(db): close reachable query branch gaps to 98.4%

- keyset: unsafe-direction guard, before-cursor with an ascending sort, and the
  empty before-page (both cursors null)
- flows: two-authority sankey so the authority-column sort comparator runs
- regions: empty dataset → the total==0 coverage-pct guard (no divide-by-zero)
- companies: base aggregation from a non-sector filter (no CPV predicate)
- contracts: the „Неизвестна" year bucket sinking below real years regardless
  of input order
- authorities/companies/contracts: backward pagination — page forward for a
  cursor, back for a before-cursor, then feed it back so keyset's reverse path
  runs

db branch 97.35% -> 98.39%. Residual gaps are unreachable defensive code:
CSV/sitemap `if(done)` re-entry (a stream never pulls after close), the
minContracts `?? DEFAULT` the orchestrator already normalises, split().pop()
`?? ` fallbacks (pop is always defined), the homoglyph map (every regex-matched
char is mapped), and cross-namespace network self-edges.

* test(web): cover read-only SQL guard, tool, and eop-fetch branches

- assertReadOnlySelect: empty/comment-only query, a forbidden keyword hidden
  in a single CTE-prefixed statement (cheap keyword layer, not just the AST
  guard), and the sqlite_master/sqlite_schema catalog-table rejection
- run_sql: a driver returning no results array (the results ?? [] fallback)
- eop-fetch: a non-Error thrown value → the generic fetch-error label

web branch 95.47% -> 96.0%. Residual gaps are deep AST-shape defenses
(sql-ast-guard), schema-validation guards (report-schema), and unreachable
code: regex capture-group ?? fallbacks (csp, always matched), PROD-gated
redirect/OPTIONS paths under vitest, and the module-init Date.now tag.

* chore: ratchet coverage floors up after deeper branch tests

Coverage rose across web/db/ingest with the new branch tests; raise the
ratchet floors to match (never down):

  web      99.2/95.4 -> 99.4/96
  db       100/97.3  -> 100/98.3
  ingest   100/95    -> 100/98.7

etl (100/98.5), config (100/100) and shared (98.8/98.3) unchanged. Monorepo
total 99.73% lines / 97.51% branches.

* style(db): wrap flows two-authority test per prettier

* test: make coverage-only tests mutation-sensitive

An adversarial mutation audit found six added tests that lit up a branch for
the ratchet without asserting its behaviour (each survived deleting the very
line it claimed to cover). Strengthened so the assertion fails under the
targeted mutation:

- db backward pagination (authorities/companies/contracts): pageSize 1 made
  slice+reverse a no-op; now pageSize 2 over 3 rows and asserts the page comes
  back in reversed fetch order (fails if rows.reverse() is dropped)
- db flows ribbon order: input was pre-sorted so the comparator was unguarded;
  now feeds unsorted pairs and asserts ranked toName order
- web app.harden nonce swap: unobservable under dev PROD=false; now stubs
  PROD=true and asserts the CSP nonce is replaced by a sha256 hash
- web retry backoff: asserts setTimeout was called with [50,150,150], pinning
  the BACKOFF_MS[i] ?? 150 fallback value

Coverage unchanged (db 98.39%, web 96%); same branches, real assertions.

* test(coverage): replace coverage-only assertions with mutation-sensitive ones

Adversarial audit pass over the coverage-ratchet suite: each finding was
confirmed by breaking the exact production line and watching the test stay
green, then fixed and re-verified so the mutation now fails.

- etl stagedRows: fixture now sets baseAmendments/ocdsAmendments non-zero so
  both amendment terms of the sum are guarded (were unexercised).
- db details: procedureMix assertions go from .length>0 to the folded DTO
  shape (contracts/valueEur/sharePct); numeric lot sort now fed out of order
  with multi-digit labels; consortium lot contractor name asserted through
  the row kind.
- db sitemaps: page math now guards the upper rowid bound (hi), and lastmod
  precedence proves signed_at wins over published_at when both are present.
- db network: centre-picker test asserts the full mapped shape for both the
  authority and (previously unexercised) company branch.
- db trend: sector options asserted through the includeSectors=true path.
- db keyset: oversized-cursor test uses a genuinely decodable payload so the
  length guard is exercised, not the JSON.parse catch; plus a near-limit pass.
- db contracts: listSingleOfferContracts asserts the limit reaches LIMIT ?.
- web retry: fake timers; ScrollToTop: rAF coalescing counted.

* test(etl): pin the clock in the catch-up default-today test

The 'defaults today' test only asserted the plan window was date-shaped
(/^\d{4}-\d{2}-\d{2}$/), which a mutation to any hard-coded ISO string would
survive. Pin the system clock and assert plan.to equals that exact date, so
the new Date() default is actually verified. Addresses the reviewer's
determinism note on real-clock reliance.

* test(etl): restore the data-integrity invariant weakened in the fake DB

A prior commit on this branch reshaped fakeDbFromFreshness so prepare()
ignores the SQL and always returns the freshness row, dropping the guard
that threw when raw staging was read for planning. Restore it as the stronger
positive invariant: the catch-up planner must read data_freshness, never
raw_*, so a regression that reads raw staging for the max-loaded date fails
loudly. Also fix a BG typo in the config test name (областти -> области).

* test(ingest): cover FX/OCDS edge branches; reconcile etl branch floor to merged-tree actual

Restore ingest to its line floor and hold branches after the merge:
- fx.ts: a malformed (unpadded) staged contract_date surfaces through
  findFxCoverageGaps' MIN/MAX, so loadFxRates must reject the range before
  fetching — asserted (skips the currency, warns, never fetches).
- ocds.ts: a release with no bids block nulls bids_received via the optional
  chain instead of throwing.

Reconcile apps/etl branches 98.5 -> 97 (achieved 97.46). etl LINES hold at 100.
The two uncovered branches are provably unreachable: eop.ts parseBucketKeys
`m[1] ?? ''` (a matched regex group is never undefined — the `??` is required
only by noUncheckedIndexedAccess) and integrity.ts `summary.message ?? '...'`
(dead — summarizeIntegrity guarantees a non-null message on the throw path).
97 is still far above the harness's original etl branch floor (58.2); covering
the branches would require a fake or a source edit, both disallowed.

check:coverage passes for all six workspaces.

* test(db,web): address review findings on the coverage ratchet (#254)

Four findings from ydimitrof's review, verified against the source before acting.

competition.test.ts — the „caps at MAX_TOP" test asserted top:50 → 50 and named a
clamp the source does not perform. `getCompetition` reads
`p.top === MAX_TOP ? MAX_TOP : DEFAULT_TOP`: an exact 50 selects the large size and
everything else falls back to 20, so nothing is ever reduced to 50. The old assertion
did kill a mutation (collapsing the toggle to DEFAULT_TOP fails it), but it left the
fallback — the half that stops a caller naming its own leaderboard size and its own
LIMIT — untested, under a name that overstated it. Renamed and extended to cover 999,
51, 35, 0, -1, NaN and an omitted top. Now kills three mutations, including the
`Math.min` clamp the old test would have passed.

vitest.shared.ts — dropped `**/*.json` and `**/*.md` from the coverage excludes. The
provider only reports files it can instrument as modules, so neither ever reached a
report; removing both leaves all six workspaces' numbers byte-identical. Recorded that
verification in the comment rather than the globs.

vitest.shared.ts — documented the contract behind `**/src/test/**`: the glob is wide,
so the exclusion holds only while src/test/ stays test HELPERS. Product code placed
there would leave the coverage denominator silently, which is the single way this list
can hide an untested module rather than an uncoverable file.

search.suggest.test.tsx — filled the dangling „upstream #…" placeholder with the real
reference (#225, the read-only D1 chokepoint for #199).

trend.test.ts — the local `customDb` returned the period series for every query,
including the sector_totals read. Dispatches on the SQL now, like the other fakes in
the file, so a later assertion on `sectors` cannot be fed rows of the wrong shape.

Coverage unchanged: etl 100/97.64, web 99.47/96.21, config 100/100, db 100/98.47,
ingest 100/98.69, shared 98.87/98.33. No floor moved.

* test(web): tighten the coverage denominator contract and the date-cell assertions (#254)

Second round of ydimitrof's review.

vitest.shared.ts — replaced the `**/src/test/**` directory glob with the three files it
actually covers: the cloudflare:workers/workflows stubs apps/etl aliases the real modules
to, and the ingest SQLite D1 shim. A directory glob was the one entry on this exclude
list that could hide an untested product MODULE rather than an uncoverable file, since
anything later dropped into src/test/ would leave the coverage denominator by virtue of
its location. With an explicit list, a new file there is measured until someone
deliberately adds it — a reviewable act. Stricter than a suffix convention, which would
still let a *.helper.ts product file through. Coverage is unchanged in all six
workspaces, confirming the list covers exactly what the glob did.

render-format.test.ts — the date-null case asserted `formatCell(null, 'date')` equals
`date(null)`, which only proves delegation and passes for any value the shared formatter
returns. Pinned to the literal em-dash, and added the non-null path (a formatted date and
an unparseable value that must be echoed, never rendered as a fake date). The pair now
kills two mutations the old assertion could not see — including a `date` branch
hard-coded to return the em-dash.

eop.test.ts — dropped the describe-local afterEach that duplicated the file-level
vi.unstubAllGlobals introduced when this file was union-merged with upstream.

contracts.test.ts — the two `describe('getContractFacets')` blocks now carry distinct
names for what each covers.

Coverage unchanged: etl 100/97.64, web 99.47/96.21, config 100/100, db 100/98.47,
ingest 100/98.69, shared 98.87/98.33.

* test(config): name both paths to the unknown procedure bucket (#254 review)

The comment said a whitespace-only value 'trims to ""' and grouped it with the nullish
inputs, which reads as if it hits the `if (!procedureType)` guard. It does not: '   ' is
truthy, passes the guard, and reaches the map lookup, where .get('') misses and the ??
fallback supplies PROCEDURE_UNKNOWN. Spelling out both paths so the map-miss branch is
visibly exercised here as well as by the unrecognised-type case above.

* style: run prettier on the three migrated test files

Line-length reflow only — no assertion or fixture changes. The three files
whose fakeD1 call sites pushed a line past printWidth after the #331 migration.

* test: give the fully-covered workspaces a line-floor margin

Per @todorkolev on #254: the five workspaces sitting at lines 100 drop to a
99 floor. Measured coverage is unchanged at 100% in all five — this only
widens the margin before the ratchet fires.

Why it mattered: `tolerance` is a percentage, so it is near-zero slack in a
small workspace. At 100/100 a single uncovered line red-builds
packages/config (28 lines), packages/test-support (99) and apps/etl (169) —
including on PRs that never touch tests. packages/db needed 6 and
packages/ingest 3.

Branch floors are untouched.

* test(web): pin the two ConflictDetail assertions to what they claim

Both findings are @ydimitrof's on #254, and both were real.

- the source-URL test selected `.cc-source, .conflict-detail`. There is no
  `.cc-source` in the component — the stat cells carry no per-field class — so
  it always widened to the whole card, where `toContain('—')` can be satisfied
  by any other dash. Now pinned to the „Източник" cell via its `<dt>`, with a
  positive control asserting the same cell holds the declaration link when the
  URL is present, so a broken finder cannot make the negative case pass.
- the in/out-window test asserted only that both rows render and both numbers
  appear, which holds with the split broken in either direction. Now asserts
  the per-row `contract-item-conflict` modifier and that the outside row sits
  behind the „Извън периода" disclosure.

Mutation-verified: forcing every row to carry the modifier, forcing none to,
and deleting the source-cell fallback each fail the suite.

---------

Co-authored-by: Yoan Dimitrov <ydimitrof@users.noreply.github.com>
Co-authored-by: Todor Kolev <tkolev@obecto.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Тестове: измерване и праг на coverage в CI

3 participants