feat: implement Phase 1 — solution skeleton and domain model
Creates the 9-project .NET 8 solution (5 src + 4 test) with Marathon.Domain fully implemented: value objects (SportCode, EventId, OddsRate, OddsValue, BetScope hierarchy), enums (Side, BetType, OddsSource, AnomalyKind), and entities (Sport, Country, League, Event, Bet, OddsSnapshot, EventResult, Anomaly) with all invariants enforced in constructors. 96 domain tests pass (FluentAssertions + xUnit). Directory.Build.props and Directory.Packages.props centralise build settings and NuGet versions. Both Marathon.sln and Marathon.slnx are committed; dotnet build Marathon.sln succeeds with 0 warnings/errors.
This commit is contained in:
@@ -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
|
||||
@@ -102,7 +102,13 @@ Marathon_<YYYY-MM-DD>_to_<YYYY-MM-DD>.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
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>12</LangVersion>
|
||||
<TreatWarningsAsErrors Condition="'$(Configuration)'=='Release'">true</TreatWarningsAsErrors>
|
||||
<AnalysisLevel>latest</AnalysisLevel>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,45 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Test infrastructure -->
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageVersion Include="xunit" Version="2.9.2" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageVersion Include="coverlet.collector" Version="6.0.2" />
|
||||
<PackageVersion Include="FluentAssertions" Version="6.12.2" />
|
||||
<PackageVersion Include="NSubstitute" Version="5.1.0" />
|
||||
<PackageVersion Include="bunit" Version="1.35.6" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Blazor / ASP.NET Core -->
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Components.Web" Version="8.0.12" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Infrastructure (future phases) -->
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="8.0.12" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.12" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.12" />
|
||||
<PackageVersion Include="AngleSharp" Version="1.2.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="8.10.0" />
|
||||
<PackageVersion Include="Polly" Version="8.5.2" />
|
||||
<PackageVersion Include="ClosedXML" Version="0.104.2" />
|
||||
<PackageVersion Include="Serilog" Version="4.2.0" />
|
||||
<PackageVersion Include="Serilog.Extensions.Hosting" Version="8.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- WPF Blazor Host (future phases) -->
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Components.WebView.Wpf" Version="8.0.100" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="8.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="8.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -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
|
||||
@@ -0,0 +1,15 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/Marathon.Application/Marathon.Application.csproj" />
|
||||
<Project Path="src/Marathon.Domain/Marathon.Domain.csproj" />
|
||||
<Project Path="src/Marathon.Hosts.WpfBlazor/Marathon.Hosts.WpfBlazor.csproj" />
|
||||
<Project Path="src/Marathon.Infrastructure/Marathon.Infrastructure.csproj" />
|
||||
<Project Path="src/Marathon.UI/Marathon.UI.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/Marathon.Application.Tests/Marathon.Application.Tests.csproj" />
|
||||
<Project Path="tests/Marathon.Domain.Tests/Marathon.Domain.Tests.csproj" />
|
||||
<Project Path="tests/Marathon.Infrastructure.Tests/Marathon.Infrastructure.Tests.csproj" />
|
||||
<Project Path="tests/Marathon.UI.Tests/Marathon.UI.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
@@ -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
|
||||
|
||||
@@ -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 | ⬜ |
|
||||
|
||||
@@ -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
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
@@ -36,11 +36,11 @@ external dependencies. This establishes the foundation that all later phases ref
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
```
|
||||
- [ ] Add `Directory.Packages.props` for centralized NuGet versions (mark
|
||||
- [x] Add `Directory.Packages.props` for centralized NuGet versions (mark
|
||||
`<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>`).
|
||||
- [ ] 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
|
||||
|
||||
<!-- Filled by Phase 1 implementer. -->
|
||||
### 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.
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Marathon.Domain\Marathon.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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 { }
|
||||
@@ -0,0 +1,37 @@
|
||||
using Marathon.Domain.Enums;
|
||||
using Marathon.Domain.ValueObjects;
|
||||
|
||||
namespace Marathon.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A detected anomaly in odds behaviour for an event.
|
||||
/// <c>Score</c> is a normalised confidence score in [0, 1] — higher means stronger signal.
|
||||
/// <c>EvidenceJson</c> is a JSON string containing the raw evidence timeline (snapshots, diffs).
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using Marathon.Domain.Enums;
|
||||
using Marathon.Domain.ValueObjects;
|
||||
|
||||
namespace Marathon.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A single betting option within an odds snapshot.
|
||||
/// Invariants enforced in constructor:
|
||||
/// <list type="bullet">
|
||||
/// <item>Win: Side ∈ {Side1, Side2}, Value == null</item>
|
||||
/// <item>Draw: Side == Draw, Value == null</item>
|
||||
/// <item>WinFora: Side ∈ {Side1, Side2}, Value != null (handicap threshold)</item>
|
||||
/// <item>Total: Side ∈ {Less, More}, Value != null (total threshold)</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Marathon.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A country or geographic group associated with a league.
|
||||
/// <c>Code</c> is the bookmaker's string identifier (e.g., breadcrumb text).
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Marathon.Domain.ValueObjects;
|
||||
|
||||
namespace Marathon.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A sporting event that can be bet on.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><c>ScheduledAt</c> is stored in Europe/Moscow time (UTC+3, no DST).
|
||||
/// The offset <c>+03:00</c> is baked in — it is NOT converted to UTC.
|
||||
/// This matches <c>initData.serverTime</c> from the scraped page, which is in Moscow time.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using Marathon.Domain.Enums;
|
||||
using Marathon.Domain.ValueObjects;
|
||||
|
||||
namespace Marathon.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// The final result of a sporting event after it has completed.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Marathon.Domain.ValueObjects;
|
||||
|
||||
namespace Marathon.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A sports league or tournament within a country and sport.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using Marathon.Domain.Enums;
|
||||
using Marathon.Domain.ValueObjects;
|
||||
|
||||
namespace Marathon.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A point-in-time capture of all odds for a specific event.
|
||||
/// </summary>
|
||||
public sealed record OddsSnapshot
|
||||
{
|
||||
public EventId EventId { get; }
|
||||
public DateTimeOffset CapturedAt { get; }
|
||||
public OddsSource Source { get; }
|
||||
public IReadOnlyList<Bet> Bets { get; }
|
||||
|
||||
public OddsSnapshot(
|
||||
EventId eventId,
|
||||
DateTimeOffset capturedAt,
|
||||
OddsSource source,
|
||||
IReadOnlyList<Bet> 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Marathon.Domain.ValueObjects;
|
||||
|
||||
namespace Marathon.Domain.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A sport supported by the bookmaker.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Marathon.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// The category of a detected anomaly.
|
||||
/// Extensible — new kinds will be added in future phases.
|
||||
/// </summary>
|
||||
public enum AnomalyKind
|
||||
{
|
||||
/// <summary>
|
||||
/// Bookmaker suspended the market, then flipped the underdog/favourite coefficients.
|
||||
/// </summary>
|
||||
SuspensionFlip,
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Marathon.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public enum BetType
|
||||
{
|
||||
Win,
|
||||
Draw,
|
||||
WinFora,
|
||||
Total,
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Marathon.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether an odds snapshot was captured from the pre-match or live section.
|
||||
/// </summary>
|
||||
public enum OddsSource
|
||||
{
|
||||
PreMatch,
|
||||
Live,
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace Marathon.Domain.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public enum Side
|
||||
{
|
||||
Side1,
|
||||
Side2,
|
||||
Draw,
|
||||
Less,
|
||||
More,
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace Marathon.Domain.ValueObjects;
|
||||
|
||||
/// <summary>
|
||||
/// 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): ... }
|
||||
/// </summary>
|
||||
public abstract record BetScope
|
||||
{
|
||||
// Private constructor prevents external derivation outside this assembly.
|
||||
private protected BetScope() { }
|
||||
}
|
||||
|
||||
/// <summary>Bet applies to the entire match.</summary>
|
||||
public sealed record MatchScope : BetScope
|
||||
{
|
||||
/// <summary>Singleton instance — MatchScope carries no additional data.</summary>
|
||||
public static readonly MatchScope Instance = new();
|
||||
}
|
||||
|
||||
/// <summary>Bet applies to a specific period (1-based). No max enforced — sport-dependent.</summary>
|
||||
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.");
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace Marathon.Domain.ValueObjects;
|
||||
|
||||
/// <summary>
|
||||
/// The bookmaker's stable event identifier — corresponds to <c>data-event-eventId</c>.
|
||||
/// 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").
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace Marathon.Domain.ValueObjects;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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");
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace Marathon.Domain.ValueObjects;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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");
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace Marathon.Domain.ValueObjects;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical sport identifier — corresponds to <c>data-sport-treeId</c> in marathonbet.by breadcrumbs.
|
||||
/// Known values: Basketball=6, Football=11, Tennis=22723, Hockey=43658.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<Application x:Class="Marathon.Hosts.WpfBlazor.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="clr-namespace:Marathon.Hosts.WpfBlazor"
|
||||
StartupUri="MainWindow.xaml">
|
||||
<Application.Resources>
|
||||
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Windows;
|
||||
|
||||
namespace Marathon.Hosts.WpfBlazor;
|
||||
|
||||
/// <summary>
|
||||
/// Interaction logic for App.xaml
|
||||
/// </summary>
|
||||
public partial class App : System.Windows.Application
|
||||
{
|
||||
}
|
||||
@@ -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)
|
||||
)]
|
||||
@@ -0,0 +1,12 @@
|
||||
<Window x:Class="Marathon.Hosts.WpfBlazor.MainWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:local="clr-namespace:Marathon.Hosts.WpfBlazor"
|
||||
mc:Ignorable="d"
|
||||
Title="MainWindow" Height="450" Width="800">
|
||||
<Grid>
|
||||
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Interaction logic for MainWindow.xaml
|
||||
/// </summary>
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Marathon.UI\Marathon.UI.csproj" />
|
||||
<ProjectReference Include="..\Marathon.Infrastructure\Marathon.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\Marathon.Application\Marathon.Application.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UseWPF>true</UseWPF>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Marathon.Domain\Marathon.Domain.csproj" />
|
||||
<ProjectReference Include="..\Marathon.Application\Marathon.Application.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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 { }
|
||||
@@ -0,0 +1,3 @@
|
||||
<div class="my-component">
|
||||
This component is defined in the <strong>Marathon.UI</strong> library.
|
||||
</div>
|
||||
@@ -0,0 +1,6 @@
|
||||
.my-component {
|
||||
border: 2px dashed red;
|
||||
padding: 1em;
|
||||
margin: 1em 0;
|
||||
background-image: url('background.png');
|
||||
}
|
||||
@@ -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<Task<IJSObjectReference>> moduleTask;
|
||||
|
||||
public ExampleJsInterop(IJSRuntime jsRuntime)
|
||||
{
|
||||
moduleTask = new (() => jsRuntime.InvokeAsync<IJSObjectReference>(
|
||||
"import", "./_content/Marathon.UI/exampleJsInterop.js").AsTask());
|
||||
}
|
||||
|
||||
public async ValueTask<string> Prompt(string message)
|
||||
{
|
||||
var module = await moduleTask.Value;
|
||||
return await module.InvokeAsync<string>("showPrompt", message);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (moduleTask.IsValueCreated)
|
||||
{
|
||||
var module = await moduleTask.Value;
|
||||
await module.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Razor">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<SupportedPlatform Include="browser" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.Web" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Marathon.Domain\Marathon.Domain.csproj" />
|
||||
<ProjectReference Include="..\Marathon.Application\Marathon.Application.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1 @@
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 378 B |
@@ -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');
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Marathon.Application\Marathon.Application.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<ArgumentException>().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<ArgumentOutOfRangeException>().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<ArgumentException>().WithParameterName("EvidenceJson");
|
||||
}
|
||||
}
|
||||
@@ -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<ArgumentException>()
|
||||
.WithParameterName("side");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ThrowsArgumentException_WhenWinBetHasNonNullValue()
|
||||
{
|
||||
var act = () => new Bet(MatchScope, BetType.Win, Side.Side1, SampleValue, SampleRate);
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.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<ArgumentException>()
|
||||
.WithParameterName("side");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ThrowsArgumentException_WhenDrawBetHasNonNullValue()
|
||||
{
|
||||
var act = () => new Bet(MatchScope, BetType.Draw, Side.Draw, SampleValue, SampleRate);
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.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<ArgumentException>()
|
||||
.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<ArgumentException>()
|
||||
.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<ArgumentException>()
|
||||
.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<ArgumentException>()
|
||||
.WithParameterName("side");
|
||||
}
|
||||
|
||||
// ── Null guards ───────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ThrowsArgumentNullException_WhenScopeIsNull()
|
||||
{
|
||||
var act = () => new Bet(null!, BetType.Win, Side.Side1, null, SampleRate);
|
||||
act.Should().Throw<ArgumentNullException>()
|
||||
.WithParameterName("scope");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ThrowsArgumentNullException_WhenRateIsNull()
|
||||
{
|
||||
var act = () => new Bet(MatchScope, BetType.Win, Side.Side1, null, null!);
|
||||
act.Should().Throw<ArgumentNullException>()
|
||||
.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.");
|
||||
}
|
||||
}
|
||||
@@ -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<ArgumentException>()
|
||||
.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<ArgumentException>()
|
||||
.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<ArgumentNullException>().WithParameterName("Id");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ThrowsArgumentNullException_WhenSportIsNull()
|
||||
{
|
||||
var act = () => new Event(SampleId, null!, "RU", "l1", "cat", ValidScheduledAt, "A", "B");
|
||||
act.Should().Throw<ArgumentNullException>().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<ArgumentException>().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<ArgumentException>().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<ArgumentException>().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<ArgumentException>().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.");
|
||||
}
|
||||
}
|
||||
@@ -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<Bet> 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<ArgumentException>()
|
||||
.WithParameterName("bets");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ThrowsArgumentNullException_WhenEventIdIsNull()
|
||||
{
|
||||
var act = () => new OddsSnapshot(null!, SampleCapturedAt, OddsSource.PreMatch, OneBet);
|
||||
act.Should().Throw<ArgumentNullException>()
|
||||
.WithParameterName("eventId");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ThrowsArgumentNullException_WhenBetsIsNull()
|
||||
{
|
||||
var act = () => new OddsSnapshot(SampleEventId, SampleCapturedAt, OddsSource.PreMatch, null!);
|
||||
act.Should().Throw<ArgumentNullException>()
|
||||
.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.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Marathon.Domain\Marathon.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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<ArgumentOutOfRangeException>()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -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<ArgumentException>()
|
||||
.WithParameterName("value");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ThrowsArgumentException_WhenValueIsNull()
|
||||
{
|
||||
var act = () => new EventId(null!);
|
||||
act.Should().Throw<ArgumentException>()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -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<ArgumentOutOfRangeException>()
|
||||
.WithParameterName("value");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Equality_IsValueBased()
|
||||
{
|
||||
var a = new OddsRate(1.65m);
|
||||
var b = new OddsRate(1.65m);
|
||||
a.Should().Be(b);
|
||||
}
|
||||
}
|
||||
@@ -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<ArgumentOutOfRangeException>()
|
||||
.WithParameterName("value");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Equality_IsValueBased()
|
||||
{
|
||||
var a = new OddsValue(3.5m);
|
||||
var b = new OddsValue(3.5m);
|
||||
a.Should().Be(b);
|
||||
}
|
||||
}
|
||||
@@ -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<ArgumentOutOfRangeException>()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Marathon.Infrastructure\Marathon.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Marathon.UI\Marathon.UI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user