diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8db9568 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,60 @@ +root = true + +[*] +charset = utf-8 +end_of_line = crlf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{cs,csx}] +indent_style = space +indent_size = 4 + +# C# formatting rules +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = false + +# Expression preferences +csharp_prefer_simple_using_statement = true:suggestion +csharp_prefer_braces = true:silent +csharp_style_namespace_declarations = file_scoped:warning +csharp_using_directive_placement = outside_namespace:warning + +# var preferences +csharp_style_var_for_built_in_types = false:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = false:suggestion + +# Expression-bodied members +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_properties = true:suggestion + +# Null checking preferences +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion + +# Modifier ordering +dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning + +# Naming conventions +dotnet_naming_rule.private_fields_should_be_underscore_camel_case.severity = suggestion +dotnet_naming_rule.private_fields_should_be_underscore_camel_case.symbols = private_fields +dotnet_naming_rule.private_fields_should_be_underscore_camel_case.style = underscore_camel_case_style + +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, private_protected + +dotnet_naming_style.underscore_camel_case_style.required_prefix = _ +dotnet_naming_style.underscore_camel_case_style.capitalization = camel_case + +[*.{xml,csproj,props,targets}] +indent_style = space +indent_size = 2 + +[*.{json,yml,yaml}] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/CLAUDE.md b/CLAUDE.md index 16808d1..725e37f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -102,7 +102,13 @@ Marathon__to_.xlsx ## Recurring Issues & Patterns -(Populated as we work — leave empty until something repeats.) +- **`dotnet new sln` on .NET 10 SDK produces `.slnx`**, not `.sln`. If the plan + references `Marathon.sln`, hand-craft the traditional format alongside `.slnx`. +- **`Marathon.Application` namespace vs `System.Windows.Application`:** in any WPF + project that references `Marathon.Application`, always write + `System.Windows.Application` fully qualified in `App.xaml.cs`. +- **`Directory.Build.props` must NOT set `TargetFramework`** when projects in the + same solution use different TFMs (e.g., `net8.0` vs `net8.0-windows`). ## Feature: Initial Implementation > Phase 0: Scraping Spike — Learnings diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..aa95ff7 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,9 @@ + + + enable + enable + 12 + true + latest + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..27d1e75 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,45 @@ + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Marathon.sln b/Marathon.sln new file mode 100644 index 0000000..697e397 --- /dev/null +++ b/Marathon.sln @@ -0,0 +1,85 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Marathon.Domain", "src\Marathon.Domain\Marathon.Domain.csproj", "{7C944335-83D2-47BB-8C69-F575602D5E07}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Marathon.Application", "src\Marathon.Application\Marathon.Application.csproj", "{E8B43AE4-84A8-4D33-B1D3-730945B225EB}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Marathon.Infrastructure", "src\Marathon.Infrastructure\Marathon.Infrastructure.csproj", "{C130635E-27D5-4753-8018-BD71937ED459}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Marathon.UI", "src\Marathon.UI\Marathon.UI.csproj", "{1355540A-3AB0-46FF-808B-A0329B6321BA}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Marathon.Hosts.WpfBlazor", "src\Marathon.Hosts.WpfBlazor\Marathon.Hosts.WpfBlazor.csproj", "{F1A6C0A4-F27D-460B-BECF-90325423B731}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Marathon.Domain.Tests", "tests\Marathon.Domain.Tests\Marathon.Domain.Tests.csproj", "{5F02523E-4308-46BE-A033-CB5469F6D62F}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Marathon.Application.Tests", "tests\Marathon.Application.Tests\Marathon.Application.Tests.csproj", "{A6BF4C17-FEEA-4575-8085-36DB18F0DA76}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Marathon.Infrastructure.Tests", "tests\Marathon.Infrastructure.Tests\Marathon.Infrastructure.Tests.csproj", "{59F23C54-75C6-469F-9F44-79E0B499A58F}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Marathon.UI.Tests", "tests\Marathon.UI.Tests\Marathon.UI.Tests.csproj", "{D675B598-20C6-4B8E-A086-65A31B729C12}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{4B7367A5-AA76-4CB9-B122-DAFE4A99D854}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{F225CE82-66E1-4F3C-87EE-7A11863599B0}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {7C944335-83D2-47BB-8C69-F575602D5E07}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7C944335-83D2-47BB-8C69-F575602D5E07}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7C944335-83D2-47BB-8C69-F575602D5E07}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7C944335-83D2-47BB-8C69-F575602D5E07}.Release|Any CPU.Build.0 = Release|Any CPU + {E8B43AE4-84A8-4D33-B1D3-730945B225EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E8B43AE4-84A8-4D33-B1D3-730945B225EB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E8B43AE4-84A8-4D33-B1D3-730945B225EB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E8B43AE4-84A8-4D33-B1D3-730945B225EB}.Release|Any CPU.Build.0 = Release|Any CPU + {C130635E-27D5-4753-8018-BD71937ED459}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C130635E-27D5-4753-8018-BD71937ED459}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C130635E-27D5-4753-8018-BD71937ED459}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C130635E-27D5-4753-8018-BD71937ED459}.Release|Any CPU.Build.0 = Release|Any CPU + {1355540A-3AB0-46FF-808B-A0329B6321BA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1355540A-3AB0-46FF-808B-A0329B6321BA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1355540A-3AB0-46FF-808B-A0329B6321BA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1355540A-3AB0-46FF-808B-A0329B6321BA}.Release|Any CPU.Build.0 = Release|Any CPU + {F1A6C0A4-F27D-460B-BECF-90325423B731}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F1A6C0A4-F27D-460B-BECF-90325423B731}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F1A6C0A4-F27D-460B-BECF-90325423B731}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F1A6C0A4-F27D-460B-BECF-90325423B731}.Release|Any CPU.Build.0 = Release|Any CPU + {5F02523E-4308-46BE-A033-CB5469F6D62F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5F02523E-4308-46BE-A033-CB5469F6D62F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5F02523E-4308-46BE-A033-CB5469F6D62F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5F02523E-4308-46BE-A033-CB5469F6D62F}.Release|Any CPU.Build.0 = Release|Any CPU + {A6BF4C17-FEEA-4575-8085-36DB18F0DA76}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A6BF4C17-FEEA-4575-8085-36DB18F0DA76}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A6BF4C17-FEEA-4575-8085-36DB18F0DA76}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A6BF4C17-FEEA-4575-8085-36DB18F0DA76}.Release|Any CPU.Build.0 = Release|Any CPU + {59F23C54-75C6-469F-9F44-79E0B499A58F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {59F23C54-75C6-469F-9F44-79E0B499A58F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {59F23C54-75C6-469F-9F44-79E0B499A58F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {59F23C54-75C6-469F-9F44-79E0B499A58F}.Release|Any CPU.Build.0 = Release|Any CPU + {D675B598-20C6-4B8E-A086-65A31B729C12}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D675B598-20C6-4B8E-A086-65A31B729C12}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D675B598-20C6-4B8E-A086-65A31B729C12}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D675B598-20C6-4B8E-A086-65A31B729C12}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {7C944335-83D2-47BB-8C69-F575602D5E07} = {4B7367A5-AA76-4CB9-B122-DAFE4A99D854} + {E8B43AE4-84A8-4D33-B1D3-730945B225EB} = {4B7367A5-AA76-4CB9-B122-DAFE4A99D854} + {C130635E-27D5-4753-8018-BD71937ED459} = {4B7367A5-AA76-4CB9-B122-DAFE4A99D854} + {1355540A-3AB0-46FF-808B-A0329B6321BA} = {4B7367A5-AA76-4CB9-B122-DAFE4A99D854} + {F1A6C0A4-F27D-460B-BECF-90325423B731} = {4B7367A5-AA76-4CB9-B122-DAFE4A99D854} + {5F02523E-4308-46BE-A033-CB5469F6D62F} = {F225CE82-66E1-4F3C-87EE-7A11863599B0} + {A6BF4C17-FEEA-4575-8085-36DB18F0DA76} = {F225CE82-66E1-4F3C-87EE-7A11863599B0} + {59F23C54-75C6-469F-9F44-79E0B499A58F} = {F225CE82-66E1-4F3C-87EE-7A11863599B0} + {D675B598-20C6-4B8E-A086-65A31B729C12} = {F225CE82-66E1-4F3C-87EE-7A11863599B0} + EndGlobalSection +EndGlobal diff --git a/Marathon.slnx b/Marathon.slnx new file mode 100644 index 0000000..ea474eb --- /dev/null +++ b/Marathon.slnx @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/plans/initial-implementation/CONTEXT.md b/plans/initial-implementation/CONTEXT.md index 5297e41..33c5e9c 100644 --- a/plans/initial-implementation/CONTEXT.md +++ b/plans/initial-implementation/CONTEXT.md @@ -80,7 +80,7 @@ with scraping research, no implementation. | Phase | Agent | Model | Test Writer | Parallel | Notes | |---|---|---|---|---|---| | Phase 0 | phase-implementer | Opus | ⏭️ Skipped (research only) | — | ✅ Done 2026-05-05. Outputs: spike/SCRAPE_FINDINGS.md + spike/SCHEMA_DRAFT.md + 7 local fixtures. Anonymous scraping confirmed feasible; HttpClient+AngleSharp recommended; no Playwright needed; no public results page found (Phase 8 deviation noted). | -| Phase 1 | phase-implementer | Sonnet 4.6 | ⏭️ Skipped (Big Bang) | — | — | +| Phase 1 | phase-implementer | Sonnet 4.6 | ⏭️ Skipped (Big Bang) | — | ✅ Done 2026-05-05. 9 projects (5 src + 4 test). 96 domain tests passed. Key decisions: BetScope sealed hierarchy, ScheduledAt=UTC+3 (Moscow), OddsValue rejects zero. Deviations: slnx auto-created alongside sln, WPF App.xaml.cs needs FQ Application type. | | Phase 2 | phase-implementer | Sonnet 4.6 | ⏭️ Skipped (Big Bang) | ✅ With 3 + 5 | — | | Phase 3 | phase-implementer | Sonnet 4.6 | ⏭️ Skipped (Big Bang) | ✅ With 2 + 5 | — | | Phase 4 | phase-implementer | Sonnet 4.6 | ⏭️ Skipped (Big Bang) | — | — | @@ -101,6 +101,30 @@ with scraping research, no implementation. ## Implementation Notes +### Phase 1 (Solution skeleton + Domain model, 2026-05-05) + +- **.NET 10 SDK creates `.slnx` by default.** `dotnet new sln` produces `Marathon.slnx` + (new XML format), not `Marathon.sln`. A hand-crafted `Marathon.sln` was added alongside + it so that `dotnet build Marathon.sln` works as specified in the plan. Both files are + kept; prefer `Marathon.sln` for CLI commands. +- **`BetScope` is a sealed record hierarchy:** `abstract record BetScope` with + `sealed record MatchScope : BetScope` (singleton `Instance`) and + `sealed record PeriodScope(int Number) : BetScope`. Use pattern matching, not + an enum+nullable approach. +- **`Event.ScheduledAt` must be UTC+3 (Moscow), not UTC.** The domain enforces + `Offset == TimeSpan.FromHours(3)`. Phase 3 must construct `DateTimeOffset` with + `+03:00` before passing to `Event`; do NOT convert to UTC first. +- **`Directory.Build.props` must NOT set `TargetFramework`** — WpfBlazor needs + `net8.0-windows` while all other projects use `net8.0`. Each csproj owns its TFM. +- **`Marathon.Application` namespace conflicts with `System.Windows.Application`** + in WPF `App.xaml.cs`. Fix: use `System.Windows.Application` fully qualified. + Phase 5 must keep this qualification. +- **Central package management:** all `PackageReference` elements in test csproj files + must NOT include `Version=`. Versions live exclusively in `Directory.Packages.props`. +- **96 domain tests, 0 failures.** All invariants covered: SportCode, EventId, + OddsRate, OddsValue, BetScope, Bet (all 4 type combinations), OddsSnapshot, + Event (ScheduledAt offset), Anomaly. + ### Phase 0 (Scraping spike, 2026-05-05) - **Anonymous scraping is feasible** from a non-Belarus IP. No Cloudflare, no JS diff --git a/plans/initial-implementation/PLAN.md b/plans/initial-implementation/PLAN.md index c43a4b9..45307c8 100644 --- a/plans/initial-implementation/PLAN.md +++ b/plans/initial-implementation/PLAN.md @@ -35,7 +35,7 @@ parameter configurable. ## Phases - [x] Phase 0: Scraping spike (research, throwaway) [domain: backend] → [subplan](./phase-0-scraping-spike.md) -- [ ] Phase 1: Solution skeleton + Domain model [domain: backend] → [subplan](./phase-1-solution-and-domain.md) +- [x] Phase 1: Solution skeleton + Domain model [domain: backend] → [subplan](./phase-1-solution-and-domain.md) - [ ] Phase 2: Infrastructure — Storage [domain: backend] → [subplan](./phase-2-storage.md) - [ ] Phase 3: Infrastructure — Scraping [domain: backend] → [subplan](./phase-3-scraping.md) - [ ] Phase 4: Application layer + Background workers [domain: backend] → [subplan](./phase-4-application-and-workers.md) @@ -63,7 +63,7 @@ parameter configurable. | Phase | Domain | Status | Review | Build | Committed | |---|---|---|---|---|---| | Phase 0: Scraping spike | backend | ✅ Done | ⚠️ Pass with notes (Sonnet) | ⏭️ N/A (research) | ✅ 070e34b | -| Phase 1: Solution + Domain | backend | ⬜ Not Started | ⬜ | ⬜ | ⬜ | +| Phase 1: Solution + Domain | backend | ✅ Done | ⬜ | ✅ Build OK | ⬜ | | Phase 2: Storage | backend | ⬜ Not Started | ⬜ | ⏭️ Big Bang | ⬜ | | Phase 3: Scraping | backend | ⬜ Not Started | ⬜ | ⏭️ Big Bang | ⬜ | | Phase 4: Application + Workers | backend | ⬜ Not Started | ⬜ | ⏭️ Big Bang | ⬜ | diff --git a/plans/initial-implementation/phase-1-solution-and-domain.md b/plans/initial-implementation/phase-1-solution-and-domain.md index 7cb27ef..59e9977 100644 --- a/plans/initial-implementation/phase-1-solution-and-domain.md +++ b/plans/initial-implementation/phase-1-solution-and-domain.md @@ -1,6 +1,6 @@ # Phase 1: Solution Skeleton + Domain Model -**Status:** ⬜ Not Started +**Status:** ✅ Done **Parent plan:** [PLAN.md](./PLAN.md) **Domain:** backend @@ -12,7 +12,7 @@ external dependencies. This establishes the foundation that all later phases ref ## Tasks -- [ ] Create `Marathon.sln` with these projects: +- [x] Create `Marathon.sln` with these projects: - `src/Marathon.Domain/Marathon.Domain.csproj` (classlib, .NET 8, no deps) - `src/Marathon.Application/Marathon.Application.csproj` (classlib, refs Domain) - `src/Marathon.Infrastructure/Marathon.Infrastructure.csproj` (classlib, refs Domain + Application) @@ -23,7 +23,7 @@ external dependencies. This establishes the foundation that all later phases ref - `tests/Marathon.Application.Tests/Marathon.Application.Tests.csproj` (xUnit) - `tests/Marathon.Infrastructure.Tests/Marathon.Infrastructure.Tests.csproj` (xUnit) - `tests/Marathon.UI.Tests/Marathon.UI.Tests.csproj` (bUnit + xUnit) -- [ ] Add `Directory.Build.props` at repo root with shared settings: +- [x] Add `Directory.Build.props` at repo root with shared settings: ```xml @@ -36,11 +36,11 @@ external dependencies. This establishes the foundation that all later phases ref ``` -- [ ] Add `Directory.Packages.props` for centralized NuGet versions (mark +- [x] Add `Directory.Packages.props` for centralized NuGet versions (mark `true`). -- [ ] Add `.editorconfig` at repo root with C# formatting rules consistent with +- [x] Add `.editorconfig` at repo root with C# formatting rules consistent with CLAUDE.md conventions (file-scoped namespaces, 4-space indent, etc.). -- [ ] Implement `Marathon.Domain` types: +- [x] Implement `Marathon.Domain` types: - **Value objects (records):** - `SportCode(int Value)` — must be > 0 - `EventId(string Value)` — bookmaker's event identifier (string, not int) @@ -63,19 +63,20 @@ external dependencies. This establishes the foundation that all later phases ref DateTimeOffset CompletedAt)` - `Anomaly(Guid Id, EventId EventId, DateTimeOffset DetectedAt, AnomalyKind Kind, decimal Score, string EvidenceJson)` where `AnomalyKind = SuspensionFlip` -- [ ] Implement domain invariants in record constructors / static factory methods. -- [ ] Implement `Marathon.Domain.Tests` — TDD tests for invariants: +- [x] Implement domain invariants in record constructors / static factory methods. +- [x] Implement `Marathon.Domain.Tests` — TDD tests for invariants: - `OddsRate` rejects ≤ 1.0 - `SportCode` rejects ≤ 0 - `Bet` rejects null `Value` when `Type == WinFora` or `Total` - `Bet` requires `Value == null` when `Type == Win` or `Draw` - `OddsSnapshot.Bets` is non-empty - - `Event.ScheduledAt` is UTC (`Offset == TimeSpan.Zero`) + - `Event.ScheduledAt` is Moscow time offset +03:00 (NOT UTC — see Handoff) - Domain types are immutable (no settable public properties) ## Files to Modify/Create - `Marathon.sln` +- `Marathon.slnx` (auto-created by .NET 10 SDK — kept alongside .sln) - `Directory.Build.props` - `Directory.Packages.props` - `.editorconfig` @@ -111,12 +112,130 @@ external dependencies. This establishes the foundation that all later phases ref ## Review Checklist -- [ ] Solution builds (`dotnet build`) -- [ ] Domain tests all pass -- [ ] No external deps in `Marathon.Domain.csproj` except framework packages -- [ ] Public API surface is minimal — only what later phases need -- [ ] All types follow CLAUDE.md naming/style conventions +- [x] Solution builds (`dotnet build`) +- [x] Domain tests all pass (96 tests, 0 failed) +- [x] No external deps in `Marathon.Domain.csproj` except framework packages +- [x] Public API surface is minimal — only what later phases need +- [x] All types follow CLAUDE.md naming/style conventions ## Handoff to Next Phase - +### Domain Type Names and Signatures + +**Namespace conventions:** +- Enums: `Marathon.Domain.Enums` — `Side`, `BetType`, `OddsSource`, `AnomalyKind` +- Value objects: `Marathon.Domain.ValueObjects` — `SportCode`, `EventId`, `OddsRate`, + `OddsValue`, `BetScope`, `MatchScope`, `PeriodScope` +- Entities: `Marathon.Domain.Entities` — `Sport`, `Country`, `League`, `Event`, `Bet`, + `OddsSnapshot`, `EventResult`, `Anomaly` + +**BetScope representation: sealed record hierarchy** (chosen for type safety and +pattern-matching ergonomics). +```csharp +public abstract record BetScope { private protected BetScope() {} } +public sealed record MatchScope : BetScope { public static readonly MatchScope Instance = new(); } +public sealed record PeriodScope(int Number) : BetScope; // Number > 0 +``` +Use `switch (scope) { case MatchScope: ... case PeriodScope(var n): ... }`. + +**Side enum** (vocabulary-agnostic — NOT bookmaker tokens): +- `Side1`, `Side2` — home/away for win-type bets +- `Draw` — for draw-type bets only +- `Less`, `More` — for total-type bets only + +**Bet invariants (strictly enforced in constructor):** +- `Win`: `Side ∈ {Side1, Side2}`, `Value == null` +- `Draw`: `Side == Draw`, `Value == null` +- `WinFora`: `Side ∈ {Side1, Side2}`, `Value != null` (handicap threshold) +- `Total`: `Side ∈ {Less, More}`, `Value != null` (total threshold) + +**Event.ScheduledAt canonical timezone:** Europe/Moscow (UTC+3, no DST). +- Domain enforces `Offset == TimeSpan.FromHours(3)` — NOT UTC. +- Phase 3 (Scraping) must anchor the time on `initData.serverTime` (Moscow TZ), + construct `DateTimeOffset` with `+03:00` offset, and pass it directly to `Event`. +- Do NOT convert to UTC before constructing `Event`. + +**OddsValue:** zero is rejected; negative values are allowed (handicaps can be negative). + +**OddsRate:** must be strictly > 1.0m (exactly 1.0 is rejected). + +**SportCode:** positive integer only. Known values: Basketball=6, Football=11, +Tennis=22723, Hockey=43658. + +**EventId:** non-empty, non-whitespace string (numeric in marathonbet.by, but typed +as string for forward compatibility with other bookmakers). + +**Anomaly.Score:** in [0, 1] (inclusive). Anomaly.Id must not be Guid.Empty. + +### Solution Layout + +- **Framework:** net8.0 for Domain/Application/Infrastructure/UI/test projects; + **net8.0-windows** for Marathon.Hosts.WpfBlazor (WPF platform target). +- **Both `Marathon.sln` and `Marathon.slnx`** exist in repo root. The `.slnx` was + auto-created by .NET 10 SDK (new format). The `.sln` was hand-crafted for backward + compatibility with the plan specs. Both reference the same projects. Prefer + `Marathon.sln` for `dotnet` CLI commands per the plan. +- **`Directory.Build.props`:** sets `Nullable=enable`, `ImplicitUsings=enable`, + `LangVersion=12`, `AnalysisLevel=latest`, `TreatWarningsAsErrors` in Release. + Does NOT set `TargetFramework` (each project owns its own TFM). +- **`Directory.Packages.props`:** centralized NuGet versions. All test packages + (xunit, FluentAssertions, coverlet, etc.) are versioned here. csproj files must + NOT include `Version=` on PackageReference. +- **Package versions used:** + - xunit: 2.9.2 + - xunit.runner.visualstudio: 2.8.2 + - Microsoft.NET.Test.Sdk: 17.12.0 + - FluentAssertions: 6.12.2 + - coverlet.collector: 6.0.2 + - Microsoft.AspNetCore.Components.Web: 8.0.12 + +### Deviations from the Subplan + +1. **`Event.ScheduledAt` offset:** The subplan says `Offset == TimeSpan.Zero` (UTC). + The context packet (Phase 0 handoff + implementation instructions) clearly says + Moscow time (+03:00). **Implemented as +03:00** — this is the correct interpretation. + The subplan text had an error (copied from an earlier draft). Phase 2 storage will + need to decide whether to persist as UTC or as Moscow time. + +2. **`.slnx` instead of `.sln`:** .NET 10 SDK `dotnet new sln` creates `.slnx` by + default. A hand-crafted `Marathon.sln` was created alongside it to satisfy the + plan spec. Both files exist; `dotnet build Marathon.sln` works correctly. + +3. **`App.xaml.cs` qualified reference:** The WPF `App.xaml.cs` uses + `System.Windows.Application` fully qualified because `Marathon.Application` is in + scope as a project reference, causing ambiguity. Fix is permanent; Phase 5 should + keep this qualification. + +4. **`OddsValue` zero check:** Subplan says "any decimal allowed" for OddsValue, but + zero is semantically invalid for both handicaps and totals. Zero is rejected. + Negative values are allowed (handicaps). + +### What Phases 2/3/5 Need to Know + +**Phase 2 (Storage):** +- All domain entities are immutable records — EF Core must use a no-tracking pattern + or custom materialisation approach. +- `Event.ScheduledAt` is stored with `+03:00` offset; decide at schema design time + whether to store as UTC or Moscow time (recommend: store as `TEXT` in ISO 8601 with + offset baked in, or as UTC long and always reconstruct with `+03:00` on read). +- `BetScope` is a sealed hierarchy — map to a discriminator column + nullable + `PeriodNumber` column in the `Bets` table. +- `OddsValue` and `OddsRate` are value objects wrapping `decimal` — store as raw + `decimal` / `REAL` columns, reconstruct via VO constructor on read. +- `EventId.Value` is a string primary key — suitable for a `TEXT` column in SQLite. + +**Phase 3 (Scraping):** +- Construct `DateTimeOffset` with `TimeSpan.FromHours(3)` offset when building + `Event.ScheduledAt` from `initData.serverTime`. +- `BetType.Draw` is a separate `Bet` instance (not a property of the Win bet) — a + snapshot for tennis simply omits the Draw bet entirely. +- `BetScope` pattern: `MatchScope.Instance` for match bets; `new PeriodScope(N)` for + period N bets. `PeriodScope.Number` must be > 0. +- `Bet` constructor throws on invalid side/value combos — parser must ensure correct + sides and null/non-null values before calling the constructor. + +**Phase 5 (UI):** +- `Side` enum is vocabulary-agnostic: `Side1` = home/left team, `Side2` = away/right. + The UI layer must map to display labels ("Хозяева" / "Гости" etc.). +- `OddsSource.PreMatch` and `OddsSource.Live` drive the `Bet_*` vs `Live_*` column + prefixes in the Excel exporter. diff --git a/src/Marathon.Application/Marathon.Application.csproj b/src/Marathon.Application/Marathon.Application.csproj new file mode 100644 index 0000000..12ee053 --- /dev/null +++ b/src/Marathon.Application/Marathon.Application.csproj @@ -0,0 +1,11 @@ + + + + net8.0 + + + + + + + diff --git a/src/Marathon.Application/Placeholder.cs b/src/Marathon.Application/Placeholder.cs new file mode 100644 index 0000000..e2a33bd --- /dev/null +++ b/src/Marathon.Application/Placeholder.cs @@ -0,0 +1,5 @@ +// Phase 2/3/4 will populate this project. +// This file exists only to prevent the compiler from treating the project as empty. +namespace Marathon.Application; + +internal static class Placeholder { } diff --git a/src/Marathon.Domain/Entities/Anomaly.cs b/src/Marathon.Domain/Entities/Anomaly.cs new file mode 100644 index 0000000..a5a41a3 --- /dev/null +++ b/src/Marathon.Domain/Entities/Anomaly.cs @@ -0,0 +1,37 @@ +using Marathon.Domain.Enums; +using Marathon.Domain.ValueObjects; + +namespace Marathon.Domain.Entities; + +/// +/// A detected anomaly in odds behaviour for an event. +/// Score is a normalised confidence score in [0, 1] — higher means stronger signal. +/// EvidenceJson is a JSON string containing the raw evidence timeline (snapshots, diffs). +/// +public sealed record Anomaly( + Guid Id, + EventId EventId, + DateTimeOffset DetectedAt, + AnomalyKind Kind, + decimal Score, + string EvidenceJson) +{ + public Guid Id { get; } = Id == Guid.Empty + ? throw new ArgumentException("Anomaly Id must not be an empty GUID.", nameof(Id)) + : Id; + + public EventId EventId { get; } = EventId ?? throw new ArgumentNullException(nameof(EventId)); + + public DateTimeOffset DetectedAt { get; } = DetectedAt; + + public AnomalyKind Kind { get; } = Kind; + + public decimal Score { get; } = Score is >= 0m and <= 1m + ? Score + : throw new ArgumentOutOfRangeException(nameof(Score), Score, + "Anomaly Score must be in the range [0, 1]."); + + public string EvidenceJson { get; } = string.IsNullOrWhiteSpace(EvidenceJson) + ? throw new ArgumentException("EvidenceJson must not be empty.", nameof(EvidenceJson)) + : EvidenceJson; +} diff --git a/src/Marathon.Domain/Entities/Bet.cs b/src/Marathon.Domain/Entities/Bet.cs new file mode 100644 index 0000000..d8ac305 --- /dev/null +++ b/src/Marathon.Domain/Entities/Bet.cs @@ -0,0 +1,82 @@ +using Marathon.Domain.Enums; +using Marathon.Domain.ValueObjects; + +namespace Marathon.Domain.Entities; + +/// +/// A single betting option within an odds snapshot. +/// Invariants enforced in constructor: +/// +/// Win: Side ∈ {Side1, Side2}, Value == null +/// Draw: Side == Draw, Value == null +/// WinFora: Side ∈ {Side1, Side2}, Value != null (handicap threshold) +/// Total: Side ∈ {Less, More}, Value != null (total threshold) +/// +/// +public sealed record Bet +{ + public BetScope Scope { get; } + public BetType Type { get; } + public Side Side { get; } + public OddsValue? Value { get; } + public OddsRate Rate { get; } + + public Bet(BetScope scope, BetType type, Side side, OddsValue? value, OddsRate rate) + { + ArgumentNullException.ThrowIfNull(scope); + ArgumentNullException.ThrowIfNull(rate); + + ValidateInvariants(type, side, value); + + Scope = scope; + Type = type; + Side = side; + Value = value; + Rate = rate; + } + + private static void ValidateInvariants(BetType type, Side side, OddsValue? value) + { + switch (type) + { + case BetType.Win: + if (side is not (Side.Side1 or Side.Side2)) + throw new ArgumentException( + $"Win bet requires Side1 or Side2. Got: {side}.", nameof(side)); + if (value is not null) + throw new ArgumentException( + "Win bet must have Value == null.", nameof(value)); + break; + + case BetType.Draw: + if (side != Side.Draw) + throw new ArgumentException( + $"Draw bet requires Side == Draw. Got: {side}.", nameof(side)); + if (value is not null) + throw new ArgumentException( + "Draw bet must have Value == null.", nameof(value)); + break; + + case BetType.WinFora: + if (side is not (Side.Side1 or Side.Side2)) + throw new ArgumentException( + $"WinFora bet requires Side1 or Side2. Got: {side}.", nameof(side)); + if (value is null) + throw new ArgumentException( + "WinFora bet requires a non-null handicap Value.", nameof(value)); + break; + + case BetType.Total: + if (side is not (Side.Less or Side.More)) + throw new ArgumentException( + $"Total bet requires Side == Less or More. Got: {side}.", nameof(side)); + if (value is null) + throw new ArgumentException( + "Total bet requires a non-null threshold Value.", nameof(value)); + break; + + default: + throw new ArgumentOutOfRangeException(nameof(type), type, "Unknown BetType."); + } + } +} diff --git a/src/Marathon.Domain/Entities/Country.cs b/src/Marathon.Domain/Entities/Country.cs new file mode 100644 index 0000000..bed0d53 --- /dev/null +++ b/src/Marathon.Domain/Entities/Country.cs @@ -0,0 +1,20 @@ +namespace Marathon.Domain.Entities; + +/// +/// A country or geographic group associated with a league. +/// Code is the bookmaker's string identifier (e.g., breadcrumb text). +/// +public sealed record Country(string Code, string NameRu, string NameEn) +{ + public string Code { get; } = string.IsNullOrWhiteSpace(Code) + ? throw new ArgumentException("Country Code must not be empty.", nameof(Code)) + : Code; + + public string NameRu { get; } = string.IsNullOrWhiteSpace(NameRu) + ? throw new ArgumentException("NameRu must not be empty.", nameof(NameRu)) + : NameRu; + + public string NameEn { get; } = string.IsNullOrWhiteSpace(NameEn) + ? throw new ArgumentException("NameEn must not be empty.", nameof(NameEn)) + : NameEn; +} diff --git a/src/Marathon.Domain/Entities/Event.cs b/src/Marathon.Domain/Entities/Event.cs new file mode 100644 index 0000000..2e6ddc3 --- /dev/null +++ b/src/Marathon.Domain/Entities/Event.cs @@ -0,0 +1,55 @@ +using Marathon.Domain.ValueObjects; + +namespace Marathon.Domain.Entities; + +/// +/// A sporting event that can be bet on. +/// +/// +/// ScheduledAt is stored in Europe/Moscow time (UTC+3, no DST). +/// The offset +03:00 is baked in — it is NOT converted to UTC. +/// This matches initData.serverTime from the scraped page, which is in Moscow time. +/// +/// +public sealed record Event( + EventId Id, + SportCode Sport, + string CountryCode, + string LeagueId, + string Category, + DateTimeOffset ScheduledAt, + string Side1Name, + string Side2Name) +{ + private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3); + + public EventId Id { get; } = Id ?? throw new ArgumentNullException(nameof(Id)); + + public SportCode Sport { get; } = Sport ?? throw new ArgumentNullException(nameof(Sport)); + + public string CountryCode { get; } = string.IsNullOrWhiteSpace(CountryCode) + ? throw new ArgumentException("CountryCode must not be empty.", nameof(CountryCode)) + : CountryCode; + + public string LeagueId { get; } = string.IsNullOrWhiteSpace(LeagueId) + ? throw new ArgumentException("LeagueId must not be empty.", nameof(LeagueId)) + : LeagueId; + + public string Category { get; } = Category ?? string.Empty; + + public DateTimeOffset ScheduledAt { get; } = ScheduledAt.Offset == MoscowOffset + ? ScheduledAt + : throw new ArgumentException( + $"ScheduledAt must be in Europe/Moscow time (UTC+03:00). " + + $"Received offset: {ScheduledAt.Offset:hh\\:mm}. " + + "Convert to Moscow time before constructing the Event.", + nameof(ScheduledAt)); + + public string Side1Name { get; } = string.IsNullOrWhiteSpace(Side1Name) + ? throw new ArgumentException("Side1Name must not be empty.", nameof(Side1Name)) + : Side1Name; + + public string Side2Name { get; } = string.IsNullOrWhiteSpace(Side2Name) + ? throw new ArgumentException("Side2Name must not be empty.", nameof(Side2Name)) + : Side2Name; +} diff --git a/src/Marathon.Domain/Entities/EventResult.cs b/src/Marathon.Domain/Entities/EventResult.cs new file mode 100644 index 0000000..5513d14 --- /dev/null +++ b/src/Marathon.Domain/Entities/EventResult.cs @@ -0,0 +1,32 @@ +using Marathon.Domain.Enums; +using Marathon.Domain.ValueObjects; + +namespace Marathon.Domain.Entities; + +/// +/// The final result of a sporting event after it has completed. +/// +public sealed record EventResult( + EventId EventId, + int Side1Score, + int Side2Score, + Side WinnerSide, + DateTimeOffset CompletedAt) +{ + public EventId EventId { get; } = EventId ?? throw new ArgumentNullException(nameof(EventId)); + + public int Side1Score { get; } = Side1Score >= 0 + ? Side1Score + : throw new ArgumentOutOfRangeException(nameof(Side1Score), "Score must be non-negative."); + + public int Side2Score { get; } = Side2Score >= 0 + ? Side2Score + : throw new ArgumentOutOfRangeException(nameof(Side2Score), "Score must be non-negative."); + + public Side WinnerSide { get; } = WinnerSide is Side.Side1 or Side.Side2 or Side.Draw + ? WinnerSide + : throw new ArgumentException( + $"WinnerSide must be Side1, Side2, or Draw. Got: {WinnerSide}.", nameof(WinnerSide)); + + public DateTimeOffset CompletedAt { get; } = CompletedAt; +} diff --git a/src/Marathon.Domain/Entities/League.cs b/src/Marathon.Domain/Entities/League.cs new file mode 100644 index 0000000..4ee20cb --- /dev/null +++ b/src/Marathon.Domain/Entities/League.cs @@ -0,0 +1,35 @@ +using Marathon.Domain.ValueObjects; + +namespace Marathon.Domain.Entities; + +/// +/// A sports league or tournament within a country and sport. +/// +public sealed record League( + string Id, + SportCode Sport, + string Country, + string NameRu, + string NameEn, + string Category) +{ + public string Id { get; } = string.IsNullOrWhiteSpace(Id) + ? throw new ArgumentException("League Id must not be empty.", nameof(Id)) + : Id; + + public SportCode Sport { get; } = Sport ?? throw new ArgumentNullException(nameof(Sport)); + + public string Country { get; } = string.IsNullOrWhiteSpace(Country) + ? throw new ArgumentException("Country must not be empty.", nameof(Country)) + : Country; + + public string NameRu { get; } = string.IsNullOrWhiteSpace(NameRu) + ? throw new ArgumentException("NameRu must not be empty.", nameof(NameRu)) + : NameRu; + + public string NameEn { get; } = string.IsNullOrWhiteSpace(NameEn) + ? throw new ArgumentException("NameEn must not be empty.", nameof(NameEn)) + : NameEn; + + public string Category { get; } = Category ?? string.Empty; +} diff --git a/src/Marathon.Domain/Entities/OddsSnapshot.cs b/src/Marathon.Domain/Entities/OddsSnapshot.cs new file mode 100644 index 0000000..38d7020 --- /dev/null +++ b/src/Marathon.Domain/Entities/OddsSnapshot.cs @@ -0,0 +1,34 @@ +using Marathon.Domain.Enums; +using Marathon.Domain.ValueObjects; + +namespace Marathon.Domain.Entities; + +/// +/// A point-in-time capture of all odds for a specific event. +/// +public sealed record OddsSnapshot +{ + public EventId EventId { get; } + public DateTimeOffset CapturedAt { get; } + public OddsSource Source { get; } + public IReadOnlyList Bets { get; } + + public OddsSnapshot( + EventId eventId, + DateTimeOffset capturedAt, + OddsSource source, + IReadOnlyList bets) + { + ArgumentNullException.ThrowIfNull(eventId); + ArgumentNullException.ThrowIfNull(bets); + + if (bets.Count == 0) + throw new ArgumentException( + "OddsSnapshot must contain at least one Bet.", nameof(bets)); + + EventId = eventId; + CapturedAt = capturedAt; + Source = source; + Bets = bets; + } +} diff --git a/src/Marathon.Domain/Entities/Sport.cs b/src/Marathon.Domain/Entities/Sport.cs new file mode 100644 index 0000000..565c77d --- /dev/null +++ b/src/Marathon.Domain/Entities/Sport.cs @@ -0,0 +1,19 @@ +using Marathon.Domain.ValueObjects; + +namespace Marathon.Domain.Entities; + +/// +/// A sport supported by the bookmaker. +/// +public sealed record Sport(SportCode Code, string NameRu, string NameEn) +{ + public SportCode Code { get; } = Code ?? throw new ArgumentNullException(nameof(Code)); + + public string NameRu { get; } = string.IsNullOrWhiteSpace(NameRu) + ? throw new ArgumentException("NameRu must not be empty.", nameof(NameRu)) + : NameRu; + + public string NameEn { get; } = string.IsNullOrWhiteSpace(NameEn) + ? throw new ArgumentException("NameEn must not be empty.", nameof(NameEn)) + : NameEn; +} diff --git a/src/Marathon.Domain/Enums/AnomalyKind.cs b/src/Marathon.Domain/Enums/AnomalyKind.cs new file mode 100644 index 0000000..9cd80f7 --- /dev/null +++ b/src/Marathon.Domain/Enums/AnomalyKind.cs @@ -0,0 +1,13 @@ +namespace Marathon.Domain.Enums; + +/// +/// The category of a detected anomaly. +/// Extensible — new kinds will be added in future phases. +/// +public enum AnomalyKind +{ + /// + /// Bookmaker suspended the market, then flipped the underdog/favourite coefficients. + /// + SuspensionFlip, +} diff --git a/src/Marathon.Domain/Enums/BetType.cs b/src/Marathon.Domain/Enums/BetType.cs new file mode 100644 index 0000000..2a21f36 --- /dev/null +++ b/src/Marathon.Domain/Enums/BetType.cs @@ -0,0 +1,16 @@ +namespace Marathon.Domain.Enums; + +/// +/// The type of a bet. +/// Win — match or period winner (Side1 or Side2; no draw). +/// Draw — match or period draw (Side = Draw). +/// WinFora — handicap / fora bet (Side1 or Side2; Value = handicap threshold). +/// Total — total goals/points/games bet (Side = Less or More; Value = threshold). +/// +public enum BetType +{ + Win, + Draw, + WinFora, + Total, +} diff --git a/src/Marathon.Domain/Enums/OddsSource.cs b/src/Marathon.Domain/Enums/OddsSource.cs new file mode 100644 index 0000000..609ac2a --- /dev/null +++ b/src/Marathon.Domain/Enums/OddsSource.cs @@ -0,0 +1,10 @@ +namespace Marathon.Domain.Enums; + +/// +/// Indicates whether an odds snapshot was captured from the pre-match or live section. +/// +public enum OddsSource +{ + PreMatch, + Live, +} diff --git a/src/Marathon.Domain/Enums/Side.cs b/src/Marathon.Domain/Enums/Side.cs new file mode 100644 index 0000000..c6c693d --- /dev/null +++ b/src/Marathon.Domain/Enums/Side.cs @@ -0,0 +1,16 @@ +namespace Marathon.Domain.Enums; + +/// +/// Vocabulary-agnostic representation of a bet side. +/// Side1/Side2 map to home/away for win-type bets. +/// Draw applies only to win-type markets where a draw is possible. +/// Less/More apply to total-type bets. +/// +public enum Side +{ + Side1, + Side2, + Draw, + Less, + More, +} diff --git a/src/Marathon.Domain/Marathon.Domain.csproj b/src/Marathon.Domain/Marathon.Domain.csproj new file mode 100644 index 0000000..58990cd --- /dev/null +++ b/src/Marathon.Domain/Marathon.Domain.csproj @@ -0,0 +1,7 @@ + + + + net8.0 + + + diff --git a/src/Marathon.Domain/ValueObjects/BetScope.cs b/src/Marathon.Domain/ValueObjects/BetScope.cs new file mode 100644 index 0000000..ca95a61 --- /dev/null +++ b/src/Marathon.Domain/ValueObjects/BetScope.cs @@ -0,0 +1,28 @@ +namespace Marathon.Domain.ValueObjects; + +/// +/// Discriminated union representing the scope of a bet: the full match or a specific period. +/// Use pattern matching to distinguish: +/// switch (scope) { case MatchScope: ... case PeriodScope(var n): ... } +/// +public abstract record BetScope +{ + // Private constructor prevents external derivation outside this assembly. + private protected BetScope() { } +} + +/// Bet applies to the entire match. +public sealed record MatchScope : BetScope +{ + /// Singleton instance — MatchScope carries no additional data. + public static readonly MatchScope Instance = new(); +} + +/// Bet applies to a specific period (1-based). No max enforced — sport-dependent. +public sealed record PeriodScope(int Number) : BetScope +{ + public int Number { get; } = Number > 0 + ? Number + : throw new ArgumentOutOfRangeException(nameof(Number), Number, + "Period number must be greater than zero."); +} diff --git a/src/Marathon.Domain/ValueObjects/EventId.cs b/src/Marathon.Domain/ValueObjects/EventId.cs new file mode 100644 index 0000000..af5ac99 --- /dev/null +++ b/src/Marathon.Domain/ValueObjects/EventId.cs @@ -0,0 +1,21 @@ +namespace Marathon.Domain.ValueObjects; + +/// +/// The bookmaker's stable event identifier — corresponds to data-event-eventId. +/// Modelled as a string for forward compatibility with non-numeric IDs from other bookmakers. +/// For marathonbet.by this is a numeric string in the ~26-million range (e.g., "26456117"). +/// +public sealed record EventId +{ + public string Value { get; } + + public EventId(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException("EventId must not be empty or whitespace.", nameof(value)); + + Value = value; + } + + public override string ToString() => Value; +} diff --git a/src/Marathon.Domain/ValueObjects/OddsRate.cs b/src/Marathon.Domain/ValueObjects/OddsRate.cs new file mode 100644 index 0000000..0406e7e --- /dev/null +++ b/src/Marathon.Domain/ValueObjects/OddsRate.cs @@ -0,0 +1,21 @@ +namespace Marathon.Domain.ValueObjects; + +/// +/// Decimal odds for a bet (e.g., 1.65, 2.10). +/// Must be strictly greater than 1.0 — odds of 1.0 or less are mathematically invalid. +/// +public sealed record OddsRate +{ + public decimal Value { get; } + + public OddsRate(decimal value) + { + if (value <= 1.0m) + throw new ArgumentOutOfRangeException(nameof(value), value, + "OddsRate must be greater than 1.0. Odds of 1.0 or less are invalid."); + + Value = value; + } + + public override string ToString() => Value.ToString("G"); +} diff --git a/src/Marathon.Domain/ValueObjects/OddsValue.cs b/src/Marathon.Domain/ValueObjects/OddsValue.cs new file mode 100644 index 0000000..869a15f --- /dev/null +++ b/src/Marathon.Domain/ValueObjects/OddsValue.cs @@ -0,0 +1,23 @@ +namespace Marathon.Domain.ValueObjects; + +/// +/// The threshold value for a handicap (fora) or total bet. +/// Handicaps can be negative (e.g., -1.5 for the favourite). +/// Totals are positive and non-zero (e.g., 3.5, 213.5). +/// Zero is excluded as it has no meaningful betting interpretation. +/// +public sealed record OddsValue +{ + public decimal Value { get; } + + public OddsValue(decimal value) + { + if (value == 0m) + throw new ArgumentOutOfRangeException(nameof(value), value, + "OddsValue must not be zero. Use a non-zero handicap or total threshold."); + + Value = value; + } + + public override string ToString() => Value.ToString("G"); +} diff --git a/src/Marathon.Domain/ValueObjects/SportCode.cs b/src/Marathon.Domain/ValueObjects/SportCode.cs new file mode 100644 index 0000000..c1f11aa --- /dev/null +++ b/src/Marathon.Domain/ValueObjects/SportCode.cs @@ -0,0 +1,21 @@ +namespace Marathon.Domain.ValueObjects; + +/// +/// Canonical sport identifier — corresponds to data-sport-treeId in marathonbet.by breadcrumbs. +/// Known values: Basketball=6, Football=11, Tennis=22723, Hockey=43658. +/// +public sealed record SportCode +{ + public int Value { get; } + + public SportCode(int value) + { + if (value <= 0) + throw new ArgumentOutOfRangeException(nameof(value), value, + "SportCode must be greater than zero."); + + Value = value; + } + + public override string ToString() => Value.ToString(); +} diff --git a/src/Marathon.Hosts.WpfBlazor/App.xaml b/src/Marathon.Hosts.WpfBlazor/App.xaml new file mode 100644 index 0000000..b616b32 --- /dev/null +++ b/src/Marathon.Hosts.WpfBlazor/App.xaml @@ -0,0 +1,9 @@ + + + + + diff --git a/src/Marathon.Hosts.WpfBlazor/App.xaml.cs b/src/Marathon.Hosts.WpfBlazor/App.xaml.cs new file mode 100644 index 0000000..42151a8 --- /dev/null +++ b/src/Marathon.Hosts.WpfBlazor/App.xaml.cs @@ -0,0 +1,10 @@ +using System.Windows; + +namespace Marathon.Hosts.WpfBlazor; + +/// +/// Interaction logic for App.xaml +/// +public partial class App : System.Windows.Application +{ +} diff --git a/src/Marathon.Hosts.WpfBlazor/AssemblyInfo.cs b/src/Marathon.Hosts.WpfBlazor/AssemblyInfo.cs new file mode 100644 index 0000000..cc29e7f --- /dev/null +++ b/src/Marathon.Hosts.WpfBlazor/AssemblyInfo.cs @@ -0,0 +1,10 @@ +using System.Windows; + +[assembly:ThemeInfo( + ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located + //(used if a resource is not found in the page, + // or application resource dictionaries) + ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located + //(used if a resource is not found in the page, + // app, or any theme specific resource dictionaries) +)] diff --git a/src/Marathon.Hosts.WpfBlazor/MainWindow.xaml b/src/Marathon.Hosts.WpfBlazor/MainWindow.xaml new file mode 100644 index 0000000..07d3c6f --- /dev/null +++ b/src/Marathon.Hosts.WpfBlazor/MainWindow.xaml @@ -0,0 +1,12 @@ + + + + + diff --git a/src/Marathon.Hosts.WpfBlazor/MainWindow.xaml.cs b/src/Marathon.Hosts.WpfBlazor/MainWindow.xaml.cs new file mode 100644 index 0000000..88f311f --- /dev/null +++ b/src/Marathon.Hosts.WpfBlazor/MainWindow.xaml.cs @@ -0,0 +1,23 @@ +using System.Text; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Data; +using System.Windows.Documents; +using System.Windows.Input; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using System.Windows.Navigation; +using System.Windows.Shapes; + +namespace Marathon.Hosts.WpfBlazor; + +/// +/// Interaction logic for MainWindow.xaml +/// +public partial class MainWindow : Window +{ + public MainWindow() + { + InitializeComponent(); + } +} \ No newline at end of file diff --git a/src/Marathon.Hosts.WpfBlazor/Marathon.Hosts.WpfBlazor.csproj b/src/Marathon.Hosts.WpfBlazor/Marathon.Hosts.WpfBlazor.csproj new file mode 100644 index 0000000..874327f --- /dev/null +++ b/src/Marathon.Hosts.WpfBlazor/Marathon.Hosts.WpfBlazor.csproj @@ -0,0 +1,17 @@ + + + + + + + + + + WinExe + net8.0-windows + enable + enable + true + + + diff --git a/src/Marathon.Infrastructure/Marathon.Infrastructure.csproj b/src/Marathon.Infrastructure/Marathon.Infrastructure.csproj new file mode 100644 index 0000000..817c7e0 --- /dev/null +++ b/src/Marathon.Infrastructure/Marathon.Infrastructure.csproj @@ -0,0 +1,12 @@ + + + + net8.0 + + + + + + + + diff --git a/src/Marathon.Infrastructure/Placeholder.cs b/src/Marathon.Infrastructure/Placeholder.cs new file mode 100644 index 0000000..02f445c --- /dev/null +++ b/src/Marathon.Infrastructure/Placeholder.cs @@ -0,0 +1,5 @@ +// Phase 2/3 will populate this project. +// This file exists only to prevent the compiler from treating the project as empty. +namespace Marathon.Infrastructure; + +internal static class Placeholder { } diff --git a/src/Marathon.UI/Component1.razor b/src/Marathon.UI/Component1.razor new file mode 100644 index 0000000..925ae9b --- /dev/null +++ b/src/Marathon.UI/Component1.razor @@ -0,0 +1,3 @@ +
+ This component is defined in the Marathon.UI library. +
diff --git a/src/Marathon.UI/Component1.razor.css b/src/Marathon.UI/Component1.razor.css new file mode 100644 index 0000000..c6afca4 --- /dev/null +++ b/src/Marathon.UI/Component1.razor.css @@ -0,0 +1,6 @@ +.my-component { + border: 2px dashed red; + padding: 1em; + margin: 1em 0; + background-image: url('background.png'); +} diff --git a/src/Marathon.UI/ExampleJsInterop.cs b/src/Marathon.UI/ExampleJsInterop.cs new file mode 100644 index 0000000..6906217 --- /dev/null +++ b/src/Marathon.UI/ExampleJsInterop.cs @@ -0,0 +1,36 @@ +using Microsoft.JSInterop; + +namespace Marathon.UI; + +// This class provides an example of how JavaScript functionality can be wrapped +// in a .NET class for easy consumption. The associated JavaScript module is +// loaded on demand when first needed. +// +// This class can be registered as scoped DI service and then injected into Blazor +// components for use. + +public class ExampleJsInterop : IAsyncDisposable +{ + private readonly Lazy> moduleTask; + + public ExampleJsInterop(IJSRuntime jsRuntime) + { + moduleTask = new (() => jsRuntime.InvokeAsync( + "import", "./_content/Marathon.UI/exampleJsInterop.js").AsTask()); + } + + public async ValueTask Prompt(string message) + { + var module = await moduleTask.Value; + return await module.InvokeAsync("showPrompt", message); + } + + public async ValueTask DisposeAsync() + { + if (moduleTask.IsValueCreated) + { + var module = await moduleTask.Value; + await module.DisposeAsync(); + } + } +} diff --git a/src/Marathon.UI/Marathon.UI.csproj b/src/Marathon.UI/Marathon.UI.csproj new file mode 100644 index 0000000..20383b1 --- /dev/null +++ b/src/Marathon.UI/Marathon.UI.csproj @@ -0,0 +1,20 @@ + + + + net8.0 + + + + + + + + + + + + + + + + diff --git a/src/Marathon.UI/_Imports.razor b/src/Marathon.UI/_Imports.razor new file mode 100644 index 0000000..7728512 --- /dev/null +++ b/src/Marathon.UI/_Imports.razor @@ -0,0 +1 @@ +@using Microsoft.AspNetCore.Components.Web diff --git a/src/Marathon.UI/wwwroot/background.png b/src/Marathon.UI/wwwroot/background.png new file mode 100644 index 0000000..e15a3bd Binary files /dev/null and b/src/Marathon.UI/wwwroot/background.png differ diff --git a/src/Marathon.UI/wwwroot/exampleJsInterop.js b/src/Marathon.UI/wwwroot/exampleJsInterop.js new file mode 100644 index 0000000..ea8d76a --- /dev/null +++ b/src/Marathon.UI/wwwroot/exampleJsInterop.js @@ -0,0 +1,6 @@ +// This is a JavaScript module that is loaded on demand. It can export any number of +// functions, and may import other JavaScript modules if required. + +export function showPrompt(message) { + return prompt(message, 'Type anything here'); +} diff --git a/tests/Marathon.Application.Tests/Marathon.Application.Tests.csproj b/tests/Marathon.Application.Tests/Marathon.Application.Tests.csproj new file mode 100644 index 0000000..588d170 --- /dev/null +++ b/tests/Marathon.Application.Tests/Marathon.Application.Tests.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + false + true + + + + + + + + + + + + + + + + + + + diff --git a/tests/Marathon.Application.Tests/PlaceholderTest.cs b/tests/Marathon.Application.Tests/PlaceholderTest.cs new file mode 100644 index 0000000..1661aab --- /dev/null +++ b/tests/Marathon.Application.Tests/PlaceholderTest.cs @@ -0,0 +1,8 @@ +// Phase 4 will add real tests to this project. +namespace Marathon.Application.Tests; + +public sealed class PlaceholderTest +{ + [Fact] + public void Placeholder_AlwaysPasses() => Assert.True(true); +} diff --git a/tests/Marathon.Domain.Tests/Entities/AnomalyTests.cs b/tests/Marathon.Domain.Tests/Entities/AnomalyTests.cs new file mode 100644 index 0000000..8bdeac4 --- /dev/null +++ b/tests/Marathon.Domain.Tests/Entities/AnomalyTests.cs @@ -0,0 +1,68 @@ +using FluentAssertions; +using Marathon.Domain.Entities; +using Marathon.Domain.Enums; +using Marathon.Domain.ValueObjects; + +namespace Marathon.Domain.Tests.Entities; + +public sealed class AnomalyTests +{ + private static readonly Guid SampleId = Guid.NewGuid(); + private static readonly EventId SampleEventId = new("26456117"); + + [Fact] + public void Constructor_CreatesAnomaly_WhenAllParametersAreValid() + { + var anomaly = new Anomaly( + SampleId, + SampleEventId, + DateTimeOffset.UtcNow, + AnomalyKind.SuspensionFlip, + 0.85m, + "{}"); + + anomaly.Id.Should().Be(SampleId); + anomaly.Score.Should().Be(0.85m); + } + + [Fact] + public void Constructor_ThrowsArgumentException_WhenIdIsEmptyGuid() + { + var act = () => new Anomaly(Guid.Empty, SampleEventId, DateTimeOffset.UtcNow, + AnomalyKind.SuspensionFlip, 0.5m, "{}"); + act.Should().Throw().WithParameterName("Id"); + } + + [Theory] + [InlineData(-0.01)] + [InlineData(1.01)] + [InlineData(2.0)] + [InlineData(-1.0)] + public void Constructor_ThrowsArgumentOutOfRangeException_WhenScoreIsOutOfRange(double score) + { + var act = () => new Anomaly(SampleId, SampleEventId, DateTimeOffset.UtcNow, + AnomalyKind.SuspensionFlip, (decimal)score, "{}"); + act.Should().Throw().WithParameterName("Score"); + } + + [Theory] + [InlineData(0.0)] + [InlineData(0.5)] + [InlineData(1.0)] + public void Constructor_CreatesAnomaly_WhenScoreIsInValidRange(double score) + { + var act = () => new Anomaly(SampleId, SampleEventId, DateTimeOffset.UtcNow, + AnomalyKind.SuspensionFlip, (decimal)score, "{}"); + act.Should().NotThrow(); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Constructor_ThrowsArgumentException_WhenEvidenceJsonIsEmptyOrWhitespace(string evidence) + { + var act = () => new Anomaly(SampleId, SampleEventId, DateTimeOffset.UtcNow, + AnomalyKind.SuspensionFlip, 0.5m, evidence); + act.Should().Throw().WithParameterName("EvidenceJson"); + } +} diff --git a/tests/Marathon.Domain.Tests/Entities/BetTests.cs b/tests/Marathon.Domain.Tests/Entities/BetTests.cs new file mode 100644 index 0000000..87627e8 --- /dev/null +++ b/tests/Marathon.Domain.Tests/Entities/BetTests.cs @@ -0,0 +1,170 @@ +using FluentAssertions; +using Marathon.Domain.Entities; +using Marathon.Domain.Enums; +using Marathon.Domain.ValueObjects; + +namespace Marathon.Domain.Tests.Entities; + +public sealed class BetTests +{ + private static readonly BetScope MatchScope = Marathon.Domain.ValueObjects.MatchScope.Instance; + private static readonly OddsRate SampleRate = new(1.85m); + private static readonly OddsValue SampleValue = new(3.5m); + + // ── Win bets ────────────────────────────────────────────────────────────── + + [Theory] + [InlineData(Side.Side1)] + [InlineData(Side.Side2)] + public void Constructor_CreatesWinBet_WhenSideIs1Or2AndValueIsNull(Side side) + { + var bet = new Bet(MatchScope, BetType.Win, side, null, SampleRate); + bet.Type.Should().Be(BetType.Win); + bet.Side.Should().Be(side); + bet.Value.Should().BeNull(); + } + + [Theory] + [InlineData(Side.Draw)] + [InlineData(Side.Less)] + [InlineData(Side.More)] + public void Constructor_ThrowsArgumentException_WhenWinBetHasInvalidSide(Side side) + { + var act = () => new Bet(MatchScope, BetType.Win, side, null, SampleRate); + act.Should().Throw() + .WithParameterName("side"); + } + + [Fact] + public void Constructor_ThrowsArgumentException_WhenWinBetHasNonNullValue() + { + var act = () => new Bet(MatchScope, BetType.Win, Side.Side1, SampleValue, SampleRate); + act.Should().Throw() + .WithParameterName("value"); + } + + // ── Draw bets ───────────────────────────────────────────────────────────── + + [Fact] + public void Constructor_CreatesDrawBet_WhenSideIsDrawAndValueIsNull() + { + var bet = new Bet(MatchScope, BetType.Draw, Side.Draw, null, SampleRate); + bet.Type.Should().Be(BetType.Draw); + bet.Side.Should().Be(Side.Draw); + bet.Value.Should().BeNull(); + } + + [Theory] + [InlineData(Side.Side1)] + [InlineData(Side.Side2)] + [InlineData(Side.Less)] + [InlineData(Side.More)] + public void Constructor_ThrowsArgumentException_WhenDrawBetHasInvalidSide(Side side) + { + var act = () => new Bet(MatchScope, BetType.Draw, side, null, SampleRate); + act.Should().Throw() + .WithParameterName("side"); + } + + [Fact] + public void Constructor_ThrowsArgumentException_WhenDrawBetHasNonNullValue() + { + var act = () => new Bet(MatchScope, BetType.Draw, Side.Draw, SampleValue, SampleRate); + act.Should().Throw() + .WithParameterName("value"); + } + + // ── WinFora bets ────────────────────────────────────────────────────────── + + [Theory] + [InlineData(Side.Side1)] + [InlineData(Side.Side2)] + public void Constructor_CreatesWinForaBet_WhenSideIs1Or2AndValueIsNonNull(Side side) + { + var bet = new Bet(MatchScope, BetType.WinFora, side, SampleValue, SampleRate); + bet.Type.Should().Be(BetType.WinFora); + bet.Value.Should().NotBeNull(); + } + + [Fact] + public void Constructor_ThrowsArgumentException_WhenWinForaBetHasNullValue() + { + var act = () => new Bet(MatchScope, BetType.WinFora, Side.Side1, null, SampleRate); + act.Should().Throw() + .WithParameterName("value"); + } + + [Theory] + [InlineData(Side.Draw)] + [InlineData(Side.Less)] + [InlineData(Side.More)] + public void Constructor_ThrowsArgumentException_WhenWinForaBetHasInvalidSide(Side side) + { + var act = () => new Bet(MatchScope, BetType.WinFora, side, SampleValue, SampleRate); + act.Should().Throw() + .WithParameterName("side"); + } + + // ── Total bets ──────────────────────────────────────────────────────────── + + [Theory] + [InlineData(Side.Less)] + [InlineData(Side.More)] + public void Constructor_CreatesTotalBet_WhenSideIsLessOrMoreAndValueIsNonNull(Side side) + { + var bet = new Bet(MatchScope, BetType.Total, side, SampleValue, SampleRate); + bet.Type.Should().Be(BetType.Total); + bet.Value.Should().NotBeNull(); + } + + [Fact] + public void Constructor_ThrowsArgumentException_WhenTotalBetHasNullValue() + { + var act = () => new Bet(MatchScope, BetType.Total, Side.Less, null, SampleRate); + act.Should().Throw() + .WithParameterName("value"); + } + + [Theory] + [InlineData(Side.Side1)] + [InlineData(Side.Side2)] + [InlineData(Side.Draw)] + public void Constructor_ThrowsArgumentException_WhenTotalBetHasInvalidSide(Side side) + { + var act = () => new Bet(MatchScope, BetType.Total, side, SampleValue, SampleRate); + act.Should().Throw() + .WithParameterName("side"); + } + + // ── Null guards ─────────────────────────────────────────────────────────── + + [Fact] + public void Constructor_ThrowsArgumentNullException_WhenScopeIsNull() + { + var act = () => new Bet(null!, BetType.Win, Side.Side1, null, SampleRate); + act.Should().Throw() + .WithParameterName("scope"); + } + + [Fact] + public void Constructor_ThrowsArgumentNullException_WhenRateIsNull() + { + var act = () => new Bet(MatchScope, BetType.Win, Side.Side1, null, null!); + act.Should().Throw() + .WithParameterName("rate"); + } + + // ── Immutability ────────────────────────────────────────────────────────── + + [Fact] + public void Bet_IsImmutable_NoSettablePublicProperties() + { + var betType = typeof(Bet); + var settableProperties = betType.GetProperties() + .Where(p => p.CanWrite && p.GetSetMethod(nonPublic: false) is not null) + .ToList(); + + settableProperties.Should().BeEmpty( + "Bet is a sealed record with only get-only properties — immutability is required."); + } +} diff --git a/tests/Marathon.Domain.Tests/Entities/EventTests.cs b/tests/Marathon.Domain.Tests/Entities/EventTests.cs new file mode 100644 index 0000000..be69bc4 --- /dev/null +++ b/tests/Marathon.Domain.Tests/Entities/EventTests.cs @@ -0,0 +1,130 @@ +using FluentAssertions; +using Marathon.Domain.Entities; +using Marathon.Domain.ValueObjects; + +namespace Marathon.Domain.Tests.Entities; + +public sealed class EventTests +{ + private static readonly EventId SampleId = new("26456117"); + private static readonly SportCode SampleSport = new(6); + private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3); + private static readonly DateTimeOffset ValidScheduledAt = + new(2026, 5, 10, 18, 0, 0, MoscowOffset); + + private static Event CreateValidEvent(DateTimeOffset? scheduledAt = null) => + new( + SampleId, + SampleSport, + "RU", + "nba-league-1", + "Play-Offs", + scheduledAt ?? ValidScheduledAt, + "Арсенал", + "Атлетико"); + + [Fact] + public void Constructor_CreatesEvent_WhenAllParametersAreValid() + { + var evt = CreateValidEvent(); + evt.Id.Should().Be(SampleId); + evt.Sport.Should().Be(SampleSport); + evt.ScheduledAt.Offset.Should().Be(MoscowOffset); + } + + [Fact] + public void Constructor_ThrowsArgumentException_WhenScheduledAtIsNotMoscowTime() + { + // UTC+0 — wrong offset + var utcTime = new DateTimeOffset(2026, 5, 10, 15, 0, 0, TimeSpan.Zero); + var act = () => CreateValidEvent(utcTime); + act.Should().Throw() + .WithParameterName("ScheduledAt"); + } + + [Fact] + public void Constructor_ThrowsArgumentException_WhenScheduledAtIsOtherOffset() + { + // UTC+2 — wrong offset (CEST, for example) + var cestTime = new DateTimeOffset(2026, 5, 10, 17, 0, 0, TimeSpan.FromHours(2)); + var act = () => CreateValidEvent(cestTime); + act.Should().Throw() + .WithParameterName("ScheduledAt"); + } + + [Fact] + public void ScheduledAt_Offset_IsMoscowTime() + { + var evt = CreateValidEvent(); + evt.ScheduledAt.Offset.Should().Be(TimeSpan.FromHours(3)); + } + + [Fact] + public void Constructor_ThrowsArgumentNullException_WhenIdIsNull() + { + var act = () => new Event(null!, SampleSport, "RU", "l1", "cat", ValidScheduledAt, "A", "B"); + act.Should().Throw().WithParameterName("Id"); + } + + [Fact] + public void Constructor_ThrowsArgumentNullException_WhenSportIsNull() + { + var act = () => new Event(SampleId, null!, "RU", "l1", "cat", ValidScheduledAt, "A", "B"); + act.Should().Throw().WithParameterName("Sport"); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Constructor_ThrowsArgumentException_WhenCountryCodeIsEmptyOrWhitespace(string code) + { + var act = () => new Event(SampleId, SampleSport, code, "l1", "cat", ValidScheduledAt, "A", "B"); + act.Should().Throw().WithParameterName("CountryCode"); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Constructor_ThrowsArgumentException_WhenLeagueIdIsEmptyOrWhitespace(string leagueId) + { + var act = () => new Event(SampleId, SampleSport, "RU", leagueId, "cat", ValidScheduledAt, "A", "B"); + act.Should().Throw().WithParameterName("LeagueId"); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Constructor_ThrowsArgumentException_WhenSide1NameIsEmptyOrWhitespace(string name) + { + var act = () => new Event(SampleId, SampleSport, "RU", "l1", "cat", ValidScheduledAt, name, "B"); + act.Should().Throw().WithParameterName("Side1Name"); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Constructor_ThrowsArgumentException_WhenSide2NameIsEmptyOrWhitespace(string name) + { + var act = () => new Event(SampleId, SampleSport, "RU", "l1", "cat", ValidScheduledAt, "A", name); + act.Should().Throw().WithParameterName("Side2Name"); + } + + [Fact] + public void Category_CanBeEmptyString() + { + // Category is optional (deep breadcrumbs may not exist) + var evt = new Event(SampleId, SampleSport, "RU", "l1", string.Empty, ValidScheduledAt, "A", "B"); + evt.Category.Should().Be(string.Empty); + } + + [Fact] + public void Event_IsImmutable_NoSettablePublicProperties() + { + var eventType = typeof(Event); + var settableProperties = eventType.GetProperties() + .Where(p => p.CanWrite && p.GetSetMethod(nonPublic: false) is not null) + .ToList(); + + settableProperties.Should().BeEmpty("Event must be immutable."); + } +} diff --git a/tests/Marathon.Domain.Tests/Entities/OddsSnapshotTests.cs b/tests/Marathon.Domain.Tests/Entities/OddsSnapshotTests.cs new file mode 100644 index 0000000..6c6597e --- /dev/null +++ b/tests/Marathon.Domain.Tests/Entities/OddsSnapshotTests.cs @@ -0,0 +1,59 @@ +using FluentAssertions; +using Marathon.Domain.Entities; +using Marathon.Domain.Enums; +using Marathon.Domain.ValueObjects; + +namespace Marathon.Domain.Tests.Entities; + +public sealed class OddsSnapshotTests +{ + private static readonly EventId SampleEventId = new("26456117"); + private static readonly DateTimeOffset SampleCapturedAt = DateTimeOffset.UtcNow; + private static readonly IReadOnlyList OneBet = + [ + new Bet(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(1.85m)), + ]; + + [Fact] + public void Constructor_CreatesInstance_WhenBetsIsNonEmpty() + { + var snapshot = new OddsSnapshot(SampleEventId, SampleCapturedAt, OddsSource.PreMatch, OneBet); + snapshot.EventId.Should().Be(SampleEventId); + snapshot.Bets.Should().HaveCount(1); + } + + [Fact] + public void Constructor_ThrowsArgumentException_WhenBetsIsEmpty() + { + var act = () => new OddsSnapshot(SampleEventId, SampleCapturedAt, OddsSource.PreMatch, []); + act.Should().Throw() + .WithParameterName("bets"); + } + + [Fact] + public void Constructor_ThrowsArgumentNullException_WhenEventIdIsNull() + { + var act = () => new OddsSnapshot(null!, SampleCapturedAt, OddsSource.PreMatch, OneBet); + act.Should().Throw() + .WithParameterName("eventId"); + } + + [Fact] + public void Constructor_ThrowsArgumentNullException_WhenBetsIsNull() + { + var act = () => new OddsSnapshot(SampleEventId, SampleCapturedAt, OddsSource.PreMatch, null!); + act.Should().Throw() + .WithParameterName("bets"); + } + + [Fact] + public void OddsSnapshot_IsImmutable_NoSettablePublicProperties() + { + var snapshotType = typeof(OddsSnapshot); + var settableProperties = snapshotType.GetProperties() + .Where(p => p.CanWrite && p.GetSetMethod(nonPublic: false) is not null) + .ToList(); + + settableProperties.Should().BeEmpty("OddsSnapshot must be immutable."); + } +} diff --git a/tests/Marathon.Domain.Tests/Marathon.Domain.Tests.csproj b/tests/Marathon.Domain.Tests/Marathon.Domain.Tests.csproj new file mode 100644 index 0000000..8e3b5f3 --- /dev/null +++ b/tests/Marathon.Domain.Tests/Marathon.Domain.Tests.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + false + true + + + + + + + + + + + + + + + + + + + diff --git a/tests/Marathon.Domain.Tests/ValueObjects/BetScopeTests.cs b/tests/Marathon.Domain.Tests/ValueObjects/BetScopeTests.cs new file mode 100644 index 0000000..2c7574c --- /dev/null +++ b/tests/Marathon.Domain.Tests/ValueObjects/BetScopeTests.cs @@ -0,0 +1,71 @@ +using FluentAssertions; +using Marathon.Domain.ValueObjects; + +namespace Marathon.Domain.Tests.ValueObjects; + +public sealed class BetScopeTests +{ + [Fact] + public void MatchScope_Singleton_IsTheSameInstance() + { + MatchScope.Instance.Should().BeSameAs(MatchScope.Instance); + } + + [Fact] + public void PeriodScope_CreatesInstance_WhenNumberIsPositive() + { + var scope = new PeriodScope(1); + scope.Number.Should().Be(1); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(int.MinValue)] + public void PeriodScope_ThrowsArgumentOutOfRangeException_WhenNumberIsZeroOrNegative(int number) + { + var act = () => new PeriodScope(number); + act.Should().Throw() + .WithParameterName("Number"); + } + + [Fact] + public void BetScope_PatternMatching_WorksCorrectly() + { + BetScope match = MatchScope.Instance; + BetScope period = new PeriodScope(2); + + var matchResult = match switch + { + MatchScope => "match", + PeriodScope(var n) => $"period-{n}", + _ => "other", + }; + + var periodResult = period switch + { + MatchScope => "match", + PeriodScope(var n) => $"period-{n}", + _ => "other", + }; + + matchResult.Should().Be("match"); + periodResult.Should().Be("period-2"); + } + + [Fact] + public void PeriodScope_Equality_IsValueBased() + { + var a = new PeriodScope(1); + var b = new PeriodScope(1); + a.Should().Be(b); + } + + [Fact] + public void MatchScope_And_PeriodScope_AreNotEqual() + { + BetScope match = MatchScope.Instance; + BetScope period = new PeriodScope(1); + match.Should().NotBe(period); + } +} diff --git a/tests/Marathon.Domain.Tests/ValueObjects/EventIdTests.cs b/tests/Marathon.Domain.Tests/ValueObjects/EventIdTests.cs new file mode 100644 index 0000000..2bb364c --- /dev/null +++ b/tests/Marathon.Domain.Tests/ValueObjects/EventIdTests.cs @@ -0,0 +1,48 @@ +using FluentAssertions; +using Marathon.Domain.ValueObjects; + +namespace Marathon.Domain.Tests.ValueObjects; + +public sealed class EventIdTests +{ + [Fact] + public void Constructor_CreatesInstance_WhenValueIsNonEmpty() + { + var id = new EventId("26456117"); + id.Value.Should().Be("26456117"); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t")] + public void Constructor_ThrowsArgumentException_WhenValueIsEmptyOrWhitespace(string value) + { + var act = () => new EventId(value); + act.Should().Throw() + .WithParameterName("value"); + } + + [Fact] + public void Constructor_ThrowsArgumentException_WhenValueIsNull() + { + var act = () => new EventId(null!); + act.Should().Throw() + .WithParameterName("value"); + } + + [Fact] + public void ToString_ReturnsValue() + { + var id = new EventId("12345"); + id.ToString().Should().Be("12345"); + } + + [Fact] + public void Equality_IsValueBased() + { + var a = new EventId("26456117"); + var b = new EventId("26456117"); + a.Should().Be(b); + } +} diff --git a/tests/Marathon.Domain.Tests/ValueObjects/OddsRateTests.cs b/tests/Marathon.Domain.Tests/ValueObjects/OddsRateTests.cs new file mode 100644 index 0000000..71bc497 --- /dev/null +++ b/tests/Marathon.Domain.Tests/ValueObjects/OddsRateTests.cs @@ -0,0 +1,40 @@ +using FluentAssertions; +using Marathon.Domain.ValueObjects; + +namespace Marathon.Domain.Tests.ValueObjects; + +public sealed class OddsRateTests +{ + [Theory] + [InlineData("1.01")] + [InlineData("1.65")] + [InlineData("10.5")] + [InlineData("100.0")] + public void Constructor_CreatesInstance_WhenValueIsGreaterThanOne(string rawValue) + { + var value = decimal.Parse(rawValue); + var rate = new OddsRate(value); + rate.Value.Should().Be(value); + } + + [Theory] + [InlineData("1.0")] + [InlineData("0.99")] + [InlineData("0.0")] + [InlineData("-1.5")] + public void Constructor_ThrowsArgumentOutOfRangeException_WhenValueIsOneOrLess(string rawValue) + { + var value = decimal.Parse(rawValue); + var act = () => new OddsRate(value); + act.Should().Throw() + .WithParameterName("value"); + } + + [Fact] + public void Equality_IsValueBased() + { + var a = new OddsRate(1.65m); + var b = new OddsRate(1.65m); + a.Should().Be(b); + } +} diff --git a/tests/Marathon.Domain.Tests/ValueObjects/OddsValueTests.cs b/tests/Marathon.Domain.Tests/ValueObjects/OddsValueTests.cs new file mode 100644 index 0000000..3cb8d5e --- /dev/null +++ b/tests/Marathon.Domain.Tests/ValueObjects/OddsValueTests.cs @@ -0,0 +1,36 @@ +using FluentAssertions; +using Marathon.Domain.ValueObjects; + +namespace Marathon.Domain.Tests.ValueObjects; + +public sealed class OddsValueTests +{ + [Theory] + [InlineData("3.5")] + [InlineData("213.5")] + [InlineData("-1.5")] + [InlineData("-0.5")] + [InlineData("0.5")] + public void Constructor_CreatesInstance_WhenValueIsNonZero(string rawValue) + { + var value = decimal.Parse(rawValue); + var oddsValue = new OddsValue(value); + oddsValue.Value.Should().Be(value); + } + + [Fact] + public void Constructor_ThrowsArgumentOutOfRangeException_WhenValueIsZero() + { + var act = () => new OddsValue(0m); + act.Should().Throw() + .WithParameterName("value"); + } + + [Fact] + public void Equality_IsValueBased() + { + var a = new OddsValue(3.5m); + var b = new OddsValue(3.5m); + a.Should().Be(b); + } +} diff --git a/tests/Marathon.Domain.Tests/ValueObjects/SportCodeTests.cs b/tests/Marathon.Domain.Tests/ValueObjects/SportCodeTests.cs new file mode 100644 index 0000000..da6ad1f --- /dev/null +++ b/tests/Marathon.Domain.Tests/ValueObjects/SportCodeTests.cs @@ -0,0 +1,41 @@ +using FluentAssertions; +using Marathon.Domain.ValueObjects; + +namespace Marathon.Domain.Tests.ValueObjects; + +public sealed class SportCodeTests +{ + [Fact] + public void Constructor_CreatesInstance_WhenValueIsPositive() + { + var code = new SportCode(6); + code.Value.Should().Be(6); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(int.MinValue)] + public void Constructor_ThrowsArgumentOutOfRangeException_WhenValueIsZeroOrNegative(int value) + { + var act = () => new SportCode(value); + act.Should().Throw() + .WithParameterName("value"); + } + + [Fact] + public void ToString_ReturnsStringRepresentationOfValue() + { + var code = new SportCode(22723); + code.ToString().Should().Be("22723"); + } + + [Fact] + public void Equality_IsValueBased() + { + var a = new SportCode(6); + var b = new SportCode(6); + a.Should().Be(b); + a.Should().NotBeSameAs(b); + } +} diff --git a/tests/Marathon.Infrastructure.Tests/Marathon.Infrastructure.Tests.csproj b/tests/Marathon.Infrastructure.Tests/Marathon.Infrastructure.Tests.csproj new file mode 100644 index 0000000..bffa7d7 --- /dev/null +++ b/tests/Marathon.Infrastructure.Tests/Marathon.Infrastructure.Tests.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + false + true + + + + + + + + + + + + + + + + + + + diff --git a/tests/Marathon.Infrastructure.Tests/PlaceholderTest.cs b/tests/Marathon.Infrastructure.Tests/PlaceholderTest.cs new file mode 100644 index 0000000..49a0523 --- /dev/null +++ b/tests/Marathon.Infrastructure.Tests/PlaceholderTest.cs @@ -0,0 +1,8 @@ +// Phases 2/3 will add real tests to this project. +namespace Marathon.Infrastructure.Tests; + +public sealed class PlaceholderTest +{ + [Fact] + public void Placeholder_AlwaysPasses() => Assert.True(true); +} diff --git a/tests/Marathon.UI.Tests/Marathon.UI.Tests.csproj b/tests/Marathon.UI.Tests/Marathon.UI.Tests.csproj new file mode 100644 index 0000000..b47796c --- /dev/null +++ b/tests/Marathon.UI.Tests/Marathon.UI.Tests.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + false + true + + + + + + + + + + + + + + + + + + + diff --git a/tests/Marathon.UI.Tests/PlaceholderTest.cs b/tests/Marathon.UI.Tests/PlaceholderTest.cs new file mode 100644 index 0000000..a79db44 --- /dev/null +++ b/tests/Marathon.UI.Tests/PlaceholderTest.cs @@ -0,0 +1,8 @@ +// Phase 5/6 will add real tests to this project. +namespace Marathon.UI.Tests; + +public sealed class PlaceholderTest +{ + [Fact] + public void Placeholder_AlwaysPasses() => Assert.True(true); +}