diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c0e2a408..ab1b95e9e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -246,6 +246,26 @@ jobs: if: always() run: supabase stop || true + business-contracts: + name: Business contracts + runs-on: ubuntu-latest + continue-on-error: true + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run advisory business contract lane + run: npm run verify:business + all: name: all runs-on: ubuntu-latest diff --git a/docs/operations/BRANCH_PROTECTION.md b/docs/operations/BRANCH_PROTECTION.md index 283ff8ede..33544f9d5 100644 --- a/docs/operations/BRANCH_PROTECTION.md +++ b/docs/operations/BRANCH_PROTECTION.md @@ -22,6 +22,7 @@ Required checks for `main` should match the hard blockers in `docs/operations/GA Do not require these until they are stable enough to block everyday work: - `Playwright E2E Advisory` +- `Business contracts` - `Course picker screenshots` - CircleCI weekly radar jobs - Lighthouse preview jobs diff --git a/docs/operations/BUSINESS_CONTRACT_ISSUE_DRAFTS.md b/docs/operations/BUSINESS_CONTRACT_ISSUE_DRAFTS.md new file mode 100644 index 000000000..7ce44f278 --- /dev/null +++ b/docs/operations/BUSINESS_CONTRACT_ISSUE_DRAFTS.md @@ -0,0 +1,93 @@ +# Business Contract Issue Drafts + +These are ready-to-file follow-up issues created from `docs/operations/BUSINESS_CONTRACT_MATRIX.md`. They are drafts rather than opened issues because the repo has not yet decided whether the advisory Business Contracts lane should auto-create GitHub issues. + +## Issue Draft: Decide canonical SG baseline surface + +Labels: `area:golfhelm`, `type:product-truth`, `needs-decision` + +### Problem + +`src/lib/golf/sg-benchmarks.ts` exposes legacy `BenchmarkLevel` metadata for NCAA/scratch/break scales while the runtime `SG_BASELINE_OPTIONS` contract exposes only `pga_tour` and `womens`. + +### Decision Needed + +Decide whether legacy benchmark metadata is still supported UI/reference metadata, or whether it should be removed/renamed so the runtime baseline contract is unambiguous. + +### Risk + +Strokes-gained comparisons can become mathematically meaningless if a player/team view mixes incompatible baselines or implies support for a baseline the cache does not use. + +### Acceptance Criteria + +- The product decision is recorded in `docs/operations/BUSINESS_CONTRACT_MATRIX.md`. +- If legacy metadata remains, comments/tests explain it is not a runtime SG baseline choice. +- If legacy metadata is removed, affected UI/actions/tests are updated in a focused PR. + +## Issue Draft: Decide canonical CoachHelm metric alias policy + +Labels: `area:coachhelm`, `type:product-truth`, `needs-decision` + +### Problem + +`src/lib/coachhelm/v3/metrics/registry.ts` has canonical v3 metric IDs and a legacy alias map. The business contracts can verify direction parity for canonical metrics today, but cannot decide whether all generators must emit canonical IDs. + +### Decision Needed + +Decide whether v2 legacy metric aliases are permanently supported product inputs or a migration bridge that should shrink over time. + +### Risk + +CoachHelm may learn or explain trends backward if an alias lacks the right lower-is-better/higher-is-better direction. + +### Acceptance Criteria + +- Alias policy is documented. +- Business contracts either require all emitted metrics to be canonical or verify every allowed alias has direction coverage. +- Any generator emitting an unknown metric fails the advisory lane or records a decision item. + +## Issue Draft: Decide advisory issue automation + +Labels: `repo-hygiene`, `type:automation`, `needs-decision` + +### Problem + +The Business Contracts lane is advisory. The mission asks for a radar that reveals issues and creates high-quality follow-up issues, but the repo has not decided whether CI should open GitHub issues automatically. + +### Decision Needed + +Choose one: + +- Keep in-repo issue drafts only. +- Add a manual script that files selected drafts. +- Add CI automation that opens/updates issues with labels and duplicate detection. + +### Risk + +Without a clear policy, advisory failures may either spam the tracker or never become actionable work. + +### Acceptance Criteria + +- The chosen policy is recorded in `docs/operations/BUSINESS_CONTRACT_MATRIX.md`. +- If automation is chosen, labels, duplicate keys, and permissions are defined before enabling it in CI. + +## Issue Draft: Decide missing-putts aggregate semantics + +Labels: `area:golfhelm`, `type:product-truth`, `needs-decision` + +### Problem + +When a hole row exists with `putts = null`, the shot aggregate preserves scoring null-honesty but currently reports `puttsPerRound` as `0` in at least one no-putt-data fixture. The product contract needs to decide whether that is a valid "zero recorded putts" value or should be `null` to mean "putting data missing." + +### Decision Needed + +Decide whether missing putting data should remain `null` across per-round aggregates, or whether `0` is acceptable when no putts are recorded. + +### Risk + +Missing putting data can look like a healthy zero-value stat, which conflicts with the no fake healthy empty-state goal. + +### Acceptance Criteria + +- The chosen behavior is recorded in `docs/operations/BUSINESS_CONTRACT_MATRIX.md`. +- A business contract is added for `puttsPerRound` once the product rule is settled. diff --git a/docs/operations/BUSINESS_CONTRACT_MATRIX.md b/docs/operations/BUSINESS_CONTRACT_MATRIX.md new file mode 100644 index 000000000..80970958e --- /dev/null +++ b/docs/operations/BUSINESS_CONTRACT_MATRIX.md @@ -0,0 +1,54 @@ +# Business Contract Matrix + +Living map of business rules protected by the advisory Business Contracts lane. + +Status values: `missing`, `starter`, `covered`, `needs decision`. + +## GolfHelm Stats Contracts + +| Contract ID | Product area | Rule | Why it matters | Source of truth | Test file | Status | Gate | Risk if broken | +|---|---|---|---|---|---|---|---|---| +| GH-STATS-001 | GolfHelm stats | Round gross score must equal the sum of hole scores when hole-level scores are present. | Coach/player dashboards show wrong scoring. | Existing pure function: `calculateStatsFromShots`; explicit rule in prompt | `src/contracts/golf/stats.contract.test.ts` | starter | advisory | Coach/player dashboards show wrong scoring. | +| GH-STATS-002 | GolfHelm stats | Score-to-par must equal gross score minus course par when course par is known. | Leaderboards and summaries become untrustworthy. | Existing pure function: `calculateStatsFromShots`; obvious golf invariant; explicit rule in prompt | `src/contracts/golf/stats.contract.test.ts` | starter | advisory | Leaderboards and summaries become untrustworthy. | +| GH-STATS-003 | GolfHelm putting | A 3-putt is counted when a hole has 3 or more putts. | Putting stats and CoachHelm putting insights stay truthful. | Existing pure function: `calculateHoleStatsFromShots`; explicit rule in prompt | `src/contracts/golf/stats.contract.test.ts` | covered | advisory | Putting stats and CoachHelm putting insights become wrong. | +| GH-STATS-004 | GolfHelm short game | Scrambling opportunity exists only when GIR is missed and the player can still save par or better. | Short-game stats should reflect real up-and-down chances. | Existing pure function: `calculateHoleStatsFromShots`; explicit rule in prompt | `src/contracts/golf/stats.contract.test.ts` | starter | advisory | Short-game stats become inflated or meaningless. | +| GH-STATS-005 | GolfHelm short game | Scrambling success cannot be counted on GIR holes. | GIR pars should not inflate short-game performance. | Existing pure function: `calculateHoleStatsFromShots`; explicit rule in prompt | `src/contracts/golf/stats.contract.test.ts` | covered | advisory | Short-game performance is overstated. | +| GH-STATS-006 | GolfHelm data honesty | Missing/null shot data cannot be treated as zero performance. | Missing facts must stay visibly missing. | Existing pure functions: `stat-formulas`, `calculateStrokesGainedForShot`; feature doc: `memory/features/golf-round-lifecycle.md`; explicit rule in prompt | `src/contracts/golf/stats.contract.test.ts` | covered | advisory | Dashboards look healthy while data is missing. | +| GH-STATS-007 | GolfHelm stats | Round summary stats must not contradict shot-level facts. | Raw shot facts and summaries must agree. | Existing pure function: `calculateStatsFromShots`; feature doc: `memory/features/golf-round-lifecycle.md`; explicit rule in prompt | `src/contracts/golf/stats.contract.test.ts` | starter | advisory | Coaches lose trust in stats. | +| GH-STATS-008 | GolfHelm strokes gained | Strokes-gained calculations must not mix incompatible baselines. | SG values are only meaningful against one baseline at a time. | Existing product constants: `SG_BASELINE_OPTIONS`, `sgBaselineScale`; explicit rule in prompt | `src/contracts/golf/stats.contract.test.ts` | starter | advisory | SG values become mathematically meaningless. | +| GH-STATS-009 | GolfHelm baselines | PGA/LPGA/team baseline choice must be explicit, not accidental fallback. | Player comparisons need clear context. | Existing product constants: `defaultSgBaseline`, `effectiveSgBaseline`; existing tests: `sg-baseline-options.test.ts`; explicit rule in prompt | `src/contracts/golf/stats.contract.test.ts` | starter | advisory | Player comparisons become wrong. | +| GH-STATS-010 | GolfHelm stat cache | Stat cache output must match raw round/shot facts for the same player/team/time period. | Cache drift undermines coach dashboards. | DB schema/RLS policies and cache functions: `src/lib/cache/golf-stats-calculator.ts`; explicit rule in prompt | Not yet added | missing | future hard gate | Coach dashboard displays stale or incorrect stats. | + +## CoachHelm Truth Contracts + +| Contract ID | Product area | Rule | Why it matters | Source of truth | Test file | Status | Gate | Risk if broken | +|---|---|---|---|---|---|---|---|---| +| CH-AI-001 | CoachHelm metric direction | Lower-is-better metrics improve when value decreases. | Polarity must not invert. | Existing product constant: `METRIC_DIRECTION`; explicit rule in prompt | `src/contracts/coachhelm/truth.contract.test.ts` | covered | advisory | CoachHelm tells coaches the opposite of reality. | +| CH-AI-002 | CoachHelm metric direction | Higher-is-better metrics improve when value increases. | Strengths/weaknesses must classify correctly. | Existing product constant: `METRIC_DIRECTION`; explicit rule in prompt | `src/contracts/coachhelm/truth.contract.test.ts` | covered | advisory | Strength/weakness classification flips. | +| CH-AI-003 | CoachHelm insight truth | CoachHelm cannot label a worsening metric as an improvement. | Improvement signs must respect metric polarity. | Existing pure function: `improvementSign`; explicit rule in prompt | `src/contracts/coachhelm/truth.contract.test.ts` | starter | advisory | AI becomes confidently wrong. | +| CH-AI-004 | CoachHelm evidence | Every insight must include supporting evidence or explicitly say insufficient data. | Evidence-backed claims are the trust contract. | Feature doc: `memory/features/coachhelm-ai.md`; existing pure function: `verifyCitations`; explicit rule in prompt | `src/contracts/coachhelm/truth.contract.test.ts` | starter | advisory | AI invents unsupported claims. | +| CH-AI-005 | CoachHelm confidence | Prediction/confidence requires a minimum sample size or must be marked low confidence. | Thin samples should not look authoritative. | Existing pure functions: `calcConfidence`, `sampleDamping`; explicit rule in prompt | `src/contracts/coachhelm/truth.contract.test.ts` | starter | advisory | CoachHelm overstates weak evidence. | +| CH-AI-006 | CoachHelm ranking | Composite/score outputs must remain within legal bounds. | Ranking values must stay finite and sane. | Existing pure functions: `scoreInsight`, `cappedStrokesImpact`; explicit rule in prompt | `src/contracts/coachhelm/truth.contract.test.ts` | covered | advisory | UI and rankings become nonsensical. | +| CH-AI-007 | CoachHelm NLG | LLM/NLG output cannot add factual claims not present in the evidence payload. | Generated prose must remain grounded. | Existing pure function: `verifyCitations`; feature doc: `memory/features/coachhelm-ai.md`; explicit rule in prompt | `src/contracts/coachhelm/truth.contract.test.ts` | starter | advisory | Hallucinated coach advice. | +| CH-AI-008 | CoachHelm UI state | If CoachHelm generation fails, UI must show a failure state, not fake insight. | Failed AI should be visible. | UI behavior: route error boundaries; server action meta result; explicit rule in prompt | `src/contracts/product-trust/states.contract.test.ts` | starter | advisory | Silent AI failure. | +| CH-AI-009 | CoachHelm budget | Budget/usage limits must prevent generation when exceeded. | Cost controls must be enforced server-side. | Existing pure constants and server helper: `FALLBACK_PRIORITY`, `checkBudget`; explicit rule in prompt | `src/contracts/coachhelm/truth.contract.test.ts` | starter | advisory | Runaway AI cost. | +| CH-AI-010 | CoachHelm access | Coach/player/team scope must be preserved in every CoachHelm insight. | Insight data is tenant scoped. | DB schema/RLS policy; feature doc: `memory/features/coachhelm-ai.md`; explicit rule in prompt | `src/contracts/access/golf-access.contract.test.ts` | starter | advisory | One team's data leaks into another team's analysis. | + +## Product Trust Contracts + +| Contract ID | Product area | Rule | Why it matters | Source of truth | Test file | Status | Gate | Risk if broken | +|---|---|---|---|---|---|---|---|---| +| TRUST-001 | Stats UI | Failed stat load cannot render a healthy empty state. | Users need to know the system failed. | UI behavior: stats client error branch; explicit rule in prompt | `src/contracts/product-trust/states.contract.test.ts` | starter | advisory | Users think there is no data instead of a system error. | +| TRUST-002 | Save flows | Failed save cannot show "Saved." | Save confirmation must reflect persistence. | Explicit rule in prompt; current source needs adapter | Not yet added | missing | advisory | User trust breaks. | +| TRUST-003 | Imports | Partial import failure must surface failed rows. | Imports must not hide corrupt/missing data. | Explicit rule in prompt; normalization helpers | `src/contracts/imports/normalization.contract.test.ts` | starter | advisory | Data silently corrupts. | +| TRUST-004 | Empty states | Empty success and failed load are different states. | Empty data and outage are different outcomes. | UI behavior: stats client error branch before empty branch; explicit rule in prompt | `src/contracts/product-trust/states.contract.test.ts` | starter | advisory | UI hides outages. | +| TRUST-005 | CoachHelm availability | CoachHelm unavailable must not show stale/generated-looking new advice. | AI failures must not masquerade as current insight. | UI behavior: CoachHelm route error boundaries; explicit rule in prompt | `src/contracts/product-trust/states.contract.test.ts` | starter | advisory | False AI trust. | + +## Needs Decision + +| Contract ID | Product area | Rule | Why it matters | Source of truth | Test file | Status | Gate | Risk if broken | +|---|---|---|---|---|---|---|---|---| +| DECISION-001 | GolfHelm SG baselines | Decide whether legacy `BenchmarkLevel` metadata for NCAA/scratch/break levels remains valid while runtime SG options are PGA/LPGA only. | Prevents accidental mixed-scale SG comparisons. | Existing constants disagree in `src/lib/golf/sg-benchmarks.ts` comments and exports | Not yet added | needs decision | advisory | SG UI or comparisons may imply unsupported baselines. | +| DECISION-002 | CoachHelm metric registry | Decide whether v2 legacy metric aliases are permanently allowed or must migrate to canonical v3 metric IDs. | Determines how strict direction/evidence contracts can be. | `METRIC_DIRECTION_ALIASES`, `memory/features/coachhelm-ai.md` | Not yet added | needs decision | advisory | Direction bugs hide behind alias drift. | +| DECISION-003 | Issue automation | Decide whether advisory failures should create real GitHub issues automatically or only generate issue drafts. | Avoids spam while still turning radar signals into work. | Explicit rule in prompt | Not yet added | needs decision | advisory | Radar signals may not become actionable follow-up. | +| DECISION-004 | GolfHelm putting | Decide whether `puttsPerRound` should be `null` or `0` when holes exist but putt values are all null. | Determines whether missing putting data can look like zero putting volume. | Existing aggregate behavior in `calculateStatsFromShots`; explicit no-fake-empty rule in prompt | Not yet added | needs decision | advisory | Missing putting data may appear healthy or complete. | diff --git a/docs/operations/GATE_MATRIX.md b/docs/operations/GATE_MATRIX.md index 46df71ed8..12c8c395d 100644 --- a/docs/operations/GATE_MATRIX.md +++ b/docs/operations/GATE_MATRIX.md @@ -25,6 +25,7 @@ This file is the current map of hard merge blockers versus advisory checks. | Check | Why Advisory | |---|---| | Playwright E2E Advisory | full suite is still stabilizing | +| Business Contracts | advisory product-truth radar for GolfHelm stats, CoachHelm evidence, access boundaries, and product trust states | | Course picker screenshots | visual evidence artifact, not a merge blocker | | Lighthouse | performance and accessibility visibility | | Knip | dead-code discovery | diff --git a/docs/operations/GOLF_STATS_COACHHELM_TEST_DISCOVERY.md b/docs/operations/GOLF_STATS_COACHHELM_TEST_DISCOVERY.md new file mode 100644 index 000000000..3fa07aeb9 --- /dev/null +++ b/docs/operations/GOLF_STATS_COACHHELM_TEST_DISCOVERY.md @@ -0,0 +1,83 @@ +# GolfHelm Stats and CoachHelm Test Discovery + +Date: 2026-06-29 + +Branch: `test-hardening/golf-stats-coachhelm-contracts` + +Scope: discovery for an advisory Business Contracts lane. This report maps current sources of truth and testing opportunities. It does not redefine product behavior. + +## GolfHelm Stats Source Map + +| Area | File path | Exports / constants | Pure / testable | Current coverage | Risk | +|---|---|---|---|---|---| +| Round totals, scoring averages, 18-hole normalization | `src/lib/golf/stat-formulas.ts` | `computeScoringAverage`, `computeScoringAverageVsPar`, `computePerRound18`, `computePuttsPerRound` | Pure | Existing unit coverage plus new business contracts | High | +| Hole scoring, score-to-par, scoring by par | `src/lib/utils/golf-stats-calculator-shots.ts` | `calculateHoleStatsFromShots`, `calculateStatsFromShots` | Pure | Existing utility tests plus new contracts | High | +| Putts / 3-putts | `src/lib/utils/golf-stats-calculator-shots.ts` | `calculateHoleStatsFromShots`, `normalizePuttFeet`, `getPuttDistanceBucket` | Pure | Existing putting tests plus new contracts | High | +| GIR | `src/lib/utils/golf-stats-calculator-shots.ts`, `src/lib/golf/stat-formulas.ts` | `isGreenHit`, `computeGirPct`, `calculateStatsFromShots` | Pure | Existing tests plus new contracts | High | +| Fairways | `src/lib/utils/golf-stats-calculator-shots.ts`, `src/lib/golf/stat-formulas.ts` | `computeFairwayPct`, fairway splits in `calculateStatsFromShots` | Pure | Existing stats tests | Medium | +| Scrambling / up-and-downs | `src/lib/utils/golf-stats-calculator-shots.ts`, `src/lib/golf/stat-formulas.ts` | `computeScramblingPct`, `calculateHoleStatsFromShots` | Pure | New contracts cover GIR gating | High | +| Sand saves | `src/lib/utils/golf-stats-calculator-shots.ts`, `src/lib/golf/stat-formulas.ts` | `computeSandSavePct`, `sand_save` handling in hole calculator | Pure | Existing utility tests; matrix tracks next fixture | Medium | +| Penalties | `src/lib/utils/golf-stats-calculator-shots.ts` | `getPenaltyCategory`, penalty aggregation, `calculateStrokesGainedForShot` | Pure | Existing SG property tests; matrix tracks starter | High | +| Driving distance | `src/lib/utils/golf-stats-calculator-shots.ts` | driving distance fields and driver/non-driver splits | Pure | Existing stats tests | Medium | +| Proximity | `src/lib/utils/golf-stats-calculator-shots.ts` | approach/putting proximity fields and bucket helpers | Pure | Existing utility tests; new import bucket contracts | High | +| Strokes gained | `src/lib/utils/golf-stats-calculator-shots.ts`, `src/lib/golf/strokes-gained.ts` | `getExpectedStrokes`, `calculateStrokesGainedForShot`, `formatStrokesGained` | Pure | Existing property tests plus new null-honesty contract | High | +| PGA/LPGA/team baselines | `src/lib/golf/sg-benchmarks.ts`, `src/lib/golf/strokes-gained.ts` | `SG_BASELINE_OPTIONS`, `sgBaselineScale`, `defaultSgBaseline`, `WOMENS_SG_SCALE` | Pure | Existing baseline tests plus new explicitness contract | High | +| Stat cache tables | `src/lib/cache/golf-stats-calculator.ts`, Supabase migrations | `getStatsFromCache`, `refreshStatsCache`, `getTeamPlayerStats` | Server/DB backed | Existing CI/RLS/schema coverage; no new DB fixture in this PR | High | +| Stat rollups | Supabase functions referenced by `stat-formulas.ts` comments | DB functions: `recompute_golf_round_totals`, `update_player_stats_cache`, `refresh_player_standing` | DB-backed | RLS/schema lanes, types drift | High | +| Coach/player stat views | `src/app/golf/actions/stats-data.ts`, `src/app/golf/(dashboard)/dashboard/stats/stats-client.tsx`, `src/app/golf/(dashboard)/dashboard/stats/team/*` | Server actions and UI states | Testable with adapters | Existing action/component tests plus new product-trust static contracts | High | + +## CoachHelm Source Map + +| Area | File path | Exports / constants | Pure / testable | Current coverage | Risk | +|---|---|---|---|---|---| +| Metric direction | `src/lib/coachhelm/v3/metrics/registry.ts` | `METRIC_DIRECTION`, `getMetricDirection`, `improvementSign` | Pure | Existing registry tests plus new parity contracts | High | +| Lower-is-better vs higher-is-better rendering | `src/lib/coachhelm/v3/standing/metric-config.ts` | `METRIC_RENDER_CONFIG`, `getMetricRenderConfig` | Pure | New parity contract against registry | High | +| Strengths / weaknesses | `src/lib/golf/strokes-gained.ts`, `src/lib/coachhelm/v2/mining/*` | `generateStatisticalStrengthsWeaknesses`, mining generators | Mostly pure | Existing mining tests | Medium | +| Evidence payloads | `src/lib/coachhelm/v2/insights/types.ts`, `src/lib/coachhelm/shared/evidence-types.ts`, `src/app/golf/actions/insight-delivery.ts` | `InsightEvidence`, `Diagnosis`, `calcConfidence`, row mappers | Mixed | Existing insight-delivery tests plus new evidence contracts | High | +| Prediction / confidence | `src/lib/coachhelm/v2/prediction/*`, `src/lib/coachhelm/shared/evidence-types.ts` | prediction engines, `calcConfidence` | Mostly pure | Existing prediction quality tests plus new sample-honesty contract | High | +| Sample-size checks | `src/lib/coachhelm/v3/ranking/score.ts`, `src/lib/coachhelm/shared/evidence-types.ts` | `sampleDamping`, `MIN_CALIBRATED_SAMPLES` | Pure / server helper | Existing ranking tests plus new business contract | High | +| Composite scores | `src/lib/coachhelm/v3/ranking/score.ts`, `src/lib/coachhelm/v3/composite/*` | `scoreInsight`, `rankInsights`, `COMPOSITE_RULES` | Pure plus loaders | Existing ranking/composite tests plus new bounds contract | High | +| Insight generation | `src/lib/coachhelm/v3/generators/*`, `src/lib/coachhelm/v2/mining/*` | generator classes and mining functions | Mixed | Existing generator tests | High | +| LLM prompt/composer | `src/lib/coachhelm/v3/llm/compose.ts`, `src/lib/coachhelm/v3/llm/round-review.ts`, `src/lib/coachhelm/v3/llm/hero-narrative.ts` | `compose`, task-specific prompt builders | Server/LLM boundary; partially testable with mocks | Existing compose tests; new citation/budget contracts | High | +| Budget / usage ceilings | `src/lib/coachhelm/v3/llm/budget.ts`, `src/lib/coachhelm/v3/llm/types.ts` | `checkBudget`, `recordSpend`, `FALLBACK_PRIORITY`, `estimateCostUsd` | Mixed | New pure metadata contract; DB behavior still needs fixture | High | +| NLG / output formatting | `src/lib/coachhelm/v2/nlg/insight-composer.ts`, `src/lib/coachhelm/v3/llm/citations.ts`, `src/lib/coachhelm/v3/themes/assemble.ts` | `InsightComposer`, `verifyCitations`, `sanitizeProse` | Pure | Existing theme tests plus new citation contract | High | + +## Product Trust Source Map + +| Flow | File path | Current behavior | Risk to catch | Risk | +|---|---|---|---|---| +| DB/API error to stats UI state | `src/app/golf/(dashboard)/dashboard/stats/stats-client.tsx` | `statsError` renders explicit error card before empty state | Failed load showing `0 stats` | High | +| Empty data to stats empty state | `src/app/golf/(dashboard)/dashboard/stats/stats-client.tsx` | Empty state appears after loading and error checks | Empty success vs failed load collapse | High | +| Failed detailed stat load | `src/app/golf/(dashboard)/dashboard/stats/stats-client.tsx` | `setStatsError('Failed to load stats. Please try again.')` | Failed stat load showing dashboard as healthy | High | +| Coach insight query failure | `src/app/golf/actions/insight-delivery.ts` | `getInsightsForCoachWithMeta` returns `{ ok:false }`; legacy shim collapses to `[]` for old callers | Failed AI insight showing fake healthy answer | High | +| Failed CoachHelm route load | `src/app/golf/(dashboard)/dashboard/coachhelm/error.tsx`, `src/app/golf/(dashboard)/dashboard/analytics/coachhelm/error.tsx` | Route error boundaries show failure copy | AI unavailable showing stale/generated-looking advice | High | +| Failed save to toast/state | `src/app/golf/actions/golf.ts`, round draft clients | Existing actions return structured errors in several paths | Failed save showing "Saved" | High | +| Partial import/data normalization | `src/lib/coachhelm/v3/ingest/providers/*`, `src/lib/utils/golf-stats-calculator-shots.ts` | Provider adapters plus normalization helpers | Partial data showing complete | Medium | + +## Testing Opportunity List + +### Safe Tests Added Now + +- `src/contracts/golf/stats.contract.test.ts`: formula null-honesty, 18-hole normalization, score-to-par aggregation, 3-putts, scrambling GIR gating, partial round scoring, SG baseline explicitness. +- `src/contracts/coachhelm/truth.contract.test.ts`: metric direction parity, improvement sign, citation verification, sample-size honesty, score bounds, LLM budget metadata. +- `src/contracts/product-trust/states.contract.test.ts`: static radar for stats failure states, CoachHelm meta error results, route error boundaries. +- `src/contracts/access/golf-access.contract.test.ts`: static radar for insight scope, `golf_team_coach_staff`, and cross-tenant RLS coverage. +- `src/contracts/imports/normalization.contract.test.ts`: normalization and bucket stability. + +### Tests Needing Tiny Adapters + +- Extract pure save result mappers from round save clients so TRUST-002 can test failed save cannot show "Saved" without rendering a whole page. +- Export or centralize import result summary builders so TRUST-003 can assert failed rows are surfaced. +- Add a small factory for CoachHelm evidence rows so all insight mappers can be tested against the same evidence contract. + +### Tests Needing Product Decision + +- Whether legacy benchmark metadata in `src/lib/golf/sg-benchmarks.ts` should remain while `SG_BASELINE_OPTIONS` exposes only PGA/LPGA runtime keys. +- Whether every CoachHelm generator must use the v3 metric registry, or whether v2 legacy metric aliases remain valid indefinitely. +- Whether the advisory business lane should create real GitHub issues automatically or maintain issue drafts in-repo until labels/permissions are finalized. + +### Tests Too Risky For This PR + +- Full DB recomputation fixture proving `golf_player_stats_cache` exactly matches raw shot facts across a seeded player/team/time period. +- End-to-end UI tests that force Supabase/API failures across every stats and CoachHelm route. +- Promptfoo/LLM semantic evals for every CoachHelm output class. diff --git a/package.json b/package.json index d5bd8be67..ed4c5702f 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,8 @@ "test:all": "vitest run", "test:integration": "vitest run --project integration", "test:rls": "vitest run --project rls", + "test:business": "vitest run --project business", + "verify:business": "npm run test:business", "test:ui": "vitest --ui", "test:coverage": "vitest run --coverage", "test:e2e": "playwright test", diff --git a/src/contracts/access/golf-access.contract.test.ts b/src/contracts/access/golf-access.contract.test.ts new file mode 100644 index 000000000..e6c2a6d35 --- /dev/null +++ b/src/contracts/access/golf-access.contract.test.ts @@ -0,0 +1,35 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const repo = process.cwd(); +const read = (path: string) => readFileSync(join(repo, path), 'utf8'); + +describe('Golf access and tenant-boundary contracts', () => { + it('CH-AI-010 preserves CoachHelm insight reads through player/team scope and RLS', () => { + const insightDelivery = read('src/app/golf/actions/insight-delivery.ts'); + + expect(insightDelivery).toContain('verifyPlayerAccess'); + expect(insightDelivery).toContain('.eq(\'player_id\', playerId)'); + expect(insightDelivery).toContain('RLS restricts reads to teams the coach staffs'); + expect(insightDelivery).toContain('recordInsightExposure'); + }); + + it('CH-AI-010 preserves coach ownership through golf_team_coach_staff in core CoachHelm services', () => { + const budget = read('src/lib/coachhelm/v3/llm/budget.ts'); + const ranking = read('src/lib/coachhelm/v3/ranking/score.ts'); + const coachhelmData = read('src/app/golf/actions/coachhelm-data.ts'); + + expect(budget).toContain(".from('golf_team_coach_staff')"); + expect(ranking).toContain(".from('golf_team_coach_staff')"); + expect(coachhelmData).toContain('golf_team_coach_staff'); + }); + + it('CH-AI-010 has behavioral RLS coverage for cross-tenant insight isolation', () => { + const rls = read('supabase/tests/rls/golf_coach_insights_cross_tenant_select.sql'); + + expect(rls).toContain('coach B CANNOT read coach A cross-tenant insight'); + expect(rls).toContain('coach A CANNOT read coach B cross-tenant insight'); + expect(rls).toContain('golf_team_coach_staff'); + }); +}); diff --git a/src/contracts/coachhelm/truth.contract.test.ts b/src/contracts/coachhelm/truth.contract.test.ts new file mode 100644 index 000000000..ed948b292 --- /dev/null +++ b/src/contracts/coachhelm/truth.contract.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest'; + +import { + getMetricDirection, + improvementSign, + METRIC_DIRECTION, + METRIC_IDS, +} from '@/lib/coachhelm/v3/metrics/registry'; +import { METRIC_RENDER_CONFIG } from '@/lib/coachhelm/v3/standing/metric-config'; +import { + cappedStrokesImpact, + sampleDamping, + scoreInsight, + STROKES_IMPACT_CEILING, +} from '@/lib/coachhelm/v3/ranking/score'; +import { verifyCitations } from '@/lib/coachhelm/v3/llm/citations'; +import { estimateCostUsd, FALLBACK_PRIORITY, MODEL_FOR_TASK } from '@/lib/coachhelm/v3/llm/types'; +import { calcConfidence } from '@/lib/coachhelm/shared/evidence-types'; + +describe('CoachHelm metric direction contracts', () => { + it('CH-AI-001 and CH-AI-002 keep metric direction complete and mirrored in render config', () => { + expect(Object.keys(METRIC_DIRECTION).sort()).toEqual([...METRIC_IDS].sort()); + expect(Object.keys(METRIC_RENDER_CONFIG).sort()).toEqual([...METRIC_IDS].sort()); + + for (const metric of METRIC_IDS) { + expect(METRIC_RENDER_CONFIG[metric].direction).toBe(METRIC_DIRECTION[metric]); + } + }); + + it('CH-AI-003 gives lower-is-better metrics a negative improvement sign and higher-is-better metrics a positive sign', () => { + expect(getMetricDirection('penalty_rate_per_round')).toBe('lower_better'); + expect(improvementSign('penalty_rate_per_round')).toBe(-1); + expect(getMetricDirection('approach_proximity_125_175ft')).toBe('lower_better'); + expect(improvementSign('approach_proximity_125_175ft')).toBe(-1); + expect(getMetricDirection('gir_pct')).toBe('higher_better'); + expect(improvementSign('gir_pct')).toBe(1); + expect(getMetricDirection('sg_total')).toBe('higher_better'); + expect(improvementSign('sg_total')).toBe(1); + }); +}); + +describe('CoachHelm evidence and confidence contracts', () => { + it('CH-AI-004 rejects unsupported numeric claims in LLM/NLG output', () => { + expect( + verifyCitations('Approach proximity improved to 28 ft with a 62% GIR rate.', [ + { field: 'approach_proximity', value: '28 ft' }, + { field: 'gir_pct', value: '62%' }, + ]), + ).toEqual({ verified: true, unmatched_tokens: [] }); + + expect( + verifyCitations('Approach proximity improved to 28 ft with a 74% GIR rate.', [ + { field: 'approach_proximity', value: '28 ft' }, + { field: 'gir_pct', value: '62%' }, + ]), + ).toEqual({ verified: false, unmatched_tokens: ['74%'] }); + }); + + it('CH-AI-005 surfaces thin samples honestly when recency and variance are placeholders', () => { + expect( + calcConfidence({ + confidence_factors: { + sample_adequacy: 0.2, + recency: 1, + variance: 1, + factors_measured: false, + }, + }), + ).toBe(0.2); + + expect(sampleDamping(0)).toBe(0.25); + expect(sampleDamping(3)).toBeLessThan(1); + expect(sampleDamping(undefined)).toBe(1); + }); + + it('CH-AI-006 keeps ranking scores finite, non-negative, and magnitude-capped', () => { + expect(cappedStrokesImpact(Number.NaN)).toBe(0); + expect(cappedStrokesImpact(42)).toBe(STROKES_IMPACT_CEILING); + + const score = scoreInsight( + { + insight_type: 'putting', + strokes_impact: 42, + confidence: 1.5, + metric: 'sg_putting', + priority: 'high', + sample_n: 12, + }, + { putting: 1.25 }, + [], + ); + + expect(Number.isFinite(score)).toBe(true); + expect(score).toBeGreaterThanOrEqual(0); + expect(score).toBeLessThanOrEqual(STROKES_IMPACT_CEILING * 1.25 * 1.5); + }); + + it('CH-AI-009 keeps model budget cost and fallback priority explicit per task', () => { + expect(Object.keys(MODEL_FOR_TASK).sort()).toEqual(Object.keys(FALLBACK_PRIORITY).sort()); + expect(FALLBACK_PRIORITY.round_review).toBeLessThan(FALLBACK_PRIORITY.coach_chat); + expect(FALLBACK_PRIORITY.coach_chat).toBeLessThan(FALLBACK_PRIORITY.hero_narrative); + expect(estimateCostUsd(MODEL_FOR_TASK.round_review, 1_000_000, 1_000_000)).toBeGreaterThan(0); + expect(estimateCostUsd('unknown-model', 1_000_000, 1_000_000)).toBe(0); + }); +}); diff --git a/src/contracts/golf/stats.contract.test.ts b/src/contracts/golf/stats.contract.test.ts new file mode 100644 index 000000000..f6b299d25 --- /dev/null +++ b/src/contracts/golf/stats.contract.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from 'vitest'; + +import { + computeFairwayPct, + computeGirPct, + computePerRound18, + computePuttsPerRound, + computeSandSavePct, + computeScoringAverage, + computeScoringAverageVsPar, + computeScramblingPct, + pct, +} from '@/lib/golf/stat-formulas'; +import { + calculateHoleStatsFromShots, + calculateStatsFromShots, + calculateStrokesGainedForShot, + normalizePuttFeet, + type HoleInfo, + type RawShot, + type RoundInfo, +} from '@/lib/utils/golf-stats-calculator-shots'; +import { + defaultSgBaseline, + effectiveSgBaseline, + SG_BASELINE_OPTIONS, + sgBaselineScale, + WOMENS_SG_SCALE, +} from '@/lib/golf/sg-benchmarks'; + +const baseShot = (overrides: Partial): RawShot => ({ + id: overrides.id ?? `shot-${overrides.hole_number ?? 1}-${overrides.shot_number ?? 1}`, + round_id: overrides.round_id ?? 'round-1', + hole_number: overrides.hole_number ?? 1, + shot_number: overrides.shot_number ?? 1, + shot_type: overrides.shot_type ?? 'approach', + club_type: overrides.club_type ?? null, + lie_before: 'lie_before' in overrides ? overrides.lie_before! : 'fairway', + lie_after: overrides.lie_after, + distance_to_hole_before: 'distance_to_hole_before' in overrides ? overrides.distance_to_hole_before! : 100, + distance_unit_before: overrides.distance_unit_before ?? 'yards', + result: overrides.result ?? 'green', + distance_to_hole_after: 'distance_to_hole_after' in overrides ? overrides.distance_to_hole_after! : 20, + distance_unit_after: overrides.distance_unit_after ?? 'feet', + shot_distance: overrides.shot_distance, + miss_direction: overrides.miss_direction ?? null, + putt_break: overrides.putt_break ?? null, + putt_distance_feet: overrides.putt_distance_feet, + putt_slope: overrides.putt_slope ?? null, + putt_made: overrides.putt_made, + is_penalty: overrides.is_penalty, + penalty_type: overrides.penalty_type, +}); + +describe('GolfHelm stat formula contracts', () => { + it('GH-STATS-006 keeps missing denominators null instead of fabricating zero performance', () => { + expect(pct(0, 0)).toBeNull(); + expect(computeFairwayPct(0, 0)).toBeNull(); + expect(computeGirPct(0, 0)).toBeNull(); + expect(computeScramblingPct(0, 0)).toBeNull(); + expect(computeSandSavePct(0, 0)).toBeNull(); + expect(computePuttsPerRound(0, 0)).toBeNull(); + expect(computePerRound18(0, 0)).toBeNull(); + expect(computeScoringAverage(0, 0)).toBeNull(); + expect(computeScoringAverageVsPar(0, 0)).toBeNull(); + }); + + it('normalizes per-round stats to 18 holes and keeps scoring averages round-based', () => { + expect(computePuttsPerRound(15, 9)).toBe(30); + expect(computePerRound18(2, 9)).toBe(4); + expect(computeScoringAverage(153, 2)).toBe(76.5); + expect(computeScoringAverageVsPar(5, 2)).toBe(2.5); + }); +}); + +describe('GolfHelm shot-derived hole contracts', () => { + it('GH-STATS-003 counts a 3-putt only when a hole has 3 or more putts', () => { + const hole = calculateHoleStatsFromShots( + [ + baseShot({ shot_number: 1, shot_type: 'tee', lie_before: 'tee', result: 'green' }), + baseShot({ shot_number: 2, shot_type: 'putt', lie_before: 'green', result: 'green', distance_to_hole_before: 30, distance_to_hole_after: 4 }), + baseShot({ shot_number: 3, shot_type: 'putt', lie_before: 'green', result: 'green', distance_to_hole_before: 4, distance_to_hole_after: 1 }), + baseShot({ shot_number: 4, shot_type: 'putt', lie_before: 'green', result: 'hole', distance_to_hole_before: 1, distance_to_hole_after: 0 }), + ], + { hole_number: 1, par: 3, score: 4, putts: null, fairway_hit: null, gir: true }, + ); + + expect(hole.putts).toBe(3); + expect(hole.threePutts).toBe(true); + }); + + it('GH-STATS-004 and GH-STATS-005 only count scrambling on missed-GIR holes', () => { + const missedGirPar = calculateHoleStatsFromShots( + [ + baseShot({ shot_number: 1, shot_type: 'tee', lie_before: 'tee', result: 'rough' }), + baseShot({ shot_number: 2, shot_type: 'around_green', lie_before: 'rough', result: 'green', distance_to_hole_before: 20 }), + baseShot({ shot_number: 3, shot_type: 'putt', lie_before: 'green', result: 'hole', distance_to_hole_before: 4 }), + ], + { hole_number: 2, par: 3, score: 3, putts: null, fairway_hit: null, gir: false }, + ); + const girPar = calculateHoleStatsFromShots( + [ + baseShot({ shot_number: 1, shot_type: 'tee', lie_before: 'tee', result: 'green' }), + baseShot({ shot_number: 2, shot_type: 'putt', lie_before: 'green', result: 'green', distance_to_hole_before: 30, distance_to_hole_after: 2 }), + baseShot({ shot_number: 3, shot_type: 'putt', lie_before: 'green', result: 'hole', distance_to_hole_before: 2 }), + ], + { hole_number: 3, par: 3, score: 3, putts: null, fairway_hit: null, gir: true }, + ); + + expect(missedGirPar.scrambleAttempt).toBe(true); + expect(missedGirPar.scrambleMade).toBe(true); + expect(girPar.scrambleAttempt).toBe(false); + expect(girPar.scrambleMade).toBe(false); + }); + + it('GH-STATS-006 preserves null score/putt facts for shotless holes in aggregates', () => { + const stats = calculateStatsFromShots( + [], + [{ round_id: 'round-1', hole_number: 4, par: 4, yardage: 400, score: null, putts: null, fairway_hit: null, gir: null }], + [{ id: 'round-1', round_date: '2026-06-01', course_name: null, round_type: 'practice', holes_played: 1 }], + ); + + expect(stats.scoringAverage).toBeNull(); + expect(stats.avgScoreToPar).toBeNull(); + expect(stats.scrambleAttempts).toBe(0); + expect(stats.threePuttsTotal).toBe(0); + }); +}); + +describe('GolfHelm round aggregate contracts', () => { + it('GH-STATS-001 and GH-STATS-002 aggregate gross score and score-to-par from complete hole facts', () => { + const round: RoundInfo = { + id: 'round-1', + round_date: '2026-06-01', + course_name: 'Contract Course', + round_type: 'practice', + course_par: 72, + holes_played: 18, + }; + const holes: HoleInfo[] = Array.from({ length: 18 }, (_, index) => ({ + round_id: 'round-1', + hole_number: index + 1, + par: 4, + yardage: 400, + score: 5, + putts: index === 0 ? 3 : 2, + fairway_hit: index % 2 === 0, + gir: false, + })); + + const stats = calculateStatsFromShots([], holes, [round]); + + expect(stats.scoringByPar.par4.avgToPar).toBe(1); + expect(stats.scoringAverage18).toBe(90); + expect(stats.avgScoreToPar).toBe(18); + expect(stats.threePuttsTotal).toBe(1); + }); + + it('GH-STATS-007 does not let partial hole data masquerade as a full scoring average', () => { + const stats = calculateStatsFromShots( + [], + [ + { round_id: 'round-1', hole_number: 1, par: 4, yardage: 390, score: 4, putts: 2, gir: true }, + { round_id: 'round-1', hole_number: 2, par: 4, yardage: 410, score: null, putts: null, gir: null }, + ], + [{ id: 'round-1', round_date: '2026-06-01', course_name: null, round_type: 'practice', holes_played: 2 }], + ); + + expect(stats.scoringAverage).toBeNull(); + expect(stats.avgScoreToPar).toBeNull(); + expect(stats.totalPars).toBe(1); + }); +}); + +describe('GolfHelm strokes-gained baseline contracts', () => { + it('GH-STATS-008 and GH-STATS-009 keep PGA/LPGA baseline selection explicit', () => { + expect(SG_BASELINE_OPTIONS.map((option) => option.key)).toEqual(['pga_tour', 'womens']); + expect(defaultSgBaseline('mens')).toBe('pga_tour'); + expect(defaultSgBaseline('womens')).toBe('womens'); + expect(effectiveSgBaseline('womens', 'mens')).toBe('womens'); + expect(sgBaselineScale('pga_tour')).toBe(1); + expect(sgBaselineScale('womens')).toBe(WOMENS_SG_SCALE); + }); + + it('GH-STATS-008 returns null for incomplete strokes-gained facts instead of zeroing them', () => { + expect(calculateStrokesGainedForShot(baseShot({ lie_before: null }))).toBeNull(); + expect(calculateStrokesGainedForShot(baseShot({ distance_to_hole_before: null }))).toBeNull(); + expect(normalizePuttFeet(390)).toBe(120); + }); +}); diff --git a/src/contracts/imports/normalization.contract.test.ts b/src/contracts/imports/normalization.contract.test.ts new file mode 100644 index 000000000..277e2d9e0 --- /dev/null +++ b/src/contracts/imports/normalization.contract.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; + +import { + getApproachDistanceBucket, + getPuttDistanceBucket, + isGreenHit, + normalizePuttFeet, + normalizeRoundType, + normalizeShotType, + normalizeToFeet, + normalizeToYards, + safeAverage, + safePercent, +} from '@/lib/utils/golf-stats-calculator-shots'; + +describe('Golf import and normalization contracts', () => { + it('TRUST-003 keeps unit normalization explicit and null-honest', () => { + expect(normalizeToYards(30, 'feet')).toBe(10); + expect(normalizeToYards(null, 'feet')).toBe(0); + expect(normalizeToFeet(10, 'yards')).toBe(30); + expect(normalizePuttFeet(390)).toBe(120); + expect(safePercent(0, 0)).toBeNull(); + expect(safeAverage(0, 0)).toBeNull(); + }); + + it('normalizes provider/import vocabulary before downstream stat contracts read it', () => { + expect(normalizeShotType('putt')).toBe('putting'); + expect(normalizeShotType('drive')).toBe('tee'); + expect(normalizeShotType('chip')).toBe('around_green'); + expect(normalizeShotType('iron')).toBe('approach'); + expect(normalizeRoundType('qualifying')).toBe('qualifier'); + expect(isGreenHit('gir')).toBe(true); + expect(isGreenHit('hole')).toBe(true); + }); + + it('keeps distance buckets stable for product/UI rollups', () => { + expect(getPuttDistanceBucket(3)).toBe('0_3'); + expect(getPuttDistanceBucket(35.1)).toBe('35_plus'); + expect(getApproachDistanceBucket(49)).toBe(''); + expect(getApproachDistanceBucket(50)).toBe('30_75'); + expect(getApproachDistanceBucket(226)).toBe('225_plus'); + }); +}); diff --git a/src/contracts/product-trust/states.contract.test.ts b/src/contracts/product-trust/states.contract.test.ts new file mode 100644 index 000000000..25e762a1d --- /dev/null +++ b/src/contracts/product-trust/states.contract.test.ts @@ -0,0 +1,40 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const repo = process.cwd(); +const read = (path: string) => readFileSync(join(repo, path), 'utf8'); + +describe('Product trust state contracts', () => { + it('TRUST-001 and TRUST-004 keep failed stat loads separate from healthy empty states', () => { + const source = read('src/app/golf/(dashboard)/dashboard/stats/stats-client.tsx'); + const errorState = source.indexOf('statsError ?'); + const emptyState = source.indexOf('Empty state when no stats available'); + + expect(source).toContain("setStatsError('Failed to load stats. Please try again.')"); + expect(source).toContain('Something Went Wrong'); + expect(source).toContain('Try Again'); + expect(errorState).toBeGreaterThan(-1); + expect(emptyState).toBeGreaterThan(-1); + expect(errorState).toBeLessThan(emptyState); + }); + + it('CH-AI-008 keeps CoachHelm coach-feed load failures distinguishable from empty insight feeds', () => { + const source = read('src/app/golf/actions/insight-delivery.ts'); + + expect(source).toContain('export type CoachInsightsResult'); + expect(source).toContain('| { ok: false; error: string }'); + expect(source).toContain('Could not load signals. Try refreshing.'); + expect(source).toContain('empty array here would render "all clear" over a real query failure'); + }); + + it('TRUST-005 keeps CoachHelm route failures in explicit error boundaries', () => { + const coachhelmError = read('src/app/golf/(dashboard)/dashboard/coachhelm/error.tsx'); + const analyticsError = read('src/app/golf/(dashboard)/dashboard/analytics/coachhelm/error.tsx'); + + expect(coachhelmError).toContain('Failed to load CoachHelm'); + expect(coachhelmError).toContain('Please try again'); + expect(analyticsError).toContain('Failed to load'); + expect(analyticsError).toContain('Please try again'); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 4c20172fd..d72385503 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -12,12 +12,14 @@ import path from 'path'; * unit — default for `npm test`. Excludes the slow lanes. * integration — *.integration.test.{ts,tsx}, longer timeout * rls — *.rls.test.{ts,tsx}, longer timeout + * business — *.contract.test.{ts,tsx}, advisory product-truth contracts * * Scripts: * npm test → unit only (fast) * npm run test:all → every project (CI) * npm run test:integration → just integration * npm run test:rls → just RLS + * npm run test:business → just business contracts */ const sharedTestConfig = { environment: 'jsdom' as const, @@ -75,6 +77,7 @@ export default defineConfig({ 'helm-intelligence', 'src/**/*.integration.test.{ts,tsx}', 'src/**/*.rls.test.{ts,tsx}', + 'src/**/*.contract.test.{ts,tsx}', ], }, }, @@ -98,6 +101,22 @@ export default defineConfig({ testTimeout: 30_000, }, }, + { + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + 'server-only': path.resolve(__dirname, './src/test/stubs/server-only.ts'), + }, + }, + test: { + ...sharedTestConfig, + name: 'business', + include: ['src/**/*.contract.test.{ts,tsx}'], + exclude: ['node_modules', '.next'], + testTimeout: 30_000, + }, + }, ], }, });