diff --git a/CHANGELOG.md b/CHANGELOG.md index 900c0da..740d0bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,272 @@ project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added + +- **Browser Bevy canvas route and automation support.** Added the `solitaire_web` + Bevy WASM build, wired `/play` to the Bevy canvas, added a + `window.__FERROUS_DEBUG__` bridge, and introduced Playwright coverage for the + web routes and interactive canvas behavior. +- **Card-game / klondike integration.** Began replacing in-house card and pile + internals with upstream `card_game` / `klondike` types, including adapter + work, GameMode-aware scoring, upstream instruction serde, `KlondikePile` + migration, and documentation for the in-place rewrite phases. +- **Android keystore integration.** Added Android Keystore JNI wiring via + `OnceLock` and improved Android token handling around the app directory. + +### Changed + +- **Core type ownership.** Routed all klondike/card imports through + `solitaire_core` and unified local `Suit` / `Rank` with upstream `card_game` + types. +- **Web/WASM build reliability.** Rebuilt WASM packages, cleaned up wasm32 build + warnings, added a Binaryen `wasm-opt` pass, pinned upstream git dependencies, + and added a CI guard for canvas WASM drift. +- **Difficulty seed catalog.** Regenerated the difficulty seed list for the + latest verified catalog. + +### Fixed + +- **Android and modal safe-area layout.** Modal cards now center within the + usable area between status and gesture bars, additional modal-spawn guards were + added, and Android build scripts now auto-discover SDK/NDK paths and strip + native libraries. +- **Core scoring and undo correctness.** Fixed recycle-count drift, undo score + compounding, foundation-to-tableau instruction coverage, and several + illegal-move paths discovered during the card-game migration. +- **Input and rendering issues.** Fixed stock/waste hit testing, accepted waste + clicks, delayed first-run onboarding until splash teardown, and kept dragged + stacks above all piles. +- **Web runtime stability.** Fixed wasm32 runtime panics, HiDPI canvas surface + sizing, WebGL2 shader compatibility, and Firefox boot/render behavior. +- **Server and data hardening.** Moved bcrypt work to `spawn_blocking`, switched + file paths to async I/O where needed, and validated `JWT_SECRET` at startup. +- **CI and deployment workflow.** Fixed deploy-branch handling, Docker registry + secret usage, and related release automation issues. + +### Tests + +- Added schema-v3 persistence round-trip coverage, foundation-to-tableau + instruction coverage, expanded WASM unit tests, and Playwright E2E specs for + browser routes and game-canvas behavior. + +## [0.39.0] — 2026-05-19 + +### Fixed + +- **No-legal-moves detection and banner.** Corrected no-move detection across + engine, WASM, and web paths, then surfaced the state to players with an + in-game banner instead of silently leaving the board stuck. +- **Release/deploy automation.** Updated deployment automation so kustomization + changes are pushed to the deploy branch instead of the main development + branch. + +## [0.38.0] — 2026-05-19 + +### Added + +- **Klondike scoring parity.** Added tableau flip bonuses and stock recycle + penalties to align scoring with standard Klondike expectations. + +### Fixed + +- **Core rule enforcement.** Auto-complete now requires an empty waste pile, + waste-origin moves reject multi-card transfers, foundation-to-foundation moves + are blocked, and undo restores score from the snapshot baseline. +- **Modal lifecycle guards.** Added missing `ModalScrim` guards to New Game, + restore prompt, and no-moves modal spawn sites. +- **Runtime and server robustness.** Tokio runtime setup degrades gracefully + instead of panicking; web replay submission casing/date formatting now matches + server expectations; avatar routes are publicly reachable when intended. +- **Android token and sync merge correctness.** Android tokens are namespaced + under the application directory, stored per user, and migrated safely; sync + merges preserve draw-one / draw-three win invariants. + +## [0.37.0] — 2026-05-19 + +### Fixed + +- **Foundation-to-tableau default.** Made `take_from_foundation` default to true + across clients so restored, startup, and web games use the same supported move + rules. + +## [0.36.12] — 2026-05-19 + +### Fixed + +- **Foundation-to-tableau default.** Set `take_from_foundation` true by default + in core so every client inherits the intended house rule without special-case + setup. + +## [0.36.11] — 2026-05-19 + +### Fixed + +- **Web foundation moves.** Enabled take-from-foundation moves in the web game + client. + +## [0.36.10] — 2026-05-19 + +### Added + +- **Web resume flow.** Browser games now persist state across page refreshes and + can resume through a dialog instead of starting over. + +## [0.36.9] — 2026-05-19 + +### Fixed + +- **Settings sync connection flow.** Clicking Connect from Settings now opens the + sync-setup modal. + +## [0.36.8] — 2026-05-19 + +### Fixed + +- **Restored/startup foundation moves.** Enabled take-from-foundation behavior + for restored and startup games, not only newly-created sessions. + +## [0.36.7] — 2026-05-19 + +### Fixed + +- **Remaining Android UI issues.** Resolved the final Android UI defects from + the review pass, including action-bar/tableau interaction and safe visual + spacing. + +## [0.36.6] — 2026-05-19 + +### Fixed + +- **Action-bar layout reservation.** Reserved action-bar height in layout so + tableau columns do not extend behind bottom controls. + +## [0.36.5] — 2026-05-19 + +### Added + +- **Responsive Android action-bar glyphs.** Action-bar glyph font size now scales + dynamically on Android to fit available space. + +## [0.36.4] — 2026-05-19 + +### Fixed + +- **Classic card labels and HUD overlap.** Corrected classic-card corner-label + colors and fixed HUD-band overlap in the Android layout. + +## [0.36.3] — 2026-05-19 + +### Fixed + +- **Core, animation, and modal review fixes.** Added the foundation-to-tableau + score penalty, hardened solver win validation, guarded zero-duration card + animations, aligned initial and dynamic tableau fan spacing, and added missing + modal guards for play-by-seed and win-summary paths. +- **Pause, messages, credentials, and server validation.** Auto-complete respects + pause state, standalone plugins register their events, sync passwords are + cleared from ECS buffers after auth task spawn, and avatar MIME validation uses + exact matches. +- **Foundation pile rendering.** Raised stack fan z-order above corner labels to + prevent bleed-through. +- **Android release workflow.** Added a manual `workflow_dispatch` trigger to + the Android release workflow. + +## [0.36.2] — 2026-05-19 + +### Fixed + +- **Comprehensive review fixes.** Addressed 26 issues across core rules, replay + controls, modal guards, sync payload timing, server replay casing, time-attack + overlays, theme refresh, auth overlays, stats ordering, animations, cursor + fallbacks, achievements, server temp-file cleanup, and runtime fallback paths. +- **Animation and Android label polish.** Cancelled stale win-cascade animations + on new game, refreshed Android corner labels on resize, lifted animating cards + above lower z-layers, and froze the web timer when auto-complete starts. +- **Web package and tooling updates.** Rebuilt the WASM package for + foundation-to-tableau moves, added ruflo scaffolding, and ignored ruflo runtime + state files. +- **Leaderboard test stability.** Made opt-in / opt-out tests robust under + parallel test execution. + +## [0.36.1] — 2026-05-18 + +### Fixed + +- **Android HUD gesture conflict.** Stock taps no longer toggle HUD visibility on + Android. + +## [0.36.0] — 2026-05-18 + +### Changed + +- **Rank model cleanup.** `Rank` now uses explicit discriminants and checked + arithmetic, making rank conversions and sequencing more robust. +- **Instruction generation.** Refined `possible_instructions` alongside the rank + arithmetic cleanup. +- **Session handoff.** Recreated `SESSION_HANDOFF.md` to reflect the `0.35.1` + state. + +## [0.35.1] — 2026-05-17 + +### Fixed + +- **Leaderboard profile sync.** Fixed three leaderboard/profile issues: wrong + toast type for failures, stale display-name label after update, and display + name not syncing to the server. + +## [0.35.0] — 2026-05-17 + +### Added + +- **Reduced-motion support.** Decorative motion animations are now gated behind + `reduce_motion_mode`. + +### Changed + +- **Performance and runtime cleanup.** Shared a single Tokio runtime across + network tasks and gated frame-hot ECS systems on resource changes. +- **Core/data refactors.** Consolidated the application directory name, added + `#[must_use]` to pure helpers, derived `Copy` for `DrawMode`, removed + redundant clones, added missing derives to `AchievementContext`, and used + saturating move-count arithmetic. +- **HUD z-layer naming.** Replaced raw HUD popover z-index arithmetic with named + layer constants. + +### Fixed + +- **Android UI and font safety.** Wired FiraMono to stock-empty labels, removed + raw physical safe-area pixels from HUD spawns, replaced unsupported chevrons, + corrected the Android help hint label, and fixed touch/drop-zone behavior. +- **Engine modal and panic hardening.** Eliminated several runtime panics, added + required transforms to modal scrims, constrained dismiss hit-tests, and guarded + home overlay respawns. +- **Sync/data/server correctness.** Deterministic pile serialization, undo skip + handling, byte URL encoding, merge timestamp handling, auth-guarded avatar + serving, atomic server writes, and user-id assertions were corrected. +- **Display-name and token-file boundaries.** Enforced the 32-character display + name limit in the sync client and aligned Android keystore temp-file cleanup + with the cleanup glob. +- **WASM error reporting.** `state()` and `step()` now return `Result` so errors + surface as JavaScript exceptions. +- **Sync and leaderboard toasts.** Pull failures and leaderboard opt-in / + opt-out failures now produce the intended warning/error feedback. + +### Documentation + +- Corrected stale focus-ring color documentation. + +## [0.34.0] — 2026-05-17 + +### Fixed + +- **Android waste fan and resume layout.** Corrected Android waste-pile fan + overlap and a layout desynchronization after resume. +- **Card-face artwork.** Fixed the wrong bottom-right suit symbol on the jack, + queen, and king of spades. +- **Android corner-label font coverage.** Wired FiraMono into Android corner + labels and added `CardImageSet` tests to guard the asset path behavior. + ## [0.33.0] — 2026-05-16 ### Fixed diff --git a/SESSION_HANDOFF.md b/SESSION_HANDOFF.md index a3b509d..5a76e6f 100644 --- a/SESSION_HANDOFF.md +++ b/SESSION_HANDOFF.md @@ -1,45 +1,36 @@ # Ferrous Solitaire — Session Handoff -**Last updated:** 2026-06-02 — Web e2e test suite complete; `/play` canvas bridge added and tested. All commits on origin/master. +**Last updated:** 2026-06-09 — card_game rewrite follow-up complete; changelog catch-up in progress; analytics follow-up tests added. --- ## Current state -- **HEAD:** `play_canvas.spec.js` added (Playwright tests for `/play` Bevy canvas route) -- **Latest tag:** `v0.35.1` -- **Working tree:** clean -- **Build:** `cargo clippy --workspace -- -D warnings` clean -- **Tests:** 1243 Rust tests passing; Playwright suite in `solitaire_server/e2e/` +- **HEAD:** `6193d31` (`fix(engine): centre modal cards within usable area (status-bar + gesture-bar)`) +- **Latest tag:** `v0.39.0` +- **Working tree:** documentation and analytics-test follow-up changes pending (`CHANGELOG.md`, `SESSION_HANDOFF.md`, `docs/card-game-integration.md`, `solitaire_data/src/matomo_client.rs`, `solitaire_engine/src/analytics_plugin.rs`) +- **Latest verification in this follow-up:** `cargo test -p solitaire_core`; Matomo client/plugin targeted tests. +- **Full previous gate:** Claude reported recent card_game work pushed to origin and `cargo test` / `clippy` gates passing before the changelog follow-up. --- -## What shipped since the last handoff (v0.35.1 → present, 2026-06-02) +## What shipped since v0.39.0 -| Commit | Summary | -|--------|---------| -| `64f975e` | 14 cross-platform UX/UI fixes from 500-game audit | -| `763fdb4` | Fix input: hit-test deck at correct position; accept waste click | -| `1cdb78c` | cargo fmt; add analytics domain to CSP | -| `baf524e` | Rebuild Bevy canvas WASM; add SolitaireGame interactive API | -| `9ff0585` | Remove Quaternions registry auth; canvas WASM drift guard | -| `de7ae16` | Delay first-run modal until splash screen despawns | -| `8b736ca` | Debug drag failures (temp logging, removed in next commit) | -| `8b262af` | Clamp wgpu surface to CSS pixels on HiDPI (prevented WASM panic) | -| `d45b7cb` | Add Playwright e2e test suite for web routes | -| `2cf7282` | Add `window.__FERROUS_DEBUG__` bridge to `/play` for automation | - -**Key audit bugs fixed (all 7 from 500-game UX audit):** timer-after-undo, radial-menu clamping, Android resume flash, tab-hidden timer, orphaned tmp files, drag threshold 4→6px, Draw-1 recycle doc comment. - -**HiDPI wgpu fix:** `WindowResolution::default().with_scale_factor_override(1.0)` added to the Bevy canvas app. Root cause was physical pixels (CSS×DPR) exceeding WebGL2's 2048px per-dimension limit on HiDPI displays. - -**E2E test architecture:** three-tier — Rust unit tests → Playwright smoke/review specs → cycle regression gate. Debug bridge contract in `docs/testing-architecture.md`. +- Browser Bevy canvas route and `window.__FERROUS_DEBUG__` automation bridge landed, with Playwright coverage for `/play`. +- In-place `card_game` / `klondike` rewrite phases are complete through the latest follow-up: + - `5e87358` integrates upstream deps cleanly. + - `ae1ecc8` unifies `Suit` / `Rank` with upstream `card_game` types. + - `d864d98` routes klondike/card imports through `solitaire_core`. + - `9bcf13d`, `56e3b62`, `26f1b00` finish schema-v3 migration coverage, undo/recycle score correctness, and rewrite-plan docs. +- Android keystore wiring, Android build-script hardening, server auth/runtime hardening, and modal safe-area centering have landed. +- `CHANGELOG.md` has been caught up from `v0.34.0` through current unreleased work; verify it before committing because it was started by a rate-limited Claude workflow and then manually tightened. +- Matomo analytics was re-reviewed: `MatomoClient` and `AnalyticsPlugin` are wired through `CoreGamePlugin` on non-wasm targets, and targeted tests now cover opt-in client creation, event encoding, buffer trimming, and analytics mode labels. --- -## What shipped before v0.35.1 +## Historical notes before v0.39.0 -See git log. CHANGELOG.md currently ends at v0.33.0 (documentation debt, low priority). +See git log and `CHANGELOG.md`. The changelog now includes `v0.34.0` through `v0.39.0`, plus current unreleased work. --- @@ -110,12 +101,7 @@ Three bugs fixed: ## Open punch list -### 1. CHANGELOG documentation debt - -CHANGELOG.md currently ends at v0.33.0. All post-v0.33.0 work is in git log. Low -priority — git log is authoritative. - -### 2. Android APK launch verification (Option A) +### 1. Android APK launch verification (Option A) Physical device test: install the latest APK on a real Android device (not AVD), confirm: @@ -129,12 +115,18 @@ confirm: This has never been gated in CI. AVD `adb shell input tap` doesn't deliver real touch events, so physical-device smoke testing is the only gate. -### 3. Matomo analytics wiring +### 2. Matomo analytics live validation -`Settings` has `analytics_enabled: bool` and `matomo_url: Option` but no -engine code consumes them — the analytics toggle in Settings is a no-op. If -analytics are ever needed, the Matomo HTTP Tracking API client needs to be written -and wired to `GameStateResource` events. +`Settings` has `analytics_enabled`, `matomo_url`, and `matomo_site_id`; the engine +consumes them via `AnalyticsPlugin` on non-wasm targets. Remaining work is live +validation against the deployed Matomo instance: +- Configure `matomo_url` and opt in through Settings. +- Play a short session that starts a game, wins or forfeits, and unlocks or + verifies an achievement event path if practical. +- Confirm Matomo receives `Game / Start`, `Game / Won` or `Game / Forfeit`, and + any achievement events. +- Decide whether the web/WASM route should eventually use browser-side tracking, + since the native `AnalyticsPlugin` is intentionally gated out on wasm32. --- diff --git a/docs/card-game-integration.md b/docs/card-game-integration.md index 3c6b2cc..61e1feb 100644 --- a/docs/card-game-integration.md +++ b/docs/card-game-integration.md @@ -2,7 +2,10 @@ **Context:** A collaborator ([Quaternions](https://git.aleshym.co/Quaternions/card_game)) is building a pure-logic Klondike library in Rust. This document maps what that library currently provides against what Ferrous Solitaire's `solitaire_core` crate requires. -**Approach:** Most gaps are closed in Ferrous Solitaire's own `solitaire_core` crate via a wrapper/adapter layer. Gaps 1, 3, and 4 have been addressed upstream. Integration is ready to begin. +**Approach:** Integration is complete. Upstream `card_game` / `klondike` now owns +authoritative Klondike rules, session history, undo snapshots, and solving. +Ferrous keeps product-specific scoring, persistence, rendering DTOs, game modes, +and typed UI errors in `solitaire_core`. --- @@ -42,10 +45,12 @@ --- -## What Ferrous Solitaire's `solitaire_core` Needs (Gaps) +## What Ferrous Solitaire's `solitaire_core` Still Owns ### 1. Scoring — remaining adapter responsibilities -Ferrous uses **Windows XP Standard** scoring. The exact table already implemented in `solitaire_core/src/scoring.rs`: +Ferrous uses **Windows XP Standard** scoring. The upstream library handles the +per-move counters and configurable deltas; Ferrous adds the product-specific +parts in `GameState` / `KlondikeAdapter`. | Event | Delta | Handled by | |---|---|---| @@ -61,11 +66,13 @@ Ferrous uses **Windows XP Standard** scoring. The exact table already implemente Reference: -**Undo penalty:** `SessionState::score()` = `KlondikeStats.score(&scoring) + undos × undo_penalty`. The −15 undo penalty is built into `SessionConfig` (default). Once `GameState` fully delegates to `Session`, our `KlondikeAdapter::score_for_undo()` helper becomes redundant. +**Undo penalty:** `SessionState::score()` = `KlondikeStats.score(&scoring) + undos × undo_penalty`. Ferrous still owns the exact user-visible score because it must restore the pre-move score when undoing recycle penalties and then apply the product's undo penalty. **Recycle penalty note:** `ScoringConfig::recycle` is a flat delta (default 0 = always free). WXP allows a fixed number of free recycles before charging a penalty, which the upstream library cannot express with a single delta. Our adapter tracks `recycle_count` from `KlondikeStats` and applies the penalty only beyond the free allowance. -**In our wrapper:** Configure `ScoringConfig` with the WXP deltas for the five events upstream handles (including undo via `SessionConfig`). Implement recycle-with-free-allowance, score floor, and time bonus in the adapter. +**In our wrapper:** `KlondikeAdapter::config_for` configures the upstream rules +and scoring deltas. `GameState` applies recycle-with-free-allowance, score floor, +time bonus, game-mode suppression, and undo score restoration. ### 2. Game Modes Ferrous has three modes that alter scoring and undo behaviour: @@ -78,7 +85,9 @@ Ferrous has three modes that alter scoring and undo behaviour: Zen is intended for relaxed play where the score does not matter. Challenge is a timed daily puzzle where the no-undo constraint is the difficulty mechanic. -**In our wrapper:** Add `GameMode` to `solitaire_core::GameState`; intercept undo calls and scoring deltas in the adapter before delegating to `KlondikeState`. +**In our wrapper:** `GameMode` lives on `solitaire_core::GameState`; undo and +scoring behavior are applied before/after delegating legal moves to the upstream +session. ### 3. Solvability Solver *(upstream merged — card_game v0.4.0)* `card_game v0.4.0` ships `Session::solve()` — a budget-bounded DFS that returns `Result>, SolveError>`. `SolveError` has two variants: @@ -87,9 +96,13 @@ Zen is intended for relaxed play where the score does not matter. Challenge is a `Solution` contains the winning move sequence as `Vec>`; `clean_solution()` removes cycles. `Session::solve()` uses `SessionConfig::solve_moves_budget` and `SessionConfig::solve_states_budget` (defaults: 100 000 each). -Our 767-line `solitaire_core::solver` reimplements the full game rules to run the DFS; `session.solve()` replaces it entirely. The solver will be removed once the `Session` is wired into `GameState`. +The old local DFS has been replaced. `solitaire_core::solver` is now a small +adapter around `Session::solve()` that preserves the engine-facing +`SolverResult`, `SolverConfig`, and first-move payload contract. -**In our wrapper:** Replace `solitaire_core::solver` with `session.solve()`. Map `Ok(Some(_))` → Winnable, `Ok(None)` → Unwinnable, `Err(_)` → Inconclusive. +**In our wrapper:** `solve_game_state` calls `session.solve()` with the requested +budgets. It maps `Ok(Some(_))` → Winnable, `Ok(None)` → Unwinnable, and budget +errors → Inconclusive. ### 4. `take_from_foundation` House Rule *(upstream merged — v0.3.0)* `MoveFromFoundationConfig` is now part of `KlondikeConfig`. When set to `Disallowed`, `is_instruction_valid` blocks foundation → tableau instructions. @@ -135,7 +148,9 @@ Ferrous tracks `PileType::Waste` as a distinct pile. `klondike` folds waste into ### 8. Undo Stack Approach *(resolved — not an issue)* `card_game v0.4.0` `Session` uses snapshot-based undo: `SessionState` stores `Vec>` where each entry holds the pre-move game state and the instruction. Undo pops the last snapshot and restores state directly — O(1), matching our existing `GameState.undo_stack`. -**Resolution:** Use `Session`'s built-in snapshot history. Our `GameState.undo_stack: VecDeque` will be removed once `GameState` is fully migrated to delegate to `Session`. +**Resolution:** `GameState` uses `Session`'s built-in snapshot history. Ferrous +keeps parallel score/recycle metadata so undo can restore product-specific score +state that upstream snapshots do not own. --- @@ -144,12 +159,12 @@ Ferrous tracks `PileType::Waste` as a distinct pile. `klondike` folds waste into Steps in dependency order. Upstream issues #10, #11, and the solver are all merged. 1. ✅ **Add `klondike = "0.3.0"` / `card_game = "0.4.0"` as dependencies** of `solitaire_core`; `KlondikeAdapter` wraps `KlondikeConfig` and exposes scoring helpers. -2. **Map pile types** — project `klondike`'s stock face-up half as `PileType::Waste`; expose the same `HashMap` the engine already reads. Wire `Session` into `KlondikeAdapter` (gap 7). -3. ✅ **Configure `KlondikeConfig`** — set `move_from_foundation: MoveFromFoundationConfig::Disallowed` by default; wire the user's house-rule toggle to `Allowed` (gap 4, upstream). +2. ✅ **Map pile types** — project `klondike`'s stock face-up half as the engine's waste pile and expose renderer-facing pile snapshots. +3. ✅ **Configure `KlondikeConfig`** — set `move_from_foundation: MoveFromFoundationConfig::Allowed` by default; wire the user's settings toggle to `Disallowed` when foundation returns are disabled (gap 4, upstream). 4. ✅ **Port scoring** — pass WXP deltas into `ScoringConfig`; `SessionConfig::undo_penalty` handles undo; implement recycle-with-free-allowance, score floor, and time bonus in the adapter (gap 1). 5. ✅ **Port `GameMode`** — intercept undo + scoring in the adapter based on mode (gap 2). -6. **Replace solver** — call `session.solve()` with budgets from our `SolverConfig`; map `Ok(Some)` → Winnable, `Ok(None)` → Unwinnable, `Err` → Inconclusive (gap 3, upstream). -7. **Implement `serde`** — define `SavedInstruction` + `SavedStateSnapshot` newtypes; serialise session history; migrate save-file schema (gap 5). +6. ✅ **Replace solver** — call `session.solve()` with budgets from `SolverConfig`; map `Ok(Some)` → Winnable, `Ok(None)` → Unwinnable, `Err` → Inconclusive (gap 3, upstream). +7. ✅ **Implement `serde`** — serialise schema v4 with upstream `KlondikeInstruction`; auto-migrate schema v3 via `SavedInstruction` compatibility types. --- @@ -192,5 +207,5 @@ The script enforces: - Upstream scoring + config PRs: #12 (closes #11), #13 (closes #10) - Upstream solver PR: #14 - `solitaire_core` source: `solitaire_core/src/` -- Scoring spec: `solitaire_core/src/scoring.rs` +- Scoring implementation: `solitaire_core/src/game_state.rs`, `solitaire_core/src/klondike_adapter.rs` - Architecture overview: `ARCHITECTURE.md` diff --git a/solitaire_data/src/matomo_client.rs b/solitaire_data/src/matomo_client.rs index 433d4c2..4e48629 100644 --- a/solitaire_data/src/matomo_client.rs +++ b/solitaire_data/src/matomo_client.rs @@ -114,3 +114,62 @@ fn url_encode(s: &str) -> String { }) .collect() } + +#[cfg(test)] +mod tests { + use super::*; + + fn pending(client: &MatomoClient) -> Vec { + client.pending.lock().expect("pending lock").clone() + } + + #[test] + fn event_buffers_encoded_matomo_query() { + let client = MatomoClient::new( + "https://analytics.example.com/", + 7, + Some("alice bob".into()), + ); + + client.event("Game Flow", "Won+Fast", Some("draw three"), Some(42.5)); + + let pending = pending(&client); + assert_eq!(pending.len(), 1); + let query = &pending[0]; + assert!(query.contains("idsite=7")); + assert!(query.contains("rec=1")); + assert!(query.contains("e_c=Game%20Flow")); + assert!(query.contains("e_a=Won%2BFast")); + assert!(query.contains("e_n=draw%20three")); + assert!(query.contains("e_v=42.5")); + assert!(query.contains("uid=alice%20bob")); + } + + #[test] + fn event_buffer_drops_oldest_entries_when_capacity_exceeded() { + let client = MatomoClient::new("https://analytics.example.com", 1, None); + + for idx in 0..101 { + client.event("Game", "Start", Some(&format!("event-{idx}")), None); + } + + let pending = pending(&client); + assert_eq!(pending.len(), 51); + assert!( + pending[0].contains("event-50"), + "oldest retained event should be event-50, got {}", + pending[0] + ); + assert!( + pending[50].contains("event-100"), + "newest retained event should be event-100, got {}", + pending[50] + ); + } + + #[test] + fn url_encode_leaves_unreserved_bytes_and_escapes_everything_else() { + assert_eq!(url_encode("AZaz09-_.~"), "AZaz09-_.~"); + assert_eq!(url_encode("a b+c/d?"), "a%20b%2Bc%2Fd%3F"); + } +} diff --git a/solitaire_engine/src/analytics_plugin.rs b/solitaire_engine/src/analytics_plugin.rs index c8c3c8b..b1e85b6 100644 --- a/solitaire_engine/src/analytics_plugin.rs +++ b/solitaire_engine/src/analytics_plugin.rs @@ -204,3 +204,61 @@ fn mode_str(mode: GameMode) -> &'static str { GameMode::Difficulty(_) => "difficulty", } } + +#[cfg(test)] +mod tests { + use solitaire_core::game_state::DifficultyLevel; + + use super::*; + + #[test] + fn client_for_requires_analytics_opt_in() { + let settings = Settings { + analytics_enabled: false, + matomo_url: Some("https://analytics.example.com".into()), + ..Settings::default() + }; + + assert!(client_for(&settings).is_none()); + } + + #[test] + fn client_for_requires_matomo_url() { + let settings = Settings { + analytics_enabled: true, + matomo_url: None, + ..Settings::default() + }; + + assert!(client_for(&settings).is_none()); + } + + #[test] + fn client_for_creates_client_when_enabled_and_configured() { + let settings = Settings { + analytics_enabled: true, + matomo_url: Some("https://analytics.example.com".into()), + matomo_site_id: 2, + sync_backend: SyncBackend::SolitaireServer { + url: "https://solitaire.example.com".into(), + username: "alice".into(), + avatar_url: None, + }, + ..Settings::default() + }; + + assert!(client_for(&settings).is_some()); + } + + #[test] + fn mode_labels_match_analytics_payload_contract() { + assert_eq!(mode_str(GameMode::Classic), "classic"); + assert_eq!(mode_str(GameMode::Zen), "zen"); + assert_eq!(mode_str(GameMode::Challenge), "challenge"); + assert_eq!(mode_str(GameMode::TimeAttack), "time_attack"); + assert_eq!( + mode_str(GameMode::Difficulty(DifficultyLevel::Grandmaster)), + "difficulty" + ); + } +}