From cdd3f40ff865139a57dffd90de92ab865a6dfd15 Mon Sep 17 00:00:00 2001 From: guigerdts Date: Mon, 24 Aug 2026 22:18:08 +0000 Subject: [PATCH 1/2] chore(specs): promote winning-numbers-pipeline capabilities to base specs --- openspec/specs/generator-output/spec.md | 77 ++++++++++++++ openspec/specs/mis-numeros-page/spec.md | 110 ++++++++++++++++++++ openspec/specs/numbers-orchestrator/spec.md | 87 ++++++++++++++++ 3 files changed, 274 insertions(+) create mode 100644 openspec/specs/generator-output/spec.md create mode 100644 openspec/specs/mis-numeros-page/spec.md create mode 100644 openspec/specs/numbers-orchestrator/spec.md diff --git a/openspec/specs/generator-output/spec.md b/openspec/specs/generator-output/spec.md new file mode 100644 index 0000000..e8e0646 --- /dev/null +++ b/openspec/specs/generator-output/spec.md @@ -0,0 +1,77 @@ +# Spec — Generator Output (`generator-output`) + +**Change**: `winning-numbers-pipeline` · **Store**: `openspec` · **Date**: 2026-08-23 +**Artifact**: base spec — promoted from change `winning-numbers-pipeline` (archive). +**Sources**: official rules verified in Engram #1870 (`domain/baloto-revancha-official-rules`): 5 distinct numbers 1–43 + Superbalota 1–16; the 0+SB refund tier makes SB mandatory. Seams: `gen_service.py:167` persists `"super_number": None, "score": None`; `sampling.py:77` passes `None` SB into `validate_combination`; `LotteryConfig` protocol already declares `super_number_min`/`super_number_max`. +**Verify**: pytest (`backend/tests/gen/`). + +## Requirements + +### REQ-01: Legality Enforced Pre-Persist With Explicit Codes + +| Field | Value | +|-------|-------| +| **ID** | R1 | +| **RFC** | MUST | + +Every persisted combination SHALL satisfy the official bet shape resolved from `LotteryConfig`: exactly `numbers_to_select` distinct integers within `[min_number, max_number]` (5 distinct in 1–43) plus one Superbalota within `[super_number_min, super_number_max]` (1–16). `validate_combination(numbers, super_number, cfg)` SHALL gate persistence — no combination may be written without passing it WITH a non-null Superbalota. Violations SHALL raise typed errors defined in `services/errors.py`: `GEN_INVALID_NUMBERS` (count, duplicate, range, or ordering violation) and `GEN_INVALID_SUPER_NUMBER` (SB missing or out-of-range); both map to HTTP 422 via the global handler. Existing `GEN_SPACE_EXHAUSTED` semantics (resampling exhaustion, zero combos persisted) are unchanged. + +#### Scenario: all generated combos legal + +- GIVEN a seeded generate request against config 5 / 1–43 / SB 1–16 +- WHEN generation completes +- THEN every persisted row passes `validate_combination(numbers, sb, cfg)` with `sb is not None` + +#### Scenario: illegal SB rejected before persist + +- GIVEN a candidate Superbalota outside 1–16 +- WHEN validation gates the write +- THEN error code `GEN_INVALID_SUPER_NUMBER` raises and zero rows persist + +#### Scenario: duplicate number rejected before persist + +- GIVEN a candidate `[7, 7, 12, 30, 41]` +- WHEN validation gates the write +- THEN error code `GEN_INVALID_NUMBERS` raises and zero rows persist + +### REQ-02: Reproducible SB From Historical Marginals + +| Field | Value | +|-------|-------| +| **ID** | R2 | +| **RFC** | MUST | + +Sampling SHALL emit `(combination, super_balota)` pairs: the Superbalota is drawn per combination from the historical SB-marginal distribution on the SAME isolated RNG stream as the numbers, so one seed reproduces the entire ticket including SB. Because stream consumption changes, `GENERATOR_VERSION` (`generators/version.py`) SHALL be bumped in the same slice so `generation_seed`/`snapshot_fingerprint` outputs differ from every pre-change value — new snapshots MUST NOT alias legacy fingerprints. Legacy rows (`super_number IS NULL`) stay readable; post-change generations contain zero `NULL` Superbalotas. Marginal fallbacks: sparse/incomplete SB marginals SHALL fall back to uniform over 1–16; zero imported draws SHALL fail with `GEN_NO_HISTORY` and persist nothing. + +#### Scenario: SB present, in range, byte-reproducible + +- GIVEN seed S generates a snapshot twice from identical history +- WHEN the outputs are compared +- THEN every combination carries an integer SB in 1–16 and both runs are identical including SB + +#### Scenario: version bump prevents fingerprint aliasing + +- GIVEN `GENERATOR_VERSION` incremented +- WHEN fingerprints are computed for a new generation +- THEN none equals a pre-change snapshot fingerprint, and legacy rows still deserialize + +#### Scenario: no history fails explicitly + +- GIVEN zero imported draws +- WHEN generation runs +- THEN `GEN_NO_HISTORY` raises and no snapshot persists + +### REQ-03: Non-Null Selection-Weighted Score + +| Field | Value | +|-------|-------| +| **ID** | R3 | +| **RFC** | MUST | + +Every persisted combination SHALL carry a non-null, finite selection-weighted score computed from its entry-selection weight and the probability distribution, replacing the `"score": None` placeholder at `gen_service.py:167`. Generator responses (`/gen/generate`, `/gen/combinations`) SHALL expose `super_number` and `score` for every combination. + +#### Scenario: scores always populated and exposed + +- GIVEN any successful seeded generation +- WHEN persisted rows and the API payload are inspected +- THEN every row has a non-null finite `score` and responses echo `super_number` and `score` diff --git a/openspec/specs/mis-numeros-page/spec.md b/openspec/specs/mis-numeros-page/spec.md new file mode 100644 index 0000000..d8e12c1 --- /dev/null +++ b/openspec/specs/mis-numeros-page/spec.md @@ -0,0 +1,110 @@ +# Spec — Mis Números Page (`mis-numeros-page`) + +**Change**: `winning-numbers-pipeline` · **Store**: `openspec` · **Date**: 2026-08-23 +**Artifact**: base spec — promoted from change `winning-numbers-pipeline` (archive). +**Binding product decisions**: Revancha is ALWAYS bundled — one ticket valid for BOTH draws (Baloto+Revancha), no toggle. Default combination count: 5. +**Prize-tier source**: Engram #1870 — eight official tiers. +**Verify**: vitest + MSW (Models/DL page patterns). + +## Requirements + +### REQ-01: Single End-To-End Action + +| Field | Value | +|-------|-------| +| **ID** | R1 | +| **RFC** | MUST | + +The page SHALL offer exactly ONE primary CTA that invokes the numbers-orchestrator end-to-end — no per-stage manual buttons. Because the sync call may run minutes-scale, the busy state SHALL hold for the whole request: CTA disabled with `aria-busy` and progress wording. A failed request SHALL render `ErrorState` whose retry re-issues the orchestrator call (DL page precedent). + +#### Scenario: one CTA drives the whole chain + +- GIVEN an MSW handler for the orchestrator POST +- WHEN the CTA is clicked +- THEN exactly one orchestrator request fires and no other stage endpoints are called + +#### Scenario: busy held through slow call, retry on failure + +- GIVEN the POST handler delays then returns 500 +- WHEN the request resolves +- THEN during flight the CTA is disabled with `aria-busy`, and afterwards ErrorState offers Retry that re-posts + +### REQ-02: Chain Progress Rendering + +| Field | Value | +|-------|-------| +| **ID** | R2 | +| **RFC** | MUST | + +The page SHALL render the response's per-stage report: all eight canonical stages in order with status labels; a `failed` stage SHALL show its error visibly. While the request is in flight the page SHALL show an indeterminate busy indicator — sync-with-stages delivers statuses only at completion. + +#### Scenario: statuses render after the response + +- GIVEN a 200 response carrying eight stage entries +- WHEN it lands +- THEN all eight render in canonical order with their statuses + +#### Scenario: failed stage surfaces without crashing + +- GIVEN a response whose `rank` stage failed +- WHEN it lands +- THEN `rank` shows its failed status and combinations are absent, and the page stays interactive + +### REQ-03: Revancha Always Bundled + +| Field | Value | +|-------|-------| +| **ID** | R3 | +| **RFC** | MUST | + +Every presented ticket SHALL be labeled as valid for BOTH draws using the owner-approved phrase “un boleto, dos sorteos (Baloto+Revancha)”. The page SHALL NOT offer a Baloto/Revancha toggle or any separate Revancha generation control. This is presentation-only: data-layer unification of Baloto(id 1)/Revancha(id 3) remains out of scope. + +#### Scenario: dual-draw presentation, no toggle + +- GIVEN generated combinations render +- WHEN the ticket area is inspected +- THEN the both-draws phrase labels the tickets and no toggle control exists in the DOM + +### REQ-04: Default Combination Count + +| Field | Value | +|-------|-------| +| **ID** | R4 | +| **RFC** | MUST | + +The page SHALL request five combinations by default; the user MAY adjust the count before running. + +#### Scenario: default payload carries five + +- GIVEN the user does not modify the count control +- WHEN the CTA fires +- THEN the orchestrator request body contains count 5 + +### REQ-05: Eight Official Tiers Table + +| Field | Value | +|-------|-------| +| **ID** | R5 | +| **RFC** | MUST | + +The page SHALL display a static prize-tier reference listing ALL EIGHT official tiers: 5+SB (jackpot), 5, 4+SB, 4, 3+SB, 3, 2+SB (paramutual), and 0+SB (bet refund) — presented as official-rules reference, never as outcome promises. + +#### Scenario: tiers table complete + +- WHEN the page renders +- THEN exactly the eight official tiers appear with their match descriptions + +### REQ-06: Randomness Disclaimer + +| Field | Value | +|-------|-------| +| **ID** | R6 | +| **RFC** | MUST | + +A visible disclaimer SHALL state that candidates are statistically informed over historical draws, that draws remain random, and that no prediction improvement is promised. It SHALL remain visible on load and after generation. + +#### Scenario: disclaimer persists across states + +- GIVEN idle and post-generation states +- WHEN each renders +- THEN the disclaimer text remains visible diff --git a/openspec/specs/numbers-orchestrator/spec.md b/openspec/specs/numbers-orchestrator/spec.md new file mode 100644 index 0000000..38de221 --- /dev/null +++ b/openspec/specs/numbers-orchestrator/spec.md @@ -0,0 +1,87 @@ +# Spec — Numbers Orchestrator (`numbers-orchestrator`) + +**Change**: `winning-numbers-pipeline` · **Store**: `openspec` · **Date**: 2026-08-23 +**Artifact**: base spec — promoted from change `winning-numbers-pipeline` (archive). +**Binding owner decisions**: orchestrator MAY auto-train ml/dl when missing (minutes-scale latency acceptable); delivery is sync-with-stages — one call returns the full per-stage report; no job/polling infra in this change. +**Seam**: rank depends on a hardcoded backtesting context hash (`meta_service.py:242`) — brittle coupling that blocks chaining; the orchestrator owns correct context derivation. +**Verify**: pytest against the service layer (`backend/tests/pipeline/`). + +## Requirements + +### REQ-01: Canonical Ordered Execution In One Call + +| Field | Value | +|-------|-------| +| **ID** | R1 | +| **RFC** | MUST | + +A single orchestrator entry point SHALL execute the canonical chain `stats → features → ml → dl → bt → rank → select → gen` in that ORDER, enforcing `bt` strictly BEFORE `rank` (rank consumes backtest context). When ml/dl models are missing, the orchestrator SHALL train them automatically. Rank's backtesting context SHALL be derived from the identity of the backtest actually executed in this run (fingerprint/checksum) — never a hardcoded value, retiring the `meta_service.py:242` coupling. Any stage failure SHALL raise `PIPE_STAGE_FAILED` (carrying the failed stage id), defined in `services/errors.py`; remaining stages SHALL NOT run, completed stages' artifacts SHALL persist intact, and NO generator output SHALL be produced for that run. + +#### Scenario: cold chain succeeds + +- GIVEN imported draws and no chain artifacts +- WHEN the orchestrator runs once +- THEN all eight stages complete in canonical order and final combinations are returned + +#### Scenario: bt-before-rank enforced with real context + +- GIVEN instrumented stages +- WHEN the chain executes +- THEN `bt` finishes before `rank` starts and `rank` receives that bt run's fingerprint-derived context (no hardcoded hash) + +#### Scenario: stage failure aborts cleanly + +- GIVEN `rank` will raise +- WHEN the chain reaches `rank` +- THEN `PIPE_STAGE_FAILED` names `rank`, `gen` never runs, and earlier artifacts persist + +### REQ-02: Detect Missing Prerequisites And Repair + +| Field | Value | +|-------|-------| +| **ID** | R2 | +| **RFC** | MUST | + +Before executing each stage, the orchestrator SHALL inspect its prerequisites (active snapshots, model artifacts, input fingerprints): missing or stale items are repaired by running exactly the deficient stages; current-and-valid stages are skipped. New draw coverage that invalidates a stage's fingerprint SHALL invalidate every downstream stage that consumes it. + +#### Scenario: partial chain heals forward + +- GIVEN stats/features snapshots exist and everything downstream is missing +- WHEN the orchestrator runs +- THEN stats/features report skipped and the five remaining stages run to completion + +#### Scenario: fresh draw invalidates downstream only + +- GIVEN a completed chain and one newly imported draw +- WHEN the orchestrator runs again +- THEN stages whose fingerprints depend on draw coverage re-run and unaffected upstream stages skip + +### REQ-03: Per-Stage Status Report + +| Field | Value | +|-------|-------| +| **ID** | R3 | +| **RFC** | MUST | + +Every orchestrator response SHALL include an ordered per-stage report: canonical stage id, status ∈ {`skipped`, `completed`, `failed`}, and artifact references (snapshot id / fingerprint) where produced; a `failed` entry carries its error code. + +#### Scenario: report matches canonical shape + +- GIVEN any finished run +- WHEN the response is inspected +- THEN eight stage entries appear in canonical order with allowed statuses and references + +### REQ-04: Fingerprint Reuse, Zero Side Effects + +| Field | Value | +|-------|-------| +| **ID** | R4 | +| **RFC** | MUST | + +Re-running with unchanged inputs SHALL reuse stored fingerprints end-to-end and return an identical result while performing zero side-effect writes: no store in the chain gains a new snapshot version. + +#### Scenario: double run writes nothing + +- GIVEN two consecutive runs with identical inputs +- WHEN both complete +- THEN payloads are identical and no stage store gained a new version From c82a6aaed25f7e2e80f742849693795538c7c2c8 Mon Sep 17 00:00:00 2001 From: guigerdts Date: Mon, 24 Aug 2026 22:56:47 +0000 Subject: [PATCH 2/2] chore: add prettier config and normalize frontend formatting --- .prettierignore | 8 ++ .prettierrc | 9 ++ frontend/README.md | 28 +++--- frontend/e2e/core-cycle.spec.ts | 23 ++--- frontend/eslint.config.js | 7 +- frontend/playwright.config.ts | 4 +- frontend/src/App.test.tsx | 54 +++++------ frontend/src/charts/AverageChart.tsx | 12 +-- frontend/src/charts/DistributionChart.tsx | 17 +--- frontend/src/charts/FrequencyChart.tsx | 12 +-- frontend/src/charts/GapChart.tsx | 2 +- frontend/src/charts/HeatmapChart.tsx | 35 +++++-- frontend/src/charts/charts.test.tsx | 38 ++------ frontend/src/components/AssistantPanel.tsx | 34 ++----- frontend/src/components/ComingSoon.test.tsx | 8 +- frontend/src/components/DataTable.tsx | 13 +-- frontend/src/components/ErrorState.tsx | 6 +- frontend/src/components/Header.tsx | 2 +- .../src/components/LotterySelector.test.tsx | 48 ++++------ frontend/src/components/LotterySelector.tsx | 7 +- frontend/src/components/NavGroup.tsx | 8 +- frontend/src/components/NavItem.tsx | 18 +--- frontend/src/components/Sidebar.test.tsx | 33 ++----- frontend/src/components/Sidebar.tsx | 17 +--- frontend/src/components/Skeleton.tsx | 5 +- frontend/src/components/shared.test.tsx | 30 +++--- frontend/src/hooks/useApi.ts | 4 +- frontend/src/hooks/useLotteries.ts | 21 +---- frontend/src/layouts/DashboardLayout.tsx | 2 +- frontend/src/main.tsx | 2 +- frontend/src/pages/Backtesting.test.tsx | 10 +- frontend/src/pages/Backtesting.tsx | 50 +++++----- frontend/src/pages/DL.test.tsx | 79 +++++++--------- frontend/src/pages/DL.tsx | 18 ++-- frontend/src/pages/Experiments.test.tsx | 14 +-- frontend/src/pages/Experiments.tsx | 15 +-- frontend/src/pages/Heatmaps.test.tsx | 68 +++++++++++--- frontend/src/pages/Heatmaps.tsx | 93 +++++++++++++++---- frontend/src/pages/History.test.tsx | 28 ++---- frontend/src/pages/History.tsx | 4 +- frontend/src/pages/Home.test.tsx | 30 ++---- frontend/src/pages/Home.tsx | 47 +++++++--- frontend/src/pages/IA.test.tsx | 46 +++++---- frontend/src/pages/IA.tsx | 8 +- frontend/src/pages/MisNumeros.test.tsx | 44 +++------ frontend/src/pages/MisNumeros.tsx | 26 ++---- frontend/src/pages/Models.test.tsx | 23 +++-- frontend/src/pages/Models.tsx | 11 +-- frontend/src/pages/MonteCarlo.test.tsx | 10 +- frontend/src/pages/MonteCarlo.tsx | 15 +-- frontend/src/pages/Networks.test.tsx | 72 +++++++++++--- frontend/src/pages/Networks.tsx | 80 +++++++++++++--- frontend/src/pages/Statistics.test.tsx | 28 +++--- frontend/src/pages/Statistics.tsx | 46 +++++++-- frontend/src/pages/Trends.test.tsx | 40 ++++---- frontend/src/pages/Trends.tsx | 24 +++-- frontend/src/services/api.test.ts | 22 +---- frontend/src/services/api.ts | 11 +-- frontend/src/services/assistant.test.ts | 16 ++-- frontend/src/services/assistant.ts | 38 ++------ frontend/src/services/backtesting.ts | 20 +--- frontend/src/services/dl.ts | 5 +- frontend/src/services/draws.ts | 8 +- frontend/src/services/experiments.ts | 15 +-- frontend/src/services/gen.ts | 8 +- frontend/src/services/graph.ts | 14 +-- frontend/src/services/lotteries.ts | 5 +- frontend/src/services/ml.ts | 15 +-- frontend/src/services/pipeline.ts | 4 +- frontend/src/services/probability.ts | 11 +-- frontend/src/services/statistics.ts | 28 ++---- frontend/src/store/useLotteryStore.test.ts | 8 +- frontend/src/store/useLotteryStore.ts | 4 +- frontend/src/types/assistant.ts | 2 +- frontend/tsconfig.json | 5 +- 75 files changed, 800 insertions(+), 875 deletions(-) create mode 100644 .prettierignore create mode 100644 .prettierrc diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..59d0b00 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,8 @@ +node_modules +dist +build +coverage +.gga +openspec +*.min.js +package-lock.json diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..4023e42 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,9 @@ +{ + "printWidth": 100, + "tabWidth": 2, + "semi": true, + "singleQuote": false, + "trailingComma": "es5", + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/frontend/README.md b/frontend/README.md index e4c79e6..3201011 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -31,20 +31,20 @@ frontend/ ## Páginas -| Ruta | Componente | Descripción | -|------|------------|-------------| -| `/` | Home | Inicio | -| `/historial` | History | Historial de sorteos | -| `/estadisticas` | Statistics | Estadísticas | -| `/heatmaps` | Heatmaps | Mapas de calor | -| `/tendencias` | Trends | Tendencias | -| `/redes` | Networks | Grafos | -| `/monte-carlo` | MonteCarlo | Simulación | -| `/ia` | IA | Asistente | -| `/modelos` | Models | Modelos ML | -| `/experimentos` | Experiments | Experimentos | -| `/backtesting` | Backtesting | Backtesting | -| `/generador` | Generator | Generador | +| Ruta | Componente | Descripción | +| --------------- | ----------- | -------------------- | +| `/` | Home | Inicio | +| `/historial` | History | Historial de sorteos | +| `/estadisticas` | Statistics | Estadísticas | +| `/heatmaps` | Heatmaps | Mapas de calor | +| `/tendencias` | Trends | Tendencias | +| `/redes` | Networks | Grafos | +| `/monte-carlo` | MonteCarlo | Simulación | +| `/ia` | IA | Asistente | +| `/modelos` | Models | Modelos ML | +| `/experimentos` | Experiments | Experimentos | +| `/backtesting` | Backtesting | Backtesting | +| `/generador` | Generator | Generador | ## Tests diff --git a/frontend/e2e/core-cycle.spec.ts b/frontend/e2e/core-cycle.spec.ts index 1400ab5..4d1a739 100644 --- a/frontend/e2e/core-cycle.spec.ts +++ b/frontend/e2e/core-cycle.spec.ts @@ -55,9 +55,8 @@ async function seedDraws(request: APIRequestContext, lotteryCode: string) { const dir = mkdtempSync(join(tmpdir(), "lip-e2e-seed-")); const csvPath = join(dir, "seed.csv"); const header = "draw_number,draw_date,numbers,super_number,jackpot,winners"; - const rows = DRAW_ROWS.map( - ([drawNumber, drawDate, numbers, jackpot, winners]) => - [drawNumber, drawDate, `"${numbers.join(",")}"`, "", jackpot, winners].join(","), + const rows = DRAW_ROWS.map(([drawNumber, drawDate, numbers, jackpot, winners]) => + [drawNumber, drawDate, `"${numbers.join(",")}"`, "", jackpot, winners].join(",") ); writeFileSync(csvPath, [header, ...rows].join("\n") + "\n", "utf8"); try { @@ -94,27 +93,19 @@ test("core cycle: seed → statistics snapshot → dashboard (TEST-005, D3)", as // --- Statistics: generate snapshot → charts render ----------------------- await page.goto("/estadisticas"); await page.getByRole("button", { name: "Generate Snapshot" }).click(); - await expect( - page.getByRole("img", { name: "Frequency distribution per number" }), - ).toBeVisible(); + await expect(page.getByRole("img", { name: "Frequency distribution per number" })).toBeVisible(); await expect(page.locator(".recharts-responsive-container")).toBeVisible(); await expect(page.getByText(/Snapshot #\d+/)).toBeVisible(); // --- Home: dashboard renders draws + frequencies from the snapshot -------- await page.goto("/"); await expect(page.locator("tbody")).toContainText("1 - 6 - 11 - 16 - 21"); - await expect( - page.getByRole("heading", { name: "Frequency summary" }), - ).toBeVisible(); - await expect( - page.getByRole("heading", { name: "Most frequent" }), - ).toBeVisible(); - await expect( - page.getByRole("heading", { name: "Least frequent" }), - ).toBeVisible(); + await expect(page.getByRole("heading", { name: "Frequency summary" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Most frequent" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Least frequent" })).toBeVisible(); // 1 and 6 each appear in two seeded draws → top frequency count is 2×. await expect(page.locator("ol").first()).toContainText("2×"); // Frontend ↔ backend connectivity is proven by the System health block. await expect(page.getByText("API status", { exact: true })).toBeVisible(); await expect(page.locator("dl")).toContainText("ok"); -}); \ No newline at end of file +}); diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 79a552e..1fe5acc 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -19,10 +19,7 @@ export default tseslint.config( }, rules: { ...reactHooks.configs.recommended.rules, - "react-refresh/only-export-components": [ - "warn", - { allowConstantExport: true }, - ], + "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], }, - }, + } ); diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index c97f07b..ecd182d 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -40,7 +40,7 @@ export default defineConfig({ { // Migrate a fresh tmp DB, then serve the API from it on :8000. command: - 'DB=$(mktemp /tmp/lip-e2e-db-XXXXXX.db) && ' + + "DB=$(mktemp /tmp/lip-e2e-db-XXXXXX.db) && " + 'LIP_DATABASE_URL="sqlite:///$DB" .venv/bin/python -m alembic upgrade head && ' + 'LIP_DATABASE_URL="sqlite:///$DB" .venv/bin/uvicorn backend.app.main:create_app --factory --host 127.0.0.1 --port 8000', cwd: BACKEND_DIR, @@ -59,4 +59,4 @@ export default defineConfig({ env: { ...process.env, VITE_API_BASE_URL: "http://localhost:8000/api/v1" }, }, ], -}); \ No newline at end of file +}); diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 30b1b59..44a20b6 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -12,9 +12,7 @@ const env = (data: unknown) => HttpResponse.json({ success: true, data, timestamp: "2026-01-01T00:00:00Z" }); const server = setupServer( - http.get("*/api/v1/lotteries", () => - env([{ id: 1, code: "L1", name: "Loto", country: "ES" }]), - ), + http.get("*/api/v1/lotteries", () => env([{ id: 1, code: "L1", name: "Loto", country: "ES" }])), http.get("*/api/v1/draws", () => env([])), http.get("*/api/v1/statistics/L1/frequencies", () => env({ @@ -27,11 +25,11 @@ const server = setupServer( draw_count: 0, checksum: "x", frequencies: [], - }), + }) ), http.get("*/api/v1/health", () => env({ status: "ok" })), http.get("*/api/v1/version", () => - env({ version: "1.0.0", app: "Lottery Intelligence Platform" }), + env({ version: "1.0.0", app: "Lottery Intelligence Platform" }) ), http.get("*/api/v1/ml/models", () => env({ @@ -43,7 +41,7 @@ const server = setupServer( checksum: "abc", input_fingerprint: "fp1", cut: 1200, - }), + }) ), http.get("*/api/v1/ml/metrics", () => env([])), http.get("*/api/v1/dl/models", () => @@ -57,7 +55,7 @@ const server = setupServer( input_fingerprint: "fp-dl", cut: 305, window: 10, - }), + }) ), http.get("*/api/v1/dl/metrics", () => env([])), http.get("*/api/v1/probability/L1/probabilities", () => @@ -71,10 +69,16 @@ const server = setupServer( draw_count: 100, checksum: "abc", probabilities: [ - { model_id: "baseline", model_version: "v1", subject: "5", draw_number: 101, value: "0.1420" }, + { + model_id: "baseline", + model_version: "v1", + subject: "5", + draw_number: 101, + value: "0.1420", + }, ], - }), - ), + }) + ) ); function renderAt(path: string) { @@ -104,7 +108,7 @@ describe("App router", () => { expect(container.querySelector("[aria-busy='true']")).not.toBeNull(); expect( - await screen.findByRole("heading", { name: /operational summary/i }, ASYNC_TIMEOUT), + await screen.findByRole("heading", { name: /operational summary/i }, ASYNC_TIMEOUT) ).toBeInTheDocument(); }, 20000); @@ -113,14 +117,10 @@ describe("App router", () => { renderAt("/"); expect( - await screen.findByRole("heading", { name: /operational summary/i }, ASYNC_TIMEOUT), - ).toBeInTheDocument(); - expect( - screen.getByRole("navigation", { name: "Main navigation" }), - ).toBeInTheDocument(); - expect( - screen.getByRole("complementary", { name: "Sidebar" }), + await screen.findByRole("heading", { name: /operational summary/i }, ASYNC_TIMEOUT) ).toBeInTheDocument(); + expect(screen.getByRole("navigation", { name: "Main navigation" })).toBeInTheDocument(); + expect(screen.getByRole("complementary", { name: "Sidebar" })).toBeInTheDocument(); }, 20000); it("navigates between routes through the sidebar links", async () => { @@ -131,7 +131,7 @@ describe("App router", () => { fireEvent.click(screen.getByRole("link", { name: "Mis Números" })); expect( - await screen.findByRole("heading", { name: /mis n[uú]meros/i }, ASYNC_TIMEOUT), + await screen.findByRole("heading", { name: /mis n[uú]meros/i }, ASYNC_TIMEOUT) ).toBeInTheDocument(); }); @@ -139,7 +139,7 @@ describe("App router", () => { renderAt("/unknown"); expect( - await screen.findByRole("heading", { name: /page not found/i }, ASYNC_TIMEOUT), + await screen.findByRole("heading", { name: /page not found/i }, ASYNC_TIMEOUT) ).toBeInTheDocument(); }); @@ -148,11 +148,9 @@ describe("App router", () => { renderAt("/ia"); expect( - await screen.findByRole("heading", { name: /ai assistant/i }, ASYNC_TIMEOUT), - ).toBeInTheDocument(); - expect( - screen.getByRole("region", { name: "System status" }), + await screen.findByRole("heading", { name: /ai assistant/i }, ASYNC_TIMEOUT) ).toBeInTheDocument(); + expect(screen.getByRole("region", { name: "System status" })).toBeInTheDocument(); }, 20000); it("deep-links to the Deep Learning page at /dl through its lazy chunk", async () => { @@ -160,11 +158,9 @@ describe("App router", () => { renderAt("/dl"); expect( - await screen.findByRole("heading", { name: /deep learning/i }, ASYNC_TIMEOUT), - ).toBeInTheDocument(); - expect( - screen.getByRole("region", { name: "Deep learning results" }), + await screen.findByRole("heading", { name: /deep learning/i }, ASYNC_TIMEOUT) ).toBeInTheDocument(); + expect(screen.getByRole("region", { name: "Deep learning results" })).toBeInTheDocument(); }, 20000); it("deep-links to the Mis Números page at /numeros through its lazy chunk", async () => { @@ -172,7 +168,7 @@ describe("App router", () => { renderAt("/numeros"); expect( - await screen.findByRole("heading", { name: /mis números/i }, ASYNC_TIMEOUT), + await screen.findByRole("heading", { name: /mis números/i }, ASYNC_TIMEOUT) ).toBeInTheDocument(); }, 20000); }); diff --git a/frontend/src/charts/AverageChart.tsx b/frontend/src/charts/AverageChart.tsx index 19b74a2..2f798ff 100644 --- a/frontend/src/charts/AverageChart.tsx +++ b/frontend/src/charts/AverageChart.tsx @@ -1,12 +1,4 @@ -import { - Bar, - BarChart, - CartesianGrid, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from "recharts"; +import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; export interface AverageSeriesRow { series_key: string; @@ -44,4 +36,4 @@ export default function AverageChart({ series }: AverageChartProps) { ); -} \ No newline at end of file +} diff --git a/frontend/src/charts/DistributionChart.tsx b/frontend/src/charts/DistributionChart.tsx index 4b91733..5374a91 100644 --- a/frontend/src/charts/DistributionChart.tsx +++ b/frontend/src/charts/DistributionChart.tsx @@ -1,12 +1,4 @@ -import { - Bar, - BarChart, - CartesianGrid, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from "recharts"; +import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; export interface DistributionRow { subject: string; @@ -29,10 +21,7 @@ function parseValue(value: number | string): number | undefined { export default function DistributionChart({ rows }: DistributionChartProps) { const data: { subject: string; value: number }[] = rows .map((row) => ({ subject: row.subject, value: parseValue(row.value) })) - .filter( - (row): row is { subject: string; value: number } => - row.value !== undefined, - ); + .filter((row): row is { subject: string; value: number } => row.value !== undefined); return (
); -} \ No newline at end of file +} diff --git a/frontend/src/charts/FrequencyChart.tsx b/frontend/src/charts/FrequencyChart.tsx index 3eba178..1fc2dba 100644 --- a/frontend/src/charts/FrequencyChart.tsx +++ b/frontend/src/charts/FrequencyChart.tsx @@ -1,12 +1,4 @@ -import { - Bar, - BarChart, - CartesianGrid, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from "recharts"; +import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; import type { FrequencyRow } from "../types/statistics"; export interface FrequencyChartProps { @@ -33,4 +25,4 @@ export default function FrequencyChart({ rows }: FrequencyChartProps) { ); -} \ No newline at end of file +} diff --git a/frontend/src/charts/GapChart.tsx b/frontend/src/charts/GapChart.tsx index 8078dd0..0a5a883 100644 --- a/frontend/src/charts/GapChart.tsx +++ b/frontend/src/charts/GapChart.tsx @@ -60,4 +60,4 @@ export default function GapChart({ rows }: GapChartProps) { ); -} \ No newline at end of file +} diff --git a/frontend/src/charts/HeatmapChart.tsx b/frontend/src/charts/HeatmapChart.tsx index 60f695f..4bffcfa 100644 --- a/frontend/src/charts/HeatmapChart.tsx +++ b/frontend/src/charts/HeatmapChart.tsx @@ -53,7 +53,7 @@ export default function HeatmapChart({ rows, numbers }: HeatmapChartProps) { } if (size <= 0) return { size: 0, matrix: [], min: 0, max: 0 }; const matrix: (number | null)[][] = Array.from({ length: size }, () => - Array.from({ length: size }, () => null), + Array.from({ length: size }, () => null) ); let min = Infinity; let max = -Infinity; @@ -86,15 +86,31 @@ export default function HeatmapChart({ rows, numbers }: HeatmapChartProps) { const value = grid.matrix[i]?.[j] ?? null; const t = value === null ? 0 : (value - grid.min) / span; nodes.push( - + {value === null ? null : {`${i + 1}–${j + 1}: ${value}`}} - , + ); if (value !== null) { nodes.push( - + {value} - , + ); } } @@ -102,9 +118,14 @@ export default function HeatmapChart({ rows, numbers }: HeatmapChartProps) { return (
- + {nodes}
); -} \ No newline at end of file +} diff --git a/frontend/src/charts/charts.test.tsx b/frontend/src/charts/charts.test.tsx index 221bcb1..c675b75 100644 --- a/frontend/src/charts/charts.test.tsx +++ b/frontend/src/charts/charts.test.tsx @@ -38,9 +38,7 @@ describe("FrequencyChart", () => { it("does not crash on empty rows", () => { render(); - expect( - screen.getByRole("img", { name: /frequency distribution/i }), - ).toBeInTheDocument(); + expect(screen.getByRole("img", { name: /frequency distribution/i })).toBeInTheDocument(); }); }); @@ -54,17 +52,11 @@ describe("GapChart", () => { }); it("does not crash on null gaps or empty rows", () => { - const withNulls = [ - { number: 4, count: 5, min_gap: null, max_gap: null, avg_gap: null }, - ]; + const withNulls = [{ number: 4, count: 5, min_gap: null, max_gap: null, avg_gap: null }]; const { rerender } = render(); - expect( - screen.getByRole("img", { name: /gap analysis/i }), - ).toBeInTheDocument(); + expect(screen.getByRole("img", { name: /gap analysis/i })).toBeInTheDocument(); rerender(); - expect( - screen.getByRole("img", { name: /gap analysis/i }), - ).toBeInTheDocument(); + expect(screen.getByRole("img", { name: /gap analysis/i })).toBeInTheDocument(); }); }); @@ -78,17 +70,11 @@ describe("AverageChart", () => { }); it("does not crash on null means or empty series", () => { - const withNulls = [ - { series_key: "empty", mean: null, non_null_count: 0 }, - ]; + const withNulls = [{ series_key: "empty", mean: null, non_null_count: 0 }]; const { rerender } = render(); - expect( - screen.getByRole("img", { name: /average gap per series/i }), - ).toBeInTheDocument(); + expect(screen.getByRole("img", { name: /average gap per series/i })).toBeInTheDocument(); rerender(); - expect( - screen.getByRole("img", { name: /average gap per series/i }), - ).toBeInTheDocument(); + expect(screen.getByRole("img", { name: /average gap per series/i })).toBeInTheDocument(); }); }); @@ -107,12 +93,8 @@ describe("DistributionChart", () => { { subject: "number-2", value: "" }, ]; const { rerender } = render(); - expect( - screen.getByRole("img", { name: /probability distribution/i }), - ).toBeInTheDocument(); + expect(screen.getByRole("img", { name: /probability distribution/i })).toBeInTheDocument(); rerender(); - expect( - screen.getByRole("img", { name: /probability distribution/i }), - ).toBeInTheDocument(); + expect(screen.getByRole("img", { name: /probability distribution/i })).toBeInTheDocument(); }); -}); \ No newline at end of file +}); diff --git a/frontend/src/components/AssistantPanel.tsx b/frontend/src/components/AssistantPanel.tsx index ae0d6d5..b6c4d6f 100644 --- a/frontend/src/components/AssistantPanel.tsx +++ b/frontend/src/components/AssistantPanel.tsx @@ -60,8 +60,7 @@ export default function AssistantPanel({ lotteryCode }: AssistantPanelProps) { }; const experimentIdNumber = Number(experimentId); - const hasExperimentId = - Number.isInteger(experimentIdNumber) && experimentIdNumber > 0; + const hasExperimentId = Number.isInteger(experimentIdNumber) && experimentIdNumber > 0; if (!lotteryCode) { return ( @@ -69,10 +68,7 @@ export default function AssistantPanel({ lotteryCode }: AssistantPanelProps) { aria-labelledby="ia-assistant-title" className="rounded-md border border-gray-200 bg-white p-4" > -

+

Assistant

@@ -104,9 +100,7 @@ export default function AssistantPanel({ lotteryCode }: AssistantPanelProps) { if (result) { return (
-

- {result.text} -

+

{result.text}

); } @@ -118,10 +112,7 @@ export default function AssistantPanel({ lotteryCode }: AssistantPanelProps) { aria-labelledby="ia-assistant-title" className="rounded-md border border-gray-200 bg-white p-4" > -

+

Assistant

@@ -140,22 +131,13 @@ export default function AssistantPanel({ lotteryCode }: AssistantPanelProps) { placeholder="¿Por qué cambió la frecuencia del número 5?" className={FIELD_CLASS} /> -
{actions.map(({ label, onRun }) => ( - ))} @@ -179,9 +161,7 @@ export default function AssistantPanel({ lotteryCode }: AssistantPanelProps) { type="button" onClick={() => { if (hasExperimentId) { - run(() => - summarizeAssistant({ experiment_id: experimentIdNumber }), - ); + run(() => summarizeAssistant({ experiment_id: experimentIdNumber })); } }} disabled={!hasExperimentId} diff --git a/frontend/src/components/ComingSoon.test.tsx b/frontend/src/components/ComingSoon.test.tsx index 5490f85..b2a501c 100644 --- a/frontend/src/components/ComingSoon.test.tsx +++ b/frontend/src/components/ComingSoon.test.tsx @@ -6,16 +6,14 @@ describe("ComingSoon", () => { it("renders the page heading with the given title", () => { render(); - expect( - screen.getByRole("heading", { name: "Generator" }), - ).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Generator" })).toBeInTheDocument(); }); it("renders the coming-soon empty state with the title interpolated", () => { render(); expect( - screen.getByText(/Próximamente — Optimization disponible en una futura fase\./), + screen.getByText(/Próximamente — Optimization disponible en una futura fase\./) ).toBeInTheDocument(); }); -}); \ No newline at end of file +}); diff --git a/frontend/src/components/DataTable.tsx b/frontend/src/components/DataTable.tsx index fbf21b7..b30897b 100644 --- a/frontend/src/components/DataTable.tsx +++ b/frontend/src/components/DataTable.tsx @@ -113,13 +113,7 @@ export default function DataTable({ {column.sortable ? ( @@ -154,10 +148,7 @@ export default function DataTable({ )) ) : sortedRows.length === 0 ? ( - + {emptyMessage} diff --git a/frontend/src/components/ErrorState.tsx b/frontend/src/components/ErrorState.tsx index d09f477..418b1b3 100644 --- a/frontend/src/components/ErrorState.tsx +++ b/frontend/src/components/ErrorState.tsx @@ -8,11 +8,7 @@ interface ErrorStateProps { * Reusable error-state block for failed API requests. Renders the message in an * alert region and an optional retry button that re-executes the failed call. */ -export default function ErrorState({ - message, - onRetry, - retryLabel = "Retry", -}: ErrorStateProps) { +export default function ErrorState({ message, onRetry, retryLabel = "Retry" }: ErrorStateProps) { return (
); -} \ No newline at end of file +} diff --git a/frontend/src/components/LotterySelector.test.tsx b/frontend/src/components/LotterySelector.test.tsx index 9d4cfb7..4cc74ca 100644 --- a/frontend/src/components/LotterySelector.test.tsx +++ b/frontend/src/components/LotterySelector.test.tsx @@ -41,8 +41,8 @@ const server = setupServer( success: true, data: lotteries, timestamp: "2026-01-01T00:00:00Z", - }), - ), + }) + ) ); beforeAll(() => server.listen()); @@ -80,15 +80,13 @@ describe("LotterySelector", () => { data: lotteries, timestamp: "2026-01-01T00:00:00Z", }); - }), + }) ); render(); expect(screen.getByText(/loading lotteries/i)).toBeInTheDocument(); - expect( - await screen.findByRole("option", { name: /Quini 6/ }), - ).toBeInTheDocument(); + expect(await screen.findByRole("option", { name: /Quini 6/ })).toBeInTheDocument(); }); it("calls setSelected with id and code when an option is chosen", async () => { @@ -112,19 +110,15 @@ describe("LotterySelector", () => { error: { code: "INTERNAL_ERROR", message: "Server error" }, timestamp: "", }, - { status: 500 }, - ), - ), + { status: 500 } + ) + ) ); render(); - expect( - await screen.findByText(/failed to load lotteries/i), - ).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: /retry/i }), - ).toBeInTheDocument(); + expect(await screen.findByText(/failed to load lotteries/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument(); }); it("retries loading lotteries when the retry button is clicked", async () => { @@ -136,9 +130,9 @@ describe("LotterySelector", () => { error: { code: "INTERNAL_ERROR", message: "Server error" }, timestamp: "", }, - { status: 500 }, - ), - ), + { status: 500 } + ) + ) ); render(); @@ -150,14 +144,12 @@ describe("LotterySelector", () => { success: true, data: lotteries, timestamp: "2026-01-01T00:00:00Z", - }), - ), + }) + ) ); fireEvent.click(screen.getByRole("button", { name: /retry/i })); - expect( - await screen.findByRole("option", { name: /Quini 6/ }), - ).toBeInTheDocument(); + expect(await screen.findByRole("option", { name: /Quini 6/ })).toBeInTheDocument(); }); it("renders a disabled empty select when no lotteries exist", async () => { @@ -167,16 +159,14 @@ describe("LotterySelector", () => { success: true, data: [], timestamp: "2026-01-01T00:00:00Z", - }), - ), + }) + ) ); render(); const select = await screen.findByRole("combobox", { name: /lottery/i }); expect(select).toBeDisabled(); - expect( - screen.getByRole("option", { name: /no lotteries available/i }), - ).toBeInTheDocument(); + expect(screen.getByRole("option", { name: /no lotteries available/i })).toBeInTheDocument(); }); -}); \ No newline at end of file +}); diff --git a/frontend/src/components/LotterySelector.tsx b/frontend/src/components/LotterySelector.tsx index 0b0408f..adadf71 100644 --- a/frontend/src/components/LotterySelector.tsx +++ b/frontend/src/components/LotterySelector.tsx @@ -58,10 +58,7 @@ export default function LotterySelector() { return (
-
); -} \ No newline at end of file +} diff --git a/frontend/src/pages/DL.test.tsx b/frontend/src/pages/DL.test.tsx index 23c227a..69191d6 100644 --- a/frontend/src/pages/DL.test.tsx +++ b/frontend/src/pages/DL.test.tsx @@ -8,15 +8,8 @@ import { useLotteryStore } from "../store/useLotteryStore"; const env = (data: unknown) => HttpResponse.json({ success: true, data, timestamp: "2026-01-01T00:00:00Z" }); -const err = ( - message: string, - status = 500, - code = "INTERNAL_ERROR", -) => - HttpResponse.json( - { success: false, error: { code, message }, timestamp: "" }, - { status }, - ); +const err = (message: string, status = 500, code = "INTERNAL_ERROR") => + HttpResponse.json({ success: false, error: { code, message }, timestamp: "" }, { status }); const snapshot = { id: 7, @@ -37,7 +30,14 @@ const metrics = [ const trainResult = { lottery_id: 1, results: [ - { family: "mlp", status: "active", snapshot_id: 7, fingerprint: "fp-dl", metrics_checksum: "cs1", error: null }, + { + family: "mlp", + status: "active", + snapshot_id: 7, + fingerprint: "fp-dl", + metrics_checksum: "cs1", + error: null, + }, ], }; @@ -63,7 +63,7 @@ const server = setupServer( trainCalls += 1; lastTrainRequest = request; return env(trainResult); - }), + }) ); const selectLottery = () => @@ -139,9 +139,7 @@ describe("DL page", () => { expect(within(table).getAllByText("mlp").length).toBeGreaterThan(0); expect(within(table).getByText("lstm")).toBeInTheDocument(); expect(within(table).getByText("0.12")).toBeInTheDocument(); - expect(table.textContent?.indexOf("mlp")).toBeLessThan( - table.textContent!.indexOf("lstm"), - ); + expect(table.textContent?.indexOf("mlp")).toBeLessThan(table.textContent!.indexOf("lstm")); }); it("refetches snapshot and metrics when the lottery selection changes (R2-S2)", async () => { @@ -157,9 +155,7 @@ describe("DL page", () => { it("prompts to select a lottery with zero API calls and a disabled Train button (parity bonus)", async () => { render(
); - expect( - await screen.findByText(/select a lottery/i), - ).toBeInTheDocument(); + expect(await screen.findByText(/select a lottery/i)).toBeInTheDocument(); expect(modelsCalls).toBe(0); expect(metricsCalls).toBe(0); expect(trainCalls).toBe(0); @@ -169,17 +165,12 @@ describe("DL page", () => { it("renders the empty-state Train CTA on 404 SNAPSHOT_NOT_FOUND, not an error (R3-S1)", async () => { selectLottery(); server.use( - http.get( - "*/api/v1/dl/models", - () => err("no active snapshot", 404, "SNAPSHOT_NOT_FOUND"), - ), - http.get("*/api/v1/dl/metrics", () => env([])), + http.get("*/api/v1/dl/models", () => err("no active snapshot", 404, "SNAPSHOT_NOT_FOUND")), + http.get("*/api/v1/dl/metrics", () => env([])) ); render(
); - expect( - await screen.findByText(/no models trained yet/i), - ).toBeInTheDocument(); + expect(await screen.findByText(/no models trained yet/i)).toBeInTheDocument(); const trainButtons = screen.getAllByRole("button", { name: /^train$/i }); expect(trainButtons.length).toBeGreaterThan(0); for (const button of trainButtons) { @@ -191,17 +182,12 @@ describe("DL page", () => { it("keeps an unrelated 404 as an error with retry (R3-S2)", async () => { selectLottery(); server.use( - http.get( - "*/api/v1/dl/models", - () => err("unknown lottery", 404, "RESOURCE_NOT_FOUND"), - ), + http.get("*/api/v1/dl/models", () => err("unknown lottery", 404, "RESOURCE_NOT_FOUND")) ); render(
); expect(await screen.findByRole("alert")).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: /retry/i }), - ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument(); }); it("shows the skeleton while models load, then the table replaces it (R6-S1)", async () => { @@ -210,7 +196,7 @@ describe("DL page", () => { http.get("*/api/v1/dl/models", async () => { await delay(100); return env(snapshot); - }), + }) ); const { container } = render(
); @@ -223,9 +209,7 @@ describe("DL page", () => { selectLottery(); let failing = true; server.use( - http.get("*/api/v1/dl/models", () => - failing ? err("backend down") : env(snapshot), - ), + http.get("*/api/v1/dl/models", () => (failing ? err("backend down") : env(snapshot))) ); render(
); @@ -243,7 +227,7 @@ describe("DL page", () => { http.post("*/api/v1/dl/train", async () => { await delay(100); return env(trainResult); - }), + }) ); render(
); await screen.findByRole("table"); @@ -272,20 +256,25 @@ describe("DL page", () => { env({ lottery_id: 1, results: [ - { family: "mlp", status: "active", snapshot_id: 7, fingerprint: "fp-dl", metrics_checksum: "cs1", error: null }, + { + family: "mlp", + status: "active", + snapshot_id: 7, + fingerprint: "fp-dl", + metrics_checksum: "cs1", + error: null, + }, { family: "lstm", status: "failed", error: "no active F4 snapshot" }, ], - }), - ), + }) + ) ); render(
); await screen.findByRole("table"); fireEvent.click(screen.getByRole("button", { name: /^train$/i })); - expect( - await screen.findByText(/lstm: no active F4 snapshot/i), - ).toBeInTheDocument(); + expect(await screen.findByText(/lstm: no active F4 snapshot/i)).toBeInTheDocument(); }); it("renders an ErrorState when training rejects and Retry re-issues the POST (R4-S3)", async () => { @@ -294,7 +283,7 @@ describe("DL page", () => { http.post("*/api/v1/dl/train", () => { trainCalls += 1; return err("training failed"); - }), + }) ); render(
); await screen.findByRole("table"); @@ -307,7 +296,7 @@ describe("DL page", () => { http.post("*/api/v1/dl/train", () => { trainCalls += 1; return env(trainResult); - }), + }) ); fireEvent.click(screen.getByRole("button", { name: /retry/i })); await waitFor(() => expect(trainCalls).toBe(2)); diff --git a/frontend/src/pages/DL.tsx b/frontend/src/pages/DL.tsx index 337d651..cebe227 100644 --- a/frontend/src/pages/DL.tsx +++ b/frontend/src/pages/DL.tsx @@ -9,8 +9,7 @@ import { useLotteryStore } from "../store/useLotteryStore"; import type { DLMetric, DLSnapshot, DLTrainResult } from "../types/dl"; const NO_LOTTERY_MESSAGE = "Select a lottery to see deep learning models."; -const NO_MODELS_MESSAGE = - "No models trained yet for this lottery. Click Train to generate them."; +const NO_MODELS_MESSAGE = "No models trained yet for this lottery. Click Train to generate them."; const NO_METRICS_MESSAGE = "No metrics available for this lottery."; const BUTTON_CLASS = "rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"; @@ -32,8 +31,7 @@ function SnapshotSummary({ snapshot }: { snapshot: DLSnapshot }) { Snapshot #{snapshot.id} - Model set{" "} - {snapshot.model_set} + Model set {snapshot.model_set} Version {snapshot.version} @@ -42,14 +40,11 @@ function SnapshotSummary({ snapshot }: { snapshot: DLSnapshot }) { Status {snapshot.status} - Checksum{" "} - {snapshot.checksum} + Checksum {snapshot.checksum} Input fingerprint{" "} - - {snapshot.input_fingerprint} - + {snapshot.input_fingerprint} Cut {snapshot.cut} @@ -109,7 +104,7 @@ export default function DL() { }; const rows = [...(metrics ?? [])].sort( - (a, b) => (FAMILY_ORDER[a.model_id] ?? 2) - (FAMILY_ORDER[b.model_id] ?? 2), + (a, b) => (FAMILY_ORDER[a.model_id] ?? 2) - (FAMILY_ORDER[b.model_id] ?? 2) ); const renderContent = () => { @@ -174,8 +169,7 @@ export default function DL() {

Deep Learning

- Active deep learning snapshot and per-family metrics for the selected - lottery. + Active deep learning snapshot and per-family metrics for the selected lottery.

} + action={ + + } /> ); } @@ -89,9 +129,17 @@ export default function Heatmaps() {
    {snapshots.map((item) => (
  • -
  • ))} @@ -106,15 +154,26 @@ export default function Heatmaps() {

    Heatmaps

    -

    Co-occurrence strength between number pairs for the selected lottery.

    +

    + Co-occurrence strength between number pairs for the selected lottery. +

    -
    -
    +
    {renderContent()}
); -} \ No newline at end of file +} diff --git a/frontend/src/pages/History.test.tsx b/frontend/src/pages/History.test.tsx index 31db562..2c5caca 100644 --- a/frontend/src/pages/History.test.tsx +++ b/frontend/src/pages/History.test.tsx @@ -1,11 +1,5 @@ import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest"; -import { - render, - screen, - waitFor, - within, - fireEvent, -} from "@testing-library/react"; +import { render, screen, waitFor, within, fireEvent } from "@testing-library/react"; import { http, HttpResponse, delay } from "msw"; import { setupServer } from "msw/node"; import History from "./History"; @@ -22,7 +16,7 @@ const env = (data: unknown) => const err = (message: string, status = 500) => HttpResponse.json( { success: false, error: { code: "INTERNAL_ERROR", message }, timestamp: "" }, - { status }, + { status } ); const draw = (id: number, drawNumber: number): Draw => ({ @@ -52,10 +46,8 @@ const server = setupServer( drawsCalls += 1; const url = new URL(request.url); const page = Number(url.searchParams.get("page") ?? "1"); - return page === 1 - ? env(drawRange(200, 200, PAGE_SIZE)) - : env(drawRange(150, 150, 3)); - }), + return page === 1 ? env(drawRange(200, 200, PAGE_SIZE)) : env(drawRange(150, 150, 3)); + }) ); const selectLottery = () => @@ -84,7 +76,7 @@ describe("History", () => { render(); expect( - await screen.findByRole("heading", { name: /history/i }, ASYNC_TIMEOUT), + await screen.findByRole("heading", { name: /history/i }, ASYNC_TIMEOUT) ).toBeInTheDocument(); const table = await screen.findByRole("table", {}, ASYNC_TIMEOUT); expect(within(table).getByText("200")).toBeInTheDocument(); @@ -117,9 +109,7 @@ describe("History", () => { it("shows skeleton placeholders while draws are loading", async () => { selectLottery(); server.use( - http.get("*/api/v1/draws", () => - delay(50).then(() => env(drawRange(200, 200, PAGE_SIZE))), - ), + http.get("*/api/v1/draws", () => delay(50).then(() => env(drawRange(200, 200, PAGE_SIZE)))) ); const { container } = render(); @@ -152,9 +142,7 @@ describe("History", () => { server.use(http.get("*/api/v1/draws", () => env([]))); render(); - expect( - await screen.findByText(/no draws available for this lottery/i), - ).toBeInTheDocument(); + expect(await screen.findByText(/no draws available for this lottery/i)).toBeInTheDocument(); expect(screen.queryByRole("table")).not.toBeInTheDocument(); }); @@ -162,7 +150,7 @@ describe("History", () => { render(); expect( - await screen.findByText(/select a lottery to see its draw history/i), + await screen.findByText(/select a lottery to see its draw history/i) ).toBeInTheDocument(); expect(drawsCalls).toBe(0); expect(screen.queryByRole("table")).not.toBeInTheDocument(); diff --git a/frontend/src/pages/History.tsx b/frontend/src/pages/History.tsx index 9250cd4..da491cd 100644 --- a/frontend/src/pages/History.tsx +++ b/frontend/src/pages/History.tsx @@ -124,9 +124,7 @@ export default function History() {

History

-

- Paginated draw history for the selected lottery. -

+

Paginated draw history for the selected lottery.

{renderContent()}
diff --git a/frontend/src/pages/Home.test.tsx b/frontend/src/pages/Home.test.tsx index 709db50..7e5bd92 100644 --- a/frontend/src/pages/Home.test.tsx +++ b/frontend/src/pages/Home.test.tsx @@ -1,11 +1,5 @@ import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest"; -import { - render, - screen, - waitFor, - within, - fireEvent, -} from "@testing-library/react"; +import { render, screen, waitFor, within, fireEvent } from "@testing-library/react"; import { http, HttpResponse, delay } from "msw"; import { setupServer } from "msw/node"; import Home from "./Home"; @@ -19,7 +13,7 @@ const env = (data: unknown) => const err = (message: string, status = 500) => HttpResponse.json( { success: false, error: { code: "INTERNAL_ERROR", message }, timestamp: "" }, - { status }, + { status } ); const draw = ( @@ -27,7 +21,7 @@ const draw = ( drawNumber: number, drawDate: string, nums: number[], - superNumber: number | null, + superNumber: number | null ): Draw => ({ id, lottery_id: 1, @@ -71,8 +65,8 @@ const server = setupServer( http.get("*/api/v1/statistics/L1/frequencies", () => env(frequencyList)), http.get("*/api/v1/health", () => env({ status: "ok" })), http.get("*/api/v1/version", () => - env({ version: "1.0.0", app: "Lottery Intelligence Platform" }), - ), + env({ version: "1.0.0", app: "Lottery Intelligence Platform" }) + ) ); const selectLottery = () => @@ -99,17 +93,13 @@ describe("Home", () => { selectLottery(); render(); - expect( - await screen.findByRole("heading", { name: /latest draws/i }), - ).toBeInTheDocument(); + expect(await screen.findByRole("heading", { name: /latest draws/i })).toBeInTheDocument(); const table = screen.getByRole("table"); expect(within(table).getByText("101")).toBeInTheDocument(); expect(within(table).getByText("100")).toBeInTheDocument(); expect(within(table).getByText("5 - 12 - 22")).toBeInTheDocument(); - expect( - screen.getByRole("heading", { name: /frequency summary/i }), - ).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: /frequency summary/i })).toBeInTheDocument(); expect(screen.getByText("Most frequent")).toBeInTheDocument(); expect(screen.getByText("Least frequent")).toBeInTheDocument(); expect(screen.getByText("14×")).toBeInTheDocument(); @@ -154,9 +144,7 @@ describe("Home", () => { server.use(http.get("*/api/v1/draws", () => env([]))); render(); - expect( - await screen.findByText(/no data available for this lottery/i), - ).toBeInTheDocument(); + expect(await screen.findByText(/no data available for this lottery/i)).toBeInTheDocument(); expect(screen.queryByRole("table")).not.toBeInTheDocument(); }); @@ -164,7 +152,7 @@ describe("Home", () => { render(); expect( - await screen.findAllByText(/select a lottery to see its operational summary/i), + await screen.findAllByText(/select a lottery to see its operational summary/i) ).not.toHaveLength(0); expect(await screen.findByText("1.0.0")).toBeInTheDocument(); }); diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 9269d05..caa0fa6 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -37,9 +37,7 @@ function topFrequencies(rows: FrequencyRow[], direction: "desc" | "asc") { function FrequencyList({ title, rows }: { title: string; rows: FrequencyRow[] }) { return (
-

- {title} -

+

{title}

    {rows.map((row, index) => (
  1. +

    {title}

    @@ -77,9 +72,24 @@ function Section({ id, title, children }: { id: string; title: string; children: */ export default function Home() { const selectedLotteryCode = useLotteryStore((s) => s.selectedLotteryCode); - const { data: drawRows, isLoading: drawsLoading, error: drawsError, execute: fetchDraws } = useApi(getDraws); - const { data: freqData, isLoading: freqLoading, error: freqError, execute: fetchFrequencies } = useApi(getFrequencies); - const { data: systemData, isLoading: systemLoading, error: systemError, execute: fetchSystem } = useApi(getSystemInfo); + const { + data: drawRows, + isLoading: drawsLoading, + error: drawsError, + execute: fetchDraws, + } = useApi(getDraws); + const { + data: freqData, + isLoading: freqLoading, + error: freqError, + execute: fetchFrequencies, + } = useApi(getFrequencies); + const { + data: systemData, + isLoading: systemLoading, + error: systemError, + execute: fetchSystem, + } = useApi(getSystemInfo); useEffect(() => { if (selectedLotteryCode) { @@ -98,7 +108,12 @@ export default function Home() { const renderDraws = () => { if (!selectedLotteryCode) return ; if (drawsError) { - return void fetchDraws(selectedLotteryCode, 1, LATEST_DRAWS)} />; + return ( + void fetchDraws(selectedLotteryCode, 1, LATEST_DRAWS)} + /> + ); } if (drawList.length === 0 && !drawsLoading) return ; return ( @@ -116,7 +131,12 @@ export default function Home() { const renderFrequencies = () => { if (!selectedLotteryCode) return ; if (freqError) { - return void fetchFrequencies(selectedLotteryCode)} />; + return ( + void fetchFrequencies(selectedLotteryCode)} + /> + ); } if (freqLoading) { return ( @@ -164,8 +184,7 @@ export default function Home() {

    Operational Summary

    - Latest draws, frequency snapshot and API status for the selected - lottery. + Latest draws, frequency snapshot and API status for the selected lottery.

    diff --git a/frontend/src/pages/IA.test.tsx b/frontend/src/pages/IA.test.tsx index 7d86b2b..a4a4db6 100644 --- a/frontend/src/pages/IA.test.tsx +++ b/frontend/src/pages/IA.test.tsx @@ -10,7 +10,7 @@ const env = (data: unknown) => const err = (message: string, status = 500) => HttpResponse.json( { success: false, error: { code: "INTERNAL_ERROR", message }, timestamp: "" }, - { status }, + { status } ); const snapshot = { @@ -61,7 +61,9 @@ const assistantResponse = { const server = setupServer( http.get("*/api/v1/health", () => env({ status: "ok" })), - http.get("*/api/v1/version", () => env({ version: "1.0.0", app: "Lottery Intelligence Platform" })), + http.get("*/api/v1/version", () => + env({ version: "1.0.0", app: "Lottery Intelligence Platform" }) + ), http.get("*/api/v1/ml/models", () => { modelsCalls += 1; return env(snapshot); @@ -97,7 +99,7 @@ const server = setupServer( lottery_code: string; }; return env({ ...assistantResponse, text: "Respuesta generada para tu pregunta." }); - }), + }) ); const selectLottery = () => @@ -170,7 +172,9 @@ describe("IA", () => { it("shows skeleton placeholders while sections load", async () => { selectLottery(); server.use( - http.get("*/api/v1/probability/L1/probabilities", () => delay(50).then(() => env(probabilityList))), + http.get("*/api/v1/probability/L1/probabilities", () => + delay(50).then(() => env(probabilityList)) + ) ); const { container } = render(); expect(container.querySelector(".animate-pulse")).not.toBeNull(); @@ -181,7 +185,7 @@ describe("IA", () => { it("prompts to select a lottery and does not call lottery-scoped endpoints", async () => { render(); expect( - (await screen.findAllByText(/select a lottery to see the ai assistant/i)).length, + (await screen.findAllByText(/select a lottery to see the ai assistant/i)).length ).toBeGreaterThan(0); expect(modelsCalls).toBe(0); expect(metricsCalls).toBe(0); @@ -199,9 +203,7 @@ describe("IA", () => { question: "¿Por qué cambió la frecuencia?", lottery_code: "L1", }); - expect( - await screen.findByText("Respuesta generada para tu pregunta."), - ).toBeInTheDocument(); + expect(await screen.findByText("Respuesta generada para tu pregunta.")).toBeInTheDocument(); }); it("shows a skeleton while an assistant request is in flight", async () => { @@ -211,7 +213,7 @@ describe("IA", () => { assistCalls += 1; await delay(50); return env({ ...assistantResponse, text: "Respuesta lenta." }); - }), + }) ); const { container } = render(); await screen.findByText("ok"); @@ -230,7 +232,9 @@ describe("IA", () => { const alert = await screen.findByRole("alert"); expect(alert).toHaveTextContent(/assistant error/i); server.use( - http.post("*/api/v1/assistant/assist", async () => env({ ...assistantResponse, text: "Recuperado." })), + http.post("*/api/v1/assistant/assist", async () => + env({ ...assistantResponse, text: "Recuperado." }) + ) ); fireEvent.click(screen.getByRole("button", { name: /retry/i })); await waitFor(() => expect(screen.getByText("Recuperado.")).toBeInTheDocument()); @@ -241,8 +245,8 @@ describe("IA", () => { selectLottery(); server.use( http.post("*/api/v1/assistant/assist", async () => - env({ ...assistantResponse, text: "No hay datos suficientes." }), - ), + env({ ...assistantResponse, text: "No hay datos suficientes." }) + ) ); render(); await screen.findByText("ok"); @@ -254,10 +258,16 @@ describe("IA", () => { it("only calls the five assistant endpoints and existing status endpoints (NFR-2)", async () => { selectLottery(); const allowed = new Set([ - "/api/v1/health", "/api/v1/version", "/api/v1/ml/models", "/api/v1/ml/metrics", + "/api/v1/health", + "/api/v1/version", + "/api/v1/ml/models", + "/api/v1/ml/metrics", "/api/v1/probability/L1/probabilities", - "/api/v1/assistant/explain", "/api/v1/assistant/interpret", "/api/v1/assistant/report", - "/api/v1/assistant/summarize", "/api/v1/assistant/assist", + "/api/v1/assistant/explain", + "/api/v1/assistant/interpret", + "/api/v1/assistant/report", + "/api/v1/assistant/summarize", + "/api/v1/assistant/assist", ]); const unexpected: string[] = []; const onRequest = ({ request }: { request: Request }) => { @@ -275,11 +285,13 @@ describe("IA", () => { fireEvent.click(screen.getByRole("button", { name: "Summarize" })); ask("Hola"); await waitFor(() => { - expect([explainCalls, interpretCalls, reportCalls, summarizeCalls, assistCalls]).toEqual([1, 1, 1, 1, 1]); + expect([explainCalls, interpretCalls, reportCalls, summarizeCalls, assistCalls]).toEqual([ + 1, 1, 1, 1, 1, + ]); }); expect(unexpected).toEqual([]); } finally { server.events.removeListener("request:start", onRequest); } }); -}); \ No newline at end of file +}); diff --git a/frontend/src/pages/IA.tsx b/frontend/src/pages/IA.tsx index 03e4e56..fd5e420 100644 --- a/frontend/src/pages/IA.tsx +++ b/frontend/src/pages/IA.tsx @@ -113,7 +113,7 @@ export default function IA() { .slice() .sort((a, b) => b.value - a.value) .slice(0, TOP_METRICS), - [metrics], + [metrics] ); const renderSystem = () => { @@ -211,8 +211,8 @@ export default function IA() {

    AI Assistant

    - Intelligence status derived from the live system, the active ML model and - the latest probability rows for the selected lottery. + Intelligence status derived from the live system, the active ML model and the latest + probability rows for the selected lottery.

    @@ -227,4 +227,4 @@ export default function IA() {
); -} \ No newline at end of file +} diff --git a/frontend/src/pages/MisNumeros.test.tsx b/frontend/src/pages/MisNumeros.test.tsx index 2df41b2..a483cba 100644 --- a/frontend/src/pages/MisNumeros.test.tsx +++ b/frontend/src/pages/MisNumeros.test.tsx @@ -9,22 +9,10 @@ import { useLotteryStore } from "../store/useLotteryStore"; const env = (data: unknown) => HttpResponse.json({ success: true, data, timestamp: "2026-01-01T00:00:00Z" }); const err = (message: string, status = 500, code = "INTERNAL_ERROR") => - HttpResponse.json( - { success: false, error: { code, message }, timestamp: "" }, - { status }, - ); + HttpResponse.json({ success: false, error: { code, message }, timestamp: "" }, { status }); /** Canonical stage order of POST /pipeline/numbers (R2/S2 contract). */ -const STAGE_ORDER = [ - "stats", - "features", - "ml", - "dl", - "bt", - "rank", - "select", - "gen", -]; +const STAGE_ORDER = ["stats", "features", "ml", "dl", "bt", "rank", "select", "gen"]; const combinations = [ { @@ -74,7 +62,7 @@ const failedRankStages = () => error_code: "PIPE_STAGE_FAILED", detail: "ranking stale for backtest context after one rerank", } - : stage, + : stage ); let pipelineCalls = 0; @@ -86,7 +74,7 @@ const server = setupServer( pipelineCalls += 1; lastPipelineBody = (await request.json()) as Record; return env({ stages: okStages(), result: generationResult }); - }), + }) ); const selectLottery = () => @@ -150,7 +138,7 @@ describe("Mis Números page", () => { pipelineCalls += 1; await delay(150); return err("pipeline failed", 502, "PIPE_STAGE_FAILED"); - }), + }) ); render(); @@ -167,7 +155,7 @@ describe("Mis Números page", () => { pipelineCalls += 1; lastPipelineBody = null; return env({ stages: okStages(), result: generationResult }); - }), + }) ); fireEvent.click(screen.getByRole("button", { name: /retry/i })); await screen.findByRole("table", { name: /generated combinations/i }); @@ -186,23 +174,21 @@ describe("Mis Números page", () => { const text = report.textContent ?? ""; for (let i = 0; i < STAGE_ORDER.length - 1; i++) { expect(text.indexOf(STAGE_ORDER[i] as string)).toBeLessThan( - text.indexOf(STAGE_ORDER[i + 1] as string), + text.indexOf(STAGE_ORDER[i + 1] as string) ); } for (const name of STAGE_ORDER) { expect(text).toContain(name); } - expect( - screen.getByRole("table", { name: /generated combinations/i }), - ).toBeInTheDocument(); + expect(screen.getByRole("table", { name: /generated combinations/i })).toBeInTheDocument(); }); it("surfaces a failed rank stage without crashing and hides combinations (R2)", async () => { selectLottery(); server.use( http.post("*/api/v1/pipeline/numbers", () => - env({ stages: failedRankStages(), result: null }), - ), + env({ stages: failedRankStages(), result: null }) + ) ); render(); @@ -214,12 +200,10 @@ describe("Mis Números page", () => { expect(report.textContent).toContain("failed"); expect(screen.getByText(/PIPE_STAGE_FAILED/)).toBeInTheDocument(); expect( - screen.queryByRole("table", { name: /generated combinations/i }), + screen.queryByRole("table", { name: /generated combinations/i }) ).not.toBeInTheDocument(); // Page stays interactive: CTA re-enabled and disclaimer still visible. - expect( - screen.getByRole("button", { name: /generate numbers/i }), - ).toBeEnabled(); + expect(screen.getByRole("button", { name: /generate numbers/i })).toBeEnabled(); expect(screen.getByText(/remain/i)).toBeInTheDocument(); }); @@ -284,9 +268,7 @@ describe("Mis Números page", () => { render(); expect(await screen.findByText(/select a lottery/i)).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: /generate numbers/i }), - ).toBeDisabled(); + expect(screen.getByRole("button", { name: /generate numbers/i })).toBeDisabled(); expect(pipelineCalls).toBe(0); }); }); diff --git a/frontend/src/pages/MisNumeros.tsx b/frontend/src/pages/MisNumeros.tsx index 558b97b..bb0f7bc 100644 --- a/frontend/src/pages/MisNumeros.tsx +++ b/frontend/src/pages/MisNumeros.tsx @@ -12,8 +12,7 @@ import type { CombinationRow, GenerationResult } from "../types/gen"; import type { PipelineRunResult, PipelineStageResult } from "../types/pipeline"; const NO_LOTTERY_MESSAGE = "Select a lottery to generate your numbers."; -const IDLE_HINT = - "Click Generate numbers to run the full analysis chain and build your ticket."; +const IDLE_HINT = "Click Generate numbers to run the full analysis chain and build your ticket."; /** Owner decision: every ticket is valid for BOTH draws; no toggle exists. */ const DUAL_DRAW_LABEL = "Un boleto, dos sorteos (Baloto + Revancha)"; const DISCLAIMER_TEXT = @@ -74,9 +73,7 @@ function StageReport({ stages }: { stages: PipelineStageResult[] }) { key={stage.name} className="flex flex-wrap items-center gap-x-3 gap-y-1 rounded border border-gray-100 px-3 py-1.5" > - - {stage.name} - + {stage.name} {DUAL_DRAW_LABEL}

- Snapshot{" "} - - #{result.snapshot_id} - + Snapshot #{result.snapshot_id} Seed {result.seed} - Fingerprint{" "} - - {result.fingerprint} - + Fingerprint {result.fingerprint}

Mis Números

- One request runs stats → features → ml → dl → backtesting → rank → - select → generate for the selected lottery. + One request runs stats → features → ml → dl → backtesting → rank → select → generate for + the selected lottery.

); }; @@ -134,9 +129,7 @@ export default function MonteCarlo() {

Monte Carlo

-

- Probability rows for the selected lottery. -

+

Probability rows for the selected lottery.

); -} \ No newline at end of file +} diff --git a/frontend/src/pages/Networks.test.tsx b/frontend/src/pages/Networks.test.tsx index 65eb9ed..96447c5 100644 --- a/frontend/src/pages/Networks.test.tsx +++ b/frontend/src/pages/Networks.test.tsx @@ -19,8 +19,13 @@ vi.mock("react-force-graph-2d", () => ({ }, })); -const env = (data: unknown) => HttpResponse.json({ success: true, data, timestamp: "2026-01-01T00:00:00Z" }); -const err = (message: string, status = 500) => HttpResponse.json({ success: false, error: { code: "INTERNAL_ERROR", message }, timestamp: "" }, { status }); +const env = (data: unknown) => + HttpResponse.json({ success: true, data, timestamp: "2026-01-01T00:00:00Z" }); +const err = (message: string, status = 500) => + HttpResponse.json( + { success: false, error: { code: "INTERNAL_ERROR", message }, timestamp: "" }, + { status } + ); const snapshot = (id: number, extra: Record = {}) => ({ snapshot_id: id, @@ -33,9 +38,24 @@ const snapshot = (id: number, extra: Record = {}) => ({ ...extra, }); -const coo = (subject: string, value: number) => ({ metric_type: "cooccurrence", subject, draw_number: null, value }); -const centrality = (subject: string, value: number) => ({ metric_type: "centrality_degree", subject, draw_number: null, value }); -const community = (subject: string, value: number) => ({ metric_type: "community_id", subject, draw_number: null, value }); +const coo = (subject: string, value: number) => ({ + metric_type: "cooccurrence", + subject, + draw_number: null, + value, +}); +const centrality = (subject: string, value: number) => ({ + metric_type: "centrality_degree", + subject, + draw_number: null, + value, +}); +const community = (subject: string, value: number) => ({ + metric_type: "community_id", + subject, + draw_number: null, + value, +}); const snap2Values = [ coo("1-2", 5), @@ -55,7 +75,10 @@ let snapshotCalls = 0; let valuesRequests: number[] = []; const snapList = () => ({ - snapshots: [snapshot(2, { version: "v2", draw_count: 150, created_at: "2026-02-01T00:00:00Z" }), snapshot(1)], + snapshots: [ + snapshot(2, { version: "v2", draw_count: 150, created_at: "2026-02-01T00:00:00Z" }), + snapshot(1), + ], }); const server = setupServer( @@ -70,10 +93,11 @@ const server = setupServer( http.get("*/api/v1/graph/L1/snapshots/2", () => { valuesRequests.push(2); return env({ rows: snap2Values, count: snap2Values.length }); - }), + }) ); -const selectLottery = () => useLotteryStore.setState({ selectedLotteryId: 1, selectedLotteryCode: "L1" }); +const selectLottery = () => + useLotteryStore.setState({ selectedLotteryId: 1, selectedLotteryCode: "L1" }); beforeAll(() => server.listen()); afterEach(() => { @@ -83,7 +107,13 @@ afterEach(() => { forceGraphProps.nodes = []; forceGraphProps.links = []; localStorage.clear(); - useLotteryStore.setState({ lotteries: [], selectedLotteryId: null, selectedLotteryCode: null, isLoading: false, error: null }); + useLotteryStore.setState({ + lotteries: [], + selectedLotteryId: null, + selectedLotteryCode: null, + isLoading: false, + error: null, + }); }); afterAll(() => server.close()); @@ -91,7 +121,9 @@ describe("Networks", () => { it("renders the network graph on mount, auto-selecting the latest snapshot", async () => { selectLottery(); render(); - expect(await screen.findByRole("heading", { name: /networks/i }, ASYNC_TIMEOUT)).toBeInTheDocument(); + expect( + await screen.findByRole("heading", { name: /networks/i }, ASYNC_TIMEOUT) + ).toBeInTheDocument(); expect(await screen.findByTestId("network-graph", {}, ASYNC_TIMEOUT)).toBeInTheDocument(); await waitFor(() => { expect(valuesRequests).toContain(2); @@ -118,10 +150,15 @@ describe("Networks", () => { it("shows skeleton placeholders while data is loading", async () => { selectLottery(); - server.use(http.get("*/api/v1/graph/L1/snapshots", () => delay(50).then(() => env(snapList())))); + server.use( + http.get("*/api/v1/graph/L1/snapshots", () => delay(50).then(() => env(snapList()))) + ); const { container } = render(); expect(container.querySelector(".animate-pulse")).not.toBeNull(); - await waitFor(() => expect(container.querySelector(".animate-pulse")).toBeNull(), ASYNC_TIMEOUT); + await waitFor( + () => expect(container.querySelector(".animate-pulse")).toBeNull(), + ASYNC_TIMEOUT + ); expect(await screen.findByTestId("network-graph", {}, ASYNC_TIMEOUT)).toBeInTheDocument(); }); @@ -133,13 +170,18 @@ describe("Networks", () => { expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument(); server.use(http.get("*/api/v1/graph/L1/snapshots", () => env(snapList()))); fireEvent.click(screen.getByRole("button", { name: /retry/i })); - await waitFor(() => expect(screen.getByTestId("network-graph")).toBeInTheDocument(), ASYNC_TIMEOUT); + await waitFor( + () => expect(screen.getByTestId("network-graph")).toBeInTheDocument(), + ASYNC_TIMEOUT + ); expect(screen.queryByRole("alert")).not.toBeInTheDocument(); }); it("prompts to select a lottery and does not call the API", async () => { render(); - expect(await screen.findByText(/select a lottery to see the network graph/i)).toBeInTheDocument(); + expect( + await screen.findByText(/select a lottery to see the network graph/i) + ).toBeInTheDocument(); expect(snapshotCalls).toBe(0); }); -}); \ No newline at end of file +}); diff --git a/frontend/src/pages/Networks.tsx b/frontend/src/pages/Networks.tsx index 336cacd..a7d3215 100644 --- a/frontend/src/pages/Networks.tsx +++ b/frontend/src/pages/Networks.tsx @@ -14,7 +14,16 @@ const NO_DATA_MESSAGE = "No network snapshot for this lottery yet."; const NO_LINKS_MESSAGE = "No co-occurrence links in this snapshot."; const GRAPH_HEIGHT = 480; const GRAPH_ARIA_LABEL = "Network graph of lottery numbers"; -const COMMUNITY_COLORS = ["#3b82f6", "#22c55e", "#f59e0b", "#ef4444", "#8b5cf6", "#06b6d4", "#ec4899", "#84cc16"]; +const COMMUNITY_COLORS = [ + "#3b82f6", + "#22c55e", + "#f59e0b", + "#ef4444", + "#8b5cf6", + "#06b6d4", + "#ec4899", + "#84cc16", +]; interface GraphNode { id: string; @@ -36,7 +45,9 @@ interface NetworkGraphProps { function snapshotClass(selected: boolean): string { return `flex flex-wrap items-center gap-x-4 gap-y-1 rounded-md border px-3 py-2 text-left text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 ${ - selected ? "border-blue-600 bg-blue-50 text-gray-900" : "border-gray-300 bg-white text-gray-700 hover:bg-gray-50" + selected + ? "border-blue-600 bg-blue-50 text-gray-900" + : "border-gray-300 bg-white text-gray-700 hover:bg-gray-50" }`; } @@ -83,8 +94,18 @@ function NetworkGraph({ nodes, links }: NetworkGraphProps) { /** Redes page: snapshot list + select → force-directed co-occurrence network graph. */ export default function Networks() { const selectedLotteryCode = useLotteryStore((s) => s.selectedLotteryCode); - const { data: snapshotList, isLoading: loadingList, error: listError, execute: fetchSnapshots } = useApi(getGraphSnapshots); - const { data: values, isLoading: loadingValues, error: valuesError, execute: fetchValues } = useApi(getGraphValues); + const { + data: snapshotList, + isLoading: loadingList, + error: listError, + execute: fetchSnapshots, + } = useApi(getGraphSnapshots); + const { + data: values, + isLoading: loadingValues, + error: valuesError, + execute: fetchValues, + } = useApi(getGraphValues); const [selectedId, setSelectedId] = useState(null); useEffect(() => { @@ -135,16 +156,28 @@ export default function Networks() { const selected = snapshots.find((item) => item.snapshot_id === selectedId) ?? null; const renderVisualization = (code: string, item: GraphSnapshotInfo) => { - if (valuesError) return void fetchValues(code, item.snapshot_id)} />; + if (valuesError) + return ( + void fetchValues(code, item.snapshot_id)} + /> + ); if (loadingValues) return ; if (links.length === 0) return ; return (

- Draws {item.draw_count} - Nodes {nodes.length} - Links {links.length} + + Draws {item.draw_count} + + + Nodes {nodes.length} + + + Links {links.length} +

); @@ -152,7 +185,13 @@ export default function Networks() { const renderContent = () => { if (!selectedLotteryCode) return ; - if (listError) return void fetchSnapshots(selectedLotteryCode, "network")} />; + if (listError) + return ( + void fetchSnapshots(selectedLotteryCode, "network")} + /> + ); if (loadingList) return ; if (snapshots.length === 0) return ; return ( @@ -160,9 +199,17 @@ export default function Networks() {
    {snapshots.map((item) => (
  • -
  • ))} @@ -176,11 +223,16 @@ export default function Networks() {

    Networks

    -

    Co-occurrence network between lottery numbers for the selected lottery.

    +

    + Co-occurrence network between lottery numbers for the selected lottery. +

    -
    +
    {renderContent()}
    ); -} \ No newline at end of file +} diff --git a/frontend/src/pages/Statistics.test.tsx b/frontend/src/pages/Statistics.test.tsx index ab8ead5..c27dbc6 100644 --- a/frontend/src/pages/Statistics.test.tsx +++ b/frontend/src/pages/Statistics.test.tsx @@ -10,7 +10,7 @@ const env = (data: unknown) => const err = (message: string, status = 500) => HttpResponse.json( { success: false, error: { code: "INTERNAL_ERROR", message }, timestamp: "" }, - { status }, + { status } ); const header = (extra: Record) => ({ snapshot_id: 1, @@ -57,7 +57,7 @@ const server = setupServer( http.post("*/api/v1/statistics/generate", () => { generateCalls += 1; return env(snapshot); - }), + }) ); const selectLottery = () => @@ -85,7 +85,7 @@ describe("Statistics", () => { render(); expect(await screen.findByRole("heading", { name: /statistics/i })).toBeInTheDocument(); expect( - await screen.findByRole("img", { name: /frequency distribution per number/i }), + await screen.findByRole("img", { name: /frequency distribution per number/i }) ).toBeInTheDocument(); expect(screen.getByText("100")).toBeInTheDocument(); expect(screen.getByText("1–100")).toBeInTheDocument(); @@ -97,18 +97,16 @@ describe("Statistics", () => { await screen.findByRole("img", { name: /frequency distribution per number/i }); fireEvent.click(screen.getByRole("tab", { name: /gaps/i })); expect( - await screen.findByRole("img", { name: /gap analysis per number/i }), + await screen.findByRole("img", { name: /gap analysis per number/i }) ).toBeInTheDocument(); fireEvent.click(screen.getByRole("tab", { name: /averages/i })); - expect( - await screen.findByRole("img", { name: /average gap per series/i }), - ).toBeInTheDocument(); + expect(await screen.findByRole("img", { name: /average gap per series/i })).toBeInTheDocument(); }); it("shows skeleton placeholders while data is loading", async () => { selectLottery(); server.use( - http.get("*/api/v1/statistics/L1/frequencies", () => delay(50).then(() => env(frequencyList))), + http.get("*/api/v1/statistics/L1/frequencies", () => delay(50).then(() => env(frequencyList))) ); const { container } = render(); expect(container.querySelector(".animate-pulse")).not.toBeNull(); @@ -126,8 +124,8 @@ describe("Statistics", () => { fireEvent.click(screen.getByRole("button", { name: /retry/i })); await waitFor(() => expect( - screen.getByRole("img", { name: /frequency distribution per number/i }), - ).toBeInTheDocument(), + screen.getByRole("img", { name: /frequency distribution per number/i }) + ).toBeInTheDocument() ); expect(screen.queryByRole("alert")).not.toBeInTheDocument(); }); @@ -135,20 +133,18 @@ describe("Statistics", () => { it("shows an empty state instead of crashing when frequencies are empty", async () => { selectLottery(); server.use( - http.get("*/api/v1/statistics/L1/frequencies", () => env(header({ frequencies: [] }))), + http.get("*/api/v1/statistics/L1/frequencies", () => env(header({ frequencies: [] }))) ); render(); expect( - await screen.findByText(/no statistics available for this lottery/i), + await screen.findByText(/no statistics available for this lottery/i) ).toBeInTheDocument(); expect(screen.queryByRole("img")).not.toBeInTheDocument(); }); it("prompts to select a lottery and does not call the API", async () => { render(); - expect( - await screen.findByText(/select a lottery to see its statistics/i), - ).toBeInTheDocument(); + expect(await screen.findByText(/select a lottery to see its statistics/i)).toBeInTheDocument(); expect(fetchCalls).toBe(0); expect(screen.queryByRole("img")).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: /generate snapshot/i })).toBeDisabled(); @@ -160,4 +156,4 @@ describe("Statistics", () => { fireEvent.click(await screen.findByRole("button", { name: /generate snapshot/i })); await waitFor(() => expect(generateCalls).toBe(1)); }); -}); \ No newline at end of file +}); diff --git a/frontend/src/pages/Statistics.tsx b/frontend/src/pages/Statistics.tsx index 976078e..ddf0346 100644 --- a/frontend/src/pages/Statistics.tsx +++ b/frontend/src/pages/Statistics.tsx @@ -24,9 +24,18 @@ const TABS: { id: StatTab; label: string }[] = [ function SnapshotHeader({ list }: { list: StatList }) { return (

    - Draws {list.draw_count} - Range {list.draws_from}–{list.draws_to} - Snapshot #{list.snapshot_id} + + Draws {list.draw_count} + + + Range{" "} + + {list.draws_from}–{list.draws_to} + + + + Snapshot #{list.snapshot_id} +

    ); } @@ -59,9 +68,24 @@ function ChartPanel({ list, loading, error, onRetry, empty, children }: ChartPan export default function Statistics() { const selectedLotteryCode = useLotteryStore((s) => s.selectedLotteryCode); const [activeTab, setActiveTab] = useState("frequencies"); - const { data: freq, isLoading: freqLoading, error: freqError, execute: fetchFrequencies } = useApi(getFrequencies); - const { data: gaps, isLoading: gapsLoading, error: gapsError, execute: fetchGaps } = useApi(getGaps); - const { data: averages, isLoading: avgLoading, error: avgError, execute: fetchAverages } = useApi(getAverages); + const { + data: freq, + isLoading: freqLoading, + error: freqError, + execute: fetchFrequencies, + } = useApi(getFrequencies); + const { + data: gaps, + isLoading: gapsLoading, + error: gapsError, + execute: fetchGaps, + } = useApi(getGaps); + const { + data: averages, + isLoading: avgLoading, + error: avgError, + execute: fetchAverages, + } = useApi(getAverages); const { isLoading: generating, execute: generate } = useApi(generateStatistics); useEffect(() => { @@ -127,7 +151,9 @@ export default function Statistics() { const tabClass = (active: boolean) => `rounded-md px-3 py-1.5 text-sm font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 ${ - active ? "bg-blue-600 text-white" : "border border-gray-300 bg-white text-gray-700 hover:bg-gray-50" + active + ? "bg-blue-600 text-white" + : "border border-gray-300 bg-white text-gray-700 hover:bg-gray-50" }`; return ( @@ -135,7 +161,9 @@ export default function Statistics() {

    Statistics

    -

    Frequencies, gaps and averages for the selected lottery.

    +

    + Frequencies, gaps and averages for the selected lottery. +

    ); -} \ No newline at end of file +} diff --git a/frontend/src/pages/Trends.test.tsx b/frontend/src/pages/Trends.test.tsx index 4b3e41d..9693e50 100644 --- a/frontend/src/pages/Trends.test.tsx +++ b/frontend/src/pages/Trends.test.tsx @@ -11,7 +11,7 @@ const env = (data: unknown) => const err = (message: string, status = 500) => HttpResponse.json( { success: false, error: { code: "INTERNAL_ERROR", message }, timestamp: "" }, - { status }, + { status } ); const draw = (id: number, drawNumber: number): Draw => ({ @@ -63,7 +63,7 @@ const server = setupServer( http.get("*/api/v1/statistics/L1/frequencies", () => { fetchCalls += 1; return env(frequencyList); - }), + }) ); const selectLottery = () => @@ -92,10 +92,10 @@ describe("Trends", () => { expect( screen.getByRole("img", { name: "Rolling frequency trend of hot numbers over recent draws", - }), + }) ).toBeInTheDocument(); expect( - screen.getByRole("img", { name: "Frequency distribution per number" }), + screen.getByRole("img", { name: "Frequency distribution per number" }) ).toBeInTheDocument(); const hot = screen.getByRole("region", { name: "Hot numbers" }); expect(within(hot).getAllByText("25×").length).toBeGreaterThan(0); @@ -108,35 +108,37 @@ describe("Trends", () => { server.use( http.get("*/api/v1/statistics/L1/frequencies", () => HttpResponse.json( - { success: false, error: { code: "RESOURCE_NOT_FOUND", message: "No snapshot" }, timestamp: "" }, - { status: 404 }, - ), - ), + { + success: false, + error: { code: "RESOURCE_NOT_FOUND", message: "No snapshot" }, + timestamp: "", + }, + { status: 404 } + ) + ) ); render(); expect( await screen.findByRole("img", { name: "Rolling frequency trend of hot numbers over recent draws", - }), + }) ).toBeInTheDocument(); expect( - screen.getByRole("img", { name: "Frequency distribution per number" }), + screen.getByRole("img", { name: "Frequency distribution per number" }) ).toBeInTheDocument(); expect(screen.queryByRole("alert")).not.toBeInTheDocument(); }); it("shows skeleton placeholders while draws are loading", async () => { selectLottery(); - server.use( - http.get("*/api/v1/draws", () => delay(50).then(() => env(drawRange(25)))), - ); + server.use(http.get("*/api/v1/draws", () => delay(50).then(() => env(drawRange(25))))); const { container } = render(); expect(container.querySelector(".animate-pulse")).not.toBeNull(); await waitFor(() => expect(container.querySelector(".animate-pulse")).toBeNull()); expect( screen.getByRole("img", { name: "Rolling frequency trend of hot numbers over recent draws", - }), + }) ).toBeInTheDocument(); }); @@ -152,18 +154,16 @@ describe("Trends", () => { expect( screen.getByRole("img", { name: "Rolling frequency trend of hot numbers over recent draws", - }), - ).toBeInTheDocument(), + }) + ).toBeInTheDocument() ); expect(screen.queryByRole("alert")).not.toBeInTheDocument(); }); it("prompts to select a lottery and does not call the API", async () => { render(); - expect( - await screen.findByText(/select a lottery to see its trends/i), - ).toBeInTheDocument(); + expect(await screen.findByText(/select a lottery to see its trends/i)).toBeInTheDocument(); expect(fetchCalls).toBe(0); expect(screen.queryByRole("img")).not.toBeInTheDocument(); }); -}); \ No newline at end of file +}); diff --git a/frontend/src/pages/Trends.tsx b/frontend/src/pages/Trends.tsx index 1b21932..da08b44 100644 --- a/frontend/src/pages/Trends.tsx +++ b/frontend/src/pages/Trends.tsx @@ -154,16 +154,20 @@ export default function Trends() { const hot = useMemo(() => extremeFrequencies(windowCounts, false), [windowCounts]); const cold = useMemo(() => extremeFrequencies(windowCounts, true), [windowCounts]); const trendData = useMemo( - () => rollingTrend(draws ?? [], hot.map((row) => row.number)), - [draws, hot], + () => + rollingTrend( + draws ?? [], + hot.map((row) => row.number) + ), + [draws, hot] ); const windowFrequencies = useMemo( - () => - [...windowCounts.entries()].map(([number, count]) => ({ number, count })), - [windowCounts], + () => [...windowCounts.entries()].map(([number, count]) => ({ number, count })), + [windowCounts] ); - const overallFrequencies = - freqData?.frequencies.length ? freqData.frequencies : windowFrequencies; + const overallFrequencies = freqData?.frequencies.length + ? freqData.frequencies + : windowFrequencies; const renderContent = () => { if (!selectedLotteryCode) return ; @@ -202,11 +206,11 @@ export default function Trends() {

    Trends

    - Rolling frequency and hot/cold numbers derived from recent draws and the - frequency snapshot for the selected lottery. + Rolling frequency and hot/cold numbers derived from recent draws and the frequency + snapshot for the selected lottery.

    {renderContent()} ); -} \ No newline at end of file +} diff --git a/frontend/src/services/api.test.ts b/frontend/src/services/api.test.ts index 5af83bc..c4485be 100644 --- a/frontend/src/services/api.test.ts +++ b/frontend/src/services/api.test.ts @@ -1,11 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { - apiClient, - NotFoundError, - ConflictError, - ValidationError, - ServerError, -} from "./api"; +import { apiClient, NotFoundError, ConflictError, ValidationError, ServerError } from "./api"; describe("apiClient", () => { const BASE = "/api/v1"; @@ -44,9 +38,7 @@ describe("apiClient", () => { }), }); - await expect(apiClient("/gen/generate")).rejects.toThrow( - "No active selection", - ); + await expect(apiClient("/gen/generate")).rejects.toThrow("No active selection"); }); it("throws NotFoundError on 404", async () => { @@ -60,9 +52,7 @@ describe("apiClient", () => { }), }); - await expect(apiClient("/lotteries/999")).rejects.toBeInstanceOf( - NotFoundError, - ); + await expect(apiClient("/lotteries/999")).rejects.toBeInstanceOf(NotFoundError); }); it("throws ConflictError on 409", async () => { @@ -90,9 +80,7 @@ describe("apiClient", () => { }), }); - await expect(apiClient("/gen/generate")).rejects.toBeInstanceOf( - ValidationError, - ); + await expect(apiClient("/gen/generate")).rejects.toBeInstanceOf(ValidationError); }); it("throws ServerError on 500", async () => { @@ -132,7 +120,7 @@ describe("apiClient", () => { expect(result).toEqual({ snapshot_id: 1 }); expect(fetchSpy).toHaveBeenCalledWith( `${BASE}/statistics/generate`, - expect.objectContaining({ method: "POST" }), + expect.objectContaining({ method: "POST" }) ); }); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 948cf17..0a24bd5 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -7,7 +7,7 @@ export class AppError extends Error { constructor( message: string, public readonly code: string, - public readonly status: number, + public readonly status: number ) { super(message); this.name = "AppError"; @@ -55,9 +55,7 @@ async function parseResponse(response: Response): Promise { try { body = await response.json(); } catch { - throw new ServerError( - `Invalid response from server (HTTP ${response.status})`, - ); + throw new ServerError(`Invalid response from server (HTTP ${response.status})`); } // Error envelope from backend @@ -113,10 +111,7 @@ function throwByStatus(status: number, message: string, code?: string): never { * Reads VITE_API_BASE_URL from env (defaults to /api/v1). * Parses SuccessEnvelope / ErrorEnvelope and maps HTTP errors to typed classes. */ -export async function apiClient( - path: string, - init?: RequestInit, -): Promise { +export async function apiClient(path: string, init?: RequestInit): Promise { const url = `${BASE_URL}${path}`; const response = await fetch(url, { headers: { diff --git a/frontend/src/services/assistant.test.ts b/frontend/src/services/assistant.test.ts index b0d04b4..6c6295a 100644 --- a/frontend/src/services/assistant.test.ts +++ b/frontend/src/services/assistant.test.ts @@ -44,7 +44,7 @@ const server = setupServer( lastUrl = request.url; lastBody = await request.json(); return env(response); - }), + }) ); beforeAll(() => server.listen()); @@ -101,9 +101,9 @@ describe("assistant service", () => { error: { code: "RESOURCE_NOT_FOUND", message: "Lottery not found" }, timestamp: "", }, - { status: 404 }, - ), - ), + { status: 404 } + ) + ) ); await expect(explainAssistant("L999")).rejects.toBeInstanceOf(NotFoundError); }); @@ -117,11 +117,11 @@ describe("assistant service", () => { error: { code: "assistant_error", message: "Generation failed" }, timestamp: "", }, - { status: 500 }, - ), - ), + { status: 500 } + ) + ) ); await expect(reportAssistant("L1")).rejects.toThrow("Generation failed"); await expect(reportAssistant("L1")).rejects.toBeInstanceOf(ServerError); }); -}); \ No newline at end of file +}); diff --git a/frontend/src/services/assistant.ts b/frontend/src/services/assistant.ts index 166ccc7..ae69652 100644 --- a/frontend/src/services/assistant.ts +++ b/frontend/src/services/assistant.ts @@ -1,53 +1,38 @@ import type { AssistantResponse, SummarizeRequest } from "../types/assistant"; import { apiClient } from "./api"; -export type ReportScope = - | "frequency" - | "gap" - | "average" - | "probability" - | "experiment"; +export type ReportScope = "frequency" | "gap" | "average" | "probability" | "experiment"; /** Request a Spanish explanation of a lottery's results (optional subject/context). */ export async function explainAssistant( lotteryCode: string, subject?: string, - context?: string, + context?: string ): Promise { const searchParams = new URLSearchParams({ lottery_code: lotteryCode }); if (subject) searchParams.set("subject", subject); if (context) searchParams.set("context", context); - return apiClient( - `/assistant/explain?${searchParams.toString()}`, - ); + return apiClient(`/assistant/explain?${searchParams.toString()}`); } /** Request a Spanish interpretation of the data behind the charts. */ -export async function interpretAssistant( - lotteryCode: string, -): Promise { +export async function interpretAssistant(lotteryCode: string): Promise { const searchParams = new URLSearchParams({ lottery_code: lotteryCode }); - return apiClient( - `/assistant/interpret?${searchParams.toString()}`, - ); + return apiClient(`/assistant/interpret?${searchParams.toString()}`); } /** Request a scoped Spanish plain-text report for a lottery. */ export async function reportAssistant( lotteryCode: string, - scope?: ReportScope, + scope?: ReportScope ): Promise { const searchParams = new URLSearchParams({ lottery_code: lotteryCode }); if (scope) searchParams.set("scope", scope); - return apiClient( - `/assistant/report?${searchParams.toString()}`, - ); + return apiClient(`/assistant/report?${searchParams.toString()}`); } /** Summarize an experiment comparison in Spanish. */ -export async function summarizeAssistant( - req: SummarizeRequest, -): Promise { +export async function summarizeAssistant(req: SummarizeRequest): Promise { return apiClient("/assistant/summarize", { method: "POST", body: JSON.stringify(req), @@ -55,12 +40,9 @@ export async function summarizeAssistant( } /** Route a free-text question to the matching generator (unknown -> capabilities). */ -export async function assist( - question: string, - lotteryCode: string, -): Promise { +export async function assist(question: string, lotteryCode: string): Promise { return apiClient("/assistant/assist", { method: "POST", body: JSON.stringify({ question, lottery_code: lotteryCode }), }); -} \ No newline at end of file +} diff --git a/frontend/src/services/backtesting.ts b/frontend/src/services/backtesting.ts index c5f67bf..1c1c9f4 100644 --- a/frontend/src/services/backtesting.ts +++ b/frontend/src/services/backtesting.ts @@ -1,8 +1,4 @@ -import type { - BacktestRun, - BacktestHistoryEntry, - BacktestResult, -} from "../types/backtesting"; +import type { BacktestRun, BacktestHistoryEntry, BacktestResult } from "../types/backtesting"; import { apiClient } from "./api"; /** Run a backtest. */ @@ -22,21 +18,15 @@ export async function runBacktest(params: { } /** Get backtest history for a lottery. */ -export async function getBacktestHistory( - lotteryId: number, -): Promise { - return apiClient( - `/backtesting/history?lottery_id=${lotteryId}`, - ); +export async function getBacktestHistory(lotteryId: number): Promise { + return apiClient(`/backtesting/history?lottery_id=${lotteryId}`); } /** Get backtest results for a lottery. */ export async function getBacktestResults( lotteryId: number, - snapshotId?: number, + snapshotId?: number ): Promise { const params = snapshotId ? `&snapshot_id=${snapshotId}` : ""; - return apiClient( - `/backtesting/results?lottery_id=${lotteryId}${params}`, - ); + return apiClient(`/backtesting/results?lottery_id=${lotteryId}${params}`); } diff --git a/frontend/src/services/dl.ts b/frontend/src/services/dl.ts index 157203e..92bf65f 100644 --- a/frontend/src/services/dl.ts +++ b/frontend/src/services/dl.ts @@ -25,10 +25,7 @@ export async function getDlModels(lotteryId: number): Promise } /** Get DL metrics for a lottery, optionally filtered by model family. */ -export async function getDlMetrics( - lotteryId: number, - modelId?: string, -): Promise { +export async function getDlMetrics(lotteryId: number, modelId?: string): Promise { const params = modelId ? `&model_id=${modelId}` : ""; return apiClient(`/dl/metrics?lottery_id=${lotteryId}${params}`); } diff --git a/frontend/src/services/draws.ts b/frontend/src/services/draws.ts index 796e522..51aa2e7 100644 --- a/frontend/src/services/draws.ts +++ b/frontend/src/services/draws.ts @@ -2,13 +2,9 @@ import type { Draw } from "../types/draw"; import { apiClient } from "./api"; /** Fetch draws for a lottery, ordered descending. */ -export async function getDraws( - lotteryCode: string, - page = 1, - pageSize = 50, -): Promise { +export async function getDraws(lotteryCode: string, page = 1, pageSize = 50): Promise { return apiClient( - `/draws?lottery=${lotteryCode}&order=desc&page=${page}&page_size=${pageSize}`, + `/draws?lottery=${lotteryCode}&order=desc&page=${page}&page_size=${pageSize}` ); } diff --git a/frontend/src/services/experiments.ts b/frontend/src/services/experiments.ts index e5c2cc3..45db0a4 100644 --- a/frontend/src/services/experiments.ts +++ b/frontend/src/services/experiments.ts @@ -2,21 +2,16 @@ import type { Experiment, ComparisonResponse } from "../types/experiment"; import { apiClient } from "./api"; /** List experiments for a lottery. */ -export async function listExperiments( - lotteryId: number, - status?: string, -): Promise { +export async function listExperiments(lotteryId: number, status?: string): Promise { const params = status ? `&status=${status}` : ""; - return apiClient( - `/experiment/?lottery_id=${lotteryId}${params}`, - ); + return apiClient(`/experiment/?lottery_id=${lotteryId}${params}`); } /** Create a new experiment. */ export async function createExperiment( lotteryId: number, name: string, - description?: string, + description?: string ): Promise { return apiClient("/experiment/create", { method: "POST", @@ -34,7 +29,7 @@ export async function runExperiment( experimentId: number, runLabel: string, engineType: string, - engineSnapshotId: number, + engineSnapshotId: number ): Promise<{ run_id: number; experiment_id: number; run_label: string }> { return apiClient(`/experiment/${experimentId}/run`, { method: "POST", @@ -49,7 +44,7 @@ export async function runExperiment( /** Compare runs within an experiment. */ export async function compareRuns( experimentId: number, - runIds: number[], + runIds: number[] ): Promise { return apiClient(`/experiment/${experimentId}/compare`, { method: "POST", diff --git a/frontend/src/services/gen.ts b/frontend/src/services/gen.ts index 3707eda..4a99f76 100644 --- a/frontend/src/services/gen.ts +++ b/frontend/src/services/gen.ts @@ -22,17 +22,15 @@ export async function generateCombinations(params: { /** Get combinations for a snapshot. */ export async function getCombinations( lotteryId: number, - snapshotId: number, + snapshotId: number ): Promise { return apiClient( - `/gen/combinations?lottery_id=${lotteryId}&snapshot_id=${snapshotId}`, + `/gen/combinations?lottery_id=${lotteryId}&snapshot_id=${snapshotId}` ); } /** List generator snapshots for a lottery. */ -export async function getSnapshots( - lotteryId: number, -): Promise { +export async function getSnapshots(lotteryId: number): Promise { return apiClient(`/gen/snapshots?lottery_id=${lotteryId}`); } diff --git a/frontend/src/services/graph.ts b/frontend/src/services/graph.ts index 2d31319..4d3fcff 100644 --- a/frontend/src/services/graph.ts +++ b/frontend/src/services/graph.ts @@ -9,7 +9,7 @@ import { apiClient } from "./api"; /** Trigger graph computation for a lottery. */ export async function computeGraph( lotteryCode: string, - graphType: GraphType = "cooccurrence", + graphType: GraphType = "cooccurrence" ): Promise { return apiClient("/graph/compute", { method: "POST", @@ -20,20 +20,16 @@ export async function computeGraph( /** List graph snapshots for a lottery. */ export async function getGraphSnapshots( lotteryCode: string, - graphType?: GraphType, + graphType?: GraphType ): Promise { const params = graphType ? `?graph_type=${graphType}` : ""; - return apiClient( - `/graph/${lotteryCode}/snapshots${params}`, - ); + return apiClient(`/graph/${lotteryCode}/snapshots${params}`); } /** Get graph values for a specific snapshot. */ export async function getGraphValues( lotteryCode: string, - snapshotId: number, + snapshotId: number ): Promise { - return apiClient( - `/graph/${lotteryCode}/snapshots/${snapshotId}`, - ); + return apiClient(`/graph/${lotteryCode}/snapshots/${snapshotId}`); } diff --git a/frontend/src/services/lotteries.ts b/frontend/src/services/lotteries.ts index bb9eb3e..7f32cbb 100644 --- a/frontend/src/services/lotteries.ts +++ b/frontend/src/services/lotteries.ts @@ -2,9 +2,6 @@ import type { Lottery } from "../types/lottery"; import { apiClient } from "./api"; /** Fetch all lotteries (paginated). */ -export async function getLotteries( - page = 1, - pageSize = 100, -): Promise { +export async function getLotteries(page = 1, pageSize = 100): Promise { return apiClient(`/lotteries?page=${page}&page_size=${pageSize}`); } diff --git a/frontend/src/services/ml.ts b/frontend/src/services/ml.ts index 97dd199..1bf152d 100644 --- a/frontend/src/services/ml.ts +++ b/frontend/src/services/ml.ts @@ -2,15 +2,9 @@ import type { MLTrainResult, MLMetrics, MLSnapshot } from "../types/ml"; import { apiClient } from "./api"; /** Trigger ML training for a lottery. */ -export async function trainModels( - lotteryId: number, - family?: string, -): Promise { +export async function trainModels(lotteryId: number, family?: string): Promise { const params = family ? `&family=${family}` : ""; - return apiClient( - `/ml/train?lottery_id=${lotteryId}${params}`, - { method: "POST" }, - ); + return apiClient(`/ml/train?lottery_id=${lotteryId}${params}`, { method: "POST" }); } /** Get the active ML snapshot for a lottery. */ @@ -19,10 +13,7 @@ export async function getModels(lotteryId: number): Promise { } /** Get ML metrics for a lottery. */ -export async function getMetrics( - lotteryId: number, - modelId?: string, -): Promise { +export async function getMetrics(lotteryId: number, modelId?: string): Promise { const params = modelId ? `&model_id=${modelId}` : ""; return apiClient(`/ml/metrics?lottery_id=${lotteryId}${params}`); } diff --git a/frontend/src/services/pipeline.ts b/frontend/src/services/pipeline.ts index 374c12e..f068ad2 100644 --- a/frontend/src/services/pipeline.ts +++ b/frontend/src/services/pipeline.ts @@ -6,9 +6,7 @@ import { apiClient } from "./api"; * call may take minutes; the response carries the ordered per-stage report and * the generation echo (null when a stage failed). */ -export async function runNumbersPipeline( - params: PipelineRunParams, -): Promise { +export async function runNumbersPipeline(params: PipelineRunParams): Promise { return apiClient("/pipeline/numbers", { method: "POST", body: JSON.stringify(params), diff --git a/frontend/src/services/probability.ts b/frontend/src/services/probability.ts index f40ed93..7ce89b0 100644 --- a/frontend/src/services/probability.ts +++ b/frontend/src/services/probability.ts @@ -1,13 +1,10 @@ -import type { - ProbabilityList, - ProbabilitySnapshot, -} from "../types/probability"; +import type { ProbabilityList, ProbabilitySnapshot } from "../types/probability"; import { apiClient } from "./api"; /** Trigger probability generation for a lottery. */ export async function generateProbability( lotteryCode: string, - scope: "incremental" | "full" = "incremental", + scope: "incremental" | "full" = "incremental" ): Promise { return apiClient("/probability/generate", { method: "POST", @@ -18,7 +15,7 @@ export async function generateProbability( /** Fetch probability rows for a lottery. */ export async function getProbabilities( lotteryCode: string, - params?: { model?: string; subject?: string; last?: number }, + params?: { model?: string; subject?: string; last?: number } ): Promise { const searchParams = new URLSearchParams(); if (params?.model) searchParams.set("model", params.model); @@ -26,6 +23,6 @@ export async function getProbabilities( if (params?.last !== undefined) searchParams.set("last", String(params.last)); const qs = searchParams.toString(); return apiClient( - `/probability/${lotteryCode}/probabilities${qs ? `?${qs}` : ""}`, + `/probability/${lotteryCode}/probabilities${qs ? `?${qs}` : ""}` ); } diff --git a/frontend/src/services/statistics.ts b/frontend/src/services/statistics.ts index f6bf37f..5d68322 100644 --- a/frontend/src/services/statistics.ts +++ b/frontend/src/services/statistics.ts @@ -1,15 +1,10 @@ -import type { - FrequencyList, - GapList, - AverageList, - StatisticsSnapshot, -} from "../types/statistics"; +import type { FrequencyList, GapList, AverageList, StatisticsSnapshot } from "../types/statistics"; import { apiClient } from "./api"; /** Trigger statistics generation for a lottery. */ export async function generateStatistics( lotteryCode: string, - scope: "incremental" | "full" = "incremental", + scope: "incremental" | "full" = "incremental" ): Promise { return apiClient("/statistics/generate", { method: "POST", @@ -18,30 +13,19 @@ export async function generateStatistics( } /** Fetch frequency distribution for a lottery. */ -export async function getFrequencies( - lotteryCode: string, - last?: number, -): Promise { +export async function getFrequencies(lotteryCode: string, last?: number): Promise { const params = last !== undefined ? `?last=${last}` : ""; - return apiClient( - `/statistics/${lotteryCode}/frequencies${params}`, - ); + return apiClient(`/statistics/${lotteryCode}/frequencies${params}`); } /** Fetch gap analysis for a lottery. */ -export async function getGaps( - lotteryCode: string, - last?: number, -): Promise { +export async function getGaps(lotteryCode: string, last?: number): Promise { const params = last !== undefined ? `?last=${last}` : ""; return apiClient(`/statistics/${lotteryCode}/gaps${params}`); } /** Fetch averages for a lottery. */ -export async function getAverages( - lotteryCode: string, - last?: number, -): Promise { +export async function getAverages(lotteryCode: string, last?: number): Promise { const params = last !== undefined ? `?last=${last}` : ""; return apiClient(`/statistics/${lotteryCode}/averages${params}`); } diff --git a/frontend/src/store/useLotteryStore.test.ts b/frontend/src/store/useLotteryStore.test.ts index 43e2df3..9beb343 100644 --- a/frontend/src/store/useLotteryStore.test.ts +++ b/frontend/src/store/useLotteryStore.test.ts @@ -94,9 +94,7 @@ describe("useLotteryStore", () => { result.current.setSelected(3, "L3"); }); - const stored = JSON.parse( - localStorage.getItem("lip:selectedLottery") ?? "{}", - ); + const stored = JSON.parse(localStorage.getItem("lip:selectedLottery") ?? "{}"); expect(stored.state.selectedLotteryId).toBe(3); expect(stored.state.selectedLotteryCode).toBe("L3"); }); @@ -108,9 +106,7 @@ describe("useLotteryStore", () => { result.current.setSelected(1, "L1"); }); - const stored = JSON.parse( - localStorage.getItem("lip:selectedLottery") ?? "{}", - ); + const stored = JSON.parse(localStorage.getItem("lip:selectedLottery") ?? "{}"); expect(stored.state.lotteries).toBeUndefined(); }); }); diff --git a/frontend/src/store/useLotteryStore.ts b/frontend/src/store/useLotteryStore.ts index 23d5870..b3475c2 100644 --- a/frontend/src/store/useLotteryStore.ts +++ b/frontend/src/store/useLotteryStore.ts @@ -59,6 +59,6 @@ export const useLotteryStore = create()( selectedLotteryId: state.selectedLotteryId, selectedLotteryCode: state.selectedLotteryCode, }), - }, - ), + } + ) ); diff --git a/frontend/src/types/assistant.ts b/frontend/src/types/assistant.ts index 286866d..5588f6b 100644 --- a/frontend/src/types/assistant.ts +++ b/frontend/src/types/assistant.ts @@ -15,4 +15,4 @@ export interface SummarizeRequest { export interface AssistRequest { question: string; lottery_code: string; -} \ No newline at end of file +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 1ffef60..d32ff68 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,7 +1,4 @@ { "files": [], - "references": [ - { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } - ] + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] }