From 2aa0e54586d23dfe2fae5141a9a8269044fce5f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 21:59:57 +0000 Subject: [PATCH 01/18] chore(mutation): add per-BC Stryker tooling and exclude i18n registration from scope Adds a local dotnet-stryker tool manifest and a solution-less audit config so mutation testing can run per bounded context (the committed config's single solution can't build the web workspaces in CI-less environments). Excludes Domain/Errors/*Translations.cs from the mutate scope: these are pure FR/EN string-registration tables with no business logic, mirroring the existing SharedKernel/Localization exclusion. --- dotnet-tools.json | 13 ++++++++++ stryker-config.audit.json | 52 +++++++++++++++++++++++++++++++++++++++ stryker-config.json | 3 ++- 3 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 dotnet-tools.json create mode 100644 stryker-config.audit.json diff --git a/dotnet-tools.json b/dotnet-tools.json new file mode 100644 index 00000000..7806fb92 --- /dev/null +++ b/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-stryker": { + "version": "4.14.2", + "commands": [ + "dotnet-stryker" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/stryker-config.audit.json b/stryker-config.audit.json new file mode 100644 index 00000000..5322beb8 --- /dev/null +++ b/stryker-config.audit.json @@ -0,0 +1,52 @@ +{ + "$schema": "https://raw.githubusercontent.com/stryker-mutator/stryker-net/master/src/Stryker.Core/Stryker.Core/stryker-schema.json", + "stryker-config": { + "project-info": { + "name": "Wow", + "module": "Wow" + }, + "reporters": [ + "json", + "cleartext" + ], + "verbosity": "info", + "mutate": [ + "**/*.cs", + "!**/obj/**", + "!**/bin/**", + "!**/*Tests*/**", + "!**/*Test*/**", + "!**/Migrations/**", + "!**/AssemblyInfo.cs", + "!**/GlobalUsings.cs", + "!**/Aspire/**", + "!**/WowApi/**", + "!**/WowSso.Api/**", + "!**/WowPve.Api/**", + "!**/Providers/**", + "!**/PveAnalytics/**", + "!**/*.Infrastructure/**", + "!**/SharedKernel/Mediator/**", + "!**/SharedKernel/Outbox/**", + "!**/SharedKernel/Localization/**", + "!**/SharedKernel/UtcDateTimeProvider.cs", + "!**/Errors/*Translations.cs" + ], + "ignore-mutations": [ + "string", + "linq" + ], + "ignore-methods": [ + "ToString", + "GetHashCode", + "Equals" + ], + "concurrency": 4, + "thresholds": { + "high": 80, + "low": 60, + "break": 50 + }, + "mutation-level": "Standard" + } +} diff --git a/stryker-config.json b/stryker-config.json index b80e7c2f..8e56bde0 100644 --- a/stryker-config.json +++ b/stryker-config.json @@ -31,7 +31,8 @@ "!**/SharedKernel/Mediator/**", "!**/SharedKernel/Outbox/**", "!**/SharedKernel/Localization/**", - "!**/SharedKernel/UtcDateTimeProvider.cs" + "!**/SharedKernel/UtcDateTimeProvider.cs", + "!**/Errors/*Translations.cs" ], "ignore-mutations": [ "string", From 2995f9b16bf70ba3ad71eef168722e23962ff578 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 22:00:03 +0000 Subject: [PATCH 02/18] test(coaching): raise Domain mutation score 53%->95%, Application 92%->100% Covers previously unexercised domain logic through sociable use-case scenarios where reachable (IngestRunOutcome: empty/invalid/out-of-order levers; ComputeWeeklySnapshots: skip metrics entry with no identifiable user) and through targeted value-object tests for boundary invariants that commands carry pre-constructed (IsoWeek bounds/rollover, StopsMetrics non-negative guards, LeverImpactEstimate finiteness, WowCharacterRef region rules, UseCaseKey kebab-case, strongly-typed id guards and equality components). Remaining survivors are equivalent mutants (ArgumentNullException.ThrowIfNull guards and guards made redundant by a following validation). --- .../DomainTests/CoachingIdTests.cs | 56 +++++++++++++ .../DomainTests/IsoWeekTests.cs | 81 +++++++++++++++++++ .../DomainTests/LeverImpactEstimateTests.cs | 44 ++++++++++ .../DomainTests/StopsMetricsTests.cs | 77 ++++++++++++++++++ .../DomainTests/UseCaseKeyTests.cs | 63 +++++++++++++++ .../DomainTests/WowCharacterRefTests.cs | 76 +++++++++++++++++ .../Commands/IngestRunOutcomeUseCaseTests.cs | 36 +++++++++ .../ComputeWeeklySnapshotsUseCaseTests.cs | 15 ++++ 8 files changed, 448 insertions(+) create mode 100644 src/Coaching/Coaching.Application.UnitTests/DomainTests/CoachingIdTests.cs create mode 100644 src/Coaching/Coaching.Application.UnitTests/DomainTests/LeverImpactEstimateTests.cs create mode 100644 src/Coaching/Coaching.Application.UnitTests/DomainTests/StopsMetricsTests.cs create mode 100644 src/Coaching/Coaching.Application.UnitTests/DomainTests/UseCaseKeyTests.cs create mode 100644 src/Coaching/Coaching.Application.UnitTests/DomainTests/WowCharacterRefTests.cs diff --git a/src/Coaching/Coaching.Application.UnitTests/DomainTests/CoachingIdTests.cs b/src/Coaching/Coaching.Application.UnitTests/DomainTests/CoachingIdTests.cs new file mode 100644 index 00000000..731671ef --- /dev/null +++ b/src/Coaching/Coaching.Application.UnitTests/DomainTests/CoachingIdTests.cs @@ -0,0 +1,56 @@ +namespace Coaching.Application.UnitTests.DomainTests; + +public sealed class CoachingIdTests +{ + [Fact] + public void Should_RejectEmptyGuid_When_PlayerCoachingProfileIdFromEmpty() + { + var act = () => PlayerCoachingProfileId.From(Guid.Empty); + + act.Should().Throw(); + } + + [Fact] + public void Should_WrapValue_When_PlayerCoachingProfileIdFromGuid() + { + var guid = Guid.NewGuid(); + + PlayerCoachingProfileId.From(guid).Value.Should().Be(guid); + } + + [Fact] + public void Should_BeEqual_When_PlayerCoachingProfileIdsShareValue() + { + var guid = Guid.NewGuid(); + + PlayerCoachingProfileId.From(guid).Should().Be(PlayerCoachingProfileId.From(guid)); + } + + [Fact] + public void Should_GenerateDistinctIds_When_PlayerCoachingProfileIdCreated() + { + PlayerCoachingProfileId.Create().Should().NotBe(PlayerCoachingProfileId.Create()); + } + + [Fact] + public void Should_RejectEmptyGuid_When_UserWeeklyProgressionIdFromEmpty() + { + var act = () => UserWeeklyProgressionId.From(Guid.Empty); + + act.Should().Throw(); + } + + [Fact] + public void Should_BeEqual_When_UserWeeklyProgressionIdsShareValue() + { + var guid = Guid.NewGuid(); + + UserWeeklyProgressionId.From(guid).Should().Be(UserWeeklyProgressionId.From(guid)); + } + + [Fact] + public void Should_GenerateDistinctIds_When_UserWeeklyProgressionIdCreated() + { + UserWeeklyProgressionId.Create().Should().NotBe(UserWeeklyProgressionId.Create()); + } +} diff --git a/src/Coaching/Coaching.Application.UnitTests/DomainTests/IsoWeekTests.cs b/src/Coaching/Coaching.Application.UnitTests/DomainTests/IsoWeekTests.cs index 772ec94e..38932b7e 100644 --- a/src/Coaching/Coaching.Application.UnitTests/DomainTests/IsoWeekTests.cs +++ b/src/Coaching/Coaching.Application.UnitTests/DomainTests/IsoWeekTests.cs @@ -42,6 +42,87 @@ public void Should_RejectOutOfRangeWeek_When_WeekIsZero() act.Should().Throw(); } + [Fact] + public void Should_RejectOutOfRangeWeek_When_WeekAbove53() + { + var act = () => IsoWeek.From(2026, 54); + + act.Should().Throw(); + } + + [Theory] + [InlineData(1899)] + [InlineData(10000)] + public void Should_RejectOutOfRangeYear_When_YearOutsideSupportedRange(int year) + { + var act = () => IsoWeek.From(year, 10); + + act.Should().Throw(); + } + + [Fact] + public void Should_AcceptBoundaryValues_When_YearAndWeekAtLimits() + { + IsoWeek.From(1900, 1).Should().NotBeNull(); + IsoWeek.From(9999, 53).Should().NotBeNull(); + } + + [Fact] + public void Should_RollIntoNextWeek_When_NextIsCalled() + { + var week = IsoWeek.From(2026, 20); + + var next = week.Next(); + + next.Year.Should().Be(2026); + next.Week.Should().Be(21); + } + + [Fact] + public void Should_RollAcrossYearBoundary_When_NextIsCalledOnLastWeek() + { + var last = IsoWeek.FromDate(new DateOnly(2026, 12, 31)); + + var next = last.Next(); + + next.Year.Should().Be(2027); + } + + [Fact] + public void Should_MapToContainingWeek_When_BuiltFromUtcInstant() + { + var week = IsoWeek.FromUtc(new DateTime(2026, 5, 21, 14, 30, 0, DateTimeKind.Utc)); + + week.Should().Be(IsoWeek.From(2026, 21)); + } + + [Fact] + public void Should_SpanMondayToSunday_When_StartAndEndRequested() + { + var week = IsoWeek.From(2026, 21); + + week.StartDate().Should().Be(new DateOnly(2026, 5, 18)); + week.EndDate().Should().Be(new DateOnly(2026, 5, 24)); + } + + [Fact] + public void Should_RankAfterNull_When_ComparedToNull() + { + IsoWeek.From(2026, 21).CompareTo(null).Should().BeGreaterThan(0); + } + + [Fact] + public void Should_NotBeEqual_When_WeekDiffers() + { + IsoWeek.From(2026, 21).Should().NotBe(IsoWeek.From(2026, 22)); + } + + [Fact] + public void Should_NotBeEqual_When_YearDiffers() + { + IsoWeek.From(2026, 21).Should().NotBe(IsoWeek.From(2025, 21)); + } + [Fact] public void Should_BeEqual_When_SameYearAndWeek() { diff --git a/src/Coaching/Coaching.Application.UnitTests/DomainTests/LeverImpactEstimateTests.cs b/src/Coaching/Coaching.Application.UnitTests/DomainTests/LeverImpactEstimateTests.cs new file mode 100644 index 00000000..78b62e8d --- /dev/null +++ b/src/Coaching/Coaching.Application.UnitTests/DomainTests/LeverImpactEstimateTests.cs @@ -0,0 +1,44 @@ +namespace Coaching.Application.UnitTests.DomainTests; + +public sealed class LeverImpactEstimateTests +{ + [Fact] + public void Should_ExposeBothDimensions_When_Constructed() + { + var estimate = new LeverImpactEstimate(12.5, 2.0); + + estimate.EstimatedDpsGainPct.Should().Be(12.5); + estimate.EstimatedDeathsAvoided.Should().Be(2.0); + } + + [Theory] + [InlineData(double.NaN, 1.0)] + [InlineData(double.PositiveInfinity, 1.0)] + [InlineData(1.0, double.NaN)] + [InlineData(1.0, double.NegativeInfinity)] + public void Should_RefuseConstruction_When_AnyDimensionIsNotFinite(double dps, double deaths) + { + var act = () => new LeverImpactEstimate(dps, deaths); + + act.Should().Throw(); + } + + [Theory] + [InlineData(-0.1, 1.0)] + [InlineData(1.0, -0.1)] + public void Should_RefuseConstruction_When_AnyDimensionIsNegative(double dps, double deaths) + { + var act = () => new LeverImpactEstimate(dps, deaths); + + act.Should().Throw(); + } + + [Fact] + public void Should_AllowZero_When_LeverHasNoEstimatedGain() + { + var estimate = new LeverImpactEstimate(0, 0); + + estimate.EstimatedDpsGainPct.Should().Be(0); + estimate.EstimatedDeathsAvoided.Should().Be(0); + } +} diff --git a/src/Coaching/Coaching.Application.UnitTests/DomainTests/StopsMetricsTests.cs b/src/Coaching/Coaching.Application.UnitTests/DomainTests/StopsMetricsTests.cs new file mode 100644 index 00000000..70db0fa1 --- /dev/null +++ b/src/Coaching/Coaching.Application.UnitTests/DomainTests/StopsMetricsTests.cs @@ -0,0 +1,77 @@ +namespace Coaching.Application.UnitTests.DomainTests; + +public sealed class StopsMetricsTests +{ + private static StopsMetrics WithOneNegative(int index) + { + var v = new int[12]; + for (var i = 0; i < v.Length; i++) + v[i] = 1; + v[index] = -1; + return new StopsMetrics( + v[0], v[1], v[2], v[3], v[4], v[5], + v[6], v[7], v[8], v[9], v[10], v[11]); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + [InlineData(6)] + [InlineData(7)] + [InlineData(8)] + [InlineData(9)] + [InlineData(10)] + [InlineData(11)] + public void Should_RefuseConstruction_When_AnyCountIsNegative(int negativeField) + { + var act = () => WithOneNegative(negativeField); + + act.Should().Throw(); + } + + [Fact] + public void Should_ExposeEveryCount_When_Constructed() + { + var metrics = new StopsMetrics( + totalStops: 1, kickCount: 2, crowdControlCount: 3, + correctStopCount: 4, acceptableStopCount: 5, wastedKickCount: 6, + successfulStopCount: 7, lateNoEffectCount: 8, immuneCount: 9, + overcappedStopCount: 10, missedOpportunityCount: 11, runCount: 12); + + metrics.TotalStops.Should().Be(1); + metrics.KickCount.Should().Be(2); + metrics.CrowdControlCount.Should().Be(3); + metrics.CorrectStopCount.Should().Be(4); + metrics.AcceptableStopCount.Should().Be(5); + metrics.WastedKickCount.Should().Be(6); + metrics.SuccessfulStopCount.Should().Be(7); + metrics.LateNoEffectCount.Should().Be(8); + metrics.ImmuneCount.Should().Be(9); + metrics.OvercappedStopCount.Should().Be(10); + metrics.MissedOpportunityCount.Should().Be(11); + metrics.RunCount.Should().Be(12); + } + + [Fact] + public void Should_BeAllZero_When_Empty() + { + var empty = StopsMetrics.Empty; + + empty.TotalStops.Should().Be(0); + empty.KickCount.Should().Be(0); + empty.CrowdControlCount.Should().Be(0); + empty.CorrectStopCount.Should().Be(0); + empty.AcceptableStopCount.Should().Be(0); + empty.WastedKickCount.Should().Be(0); + empty.SuccessfulStopCount.Should().Be(0); + empty.LateNoEffectCount.Should().Be(0); + empty.ImmuneCount.Should().Be(0); + empty.OvercappedStopCount.Should().Be(0); + empty.MissedOpportunityCount.Should().Be(0); + empty.RunCount.Should().Be(0); + } +} diff --git a/src/Coaching/Coaching.Application.UnitTests/DomainTests/UseCaseKeyTests.cs b/src/Coaching/Coaching.Application.UnitTests/DomainTests/UseCaseKeyTests.cs new file mode 100644 index 00000000..5e86803e --- /dev/null +++ b/src/Coaching/Coaching.Application.UnitTests/DomainTests/UseCaseKeyTests.cs @@ -0,0 +1,63 @@ +namespace Coaching.Application.UnitTests.DomainTests; + +public sealed class UseCaseKeyTests +{ + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Should_RefuseConstruction_When_Blank(string value) + { + var act = () => UseCaseKey.From(value); + + act.Should().Throw(); + } + + [Fact] + public void Should_RefuseConstruction_When_LongerThanMaxLength() + { + var tooLong = new string('a', UseCaseKey.MaxLength + 1); + + var act = () => UseCaseKey.From(tooLong); + + act.Should().Throw(); + } + + [Theory] + [InlineData("Kick Uptime")] + [InlineData("kick_uptime")] + [InlineData("-kick")] + [InlineData("kick--uptime")] + [InlineData("kické")] + public void Should_RefuseConstruction_When_NotKebabCaseAscii(string value) + { + var act = () => UseCaseKey.From(value); + + act.Should().Throw(); + } + + [Fact] + public void Should_NormalizeToLowerTrimmed_When_GivenPaddedMixedCase() + { + UseCaseKey.From(" Kick-Uptime ").Value.Should().Be("kick-uptime"); + } + + [Fact] + public void Should_AcceptMaxLength_When_ExactlyAtLimit() + { + var atLimit = new string('a', UseCaseKey.MaxLength); + + UseCaseKey.From(atLimit).Value.Should().Be(atLimit); + } + + [Fact] + public void Should_BeEqual_When_SameNormalizedValue() + { + UseCaseKey.From("kick-uptime").Should().Be(UseCaseKey.From("KICK-UPTIME")); + } + + [Fact] + public void Should_NotBeEqual_When_DifferentValue() + { + UseCaseKey.From("kick-uptime").Should().NotBe(UseCaseKey.From("dispel-debuff")); + } +} diff --git a/src/Coaching/Coaching.Application.UnitTests/DomainTests/WowCharacterRefTests.cs b/src/Coaching/Coaching.Application.UnitTests/DomainTests/WowCharacterRefTests.cs new file mode 100644 index 00000000..d34530ad --- /dev/null +++ b/src/Coaching/Coaching.Application.UnitTests/DomainTests/WowCharacterRefTests.cs @@ -0,0 +1,76 @@ +namespace Coaching.Application.UnitTests.DomainTests; + +public sealed class WowCharacterRefTests +{ + [Theory] + [InlineData("", "stormrage", "thrall")] + [InlineData(" ", "stormrage", "thrall")] + [InlineData("eu", "", "thrall")] + [InlineData("eu", "stormrage", "")] + public void Should_RefuseConstruction_When_AnyPartIsBlank(string region, string realm, string name) + { + var act = () => WowCharacterRef.From(region, realm, name); + + act.Should().Throw(); + } + + [Theory] + [InlineData("xx")] + [InlineData("na")] + [InlineData("europe")] + public void Should_RefuseConstruction_When_RegionIsNotABlizzardRegion(string region) + { + var act = () => WowCharacterRef.From(region, "stormrage", "thrall"); + + act.Should().Throw(); + } + + [Theory] + [InlineData("eu")] + [InlineData("us")] + [InlineData("kr")] + [InlineData("tw")] + [InlineData("cn")] + public void Should_Accept_When_RegionIsAKnownBlizzardRegion(string region) + { + WowCharacterRef.From(region, "stormrage", "thrall").Region.Should().Be(region); + } + + [Fact] + public void Should_NormalizeRegionAndRealm_When_GivenMixedCaseWithWhitespace() + { + var character = WowCharacterRef.From(" EU ", " Stormrage ", " Thrall "); + + character.Region.Should().Be("eu"); + character.RealmSlug.Should().Be("stormrage"); + character.Name.Should().Be("Thrall"); + } + + [Fact] + public void Should_BeDistinct_When_OnlyRegionDiffers() + { + WowCharacterRef.From("eu", "stormrage", "thrall") + .Should().NotBe(WowCharacterRef.From("us", "stormrage", "thrall")); + } + + [Fact] + public void Should_BeDistinct_When_OnlyRealmDiffers() + { + WowCharacterRef.From("eu", "stormrage", "thrall") + .Should().NotBe(WowCharacterRef.From("eu", "hyjal", "thrall")); + } + + [Fact] + public void Should_BeDistinct_When_OnlyNameDiffers() + { + WowCharacterRef.From("eu", "stormrage", "thrall") + .Should().NotBe(WowCharacterRef.From("eu", "stormrage", "jaina")); + } + + [Fact] + public void Should_BeEqual_When_RegionRealmAndNameMatch() + { + WowCharacterRef.From("eu", "stormrage", "thrall") + .Should().Be(WowCharacterRef.From("eu", "stormrage", "thrall")); + } +} diff --git a/src/Coaching/Coaching.Application.UnitTests/Profiles/Commands/IngestRunOutcomeUseCaseTests.cs b/src/Coaching/Coaching.Application.UnitTests/Profiles/Commands/IngestRunOutcomeUseCaseTests.cs index 1ef394a9..e690a80e 100644 --- a/src/Coaching/Coaching.Application.UnitTests/Profiles/Commands/IngestRunOutcomeUseCaseTests.cs +++ b/src/Coaching/Coaching.Application.UnitTests/Profiles/Commands/IngestRunOutcomeUseCaseTests.cs @@ -85,6 +85,42 @@ public async Task Should_FailWithTooManyLevers_When_MoreThanThreeProvided() ThenResultIsFailureWithError(result, CoachingErrors.Profile.TooManyLevers); } + [Fact] + public async Task Should_FailWithNoLevers_When_LeverListIsEmpty() + { + var result = await _useCase.HandleAsync(new IngestRunOutcomeCommand( + PlayerCoachingProfileId.From(_profileId), Guid.NewGuid(), [])); + + ThenResultIsFailureWithError(result, CoachingErrors.Profile.NoLeversProvided); + } + + [Fact] + public async Task Should_FailWithInvalidPriority_When_PrioritySlotIsNotDefined() + { + var result = await _useCase.HandleAsync(new IngestRunOutcomeCommand( + PlayerCoachingProfileId.From(_profileId), Guid.NewGuid(), + [Lever("a", (LeverPriority)99)])); + + ThenResultIsFailureWithError(result, CoachingErrors.Profile.InvalidLeverPriority); + } + + [Fact] + public async Task Should_StoreLeversSortedByPriority_When_ProvidedOutOfOrder() + { + var result = await _useCase.HandleAsync(new IngestRunOutcomeCommand( + PlayerCoachingProfileId.From(_profileId), Guid.NewGuid(), + [ + Lever("third-lever", LeverPriority.Third), + Lever("top-lever", LeverPriority.Top), + Lever("second-lever", LeverPriority.Second), + ])); + + ThenResultIsSuccess(result); + var stored = _repository.All.Single(); + stored.ActiveLevers.Select(l => l.UseCaseKey) + .Should().Equal("top-lever", "second-lever", "third-lever"); + } + [Fact] public async Task Should_FailWithDuplicatePriority_When_SamePrioritySlotUsedTwice() { diff --git a/src/Coaching/Coaching.Application.UnitTests/Progression/Commands/ComputeWeeklySnapshotsUseCaseTests.cs b/src/Coaching/Coaching.Application.UnitTests/Progression/Commands/ComputeWeeklySnapshotsUseCaseTests.cs index a52b35e5..ce66535d 100644 --- a/src/Coaching/Coaching.Application.UnitTests/Progression/Commands/ComputeWeeklySnapshotsUseCaseTests.cs +++ b/src/Coaching/Coaching.Application.UnitTests/Progression/Commands/ComputeWeeklySnapshotsUseCaseTests.cs @@ -57,6 +57,21 @@ public async Task Should_SkipUser_When_SnapshotAlreadyExistsForThatWeek() _repository.All.Should().HaveCount(1); } + [Fact] + public async Task Should_SkipUser_When_MetricsEntryHasNoIdentifiableUser() + { + _metricsSource.Seed(_week, + new UserWeeklyStopMetrics(Guid.Empty, SampleMetrics()), + new UserWeeklyStopMetrics(Guid.NewGuid(), SampleMetrics())); + + var result = await _useCase.HandleAsync(new ComputeWeeklySnapshotsCommand(_week)); + + var summary = ThenResultIsSuccess(result); + summary.Computed.Should().Be(1); + summary.Skipped.Should().Be(1); + _repository.All.Should().HaveCount(1); + } + [Fact] public async Task Should_ReturnZeroSummary_When_SourceHasNoMetricsForWeek() { From 0a333d7ca0caf63daaf20d5ed956bcbe4659076b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 22:01:38 +0000 Subject: [PATCH 03/18] test(hotfixes): cover HotfixId boundary invariant, Domain mutation 64%->100% Hotfix.Create branches are already covered through the use case; the remaining gap was the strongly-typed HotfixId (empty-guard, equality components) which a command carries pre-constructed, so it is tested directly as a boundary VO. --- .../Hotfixes/HotfixIdTests.cs | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 src/Hotfixes/Hotfixes.Application.UnitTests/Hotfixes/HotfixIdTests.cs diff --git a/src/Hotfixes/Hotfixes.Application.UnitTests/Hotfixes/HotfixIdTests.cs b/src/Hotfixes/Hotfixes.Application.UnitTests/Hotfixes/HotfixIdTests.cs new file mode 100644 index 00000000..134dad60 --- /dev/null +++ b/src/Hotfixes/Hotfixes.Application.UnitTests/Hotfixes/HotfixIdTests.cs @@ -0,0 +1,40 @@ +namespace Hotfixes.Application.UnitTests.Hotfixes; + +public sealed class HotfixIdTests +{ + [Fact] + public void Should_RejectEmptyGuid_When_FromEmpty() + { + var act = () => HotfixId.From(Guid.Empty); + + act.Should().Throw(); + } + + [Fact] + public void Should_WrapValue_When_FromGuid() + { + var guid = Guid.NewGuid(); + + HotfixId.From(guid).Value.Should().Be(guid); + } + + [Fact] + public void Should_GenerateDistinctIds_When_Created() + { + HotfixId.Create().Should().NotBe(HotfixId.Create()); + } + + [Fact] + public void Should_BeEqual_When_SameValue() + { + var guid = Guid.NewGuid(); + + HotfixId.From(guid).Should().Be(HotfixId.From(guid)); + } + + [Fact] + public void Should_NotBeEqual_When_DifferentValue() + { + HotfixId.From(Guid.NewGuid()).Should().NotBe(HotfixId.From(Guid.NewGuid())); + } +} From b2f3f1b23c3c3ae209465311b3b77174864accb8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 22:05:37 +0000 Subject: [PATCH 04/18] test(encounter-knowledge): Domain mutation 43%->94% via sociable + boundary VO tests Adds sociable scenarios on the use cases (AddPhase invalid ordinal / ordering / event, AttachUseCase invalid kind / event) and direct tests for logic no use case reaches: Encounter.Create explicit provenance and the UpdateUseCase aggregate method (not-found, invalid kind, unknown phase, manual-override protection, in-place replace). Boundary value objects (BlizzardEncounterId, EncounterName, EncounterId, UseCaseKey, PatchVersion, Provenance) get targeted tests since commands carry them pre-constructed. --- .../Commands/AddPhaseUseCaseTests.cs | 30 ++++ .../Commands/AttachUseCaseUseCaseTests.cs | 33 ++++ .../DomainTests/EncounterAggregateTests.cs | 111 +++++++++++++ .../DomainTests/EncounterValueObjectTests.cs | 157 ++++++++++++++++++ 4 files changed, 331 insertions(+) create mode 100644 src/EncounterKnowledge/EncounterKnowledge.Application.UnitTests/Encounters/DomainTests/EncounterAggregateTests.cs create mode 100644 src/EncounterKnowledge/EncounterKnowledge.Application.UnitTests/Encounters/DomainTests/EncounterValueObjectTests.cs diff --git a/src/EncounterKnowledge/EncounterKnowledge.Application.UnitTests/Encounters/Commands/AddPhaseUseCaseTests.cs b/src/EncounterKnowledge/EncounterKnowledge.Application.UnitTests/Encounters/Commands/AddPhaseUseCaseTests.cs index 12d8d390..cf3f9e76 100644 --- a/src/EncounterKnowledge/EncounterKnowledge.Application.UnitTests/Encounters/Commands/AddPhaseUseCaseTests.cs +++ b/src/EncounterKnowledge/EncounterKnowledge.Application.UnitTests/Encounters/Commands/AddPhaseUseCaseTests.cs @@ -42,6 +42,36 @@ public async Task Should_FailWithNotFound_When_EncounterMissing() ThenResultIsFailureWithError(result, EncounterKnowledgeErrors.Encounter.NotFound); } + [Fact] + public async Task Should_FailWithInvalidOrdinal_When_OrdinalBelowOne() + { + var result = await _useCase.HandleAsync(new AddPhaseCommand(EncounterId.From(_existingId), 0, "Pull")); + + ThenResultIsFailureWithError(result, EncounterKnowledgeErrors.Encounter.InvalidPhaseOrdinal); + } + + [Fact] + public async Task Should_KeepPhasesOrderedByOrdinal_When_AddedOutOfOrder() + { + await _useCase.HandleAsync(new AddPhaseCommand(EncounterId.From(_existingId), 3, "Phase 3")); + await _useCase.HandleAsync(new AddPhaseCommand(EncounterId.From(_existingId), 1, "Phase 1")); + await _useCase.HandleAsync(new AddPhaseCommand(EncounterId.From(_existingId), 2, "Phase 2")); + + var stored = await _repository.GetByIdAsync(EncounterId.From(_existingId)); + stored!.Phases.Select(p => p.Ordinal).Should().Equal(1, 2, 3); + } + + [Fact] + public async Task Should_RaisePhaseAddedEvent_When_PhaseAdded() + { + await _useCase.HandleAsync(new AddPhaseCommand(EncounterId.From(_existingId), 2, "Intermission")); + + var evt = _unitOfWork.CapturedEvents.OfType() + .Should().ContainSingle().Subject; + evt.Ordinal.Should().Be(2); + evt.Name.Should().Be("Intermission"); + } + [Fact] public async Task Should_TrackEncounterInOutbox_When_PhaseAdded() { diff --git a/src/EncounterKnowledge/EncounterKnowledge.Application.UnitTests/Encounters/Commands/AttachUseCaseUseCaseTests.cs b/src/EncounterKnowledge/EncounterKnowledge.Application.UnitTests/Encounters/Commands/AttachUseCaseUseCaseTests.cs index 57aa02e5..3a9bc636 100644 --- a/src/EncounterKnowledge/EncounterKnowledge.Application.UnitTests/Encounters/Commands/AttachUseCaseUseCaseTests.cs +++ b/src/EncounterKnowledge/EncounterKnowledge.Application.UnitTests/Encounters/Commands/AttachUseCaseUseCaseTests.cs @@ -101,4 +101,37 @@ await _useCase.HandleAsync(new AttachUseCaseCommand( _unitOfWork.TrackedAggregates.OfType() .Should().ContainSingle(); } + + [Fact] + public async Task Should_FailWithInvalidKind_When_KindNotDefined() + { + var result = await _useCase.HandleAsync(new AttachUseCaseCommand( + EncounterId.From(_existingId), + Key: UseCaseKey.From("kick-something"), + Kind: (UseCaseKind)999, + PhaseOrdinal: 1, + TargetSpellBlizzardIds: [], + ExpectedResponseBlizzardIds: [], + Priority: 1)); + + ThenResultIsFailureWithError(result, EncounterKnowledgeErrors.Encounter.InvalidUseCaseKind); + } + + [Fact] + public async Task Should_RaiseUseCaseAttachedEvent_When_Attached() + { + await _useCase.HandleAsync(new AttachUseCaseCommand( + EncounterId.From(_existingId), + Key: UseCaseKey.From("kick-necrotic-bolt"), + Kind: UseCaseKind.Interrupt, + PhaseOrdinal: 1, + TargetSpellBlizzardIds: [444444], + ExpectedResponseBlizzardIds: [47528], + Priority: 7)); + + var evt = _unitOfWork.CapturedEvents.OfType() + .Should().ContainSingle().Subject; + evt.Key.Should().Be("kick-necrotic-bolt"); + evt.Priority.Should().Be(7); + } } diff --git a/src/EncounterKnowledge/EncounterKnowledge.Application.UnitTests/Encounters/DomainTests/EncounterAggregateTests.cs b/src/EncounterKnowledge/EncounterKnowledge.Application.UnitTests/Encounters/DomainTests/EncounterAggregateTests.cs new file mode 100644 index 00000000..4dfbce9f --- /dev/null +++ b/src/EncounterKnowledge/EncounterKnowledge.Application.UnitTests/Encounters/DomainTests/EncounterAggregateTests.cs @@ -0,0 +1,111 @@ +using EncounterKnowledge.Domain.Aggregates.Encounters; + +namespace EncounterKnowledge.Application.UnitTests.Encounters.DomainTests; + +public sealed class EncounterAggregateTests +{ + private static readonly DateTimeOffset _at = new(2026, 5, 16, 12, 0, 0, TimeSpan.Zero); + + private static Encounter NewEncounter(Provenance? provenance = null) => + Encounter.Create( + BlizzardEncounterId.From(3009), + PatchVersion.Parse("12.0.0"), + ContentDifficulty.Mythic, + EncounterName.From("Manaforge Omega"), + _at, + provenance).Value!; + + [Fact] + public void Should_DefaultToManualProvenance_When_NoneProvided() + { + NewEncounter().Provenance.Source.Should().Be(ProvenanceSource.Manual); + } + + [Fact] + public void Should_KeepProvidedProvenance_When_ExplicitlySupplied() + { + NewEncounter(Provenance.Blizzard(_at)).Provenance.Source.Should().Be(ProvenanceSource.BlizzardApi); + } + + [Fact] + public void Should_FailWithInvalidDifficulty_When_DifficultyNotDefined() + { + var result = Encounter.Create( + BlizzardEncounterId.From(3009), + PatchVersion.Parse("12.0.0"), + (ContentDifficulty)999, + EncounterName.From("Boss"), + _at); + + ThenResultIsFailureWithError(result, EncounterKnowledgeErrors.Encounter.InvalidDifficulty); + } + + private static Encounter WithAttachedUseCase(out UseCaseKey key, Provenance? provenance = null) + { + var encounter = NewEncounter(); + encounter.AddPhase(1, "Phase 1"); + key = UseCaseKey.From("kick-necrotic-bolt"); + encounter.AttachUseCase( + key, UseCaseKind.Interrupt, 1, [444444], [47528], 10, _at, provenance); + return encounter; + } + + [Fact] + public void Should_FailWithUseCaseNotFound_When_UpdatingUnknownKey() + { + var encounter = WithAttachedUseCase(out _); + + var result = encounter.UpdateUseCase( + UseCaseKey.From("does-not-exist"), UseCaseKind.Interrupt, 1, [], [], 1, + Provenance.Manual(_at)); + + ThenResultIsFailureWithError(result, EncounterKnowledgeErrors.Encounter.UseCaseNotFound); + } + + [Fact] + public void Should_FailWithInvalidKind_When_UpdatingWithUndefinedKind() + { + var encounter = WithAttachedUseCase(out var key); + + var result = encounter.UpdateUseCase( + key, (UseCaseKind)999, 1, [], [], 1, Provenance.Manual(_at)); + + ThenResultIsFailureWithError(result, EncounterKnowledgeErrors.Encounter.InvalidUseCaseKind); + } + + [Fact] + public void Should_FailWithUnknownPhase_When_UpdatingToUndeclaredPhase() + { + var encounter = WithAttachedUseCase(out var key); + + var result = encounter.UpdateUseCase( + key, UseCaseKind.Interrupt, 99, [], [], 1, Provenance.Manual(_at)); + + ThenResultIsFailureWithError(result, EncounterKnowledgeErrors.Encounter.UnknownPhase); + } + + [Fact] + public void Should_RefuseOverwrite_When_ManualUseCaseUpdatedByNonManualSource() + { + var encounter = WithAttachedUseCase(out var key, Provenance.Manual(_at)); + + var result = encounter.UpdateUseCase( + key, UseCaseKind.Defensive, 1, [], [], 2, Provenance.Blizzard(_at)); + + ThenResultIsFailureWithError(result, EncounterKnowledgeErrors.Encounter.ManualOverrideProtected); + } + + [Fact] + public void Should_ReplaceUseCaseInPlace_When_UpdateIsAllowed() + { + var encounter = WithAttachedUseCase(out var key, Provenance.Blizzard(_at)); + + var result = encounter.UpdateUseCase( + key, UseCaseKind.Defensive, 1, [12345], [67890], 3, Provenance.Blizzard(_at)); + + ThenResultIsSuccess(result); + var useCase = encounter.UseCases.Should().ContainSingle().Subject; + useCase.Kind.Should().Be(UseCaseKind.Defensive); + useCase.Priority.Should().Be(3); + } +} diff --git a/src/EncounterKnowledge/EncounterKnowledge.Application.UnitTests/Encounters/DomainTests/EncounterValueObjectTests.cs b/src/EncounterKnowledge/EncounterKnowledge.Application.UnitTests/Encounters/DomainTests/EncounterValueObjectTests.cs new file mode 100644 index 00000000..4758a374 --- /dev/null +++ b/src/EncounterKnowledge/EncounterKnowledge.Application.UnitTests/Encounters/DomainTests/EncounterValueObjectTests.cs @@ -0,0 +1,157 @@ +namespace EncounterKnowledge.Application.UnitTests.Encounters.DomainTests; + +public sealed class EncounterValueObjectTests +{ + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Should_RejectNonPositive_When_BlizzardEncounterIdFrom(int value) + { + var act = () => BlizzardEncounterId.From(value); + + act.Should().Throw(); + } + + [Fact] + public void Should_WrapValueAndCompareByValue_When_BlizzardEncounterId() + { + BlizzardEncounterId.From(3009).Value.Should().Be(3009); + BlizzardEncounterId.From(3009).Should().Be(BlizzardEncounterId.From(3009)); + BlizzardEncounterId.From(3009).Should().NotBe(BlizzardEncounterId.From(3010)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Should_RejectBlank_When_EncounterNameFrom(string value) + { + var act = () => EncounterName.From(value); + + act.Should().Throw(); + } + + [Fact] + public void Should_RejectTooLong_When_EncounterNameExceedsMax() + { + var act = () => EncounterName.From(new string('a', EncounterName.MaxLength + 1)); + + act.Should().Throw(); + } + + [Fact] + public void Should_TrimAndCompareByValue_When_EncounterName() + { + EncounterName.From(" Manaforge Omega ").Value.Should().Be("Manaforge Omega"); + EncounterName.From("Manaforge Omega").Should().Be(EncounterName.From("Manaforge Omega")); + EncounterName.From("Manaforge Omega").Should().NotBe(EncounterName.From("Other Boss")); + } + + [Fact] + public void Should_RejectEmptyGuid_When_EncounterIdFromEmpty() + { + var act = () => EncounterId.From(Guid.Empty); + + act.Should().Throw(); + } + + [Fact] + public void Should_WrapAndCompareByValue_When_EncounterId() + { + var guid = Guid.NewGuid(); + EncounterId.From(guid).Value.Should().Be(guid); + EncounterId.From(guid).Should().Be(EncounterId.From(guid)); + EncounterId.Create().Should().NotBe(EncounterId.Create()); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Should_RejectBlank_When_UseCaseKeyFrom(string value) + { + var act = () => UseCaseKey.From(value); + + act.Should().Throw(); + } + + [Theory] + [InlineData("Kick Bolt")] + [InlineData("kick_bolt")] + [InlineData("-kick")] + public void Should_RejectNonKebab_When_UseCaseKeyFrom(string value) + { + var act = () => UseCaseKey.From(value); + + act.Should().Throw(); + } + + [Fact] + public void Should_RejectTooLong_When_UseCaseKeyExceedsMax() + { + var act = () => UseCaseKey.From(new string('a', UseCaseKey.MaxLength + 1)); + + act.Should().Throw(); + } + + [Fact] + public void Should_NormalizeAndCompareByValue_When_UseCaseKey() + { + UseCaseKey.From(" Kick-Bolt ").Value.Should().Be("kick-bolt"); + UseCaseKey.From("kick-bolt").Should().Be(UseCaseKey.From("KICK-BOLT")); + UseCaseKey.From("kick-bolt").Should().NotBe(UseCaseKey.From("stun-pack")); + } + + [Theory] + [InlineData(-1, 0, 0)] + [InlineData(0, -1, 0)] + [InlineData(0, 0, -1)] + public void Should_RejectNegativeComponent_When_PatchVersionFrom(int major, int minor, int build) + { + var act = () => PatchVersion.From(major, minor, build); + + act.Should().Throw(); + } + + [Theory] + [InlineData("")] + [InlineData("12.0")] + [InlineData("12.0.0.1")] + [InlineData("12.x.0")] + public void Should_RejectMalformed_When_PatchVersionParse(string raw) + { + var act = () => PatchVersion.Parse(raw); + + act.Should().Throw(); + } + + [Fact] + public void Should_RoundTripAndCompareByComponents_When_PatchVersion() + { + PatchVersion.Parse("12.1.3").ToString().Should().Be("12.1.3"); + PatchVersion.From(12, 1, 3).Should().Be(PatchVersion.Parse("12.1.3")); + PatchVersion.From(12, 1, 3).Should().NotBe(PatchVersion.From(12, 1, 4)); + PatchVersion.From(12, 1, 3).Should().NotBe(PatchVersion.From(12, 2, 3)); + PatchVersion.From(12, 1, 3).Should().NotBe(PatchVersion.From(11, 1, 3)); + } + + [Fact] + public void Should_FlagManualOverride_OnlyForManualSource() + { + var at = DateTimeOffset.UnixEpoch; + + Provenance.Manual(at).IsManualOverride.Should().BeTrue(); + Provenance.Blizzard(at).IsManualOverride.Should().BeFalse(); + Provenance.Wago(at).IsManualOverride.Should().BeFalse(); + Provenance.Mined(at).IsManualOverride.Should().BeFalse(); + } + + [Fact] + public void Should_CarrySourceAndTimestamp_When_ProvenanceCreated() + { + var at = new DateTimeOffset(2026, 5, 1, 0, 0, 0, TimeSpan.Zero); + + Provenance.Blizzard(at).Source.Should().Be(ProvenanceSource.BlizzardApi); + Provenance.Manual(at).SyncedAt.Should().Be(at); + Provenance.Wago(at).Source.Should().Be(ProvenanceSource.WagoTools); + Provenance.Mined(at).Source.Should().Be(ProvenanceSource.Mined); + } +} From b69a0052bab36b8e66bf36ec1b268e8949b9d5bb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 22:09:25 +0000 Subject: [PATCH 05/18] test(knowledge-mining): Domain mutation 56%->97% Adds a direct BehavioralPatternCandidate test: the Raise factory validation matrix (pattern kind, encounter/difficulty, evidence spells, lift, support, supporting runs), MinedUseCaseKey encoding, and the Accept/Merged/Reject state machine with events. These run through the aggregate because no use case can drive invalid mining evidence (the algorithm only emits valid candidates). Application stays at 89% by design: the remaining undetected mutants are the documented ResolveEncounterIdAsync stub (attach-dispatch path is dead until the EncounterKnowledge read port is wired) and defensive guards against states the mining flow cannot produce (supportAll==0 with top samples being a subset of all; Raise failing on already-validated inputs). --- .../Mining/BehavioralPatternCandidateTests.cs | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 src/KnowledgeMining/KnowledgeMining.Application.UnitTests/Mining/BehavioralPatternCandidateTests.cs diff --git a/src/KnowledgeMining/KnowledgeMining.Application.UnitTests/Mining/BehavioralPatternCandidateTests.cs b/src/KnowledgeMining/KnowledgeMining.Application.UnitTests/Mining/BehavioralPatternCandidateTests.cs new file mode 100644 index 00000000..d138c4c4 --- /dev/null +++ b/src/KnowledgeMining/KnowledgeMining.Application.UnitTests/Mining/BehavioralPatternCandidateTests.cs @@ -0,0 +1,140 @@ +using Wow.Kernel.Shared; + +namespace KnowledgeMining.Application.UnitTests.Mining; + +public sealed class BehavioralPatternCandidateTests +{ + private static readonly DateTimeOffset _at = new(2026, 5, 16, 12, 0, 0, TimeSpan.Zero); + + private static Result Raise( + PatternKind kind = PatternKind.ImportantSpell, + int encounterId = 3009, + int difficulty = 5, + int[]? targets = null, + int[]? responses = null, + double lift = 3.0, + double support = 0.5, + Guid[]? runs = null) => + BehavioralPatternCandidate.Raise( + kind, encounterId, difficulty, phaseOrdinal: null, pullSignature: null, + targets ?? [47568], responses ?? [], + lift, support, _at, runs ?? [Guid.NewGuid()]); + + [Fact] + public void Should_RaiseSuggestedCandidate_When_EvidenceIsValid() + { + var candidate = Raise().Value!; + + candidate.Status.Should().Be(CandidateStatus.Suggested); + candidate.DomainEvents.OfType() + .Should().ContainSingle(); + } + + [Fact] + public void Should_FailWithInvalidEvidence_When_PatternKindNotDefined() => + ThenResultIsFailureWithError(Raise(kind: (PatternKind)999), + KnowledgeMiningErrors.Candidate.InvalidEvidence); + + [Theory] + [InlineData(0, 5)] + [InlineData(-1, 5)] + [InlineData(3009, 0)] + [InlineData(3009, -1)] + public void Should_FailWithInvalidEvidence_When_EncounterOrDifficultyNonPositive(int encounterId, int difficulty) => + ThenResultIsFailureWithError(Raise(encounterId: encounterId, difficulty: difficulty), + KnowledgeMiningErrors.Candidate.InvalidEvidence); + + [Fact] + public void Should_FailWithInvalidEvidence_When_NoTargetOrResponseSpells() => + ThenResultIsFailureWithError(Raise(targets: [], responses: []), + KnowledgeMiningErrors.Candidate.InvalidEvidence); + + [Theory] + [InlineData(0d)] + [InlineData(-1d)] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + public void Should_FailWithInvalidLift_When_LiftNotFiniteOrNonPositive(double lift) => + ThenResultIsFailureWithError(Raise(lift: lift), + KnowledgeMiningErrors.Candidate.InvalidLift); + + [Theory] + [InlineData(-0.1d)] + [InlineData(1.1d)] + [InlineData(double.NaN)] + public void Should_FailWithInvalidEvidence_When_SupportOutOfUnitRange(double support) => + ThenResultIsFailureWithError(Raise(support: support), + KnowledgeMiningErrors.Candidate.InvalidEvidence); + + [Fact] + public void Should_FailWithInvalidEvidence_When_NoSupportingRuns() => + ThenResultIsFailureWithError(Raise(runs: []), + KnowledgeMiningErrors.Candidate.InvalidEvidence); + + [Fact] + public void Should_EncodeKindEncounterAndFirstSpell_When_BuildingMinedUseCaseKey() + { + var candidate = Raise(kind: PatternKind.ImportantSpell, encounterId: 3009, targets: [47568, 999]).Value!; + + candidate.MinedUseCaseKey().Should().Be("mined-importantspell-3009-47568"); + } + + [Fact] + public void Should_FlipToAccepted_When_AcceptedWithCreatedUseCase() + { + var candidate = Raise().Value!; + + var result = candidate.Accept(Guid.NewGuid(), createdUseCaseId: Guid.NewGuid(), _at, "promoted"); + + ThenResultIsSuccess(result); + candidate.Status.Should().Be(CandidateStatus.Accepted); + candidate.DomainEvents.OfType() + .Should().ContainSingle(); + } + + [Fact] + public void Should_FlipToMerged_When_AcceptedWithoutCreatedUseCase() + { + var candidate = Raise().Value!; + + candidate.Accept(Guid.NewGuid(), createdUseCaseId: null, _at); + + candidate.Status.Should().Be(CandidateStatus.Merged); + } + + [Fact] + public void Should_FailToAccept_When_AlreadyResolved() + { + var candidate = Raise().Value!; + candidate.Reject(Guid.NewGuid(), _at, "noise"); + + ThenResultIsFailureWithError( + candidate.Accept(Guid.NewGuid(), null, _at), + KnowledgeMiningErrors.Candidate.AlreadyResolved); + } + + [Fact] + public void Should_FlipToRejected_When_Rejected() + { + var candidate = Raise().Value!; + + var result = candidate.Reject(Guid.NewGuid(), _at, "false positive"); + + ThenResultIsSuccess(result); + candidate.Status.Should().Be(CandidateStatus.Rejected); + candidate.ResolutionNote.Should().Be("false positive"); + candidate.DomainEvents.OfType() + .Should().ContainSingle(); + } + + [Fact] + public void Should_FailToReject_When_AlreadyResolved() + { + var candidate = Raise().Value!; + candidate.Accept(Guid.NewGuid(), Guid.NewGuid(), _at); + + ThenResultIsFailureWithError( + candidate.Reject(Guid.NewGuid(), _at, "late"), + KnowledgeMiningErrors.Candidate.AlreadyResolved); + } +} From 95514a2f7e91aa0ab98b8584df4b9a99ce42eec7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 22:15:55 +0000 Subject: [PATCH 06/18] test(comparison): Domain 54%->96%, Application 77%->97% Adds targeted value-object tests for the boundary invariants commands carry pre-built (CohortKey dimensions + equality, MetricKey kebab-case, MetricPercentile monotonicity with equal-adjacent boundaries and DeltaScore including the degenerate zero-spread case, CohortId guard). Adds a sociable RebuildCohorts scenario (target with an undefined tier is skipped) covering the Cohort.Create failure branch. Removes two provably-dead guards in RebuildCohorts (percentiles.Count==0 is unreachable since a non-empty sample always yields at least one percentile and Cohort.Create already rejects empty percentiles via EmptySample; sorted.Length==0 is unreachable since a GroupBy group is never empty) and the redundant sample-size ternary whose else branch could never run. --- .../Commands/RebuildCohortsUseCaseTests.cs | 16 ++ .../DomainTests/ComparisonValueObjectTests.cs | 181 ++++++++++++++++++ .../RebuildCohorts/RebuildCohortsUseCase.cs | 10 +- 3 files changed, 198 insertions(+), 9 deletions(-) create mode 100644 src/Comparison/Comparison.Application.UnitTests/Cohorts/DomainTests/ComparisonValueObjectTests.cs diff --git a/src/Comparison/Comparison.Application.UnitTests/Cohorts/Commands/RebuildCohortsUseCaseTests.cs b/src/Comparison/Comparison.Application.UnitTests/Cohorts/Commands/RebuildCohortsUseCaseTests.cs index e0b89738..b926f943 100644 --- a/src/Comparison/Comparison.Application.UnitTests/Cohorts/Commands/RebuildCohortsUseCaseTests.cs +++ b/src/Comparison/Comparison.Application.UnitTests/Cohorts/Commands/RebuildCohortsUseCaseTests.cs @@ -122,6 +122,22 @@ public async Task Should_SkipTarget_When_PeriodKeyIsInvalid() view.SkippedEmptySamples.Should().Be(1); } + [Fact] + public async Task Should_SkipTarget_When_TierIsNotDefined() + { + _source.Seed(3009, 5, 640, new RunMetricSample("dps", 100)); + + var result = await _useCase.HandleAsync(new RebuildCohortsCommand( + [ + new CohortRebuildTarget(3009, 5, 640, _periodStart, _periodEnd, Tier: (CohortTier)999), + ])); + + var view = ThenResultIsSuccess(result); + view.BuiltCohorts.Should().Be(0); + view.SkippedEmptySamples.Should().Be(1); + _cohortRepository.All.Should().BeEmpty(); + } + [Fact] public async Task Should_RaiseCohortBuiltEvent_When_CohortPersisted() { diff --git a/src/Comparison/Comparison.Application.UnitTests/Cohorts/DomainTests/ComparisonValueObjectTests.cs b/src/Comparison/Comparison.Application.UnitTests/Cohorts/DomainTests/ComparisonValueObjectTests.cs new file mode 100644 index 00000000..c8e15d5e --- /dev/null +++ b/src/Comparison/Comparison.Application.UnitTests/Cohorts/DomainTests/ComparisonValueObjectTests.cs @@ -0,0 +1,181 @@ +namespace Comparison.Application.UnitTests.Cohorts.DomainTests; + +public sealed class ComparisonValueObjectTests +{ + private static readonly DateTime _start = new(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc); + private static readonly DateTime _end = new(2026, 5, 8, 0, 0, 0, DateTimeKind.Utc); + + private static CohortKey Key( + int encounter = 3009, int difficulty = 5, int ilvlFloor = 600, + DateTime? start = null, DateTime? end = null, string? pull = null) => + CohortKey.From(encounter, difficulty, ilvlFloor, start ?? _start, end ?? _end, pull); + + [Fact] + public void Should_RejectNonPositiveEncounter_When_CohortKeyFrom() + { + ((Action)(() => Key(encounter: 0))).Should().Throw(); + } + + [Fact] + public void Should_RejectNonPositiveDifficulty_When_CohortKeyFrom() + { + ((Action)(() => Key(difficulty: 0))).Should().Throw(); + } + + [Fact] + public void Should_RejectNegativeIlvlFloor_When_CohortKeyFrom() + { + ((Action)(() => Key(ilvlFloor: -1))).Should().Throw(); + } + + [Fact] + public void Should_RejectInvertedPeriod_When_StartNotBeforeEnd() + { + ((Action)(() => Key(start: _end, end: _start))).Should().Throw(); + } + + [Fact] + public void Should_RejectEmptyPullSignature_When_Provided() + { + ((Action)(() => Key(pull: ""))).Should().Throw(); + } + + [Fact] + public void Should_AllowNullPullSignature_When_NotScopedToPullShape() + { + Key(pull: null).PullSignature.Should().BeNull(); + } + + [Fact] + public void Should_AllowZeroIlvlFloor_When_BracketStartsAtZero() + { + Key(ilvlFloor: 0).IlvlBracketFloor.Should().Be(0); + } + + [Fact] + public void Should_RejectEqualPeriodBounds_When_StartEqualsEnd() + { + ((Action)(() => Key(start: _start, end: _start))).Should().Throw(); + } + + [Fact] + public void Should_DistinguishByEachComponent_When_CohortKeyEquality() + { + Key().Should().Be(Key()); + Key().Should().NotBe(Key(encounter: 3010)); + Key().Should().NotBe(Key(difficulty: 4)); + Key().Should().NotBe(Key(ilvlFloor: 610)); + Key().Should().NotBe(Key(end: _end.AddDays(1))); + Key().Should().NotBe(Key(start: _start.AddDays(-1))); + Key(pull: "abc").Should().NotBe(Key(pull: "def")); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("Kicks Rate")] + [InlineData("kicks_rate")] + public void Should_Reject_When_MetricKeyBlankOrNonKebab(string value) + { + ((Action)(() => MetricKey.From(value))).Should().Throw(); + } + + [Fact] + public void Should_RejectTooLong_When_MetricKeyExceedsMax() + { + ((Action)(() => MetricKey.From(new string('a', MetricKey.MaxLength + 1)))) + .Should().Throw(); + } + + [Fact] + public void Should_NormalizeAndCompareByValue_When_MetricKey() + { + MetricKey.From(" Kicks-Rate ").Value.Should().Be("kicks-rate"); + MetricKey.From("kicks-rate").Should().Be(MetricKey.From("KICKS-RATE")); + MetricKey.From("kicks-rate").Should().NotBe(MetricKey.From("deaths-avoidable")); + } + + [Fact] + public void Should_RejectEmptyKey_When_MetricPercentile() + { + ((Action)(() => new MetricPercentile("", 1, 2, 3, 4, 10))).Should().Throw(); + } + + [Fact] + public void Should_AcceptNonDecreasingPercentiles_When_HigherIsBetter() + { + new MetricPercentile("dps", 1, 2, 3, 4, 10).P95.Should().Be(3); + } + + [Fact] + public void Should_AcceptNonIncreasingPercentiles_When_LowerIsBetter() + { + new MetricPercentile("avoidable-damage", 4, 3, 2, 1, 10).P50.Should().Be(4); + } + + [Theory] + [InlineData(2, 2, 3, 4)] + [InlineData(2, 3, 3, 4)] + [InlineData(2, 3, 4, 4)] + [InlineData(5, 5, 5, 5)] + public void Should_AcceptEqualAdjacentPercentiles_When_NonDecreasing(double p50, double p75, double p95, double p99) + { + new MetricPercentile("dps", p50, p75, p95, p99, 10).P50.Should().Be(p50); + } + + [Theory] + [InlineData(4, 4, 3, 2)] + [InlineData(4, 3, 3, 2)] + [InlineData(4, 3, 2, 2)] + public void Should_AcceptEqualAdjacentPercentiles_When_NonIncreasing(double p50, double p75, double p95, double p99) + { + new MetricPercentile("avoidable-damage", p50, p75, p95, p99, 10).P50.Should().Be(p50); + } + + [Theory] + [InlineData(1, 3, 2, 4)] + [InlineData(3, 1, 2, 4)] + [InlineData(1, 2, 4, 3)] + [InlineData(2, 1, 3, 4)] + public void Should_RejectNonMonotonicPercentiles_When_OrderingIsMixed(double p50, double p75, double p95, double p99) + { + ((Action)(() => new MetricPercentile("dps", p50, p75, p95, p99, 10))).Should().Throw(); + } + + [Fact] + public void Should_RejectNegativeSampleSize_When_MetricPercentile() + { + ((Action)(() => new MetricPercentile("dps", 1, 2, 3, 4, -1))).Should().Throw(); + } + + [Fact] + public void Should_ScoreRelativeToSpread_When_DeltaScoreOnHealthyDistribution() + { + var percentile = new MetricPercentile("dps", 100, 150, 200, 250, 10); + + percentile.DeltaScore(200).Should().BeApproximately(1.0, 1e-9); + percentile.DeltaScore(100).Should().Be(0); + percentile.DeltaScore(50).Should().BeApproximately(-0.5, 1e-9); + } + + [Fact] + public void Should_ReturnZero_When_DeltaScoreOnDegenerateDistribution() + { + new MetricPercentile("dps", 100, 100, 100, 100, 10).DeltaScore(999).Should().Be(0); + } + + [Fact] + public void Should_RejectEmptyGuid_When_CohortIdFromEmpty() + { + ((Action)(() => CohortId.From(Guid.Empty))).Should().Throw(); + } + + [Fact] + public void Should_WrapAndCompareByValue_When_CohortId() + { + var guid = Guid.NewGuid(); + CohortId.From(guid).Value.Should().Be(guid); + CohortId.From(guid).Should().Be(CohortId.From(guid)); + CohortId.Create().Should().NotBe(CohortId.Create()); + } +} diff --git a/src/Comparison/Comparison.Application/UseCases/Cohorts/Commands/RebuildCohorts/RebuildCohortsUseCase.cs b/src/Comparison/Comparison.Application/UseCases/Cohorts/Commands/RebuildCohorts/RebuildCohortsUseCase.cs index d356f24a..d6a4b15b 100644 --- a/src/Comparison/Comparison.Application/UseCases/Cohorts/Commands/RebuildCohorts/RebuildCohortsUseCase.cs +++ b/src/Comparison/Comparison.Application/UseCases/Cohorts/Commands/RebuildCohorts/RebuildCohortsUseCase.cs @@ -45,16 +45,9 @@ public async Task> HandleAsync( continue; } - var sampleSize = samples.Select(s => s.MetricKey).Distinct().Count() > 0 - ? samples.GroupBy(s => s.MetricKey).Max(g => g.Count()) - : 0; + var sampleSize = samples.GroupBy(s => s.MetricKey).Max(g => g.Count()); var percentiles = ComputePercentiles(samples, sampleSize); - if (percentiles.Count == 0) - { - skipped++; - continue; - } var keyResult = Guard.TryBuild( () => CohortKey.From( @@ -95,7 +88,6 @@ private static List ComputePercentiles( foreach (var group in byMetric) { var sorted = group.Select(s => s.Value).OrderBy(v => v).ToArray(); - if (sorted.Length == 0) continue; result.Add(new MetricPercentile( MetricKey: group.Key, P50: Percentile(sorted, 0.50), From d9aa66d23f8879cd2b681f4a0cfa539b66c0c058 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 22:27:43 +0000 Subject: [PATCH 07/18] test(log-ingestion): Application mutation 45%->82% Covers the previously-untested core of the ingestion pipeline with sociable tests driving the application services directly: the Raid and Mythic+ ingestion strategies (wrong-input, duplicate fingerprint, invalid player, persisted session/run detail, per-pull event batching, malformed-pull skipping, Mythic 0 keystone exemption, completed vs abandoned outcome), SegmentHasher determinism/discrimination/canonicalisation, the resumable-upload completion handler (success, typed-error persistence, gzip decompression) and the ListUserUploads / GetRaidSessionPlayerNames queries. Remaining survivors are equivalent mutants (StreamReader BOM/buffer/leaveOpen flags, SHA payload prefix constants) and a few weak spots in the pre-existing UploadCombatLogUseCase dedup path. --- .../Ingestion/LogIngestionStrategyTests.cs | 278 ++++++++++++++++++ .../Ingestion/SegmentHasherTests.cs | 149 ++++++++++ .../GetRaidSessionPlayerNamesQueryTests.cs | 33 +++ .../CompleteResumableUploadHandlerTests.cs | 121 ++++++++ 4 files changed, 581 insertions(+) create mode 100644 src/LogIngestion/LogIngestion.Application.UnitTests/Ingestion/LogIngestionStrategyTests.cs create mode 100644 src/LogIngestion/LogIngestion.Application.UnitTests/Ingestion/SegmentHasherTests.cs create mode 100644 src/LogIngestion/LogIngestion.Application.UnitTests/Queries/GetRaidSessionPlayerNamesQueryTests.cs create mode 100644 src/LogIngestion/LogIngestion.Application.UnitTests/UseCases/CompleteResumableUploadHandlerTests.cs diff --git a/src/LogIngestion/LogIngestion.Application.UnitTests/Ingestion/LogIngestionStrategyTests.cs b/src/LogIngestion/LogIngestion.Application.UnitTests/Ingestion/LogIngestionStrategyTests.cs new file mode 100644 index 00000000..1784aa3c --- /dev/null +++ b/src/LogIngestion/LogIngestion.Application.UnitTests/Ingestion/LogIngestionStrategyTests.cs @@ -0,0 +1,278 @@ +using EventStore.Application.UnitTests.Fakes; +using LogIngestion.Application.UseCases.Logs.Commands.IngestLog.Strategies; +using LogIngestion.Domain.Errors; + +namespace LogIngestion.Application.UnitTests.Ingestion; + +public sealed class LogIngestionStrategyTests +{ + private static readonly DateTimeOffset _start = new(2026, 5, 16, 20, 0, 0, TimeSpan.Zero); + private static readonly CancellationToken _ct = CancellationToken.None; + + private static IngestionCombatant Player(string name = "Thrall") => + new(name, "stormrage", WowSpec.RestorationShaman); + + private static IngestionCombatEvent Event() => + new("SPELL_CAST_SUCCESS", _start.AddSeconds(10), "src", "Thrall", "tgt", "Boss", 12345, "Kick", 0, 0); + + // ---- Raid strategy ---- + + private readonly FakeRaidSessionRepository _raidSessions = new(); + private readonly FakeDungeonRunRepository _dungeonRuns = new(); + private readonly FakeCombatEventRepository _combatEvents = new(); + private readonly FakeOutboxUnitOfWork _unitOfWork = new(); + + private RaidLogIngestionStrategy RaidStrategy() => new(_raidSessions, _combatEvents, _unitOfWork); + private MythicPlusLogIngestionStrategy MplusStrategy() => new(_dungeonRuns, _combatEvents, _unitOfWork); + + private static RaidIngestionInput Raid( + int instanceId = 1273, + IReadOnlyList? pulls = null) => + new() + { + Source = IngestionSource.CombatLogFile, + RaidName = "Manaforge Omega", + InstanceId = instanceId, + Difficulty = ContentDifficulty.Heroic, + SessionStartedAt = _start, + Pulls = pulls ?? + [ + new IngestionPull(3009, "Boss", _start, _start.AddMinutes(5), true, null, [Player()], [Event()]), + ], + }; + + [Fact] + public async Task Should_FailWithIncompleteLog_When_RaidStrategyGivenMythicPlusInput() + { + var result = await RaidStrategy().ExecuteAsync(Mplus(), new IngestionContext(), _ct); + + ThenResultIsFailureWithError(result, LogIngestionErrors.IncompleteLog); + } + + [Fact] + public async Task Should_FailWithDuplicateRun_When_RaidFingerprintAlreadyIngested() + { + _raidSessions.SeedExistingFingerprint(1273, _start); + + var result = await RaidStrategy().ExecuteAsync(Raid(), new IngestionContext(), _ct); + + ThenResultIsFailureWithError(result, LogIngestionErrors.DuplicateRun); + } + + [Fact] + public async Task Should_FailWithInvalidPlayer_When_RaidPullHasUnnamedPlayer() + { + var pulls = new IngestionPull[] + { + new(3009, "Boss", _start, _start.AddMinutes(5), true, null, [new IngestionCombatant("", "stormrage", WowSpec.RestorationShaman)], []), + }; + + var result = await RaidStrategy().ExecuteAsync(Raid(pulls: pulls), new IngestionContext(), _ct); + + ThenResultIsFailureWithError(result, LogIngestionErrors.InvalidPlayer); + } + + [Fact] + public async Task Should_PersistSessionAndEvents_When_RaidIngestionSucceeds() + { + var owner = Guid.NewGuid(); + var guild = Guid.NewGuid(); + + var result = await RaidStrategy().ExecuteAsync( + Raid(), new IngestionContext(OwnerUserId: owner, GuildId: guild), _ct); + + var value = ThenResultIsSuccess(result); + value.Type.Should().Be(IngestedLogType.Raid); + value.PullIds.Should().HaveCount(1); + + var snapshot = _raidSessions.GetSnapshot(RaidSessionId.From(value.Id)); + snapshot.InstanceId.Should().Be(1273); + snapshot.Difficulty.Should().Be((int)ContentDifficulty.Heroic); + snapshot.OwnerUserId.Should().Be(owner); + snapshot.GuildId.Should().Be(guild); + var pull = snapshot.Encounters.Single().Pulls.Single(); + pull.Success.Should().BeTrue(); + + _combatEvents.SaveBatchCallCount.Should().Be(1); + var record = _combatEvents.GetByUnit(value.PullIds[0]).Single(); + record.EventType.Should().Be("SPELL_CAST_SUCCESS"); + record.SpellId.Should().Be(12345); + record.TimestampMs.Should().Be(10_000); + } + + [Fact] + public async Task Should_NotPersistEvents_When_PullHasNoEvents() + { + var pulls = new IngestionPull[] + { + new(3009, "Boss", _start, _start.AddMinutes(5), true, null, [Player()], []), + }; + + await RaidStrategy().ExecuteAsync(Raid(pulls: pulls), new IngestionContext(), _ct); + + _combatEvents.SaveBatchCallCount.Should().Be(0); + } + + [Fact] + public async Task Should_RecordPull_When_EndedExactlyEqualsStarted() + { + var pulls = new IngestionPull[] + { + new(3009, "Instant", _start, _start, true, null, [Player()], []), + }; + + var result = await RaidStrategy().ExecuteAsync(Raid(pulls: pulls), new IngestionContext(), _ct); + + var value = ThenResultIsSuccess(result); + value.PullIds.Should().HaveCount(1); + } + + [Fact] + public async Task Should_SkipPull_When_EndedBeforeStarted() + { + var pulls = new IngestionPull[] + { + new(3009, "Good", _start, _start.AddMinutes(5), true, null, [Player()], []), + new(3010, "Malformed", _start.AddMinutes(10), _start.AddMinutes(9), false, 50, [Player()], []), + }; + + var result = await RaidStrategy().ExecuteAsync(Raid(pulls: pulls), new IngestionContext(), _ct); + + var value = ThenResultIsSuccess(result); + value.PullIds.Should().HaveCount(1); + } + + // ---- Mythic+ strategy ---- + + private static MythicPlusIngestionInput Mplus( + int instanceId = 2649, + int keyLevel = 15, + IReadOnlyList? affixIds = null, + ContentDifficulty difficulty = ContentDifficulty.MythicPlus, + IReadOnlyList? combatants = null, + IReadOnlyList? events = null, + MythicPlusOutcome? outcome = null) => + new() + { + Source = IngestionSource.CombatLogFile, + DungeonName = "Priory of the Sacred Flame", + InstanceId = instanceId, + KeyLevel = keyLevel, + AffixIds = affixIds ?? [9, 10], + Difficulty = difficulty, + StartedAt = _start, + Combatants = combatants ?? [Player()], + Events = events ?? [], + Outcome = outcome, + }; + + private static MythicPlusOutcome Completed() => new() + { + Status = MythicPlusOutcomeStatus.Completed, + DurationMs = 1_500_000, + EndedAt = _start.AddMinutes(25), + Timed = true, + }; + + [Fact] + public async Task Should_FailWithIncompleteLog_When_MplusStrategyGivenRaidInput() + { + var result = await MplusStrategy().ExecuteAsync(Raid(), new IngestionContext(), _ct); + + ThenResultIsFailureWithError(result, LogIngestionErrors.IncompleteLog); + } + + [Fact] + public async Task Should_FailWithDuplicateRun_When_MplusFingerprintAlreadyIngested() + { + _dungeonRuns.SeedExistingFingerprint(2649, _start); + + var result = await MplusStrategy().ExecuteAsync(Mplus(), new IngestionContext(), _ct); + + ThenResultIsFailureWithError(result, LogIngestionErrors.DuplicateRun); + } + + [Fact] + public async Task Should_FailWithInvalidPlayer_When_MplusHasUnnamedPlayer() + { + var result = await MplusStrategy().ExecuteAsync( + Mplus(combatants: [new IngestionCombatant("", "stormrage", WowSpec.FrostMage)]), + new IngestionContext(), _ct); + + ThenResultIsFailureWithError(result, LogIngestionErrors.InvalidPlayer); + } + + [Fact] + public async Task Should_PersistRunAndEvents_When_CompletedMythicPlusSucceeds() + { + var owner = Guid.NewGuid(); + + var result = await MplusStrategy().ExecuteAsync( + Mplus(events: [Event()], outcome: Completed()), + new IngestionContext(OwnerUserId: owner), _ct); + + var value = ThenResultIsSuccess(result); + value.Type.Should().Be(IngestedLogType.MythicPlus); + + var snapshot = _dungeonRuns.GetSnapshot(DungeonRunId.From(value.Id)); + snapshot.InstanceId.Should().Be(2649); + snapshot.Difficulty.Should().Be((int)ContentDifficulty.MythicPlus); + snapshot.KeyLevel.Should().Be(15); + snapshot.OwnerUserId.Should().Be(owner); + snapshot.Timed.Should().BeTrue(); + snapshot.DurationMs.Should().Be(1_500_000); + + _combatEvents.SaveBatchCallCount.Should().Be(1); + _combatEvents.GetByUnit(value.Id).Single().SpellId.Should().Be(12345); + } + + [Fact] + public async Task Should_PersistRun_When_AbandonedMythicPlus() + { + var abandoned = new MythicPlusOutcome + { + Status = MythicPlusOutcomeStatus.Abandoned, + DurationMs = 600_000, + EndedAt = _start.AddMinutes(10), + }; + + var result = await MplusStrategy().ExecuteAsync( + Mplus(outcome: abandoned), new IngestionContext(), _ct); + + var value = ThenResultIsSuccess(result); + var snapshot = _dungeonRuns.GetSnapshot(DungeonRunId.From(value.Id)); + snapshot.Timed.Should().BeFalse(); + snapshot.DurationMs.Should().Be(600_000); + } + + [Fact] + public async Task Should_NotRequireKeystone_When_MythicZero() + { + var result = await MplusStrategy().ExecuteAsync( + Mplus(keyLevel: 0, affixIds: [], difficulty: ContentDifficulty.MythicZero, outcome: Completed()), + new IngestionContext(), _ct); + + var value = ThenResultIsSuccess(result); + var snapshot = _dungeonRuns.GetSnapshot(DungeonRunId.From(value.Id)); + snapshot.KeyLevel.Should().BeNull(); + snapshot.AffixIds.Should().BeNull(); + } + + [Fact] + public async Task Should_FailWithInvalidKeyLevel_When_KeyLevelBelowMinimum() + { + var result = await MplusStrategy().ExecuteAsync( + Mplus(keyLevel: 1), new IngestionContext(), _ct); + + result.IsFailure.Should().BeTrue(); + } + + [Fact] + public async Task Should_FailWithInvalidAffixes_When_AffixCountOutOfRange() + { + var result = await MplusStrategy().ExecuteAsync( + Mplus(affixIds: []), new IngestionContext(), _ct); + + result.IsFailure.Should().BeTrue(); + } +} diff --git a/src/LogIngestion/LogIngestion.Application.UnitTests/Ingestion/SegmentHasherTests.cs b/src/LogIngestion/LogIngestion.Application.UnitTests/Ingestion/SegmentHasherTests.cs new file mode 100644 index 00000000..03ffe4a3 --- /dev/null +++ b/src/LogIngestion/LogIngestion.Application.UnitTests/Ingestion/SegmentHasherTests.cs @@ -0,0 +1,149 @@ +namespace LogIngestion.Application.UnitTests.Ingestion; + +public sealed class SegmentHasherTests +{ + private static readonly DateTimeOffset _start = new(2026, 5, 16, 20, 0, 0, TimeSpan.Zero); + + private static IngestionCombatant Player(string name) => + new(name, "stormrage", WowSpec.RestorationShaman); + + private static MythicPlusIngestionInput Mplus( + int instanceId = 2649, + DateTimeOffset? startedAt = null, + DateTimeOffset? endedAt = null, + params string[] players) => + new() + { + Source = IngestionSource.CombatLogFile, + DungeonName = "Priory", + InstanceId = instanceId, + KeyLevel = 15, + AffixIds = [9, 10], + Difficulty = ContentDifficulty.MythicPlus, + StartedAt = startedAt ?? _start, + Combatants = [.. (players.Length == 0 ? ["Thrall", "Jaina"] : players).Select(Player)], + Events = [], + Outcome = endedAt is null ? null : new MythicPlusOutcome + { + Status = MythicPlusOutcomeStatus.Completed, + DurationMs = 1000, + EndedAt = endedAt.Value, + Timed = true, + }, + }; + + private static IngestionPull Pull(int encounterId, DateTimeOffset startedAt, DateTimeOffset endedAt) => + new(encounterId, "Boss", startedAt, endedAt, Success: true, WipePercent: null, Players: [], Events: []); + + private static RaidIngestionInput Raid( + int instanceId = 1273, + ContentDifficulty difficulty = ContentDifficulty.Heroic, + DateTimeOffset? startedAt = null, + IReadOnlyList? pulls = null) => + new() + { + Source = IngestionSource.CombatLogFile, + RaidName = "Manaforge", + InstanceId = instanceId, + Difficulty = difficulty, + SessionStartedAt = startedAt ?? _start, + Pulls = pulls ?? [Pull(3009, _start, _start.AddMinutes(5))], + }; + + [Fact] + public void Should_ProduceIdenticalHash_When_SameMythicPlusSegmentHashedTwice() + { + SegmentHasher.Compute(Mplus()).Should().Be(SegmentHasher.Compute(Mplus())); + } + + [Fact] + public void Should_BeInvariantToCombatantOrder_When_HashingMythicPlus() + { + SegmentHasher.Compute(Mplus(players: ["Thrall", "Jaina"])) + .Should().Be(SegmentHasher.Compute(Mplus(players: ["Jaina", "Thrall"]))); + } + + [Fact] + public void Should_Differentiate_When_MythicPlusInstanceDiffers() + { + SegmentHasher.Compute(Mplus(instanceId: 2649)) + .Should().NotBe(SegmentHasher.Compute(Mplus(instanceId: 2650))); + } + + [Fact] + public void Should_Differentiate_When_MythicPlusStartDiffers() + { + SegmentHasher.Compute(Mplus(startedAt: _start)) + .Should().NotBe(SegmentHasher.Compute(Mplus(startedAt: _start.AddSeconds(1)))); + } + + [Fact] + public void Should_Differentiate_When_MythicPlusOutcomeEndDiffers() + { + SegmentHasher.Compute(Mplus(endedAt: _start.AddMinutes(20))) + .Should().NotBe(SegmentHasher.Compute(Mplus(endedAt: _start.AddMinutes(25)))); + } + + [Fact] + public void Should_Differentiate_When_MythicPlusCompositionDiffers() + { + SegmentHasher.Compute(Mplus(players: ["Thrall", "Jaina"])) + .Should().NotBe(SegmentHasher.Compute(Mplus(players: ["Thrall", "Sylvanas"]))); + } + + [Fact] + public void Should_FallBackToStart_When_MythicPlusHasNoOutcome() + { + SegmentHasher.Compute(Mplus(endedAt: null)).Should().NotBeNull(); + } + + [Fact] + public void Should_ProduceIdenticalHash_When_SameRaidSegmentHashedTwice() + { + SegmentHasher.Compute(Raid()).Should().Be(SegmentHasher.Compute(Raid())); + } + + [Fact] + public void Should_BeInvariantToPullOrder_When_HashingRaid() + { + var p1 = Pull(3009, _start, _start.AddMinutes(5)); + var p2 = Pull(3010, _start.AddMinutes(6), _start.AddMinutes(10)); + + SegmentHasher.Compute(Raid(pulls: [p1, p2])) + .Should().Be(SegmentHasher.Compute(Raid(pulls: [p2, p1]))); + } + + [Fact] + public void Should_Differentiate_When_RaidDifficultyDiffers() + { + SegmentHasher.Compute(Raid(difficulty: ContentDifficulty.Heroic)) + .Should().NotBe(SegmentHasher.Compute(Raid(difficulty: ContentDifficulty.Mythic))); + } + + [Fact] + public void Should_Differentiate_When_RaidInstanceDiffers() + { + SegmentHasher.Compute(Raid(instanceId: 1273)) + .Should().NotBe(SegmentHasher.Compute(Raid(instanceId: 1280))); + } + + [Fact] + public void Should_Differentiate_When_RaidLastPullEndDiffers() + { + SegmentHasher.Compute(Raid(pulls: [Pull(3009, _start, _start.AddMinutes(5))])) + .Should().NotBe(SegmentHasher.Compute(Raid(pulls: [Pull(3009, _start, _start.AddMinutes(9))]))); + } + + [Fact] + public void Should_NotThrowAndAnchorOnSessionStart_When_RaidHasNoPulls() + { + SegmentHasher.Compute(Raid(pulls: [])).Should().NotBeNull(); + } + + [Fact] + public void Should_DiscriminateRaidFromMythicPlus_When_SameInstanceAndStart() + { + SegmentHasher.Compute(Mplus(instanceId: 5, startedAt: _start)) + .Should().NotBe(SegmentHasher.Compute(Raid(instanceId: 5, startedAt: _start))); + } +} diff --git a/src/LogIngestion/LogIngestion.Application.UnitTests/Queries/GetRaidSessionPlayerNamesQueryTests.cs b/src/LogIngestion/LogIngestion.Application.UnitTests/Queries/GetRaidSessionPlayerNamesQueryTests.cs new file mode 100644 index 00000000..b8477c6b --- /dev/null +++ b/src/LogIngestion/LogIngestion.Application.UnitTests/Queries/GetRaidSessionPlayerNamesQueryTests.cs @@ -0,0 +1,33 @@ +using LogIngestion.Application.UseCases.RaidSessions.Queries.GetRaidSessionPlayerNames; + +namespace LogIngestion.Application.UnitTests.Queries; + +public sealed class GetRaidSessionPlayerNamesQueryTests +{ + private readonly FakeRaidSessionRepository _repository = new(); + private readonly GetRaidSessionPlayerNamesQuery _query; + + public GetRaidSessionPlayerNamesQueryTests() + { + _query = new GetRaidSessionPlayerNamesQuery(_repository); + } + + [Fact] + public async Task Should_ReturnDistinctPlayerNames_When_SessionExists() + { + var snapshot = _repository.SeedValidSession(); + + var result = await _query.HandleAsync(new GetRaidSessionPlayerNamesRequest(snapshot.Id)); + + var names = ThenResultIsSuccess(result); + names.PlayerNames.Should().Contain("Thrall"); + } + + [Fact] + public async Task Should_FailWithSessionNotFound_When_SessionMissing() + { + var result = await _query.HandleAsync(new GetRaidSessionPlayerNamesRequest(Guid.NewGuid())); + + ThenResultIsFailureWithError(result, LogIngestion.Domain.Errors.LogIngestionErrors.SessionNotFound); + } +} diff --git a/src/LogIngestion/LogIngestion.Application.UnitTests/UseCases/CompleteResumableUploadHandlerTests.cs b/src/LogIngestion/LogIngestion.Application.UnitTests/UseCases/CompleteResumableUploadHandlerTests.cs new file mode 100644 index 00000000..6a57a173 --- /dev/null +++ b/src/LogIngestion/LogIngestion.Application.UnitTests/UseCases/CompleteResumableUploadHandlerTests.cs @@ -0,0 +1,121 @@ +using System.IO.Compression; +using System.Text; +using LogIngestion.Application.Ports; +using LogIngestion.Application.UseCases.Logs.Commands.CompleteResumableUpload; +using LogIngestion.Application.UseCases.Logs.Commands.IngestLog; +using LogIngestion.Application.UseCases.Logs.Commands.UploadCombatLog; +using LogIngestion.Domain.Errors; +using Wow.Kernel.Shared; +using Wow.Kernel.Shared.Localization; +using Wow.Kernel.Shared.Mediator; + +namespace LogIngestion.Application.UnitTests.UseCases; + +public sealed class CompleteResumableUploadHandlerTests +{ + private static readonly DateTime _now = new(2026, 5, 19, 12, 0, 0, DateTimeKind.Utc); + private readonly FakeCurrentDateTimeProvider _clock = FakeCurrentDateTimeProvider.At(_now); + private readonly StubResultStore _resultStore = new(); + private readonly Guid _owner = Guid.NewGuid(); + + private static Func> PlainContent(string text) => + _ => Task.FromResult(new MemoryStream(Encoding.UTF8.GetBytes(text))); + + private static Func> GzipContent(string text) => + _ => + { + var buffer = new MemoryStream(); + using (var gz = new GZipStream(buffer, CompressionMode.Compress, leaveOpen: true)) + { + var bytes = Encoding.UTF8.GetBytes(text); + gz.Write(bytes, 0, bytes.Length); + } + buffer.Position = 0; + return Task.FromResult(buffer); + }; + + private CompleteResumableUploadHandler Handler(Result outcome) => + new(new StubMediator(outcome), _resultStore, _clock); + + private static ResumableUploadContext Context(Guid owner, bool gzip = false) + { + var metadata = new Dictionary(); + if (gzip) + metadata["contentEncoding"] = "gzip"; + return new ResumableUploadContext("upload-1", owner, metadata); + } + + [Fact] + public async Task Should_StoreSuccessResult_When_PipelineSucceeds() + { + var pipelineResult = new UploadCombatLogResult( + [new UploadedSegment(IngestedLogType.Raid, Guid.NewGuid(), new string('a', 64))], []); + + await Handler(Result.Success(pipelineResult)) + .HandleAsync(Context(_owner), PlainContent("COMBATLOG"), CancellationToken.None); + + _resultStore.SavedResult.Should().NotBeNull(); + _resultStore.SavedResult!.Value.ownerUserId.Should().Be(_owner); + _resultStore.SavedError.Should().BeNull(); + } + + [Fact] + public async Task Should_StoreErrorKey_When_PipelineFails() + { + await Handler(Result.Failure(LogIngestionErrors.IncompleteLog)) + .HandleAsync(Context(_owner), PlainContent("bad"), CancellationToken.None); + + _resultStore.SavedError.Should().NotBeNull(); + _resultStore.SavedError!.Value.errorKey.Should().Be(LogIngestionErrors.IncompleteLog); + _resultStore.SavedResult.Should().BeNull(); + } + + [Fact] + public async Task Should_DecompressGzip_When_ContentEncodingIsGzip() + { + var mediator = new StubMediator(Result.Success( + new UploadCombatLogResult([], []))); + var handler = new CompleteResumableUploadHandler(mediator, _resultStore, _clock); + + await handler.HandleAsync(Context(_owner, gzip: true), GzipContent("COMBATLOG GZIP"), CancellationToken.None); + + mediator.LastReadContent.Should().Contain("COMBATLOG GZIP"); + } + + private sealed class StubResultStore : IUploadResultStore + { + public (string uploadId, Guid ownerUserId, UploadCombatLogResult result)? SavedResult { get; private set; } + public (string uploadId, Guid ownerUserId, TranslationKey errorKey)? SavedError { get; private set; } + + public void SaveResult(string uploadId, Guid ownerUserId, UploadCombatLogResult result, DateTimeOffset now) + => SavedResult = (uploadId, ownerUserId, result); + + public void SaveError(string uploadId, Guid ownerUserId, TranslationKey errorKey, DateTimeOffset now) + => SavedError = (uploadId, ownerUserId, errorKey); + + public UploadResultEntry? Get(string uploadId, Guid ownerUserId, DateTimeOffset now) => null; + } + + private sealed class StubMediator(Result outcome) : IMediator + { + public string? LastReadContent { get; private set; } + + public Task> ExecuteAsync( + TCommand command, CancellationToken cancellationToken = default) + where TCommand : notnull + { + if (command is UploadCombatLogCommand upload) + LastReadContent = upload.LogReader.ReadToEnd(); + return Task.FromResult((Result)(object)outcome); + } + + public Task ExecuteAsync(TCommand command, CancellationToken cancellationToken = default) + where TCommand : notnull + => Task.FromResult(Result.Success()); + + public Task> SendAsync( + TRequest request, CancellationToken cancellationToken = default) + where TRequest : notnull + => throw new NotSupportedException(); + } +} From e50296f9c10d173ecc43963f3b29a5a1e222641a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 22:30:36 +0000 Subject: [PATCH 08/18] test(log-ingestion): Domain mutation 69%->82% via boundary VO tests Adds targeted tests for previously-uncovered value objects: ShareToken (length + url-safe char ranges + trim + equality), SegmentHash (length + lowercase hex-only char ranges + equality), UploadJournalEntry (Create + FromSnapshot invariants + snapshot roundtrip) and PlayerLoadout (component equality, defensive input copy). Remaining survivors are diffuse and largely equivalent (SeasonSlug normalization regex internals, ArgumentNullException guards on aggregate factories, DungeonRun Complete/Abandon branches already exercised via snapshot roundtrip). --- .../Domain/LogIngestionBoundaryVoTests.cs | 178 ++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 src/LogIngestion/LogIngestion.Application.UnitTests/Domain/LogIngestionBoundaryVoTests.cs diff --git a/src/LogIngestion/LogIngestion.Application.UnitTests/Domain/LogIngestionBoundaryVoTests.cs b/src/LogIngestion/LogIngestion.Application.UnitTests/Domain/LogIngestionBoundaryVoTests.cs new file mode 100644 index 00000000..c913e2a8 --- /dev/null +++ b/src/LogIngestion/LogIngestion.Application.UnitTests/Domain/LogIngestionBoundaryVoTests.cs @@ -0,0 +1,178 @@ +using LogIngestion.Domain.Aggregates; + +namespace LogIngestion.Application.UnitTests.Domain; + +public sealed class LogIngestionBoundaryVoTests +{ + // ---- ShareToken ---- + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("short")] // < 16 + [InlineData("0123456789012345678901234567890123456789012345678901234567890123456789")] // > 64 + [InlineData("token-with-bad-char!!")] // '!' not url-safe + [InlineData("token with spaces 00")] // space not url-safe + public void Should_RejectShareToken_When_Invalid(string value) + { + ShareToken.Create(value).IsFailure.Should().BeTrue(); + } + + [Theory] + [InlineData("ABCDEFGHIJKLMNOP")] // exactly 16, upper + [InlineData("abcdefghijklmnop")] // lower + [InlineData("0123456789-_aaaa")] // digits, hyphen, underscore + public void Should_AcceptShareToken_When_UrlSafeAndWithinLength(string value) + { + ShareToken.Create(value).Value!.Value.Should().Be(value); + } + + [Fact] + public void Should_TrimAndCompareByValue_When_ShareToken() + { + ShareToken.Create(" abcdefghijklmnop ").Value!.Value.Should().Be("abcdefghijklmnop"); + ShareToken.Create("abcdefghijklmnop").Value! + .Should().Be(ShareToken.Create("abcdefghijklmnop").Value!); + } + + [Theory] + [InlineData('@')] // just before 'A' + [InlineData('[')] // just after 'Z' + [InlineData('`')] // just before 'a' + [InlineData('{')] // just after 'z' + [InlineData('/')] // just before '0' + [InlineData(':')] // just after '9' + public void Should_RejectShareToken_When_CharOutsideUrlSafeRanges(char bad) + { + ShareToken.Create(new string('a', 15) + bad).IsFailure.Should().BeTrue(); + } + + // ---- SegmentHash ---- + + [Theory] + [InlineData(0)] + [InlineData(3)] + [InlineData(63)] + [InlineData(65)] + public void Should_RejectSegmentHash_When_WrongLength(int length) + { + ((Action)(() => SegmentHash.From(new string('a', length)))).Should().Throw(); + } + + [Theory] + [InlineData('A')] // uppercase not allowed (lowercase hex only) + [InlineData('g')] // just after 'f' + [InlineData('/')] // just before '0' + [InlineData(':')] // just after '9' + public void Should_RejectSegmentHash_When_NonLowercaseHexChar(char bad) + { + var value = new string('a', 63) + bad; + + ((Action)(() => SegmentHash.From(value))).Should().Throw(); + } + + [Fact] + public void Should_AcceptSegmentHash_When_ExactlySixtyFourLowercaseHex() + { + var value = string.Concat(Enumerable.Repeat("0123456789abcdef", 4)); + + SegmentHash.From(value).Value.Should().Be(value); + } + + [Fact] + public void Should_CompareByValue_When_SegmentHash() + { + var value = new string('a', 64); + SegmentHash.From(value).Should().Be(SegmentHash.From(value)); + SegmentHash.From(value).Should().NotBe(SegmentHash.From(new string('b', 64))); + } + + // ---- UploadJournalEntry ---- + + private static SegmentHash Hash() => SegmentHash.From(new string('a', 64)); + + [Fact] + public void Should_CreateJournalEntry_When_AllFieldsValid() + { + var userId = OwnerUserId.From(Guid.NewGuid()); + var runId = Guid.NewGuid(); + var at = new DateTimeOffset(2026, 5, 19, 0, 0, 0, TimeSpan.Zero); + + var entry = UploadJournalEntry.Create(userId, Hash(), runId, IngestedSegmentKind.Raid, at); + + entry.RunId.Should().Be(runId); + entry.Kind.Should().Be(IngestedSegmentKind.Raid); + entry.IngestedAt.Should().Be(at); + } + + [Fact] + public void Should_RejectJournalEntry_When_RunIdEmpty() + { + ((Action)(() => UploadJournalEntry.Create( + OwnerUserId.From(Guid.NewGuid()), Hash(), Guid.Empty, IngestedSegmentKind.Raid, DateTimeOffset.UnixEpoch))) + .Should().Throw(); + } + + [Fact] + public void Should_RejectJournalEntry_When_KindUndefined() + { + ((Action)(() => UploadJournalEntry.Create( + OwnerUserId.From(Guid.NewGuid()), Hash(), Guid.NewGuid(), (IngestedSegmentKind)99, DateTimeOffset.UnixEpoch))) + .Should().Throw(); + } + + [Fact] + public void Should_RejectJournalEntry_When_IngestedAtDefault() + { + ((Action)(() => UploadJournalEntry.Create( + OwnerUserId.From(Guid.NewGuid()), Hash(), Guid.NewGuid(), IngestedSegmentKind.MythicPlus, default))) + .Should().Throw(); + } + + [Fact] + public void Should_RoundtripThroughSnapshot_When_JournalEntryRehydrated() + { + var original = UploadJournalEntry.Create( + OwnerUserId.From(Guid.NewGuid()), Hash(), Guid.NewGuid(), + IngestedSegmentKind.MythicPlus, new DateTimeOffset(2026, 5, 19, 0, 0, 0, TimeSpan.Zero)); + + var rehydrated = UploadJournalEntry.FromSnapshot(original.ToSnapshot()); + + rehydrated.ToSnapshot().Should().Be(original.ToSnapshot()); + } + + [Fact] + public void Should_RejectSnapshot_When_RunIdEmpty() + { + var corrupt = new LogIngestion.Domain.Snapshots.UploadJournalEntrySnapshot( + Guid.NewGuid(), Guid.NewGuid(), new string('a', 64), Guid.Empty, (int)IngestedSegmentKind.Raid, + new DateTimeOffset(2026, 5, 19, 0, 0, 0, TimeSpan.Zero)); + + ((Action)(() => UploadJournalEntry.FromSnapshot(corrupt))).Should().Throw(); + } + + // ---- PlayerLoadout ---- + + [Fact] + public void Should_CompareByItemsAndTalents_When_PlayerLoadout() + { + var a = new PlayerLoadout([new EquippedItem(1, 600)], [new TalentEntry(10, 20, 1)]); + var b = new PlayerLoadout([new EquippedItem(1, 600)], [new TalentEntry(10, 20, 1)]); + var differentItem = new PlayerLoadout([new EquippedItem(2, 600)], [new TalentEntry(10, 20, 1)]); + var differentTalent = new PlayerLoadout([new EquippedItem(1, 600)], [new TalentEntry(10, 20, 2)]); + + a.Should().Be(b); + a.Should().NotBe(differentItem); + a.Should().NotBe(differentTalent); + } + + [Fact] + public void Should_CopyInputCollections_When_PlayerLoadoutConstructed() + { + var items = new List { new(1, 600) }; + var loadout = new PlayerLoadout(items, []); + items.Add(new EquippedItem(2, 610)); + + loadout.Items.Should().HaveCount(1); + } +} From 95e69c60a6aafea976181f4a2f25d8e184b8f0c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 22:32:37 +0000 Subject: [PATCH 09/18] test(spell-knowledge): Domain mutation 47%->65% via boundary VO tests Adds targeted value-object tests (PatchVersion 3/4-part parse + negative components + revision-aware equality, SpellAttributes group-count guard + interruptibility bit decode + HasFlag bounds + equality, SpellName, SpellTag kebab-case, BlizzardSpellId, SpellId). Remaining survivors are in the Spell and WagoSpellRecord aggregates and the SpellSchool mask VO (follow-up). --- .../Domain/SpellKnowledgeValueObjectTests.cs | 179 ++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 src/SpellKnowledge/SpellKnowledge.Application.UnitTests/Spells/Domain/SpellKnowledgeValueObjectTests.cs diff --git a/src/SpellKnowledge/SpellKnowledge.Application.UnitTests/Spells/Domain/SpellKnowledgeValueObjectTests.cs b/src/SpellKnowledge/SpellKnowledge.Application.UnitTests/Spells/Domain/SpellKnowledgeValueObjectTests.cs new file mode 100644 index 00000000..26e9eaf3 --- /dev/null +++ b/src/SpellKnowledge/SpellKnowledge.Application.UnitTests/Spells/Domain/SpellKnowledgeValueObjectTests.cs @@ -0,0 +1,179 @@ +namespace SpellKnowledge.Application.UnitTests.Spells.Domain; + +public sealed class SpellKnowledgeValueObjectTests +{ + // ---- PatchVersion ---- + + [Theory] + [InlineData(-1, 0, 0)] + [InlineData(0, -1, 0)] + [InlineData(0, 0, -1)] + public void Should_RejectNegativeComponent_When_PatchVersionFrom(int major, int minor, int build) + { + ((Action)(() => PatchVersion.From(major, minor, build))).Should().Throw(); + } + + [Fact] + public void Should_RejectNegativeRevision_When_PatchVersionFrom() + { + ((Action)(() => PatchVersion.From(11, 0, 5, -1))).Should().Throw(); + } + + [Theory] + [InlineData("")] + [InlineData("11.0")] + [InlineData("11.0.5.6.7")] + [InlineData("11.x.5")] + [InlineData("11.0.5.notnum")] + public void Should_RejectMalformed_When_PatchVersionParse(string raw) + { + ((Action)(() => PatchVersion.Parse(raw))).Should().Throw(); + } + + [Fact] + public void Should_ParseThreePartAndFourPart_When_PatchVersion() + { + PatchVersion.Parse("11.0.5").ToString().Should().Be("11.0.5"); + PatchVersion.Parse("11.0.7.56000").ToString().Should().Be("11.0.7.56000"); + } + + [Fact] + public void Should_DistinguishByEveryComponentIncludingRevision_When_PatchVersionEquality() + { + PatchVersion.From(11, 0, 5).Should().Be(PatchVersion.From(11, 0, 5)); + PatchVersion.From(11, 0, 5).Should().NotBe(PatchVersion.From(12, 0, 5)); + PatchVersion.From(11, 0, 5).Should().NotBe(PatchVersion.From(11, 1, 5)); + PatchVersion.From(11, 0, 5).Should().NotBe(PatchVersion.From(11, 0, 6)); + PatchVersion.From(11, 0, 5).Should().NotBe(PatchVersion.From(11, 0, 5, 1)); + PatchVersion.From(11, 0, 5, 1).Should().NotBe(PatchVersion.From(11, 0, 5, 2)); + } + + // ---- SpellAttributes ---- + + [Fact] + public void Should_RejectFewerThanMinGroups_When_SpellAttributesFrom() + { + ((Action)(() => SpellAttributes.From(new long[SpellAttributes.MinAttributeGroupCount - 1]))) + .Should().Throw(); + } + + [Fact] + public void Should_DecodeInterruptible_When_Attr7Bit11Set() + { + var groups = new long[SpellAttributes.DefaultAttributeGroupCount]; + groups[7] = 1L << 11; + + SpellAttributes.From(groups).IsInterruptible.Should().BeTrue(); + SpellAttributes.Empty().IsInterruptible.Should().BeFalse(); + } + + [Fact] + public void Should_DecodeInterruptibleFromRawGroups_When_DecodeIsInterruptible() + { + var groups = new long[15]; + groups[7] = 1L << 11; + + SpellAttributes.DecodeIsInterruptible(groups).Should().BeTrue(); + SpellAttributes.DecodeIsInterruptible(new long[3]).Should().BeFalse(); + } + + [Fact] + public void Should_RejectOutOfRange_When_HasFlagArgsInvalid() + { + var attrs = SpellAttributes.Empty(); + + ((Action)(() => attrs.HasFlag(-1, 0))).Should().Throw(); + ((Action)(() => attrs.HasFlag(99, 0))).Should().Throw(); + ((Action)(() => attrs.HasFlag(7, -1))).Should().Throw(); + ((Action)(() => attrs.HasFlag(7, 32))).Should().Throw(); + } + + [Fact] + public void Should_CompareByGroups_When_SpellAttributesEquality() + { + var a = SpellAttributes.From(new long[8]); + var b = SpellAttributes.From(new long[8]); + var different = new long[8]; + different[7] = 5; + + a.Should().Be(b); + a.Should().NotBe(SpellAttributes.From(different)); + } + + // ---- SpellName ---- + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Should_RejectBlank_When_SpellNameFrom(string value) + { + ((Action)(() => SpellName.From(value))).Should().Throw(); + } + + [Fact] + public void Should_RejectTooLong_When_SpellNameExceedsMax() + { + ((Action)(() => SpellName.From(new string('a', SpellName.MaxLength + 1)))).Should().Throw(); + } + + [Fact] + public void Should_TrimAndCompareByValue_When_SpellName() + { + SpellName.From(" Kick ").Value.Should().Be("Kick"); + SpellName.From("Kick").Should().Be(SpellName.From("Kick")); + SpellName.From("Kick").Should().NotBe(SpellName.From("Pummel")); + } + + // ---- SpellTag ---- + + [Theory] + [InlineData("")] + [InlineData("AoE Stun")] + [InlineData("aoe_stun")] + [InlineData("-aoe")] + public void Should_RejectInvalid_When_SpellTagFrom(string value) + { + ((Action)(() => SpellTag.From(value))).Should().Throw(); + } + + [Fact] + public void Should_RejectTooLong_When_SpellTagExceedsMax() + { + ((Action)(() => SpellTag.From(new string('a', SpellTag.MaxLength + 1)))).Should().Throw(); + } + + [Fact] + public void Should_NormalizeAndCompareByValue_When_SpellTag() + { + SpellTag.From(" AOE-Stun ").Value.Should().Be("aoe-stun"); + SpellTag.From("aoe-stun").Should().Be(SpellTag.From("AOE-STUN")); + SpellTag.From("aoe-stun").Should().NotBe(SpellTag.From("raid-cd")); + } + + // ---- BlizzardSpellId / SpellId ---- + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Should_RejectNonPositive_When_BlizzardSpellIdFrom(int value) + { + ((Action)(() => BlizzardSpellId.From(value))).Should().Throw(); + } + + [Fact] + public void Should_CompareByValue_When_BlizzardSpellId() + { + BlizzardSpellId.From(47528).Value.Should().Be(47528); + BlizzardSpellId.From(47528).Should().Be(BlizzardSpellId.From(47528)); + BlizzardSpellId.From(47528).Should().NotBe(BlizzardSpellId.From(47529)); + } + + [Fact] + public void Should_RejectEmptyGuidAndCompareByValue_When_SpellId() + { + ((Action)(() => SpellId.From(Guid.Empty))).Should().Throw(); + var guid = Guid.NewGuid(); + SpellId.From(guid).Should().Be(SpellId.From(guid)); + SpellId.Create().Should().NotBe(SpellId.Create()); + } +} From b2ee937c73645f4d3c17b66f3256ed17d07a0a2e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 22:51:10 +0000 Subject: [PATCH 10/18] test(analysis): Domain mutation 61%->90%; document quality bar in CLAUDE.md Adds boundary value-object tests (TargetBreakdown/SpellBreakdown validation matrix + per-component equality + AverageAmount, PatchVersion, PlayerStat rates/clamp/equality/default-empty-breakdowns) and stop-coaching domain tests (RunGrade compute + From boundaries + equality, ClassifiedCast, StopAction, MissedOpportunity validation + equality, StopReport asymmetric counters + snapshot field mapping, RunAnalysis snapshot roundtrip preserving grade and breakdowns). Adds a mandatory Quality Bar section to CLAUDE.md: every BC (Domain and Application) must reach >=90% coverage and 90-95% mutation; only provably equivalent mutants are tolerated, each justifiable in one sentence; we never reason in terms of compute budget. Remaining survivors are equivalent (ThrowIfNull / "?? throw" reconstitution guards, id-guard statements, computed RunGrade thresholds not hittable exactly). --- CLAUDE.md | 10 + .../Domain/AnalysisBreakdownVoTests.cs | 240 ++++++++++++++++++ .../Domain/RunAnalysisTests.cs | 66 +++++ .../Domain/StopAnalysisVoTests.cs | 210 +++++++++++++++ .../Domain/StopReportTests.cs | 45 ++++ 5 files changed, 571 insertions(+) create mode 100644 src/Analysis/Analysis.Application.UnitTests/Domain/AnalysisBreakdownVoTests.cs create mode 100644 src/Analysis/Analysis.Application.UnitTests/Domain/RunAnalysisTests.cs create mode 100644 src/Analysis/Analysis.Application.UnitTests/Domain/StopAnalysisVoTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 2ae4a1e4..7bc70cca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -276,6 +276,16 @@ public static MyEntity FromSnapshot(MySnapshot snapshot) => new() { ... }; - Stryker.NET with 80% high / 60% low / 50% break thresholds - Excludes: tests, migrations, ToString/GetHashCode/Equals +### Quality Bar (Mandatory, non-negotiable) + +La qualité prime sur tout le reste. Le temps de calcul ou la longueur de la session ne sont **jamais** une raison pour livrer en dessous de ce seuil. + +- **Chaque bounded context** (Domain *et* Application) doit atteindre **≥ 90 % de couverture de code** et **90–95 % de score de mutation**. Pas d'exception « partielle » : un BC laissé à 65 % est une régression à finir dans la même session, pas un acquis. +- **On ne raisonne pas en budget.** On ne clôt pas un BC « parce que c'est long ». On le finit. +- **Les seuls survivants tolérés** sont les mutants prouvablement **équivalents** (ex. `ArgumentNullException.ThrowIfNull` sur un chemin où un `null` lèverait de toute façon, garde rendue redondante par une validation suivante, `ThrowIfCancellationRequested`). Chaque survivant toléré doit être justifiable en une phrase. +- **Préférence sociable** : couvrir la logique atteignable via les use cases avec des tests parlant métier. Les tests VO/aggregate directs sont réservés aux invariants de frontière (VOs construits hors use case, méthodes d'aggregate sans use case pilote). +- **Décisions de scope** (exclure de `stryker-config.json`, supprimer une garde défensive non justifiée) sont permises quand elles sont documentées — jamais pour gonfler artificiellement le score. + --- ## Code Style diff --git a/src/Analysis/Analysis.Application.UnitTests/Domain/AnalysisBreakdownVoTests.cs b/src/Analysis/Analysis.Application.UnitTests/Domain/AnalysisBreakdownVoTests.cs new file mode 100644 index 00000000..f5baad37 --- /dev/null +++ b/src/Analysis/Analysis.Application.UnitTests/Domain/AnalysisBreakdownVoTests.cs @@ -0,0 +1,240 @@ +namespace Analysis.Application.UnitTests.Domain; + +public sealed class AnalysisBreakdownVoTests +{ + private static TargetBreakdown Target() => + TargetBreakdown.Create("guid-1", "Boss", BreakdownMetric.DamageDone, + totalAmount: 1000, overhealing: 0, hitCount: 10, critCount: 3, + minAmount: 50, maxAmount: 200).Value!; + + [Fact] + public void Should_CreateTargetBreakdown_When_Valid() + { + var breakdown = Target(); + breakdown.TargetName.Should().Be("Boss"); + breakdown.AverageAmount.Should().Be(100d); + } + + [Fact] + public void Should_DefaultName_When_TargetNameNull() + { + TargetBreakdown.Create("guid-1", null!, BreakdownMetric.DamageDone, 0, 0, 0, 0, 0, 0) + .Value!.TargetName.Should().BeEmpty(); + } + + [Fact] + public void Should_ReturnZeroAverage_When_TargetHitCountZero() + { + TargetBreakdown.Create("guid-1", "B", BreakdownMetric.DamageDone, 100, 0, 0, 0, 0, 0) + .Value!.AverageAmount.Should().Be(0d); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Should_RejectTargetBreakdown_When_GuidBlank(string guid) + { + TargetBreakdown.Create(guid, "B", BreakdownMetric.DamageDone, 0, 0, 0, 0, 0, 0) + .IsFailure.Should().BeTrue(); + } + + [Fact] + public void Should_RejectTargetBreakdown_When_MetricUndefined() + { + TargetBreakdown.Create("g", "B", (BreakdownMetric)99, 0, 0, 0, 0, 0, 0).IsFailure.Should().BeTrue(); + } + + [Theory] + [InlineData(-1, 0, 0, 0, 0, 0)] + [InlineData(0, -1, 0, 0, 0, 0)] + [InlineData(0, 0, -1, 0, 0, 0)] + [InlineData(0, 0, 0, -1, 0, 0)] + [InlineData(0, 0, 0, 0, -1, 0)] + [InlineData(0, 0, 0, 0, 0, -1)] + public void Should_RejectTargetBreakdown_When_AnyFieldNegative( + long total, long over, int hit, int crit, long min, long max) + { + TargetBreakdown.Create("g", "B", BreakdownMetric.DamageDone, total, over, hit, crit, min, max) + .IsFailure.Should().BeTrue(); + } + + [Fact] + public void Should_RejectTargetBreakdown_When_CritExceedsHitOrMaxBelowMin() + { + TargetBreakdown.Create("g", "B", BreakdownMetric.DamageDone, 0, 0, 2, 3, 0, 0).IsFailure.Should().BeTrue(); + TargetBreakdown.Create("g", "B", BreakdownMetric.DamageDone, 0, 0, 5, 1, 100, 50).IsFailure.Should().BeTrue(); + } + + [Fact] + public void Should_DistinguishByEachComponent_When_TargetBreakdownEquality() + { + Target().Should().Be(Target()); + Target().Should().NotBe(TargetBreakdown.Create("guid-2", "Boss", BreakdownMetric.DamageDone, 1000, 0, 10, 3, 50, 200).Value!); + Target().Should().NotBe(TargetBreakdown.Create("guid-1", "Other", BreakdownMetric.DamageDone, 1000, 0, 10, 3, 50, 200).Value!); + Target().Should().NotBe(TargetBreakdown.Create("guid-1", "Boss", BreakdownMetric.HealingDone, 1000, 0, 10, 3, 50, 200).Value!); + Target().Should().NotBe(TargetBreakdown.Create("guid-1", "Boss", BreakdownMetric.DamageDone, 999, 0, 10, 3, 50, 200).Value!); + Target().Should().NotBe(TargetBreakdown.Create("guid-1", "Boss", BreakdownMetric.DamageDone, 1000, 1, 10, 3, 50, 200).Value!); + Target().Should().NotBe(TargetBreakdown.Create("guid-1", "Boss", BreakdownMetric.DamageDone, 1000, 0, 11, 3, 50, 200).Value!); + Target().Should().NotBe(TargetBreakdown.Create("guid-1", "Boss", BreakdownMetric.DamageDone, 1000, 0, 10, 4, 50, 200).Value!); + Target().Should().NotBe(TargetBreakdown.Create("guid-1", "Boss", BreakdownMetric.DamageDone, 1000, 0, 10, 3, 60, 200).Value!); + Target().Should().NotBe(TargetBreakdown.Create("guid-1", "Boss", BreakdownMetric.DamageDone, 1000, 0, 10, 3, 50, 201).Value!); + } + + private static SpellBreakdown Spell() => + SpellBreakdown.Create(12345, "Kick", BreakdownMetric.DamageDone, 1000, 0, 10, 3, 50, 200).Value!; + + [Fact] + public void Should_CreateSpellBreakdown_When_Valid() + { + Spell().AverageAmount.Should().Be(100d); + } + + [Fact] + public void Should_ReturnZeroAverage_When_SpellHitCountZero() + { + SpellBreakdown.Create(1, "K", BreakdownMetric.DamageDone, 100, 0, 0, 0, 0, 0).Value!.AverageAmount.Should().Be(0d); + } + + [Fact] + public void Should_RejectSpellBreakdown_When_MetricUndefinedOrInvariantsBroken() + { + SpellBreakdown.Create(1, "K", (BreakdownMetric)99, 0, 0, 0, 0, 0, 0).IsFailure.Should().BeTrue(); + SpellBreakdown.Create(1, "K", BreakdownMetric.DamageDone, -1, 0, 0, 0, 0, 0).IsFailure.Should().BeTrue(); + SpellBreakdown.Create(1, "K", BreakdownMetric.DamageDone, 0, 0, 2, 3, 0, 0).IsFailure.Should().BeTrue(); + SpellBreakdown.Create(1, "K", BreakdownMetric.DamageDone, 0, 0, 5, 1, 100, 50).IsFailure.Should().BeTrue(); + } + + [Fact] + public void Should_DistinguishByEachComponent_When_SpellBreakdownEquality() + { + Spell().Should().Be(Spell()); + Spell().Should().NotBe(SpellBreakdown.Create(999, "Kick", BreakdownMetric.DamageDone, 1000, 0, 10, 3, 50, 200).Value!); + Spell().Should().NotBe(SpellBreakdown.Create(12345, "Pummel", BreakdownMetric.DamageDone, 1000, 0, 10, 3, 50, 200).Value!); + Spell().Should().NotBe(SpellBreakdown.Create(12345, "Kick", BreakdownMetric.HealingDone, 1000, 0, 10, 3, 50, 200).Value!); + Spell().Should().NotBe(SpellBreakdown.Create(12345, "Kick", BreakdownMetric.DamageDone, 1, 0, 10, 3, 50, 200).Value!); + } + + [Theory] + [InlineData(-1, 0, 0)] + [InlineData(0, -1, 0)] + [InlineData(0, 0, -1)] + public void Should_RejectNegative_When_PatchVersionFrom(int major, int minor, int build) + { + ((Action)(() => PatchVersion.From(major, minor, build))).Should().Throw(); + } + + [Theory] + [InlineData("")] + [InlineData("11.0")] + [InlineData("11.0.5.6")] + [InlineData("11.x.5")] + public void Should_RejectMalformed_When_PatchVersionParse(string raw) + { + ((Action)(() => PatchVersion.Parse(raw))).Should().Throw(); + } + + [Fact] + public void Should_RoundTripAndCompareByComponents_When_PatchVersion() + { + PatchVersion.Parse("11.0.5").ToString().Should().Be("11.0.5"); + PatchVersion.From(11, 0, 5).Should().Be(PatchVersion.From(11, 0, 5)); + PatchVersion.From(11, 0, 5).Should().NotBe(PatchVersion.From(12, 0, 5)); + PatchVersion.From(11, 0, 5).Should().NotBe(PatchVersion.From(11, 1, 5)); + PatchVersion.From(11, 0, 5).Should().NotBe(PatchVersion.From(11, 0, 6)); + } + + private static PlayerStat Stat() => + PlayerStat.Create("Thrall", "Stormrage", WowSpec.RestorationShaman, + totalDamage: 1000, totalHealing: 2000, totalDamageTaken: 500, + interrupts: 3, deaths: 1, activeTimeMs: 30_000, castCount: 100).Value!; + + [Fact] + public void Should_CreatePlayerStat_When_Valid() + { + var stat = Stat(); + stat.Name.Should().Be("Thrall"); + stat.DamagePerSecond(1000).Should().Be(1000d); + stat.HealingPerSecond(1000).Should().Be(2000d); + } + + [Fact] + public void Should_DefaultToEmptyBreakdowns_When_NoneProvided() + { + var stat = Stat(); + stat.SpellBreakdowns.Should().NotBeNull().And.BeEmpty(); + stat.TargetBreakdowns.Should().NotBeNull().And.BeEmpty(); + } + + [Theory] + [InlineData("", "Stormrage")] + [InlineData("Thrall", "")] + public void Should_RejectPlayerStat_When_NameOrRealmBlank(string name, string realm) + { + PlayerStat.Create(name, realm, WowSpec.RestorationShaman, 0, 0, 0, 0, 0, 0, 0).IsFailure.Should().BeTrue(); + } + + [Fact] + public void Should_RejectPlayerStat_When_SpecUndefinedOrNegativeMetric() + { + PlayerStat.Create("T", "R", (WowSpec)9999, 0, 0, 0, 0, 0, 0, 0).IsFailure.Should().BeTrue(); + PlayerStat.Create("T", "R", WowSpec.RestorationShaman, -1, 0, 0, 0, 0, 0, 0).IsFailure.Should().BeTrue(); + } + + [Fact] + public void Should_ReturnZeroRates_When_DurationNonPositive() + { + Stat().DamagePerSecond(0).Should().Be(0d); + Stat().HealingPerSecond(-1).Should().Be(0d); + Stat().ActiveTimePercent(0).Should().Be(0d); + } + + [Fact] + public void Should_ClampActiveTimePercent_When_ActiveExceedsDuration() + { + Stat().ActiveTimePercent(10_000).Should().Be(100d); + Stat().ActiveTimePercent(60_000).Should().BeApproximately(50d, 1e-9); + } + + [Fact] + public void Should_KeepProvidedBreakdowns_When_PlayerStatCreated() + { + var stat = PlayerStat.Create("Thrall", "Stormrage", WowSpec.RestorationShaman, + 1000, 2000, 500, 3, 1, 30_000, 100, + spellBreakdowns: [Spell()], + targetBreakdowns: [Target()]).Value!; + + stat.SpellBreakdowns.Should().ContainSingle(); + stat.TargetBreakdowns.Should().ContainSingle(); + } + + [Fact] + public void Should_DistinguishByEachComponent_When_PlayerStatEquality() + { + PlayerStat S(string name = "Thrall", string realm = "Stormrage", + WowSpec spec = WowSpec.RestorationShaman, long dmg = 1000, long heal = 2000, + long taken = 500, int interrupts = 3, int deaths = 1, long active = 30_000, int casts = 100) + => PlayerStat.Create(name, realm, spec, dmg, heal, taken, interrupts, deaths, active, casts).Value!; + + S().Should().Be(S()); + S().Should().NotBe(S(name: "Jaina")); + S().Should().NotBe(S(realm: "Hyjal")); + S().Should().NotBe(S(spec: WowSpec.FrostMage)); + S().Should().NotBe(S(dmg: 999)); + S().Should().NotBe(S(heal: 1999)); + S().Should().NotBe(S(taken: 499)); + S().Should().NotBe(S(interrupts: 2)); + S().Should().NotBe(S(deaths: 0)); + S().Should().NotBe(S(active: 29_000)); + S().Should().NotBe(S(casts: 99)); + } + + [Fact] + public void Should_DistinguishByRemainingComponents_When_SpellBreakdownEquality() + { + Spell().Should().NotBe(SpellBreakdown.Create(12345, "Kick", BreakdownMetric.DamageDone, 1000, 1, 10, 3, 50, 200).Value!); + Spell().Should().NotBe(SpellBreakdown.Create(12345, "Kick", BreakdownMetric.DamageDone, 1000, 0, 11, 3, 50, 200).Value!); + Spell().Should().NotBe(SpellBreakdown.Create(12345, "Kick", BreakdownMetric.DamageDone, 1000, 0, 10, 4, 50, 200).Value!); + Spell().Should().NotBe(SpellBreakdown.Create(12345, "Kick", BreakdownMetric.DamageDone, 1000, 0, 10, 3, 60, 200).Value!); + Spell().Should().NotBe(SpellBreakdown.Create(12345, "Kick", BreakdownMetric.DamageDone, 1000, 0, 10, 3, 50, 201).Value!); + } +} diff --git a/src/Analysis/Analysis.Application.UnitTests/Domain/RunAnalysisTests.cs b/src/Analysis/Analysis.Application.UnitTests/Domain/RunAnalysisTests.cs new file mode 100644 index 00000000..23cd0d49 --- /dev/null +++ b/src/Analysis/Analysis.Application.UnitTests/Domain/RunAnalysisTests.cs @@ -0,0 +1,66 @@ +using Analysis.Domain.ValueObjects; + +namespace Analysis.Application.UnitTests.Domain; + +public sealed class RunAnalysisTests +{ + private static readonly DateTimeOffset _at = new(2026, 5, 16, 12, 0, 0, TimeSpan.Zero); + + private static PlayerStat PlayerWithBreakdowns() + { + var spell = SpellBreakdown.Create(12345, "Kick", BreakdownMetric.DamageDone, 1000, 0, 10, 3, 50, 200).Value!; + var target = TargetBreakdown.Create("guid-1", "Boss", BreakdownMetric.DamageDone, 1000, 0, 10, 3, 50, 200).Value!; + return PlayerStat.Create("Thrall", "Stormrage", WowSpec.RestorationShaman, + 1000, 2000, 500, 3, 1, 30_000, 100, + spellBreakdowns: [spell], targetBreakdowns: [target]).Value!; + } + + private static RunAnalysis NewAnalysis(bool timed = true, double? percentile = 95) + => RunAnalysis.Create( + RunAnalysisId.New(), + ingestionUnitId: Guid.NewGuid(), + DungeonSlug.FromRawName("The Stonevault"), + KeyLevel.From(18).Value!, + AffixWeek.From([9, 3, 152]).Value!, + ContentDifficulty.MythicPlus, + durationMs: 1_700_000, + timed: timed, + playerStats: [PlayerWithBreakdowns()], + analyzedAt: _at, + partyDpsPercentile: percentile); + + [Fact] + public void Should_ComputeGradeAndRaiseEvent_When_Created() + { + var analysis = NewAnalysis(); + + analysis.Grade.Score.Should().Be(91); + analysis.Grade.Letter.Should().Be(RunGradeLetter.A); + analysis.DomainEvents.OfType().Should().ContainSingle(); + } + + [Fact] + public void Should_RoundtripThroughSnapshot_PreservingGradeAndBreakdowns() + { + var original = NewAnalysis(); + + var rehydrated = RunAnalysis.FromSnapshot(original.ToSnapshot()); + + rehydrated.ToSnapshot().Should().BeEquivalentTo(original.ToSnapshot()); + rehydrated.Grade.Should().Be(original.Grade); + rehydrated.Dungeon.Value.Should().Be("the-stonevault"); + rehydrated.Level.Value.Should().Be(18); + rehydrated.Difficulty.Should().Be(ContentDifficulty.MythicPlus); + + var player = rehydrated.PlayerStats.Should().ContainSingle().Subject; + player.SpellBreakdowns.Should().ContainSingle(); + player.TargetBreakdowns.Should().ContainSingle(); + } + + [Fact] + public void Should_ReflectTimedAndPercentile_InGrade() + { + NewAnalysis(timed: true, percentile: 99).Grade.Score + .Should().BeGreaterThan(NewAnalysis(timed: false, percentile: 1).Grade.Score); + } +} diff --git a/src/Analysis/Analysis.Application.UnitTests/Domain/StopAnalysisVoTests.cs b/src/Analysis/Analysis.Application.UnitTests/Domain/StopAnalysisVoTests.cs new file mode 100644 index 00000000..6bd08bce --- /dev/null +++ b/src/Analysis/Analysis.Application.UnitTests/Domain/StopAnalysisVoTests.cs @@ -0,0 +1,210 @@ +namespace Analysis.Application.UnitTests.Domain; + +public sealed class StopAnalysisVoTests +{ + // ---- RunGrade ---- + + [Fact] + public void Should_GradeHigher_When_TimedHighKeyLowDeaths() + { + var timed = RunGrade.Compute(timed: true, keyLevel: 20, totalDeaths: 0, dpsPercentile: 95); + var untimed = RunGrade.Compute(timed: false, keyLevel: 20, totalDeaths: 10, dpsPercentile: 10); + + timed.Score.Should().BeGreaterThan(untimed.Score); + timed.Letter.Should().Be(RunGradeLetter.S); + } + + [Fact] + public void Should_SanitizeInputs_When_NegativeDeathsOrOutOfRangePercentile() + { + var a = RunGrade.Compute(true, 10, totalDeaths: -5, dpsPercentile: 150); + var b = RunGrade.Compute(true, 10, totalDeaths: 0, dpsPercentile: 100); + + a.Score.Should().Be(b.Score); + } + + [Fact] + public void Should_CapDeathPenalty_When_ManyDeaths() + { + var eight = RunGrade.Compute(true, 10, totalDeaths: 8, dpsPercentile: null); + var twenty = RunGrade.Compute(true, 10, totalDeaths: 20, dpsPercentile: null); + + eight.Score.Should().Be(twenty.Score); + } + + [Theory] + [InlineData(RunGradeLetter.S, 95)] + [InlineData(RunGradeLetter.A, 85)] + [InlineData(RunGradeLetter.F, 10)] + public void Should_ReconstructGrade_When_ScoreWithinLetterRange(RunGradeLetter letter, int score) + { + RunGrade.From(letter, score).Letter.Should().Be(letter); + } + + [Fact] + public void Should_Reject_When_ScoreOutsideLetterRange() + { + ((Action)(() => RunGrade.From(RunGradeLetter.S, 50))).Should().Throw(); + } + + [Theory] + [InlineData(RunGradeLetter.S, 92)] + [InlineData(RunGradeLetter.S, 100)] + [InlineData(RunGradeLetter.A, 80)] + [InlineData(RunGradeLetter.A, 91)] + [InlineData(RunGradeLetter.F, 0)] + [InlineData(RunGradeLetter.F, 39)] + public void Should_AcceptScore_AtExactLetterBoundaries(RunGradeLetter letter, int score) + { + RunGrade.From(letter, score).Score.Should().Be(score); + } + + [Theory] + [InlineData(RunGradeLetter.S, 91)] + [InlineData(RunGradeLetter.A, 79)] + [InlineData(RunGradeLetter.A, 92)] + [InlineData(RunGradeLetter.F, 40)] + public void Should_RejectScore_JustOutsideLetterBoundaries(RunGradeLetter letter, int score) + { + ((Action)(() => RunGrade.From(letter, score))).Should().Throw(); + } + + [Fact] + public void Should_CompareByLetterAndScore_When_RunGradeEquality() + { + RunGrade.From(RunGradeLetter.A, 85).Should().Be(RunGrade.From(RunGradeLetter.A, 85)); + RunGrade.From(RunGradeLetter.A, 85).Should().NotBe(RunGrade.From(RunGradeLetter.A, 86)); + RunGrade.From(RunGradeLetter.A, 85).Should().NotBe(RunGrade.From(RunGradeLetter.B, 75)); + } + + // ---- ClassifiedCast ---- + + private static ClassifiedCast Cast( + int spellId = 12345, string source = "Boss", long start = 1000, long? finish = 2000, + bool interruptible = true, bool dangerous = true, bool stopped = false, int? mobId = 555) => + ClassifiedCast.Create(spellId, source, start, finish, interruptible, dangerous, stopped, mobId).Value!; + + [Theory] + [InlineData(0, "Boss", 1000L, 555)] + [InlineData(12345, "", 1000L, 555)] + [InlineData(12345, "Boss", -1L, 555)] + [InlineData(12345, "Boss", 1000L, 0)] + public void Should_RejectClassifiedCast_When_FieldsInvalid(int spellId, string source, long start, int mobId) + { + ClassifiedCast.Create(spellId, source, start, finishTimeMs: start + 100, true, true, false, mobId) + .IsFailure.Should().BeTrue(); + } + + [Fact] + public void Should_RejectClassifiedCast_When_FinishBeforeStart() + { + ClassifiedCast.Create(1, "Boss", 1000, finishTimeMs: 999, true, true, false).IsFailure.Should().BeTrue(); + } + + [Fact] + public void Should_FlagCompletion_When_FinishTimePresent() + { + Cast(finish: 2000).IsCompleted.Should().BeTrue(); + Cast(finish: null).IsCompleted.Should().BeFalse(); + } + + [Fact] + public void Should_DistinguishByEachComponent_When_ClassifiedCastEquality() + { + Cast().Should().Be(Cast()); + Cast().Should().NotBe(Cast(spellId: 999)); + Cast().Should().NotBe(Cast(source: "Other")); + Cast().Should().NotBe(Cast(mobId: 556)); + Cast().Should().NotBe(Cast(start: 1001)); + Cast().Should().NotBe(Cast(finish: 2001)); + Cast().Should().NotBe(Cast(interruptible: false)); + Cast().Should().NotBe(Cast(dangerous: false)); + Cast().Should().NotBe(Cast(stopped: true)); + } + + // ---- StopAction ---- + + private static StopAction Stop( + StopKind kind = StopKind.Kick, string player = "Thrall", int spellId = 47528, + long ts = 1500, StopJusteness just = StopJusteness.Correct, + StopEffectiveness eff = StopEffectiveness.Successful) => + StopAction.Create(kind, player, spellId, ts, Cast(), just, eff).Value!; + + [Fact] + public void Should_RejectStopAction_When_FieldsInvalid() + { + StopAction.Create((StopKind)9, "T", 1, 0, null, StopJusteness.Correct, StopEffectiveness.Successful).IsFailure.Should().BeTrue(); + StopAction.Create(StopKind.Kick, " ", 1, 0, null, StopJusteness.Correct, StopEffectiveness.Successful).IsFailure.Should().BeTrue(); + StopAction.Create(StopKind.Kick, "T", 0, 0, null, StopJusteness.Correct, StopEffectiveness.Successful).IsFailure.Should().BeTrue(); + StopAction.Create(StopKind.Kick, "T", 1, -1, null, StopJusteness.Correct, StopEffectiveness.Successful).IsFailure.Should().BeTrue(); + StopAction.Create(StopKind.Kick, "T", 1, 0, null, (StopJusteness)9, StopEffectiveness.Successful).IsFailure.Should().BeTrue(); + StopAction.Create(StopKind.Kick, "T", 1, 0, null, StopJusteness.Correct, (StopEffectiveness)9).IsFailure.Should().BeTrue(); + } + + [Fact] + public void Should_AllowNullTargetCast_When_StopActionCreated() + { + StopAction.Create(StopKind.CrowdControl, "Thrall", 47528, 1500, null, StopJusteness.Acceptable, StopEffectiveness.Immune) + .IsSuccess.Should().BeTrue(); + } + + [Fact] + public void Should_DistinguishByEachComponent_When_StopActionEquality() + { + Stop().Should().Be(Stop()); + Stop().Should().NotBe(Stop(kind: StopKind.CrowdControl)); + Stop().Should().NotBe(Stop(player: "Jaina")); + Stop().Should().NotBe(Stop(spellId: 47529)); + Stop().Should().NotBe(Stop(ts: 1501)); + Stop().Should().NotBe(Stop(just: StopJusteness.WastedKick)); + Stop().Should().NotBe(Stop(eff: StopEffectiveness.Immune)); + } + + // ---- MissedOpportunity ---- + + [Fact] + public void Should_CreateMissedOpportunity_When_DangerousUnstoppedCastWithReadyPlayers() + { + var result = MissedOpportunity.Create( + Cast(dangerous: true, stopped: false), ["Thrall", " ", "Jaina"]); + + var missed = ThenResultIsSuccess(result); + missed.PlayersWithToolReady.Should().Equal("Thrall", "Jaina"); + } + + [Fact] + public void Should_RejectMissedOpportunity_When_CastNotDangerous() + { + MissedOpportunity.Create(Cast(dangerous: false), ["Thrall"]).IsFailure.Should().BeTrue(); + } + + [Fact] + public void Should_RejectMissedOpportunity_When_CastWasStopped() + { + MissedOpportunity.Create(Cast(dangerous: true, stopped: true), ["Thrall"]).IsFailure.Should().BeTrue(); + } + + [Fact] + public void Should_RejectMissedOpportunity_When_NoReadyPlayers() + { + MissedOpportunity.Create(Cast(dangerous: true, stopped: false), [" "]).IsFailure.Should().BeTrue(); + } + + [Fact] + public void Should_RejectMissedOpportunity_When_PlayerListIsNull() + { + MissedOpportunity.Create(Cast(dangerous: true, stopped: false), null!).IsFailure.Should().BeTrue(); + } + + [Fact] + public void Should_DistinguishByCastAndPlayers_When_MissedOpportunityEquality() + { + MissedOpportunity Miss(int spellId, params string[] players) => + MissedOpportunity.Create(Cast(spellId: spellId, dangerous: true, stopped: false), players).Value!; + + Miss(99, "Thrall", "Jaina").Should().Be(Miss(99, "Thrall", "Jaina")); + Miss(99, "Thrall").Should().NotBe(Miss(98, "Thrall")); + Miss(99, "Thrall").Should().NotBe(Miss(99, "Jaina")); + Miss(99, "Thrall").Should().NotBe(Miss(99, "Thrall", "Jaina")); + } +} diff --git a/src/Analysis/Analysis.Application.UnitTests/Domain/StopReportTests.cs b/src/Analysis/Analysis.Application.UnitTests/Domain/StopReportTests.cs index a6a5df9f..acea7c9a 100644 --- a/src/Analysis/Analysis.Application.UnitTests/Domain/StopReportTests.cs +++ b/src/Analysis/Analysis.Application.UnitTests/Domain/StopReportTests.cs @@ -87,6 +87,51 @@ public void Counters_Should_AggregateAcrossStopKinds() report.OvercappedStopCount.Should().Be(0); } + [Fact] + public void Counters_Should_DistinguishEachCategory_When_CountsAreAsymmetric() + { + var stops = new[] + { + MakeStop(StopKind.Kick, StopJusteness.Correct, StopEffectiveness.Successful), + MakeStop(StopKind.Kick, StopJusteness.Correct, StopEffectiveness.Successful), + MakeStop(StopKind.Kick, StopJusteness.Correct, StopEffectiveness.Successful), + MakeStop(StopKind.Kick, StopJusteness.WastedKick, StopEffectiveness.LateNoEffect), + MakeStop(StopKind.CrowdControl, StopJusteness.Acceptable, StopEffectiveness.OvercappedStop), + }; + + var report = StopReport.Create(StopReportId.New(), Guid.NewGuid(), + DateTimeOffset.UtcNow, "v1", stops, []).Value!; + + report.TotalStops.Should().Be(5); + report.KickCount.Should().Be(4); + report.CrowdControlCount.Should().Be(1); + report.CorrectStopCount.Should().Be(3); + report.WastedKickCount.Should().Be(1); + report.AcceptableStopCount.Should().Be(1); + report.SuccessfulStopCount.Should().Be(3); + report.LateNoEffectCount.Should().Be(1); + report.ImmuneCount.Should().Be(0); + report.OvercappedStopCount.Should().Be(1); + } + + [Fact] + public void Snapshot_Should_MapStopActionFieldsFaithfully() + { + var stop = StopAction.Create(StopKind.CrowdControl, "Thrall", 6552, 2_400L, null, + StopJusteness.WastedKick, StopEffectiveness.OvercappedStop).Value!; + + var snapshot = StopReport.Create(StopReportId.New(), Guid.NewGuid(), + DateTimeOffset.UtcNow, "v1", [stop], []).Value!.ToSnapshot(); + + var mapped = snapshot.Stops.Should().ContainSingle().Subject; + mapped.Kind.Should().Be((int)StopKind.CrowdControl); + mapped.SourcePlayerName.Should().Be("Thrall"); + mapped.SourceSpellId.Should().Be(6552); + mapped.TimestampMs.Should().Be(2_400L); + mapped.Justeness.Should().Be((int)StopJusteness.WastedKick); + mapped.Effectiveness.Should().Be((int)StopEffectiveness.OvercappedStop); + } + [Fact] public void MissedOpportunityCount_Should_MatchProvidedList() { From 2d483a32f4e6a601e843282576b81603828341f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 23:01:30 +0000 Subject: [PATCH 11/18] test(spell-knowledge): Domain mutation 65%->91%, Application 92% Completes coverage of the Spell and WagoSpellRecord aggregates (Create/Annotate/ SyncFromBlizzard branches, provenance defaults, tag dedup, annotated-event field mapping, FromSnapshot re-validation including zero-boundary durations) and the SpellSchool / SpellAttributes / AffixCatalog value objects (code+name resolution, bit-decode boundaries, defensive group copy, known-vs-synthetic affix lookup). Kills the ArgumentNullException.ThrowIfNull guards by asserting the precise exception type (a removed guard surfaces as NullReferenceException, a different type) rather than treating them as equivalent. Remaining survivors are genuinely equivalent (HasFlag bounds whose fallback indexer throws the same exception type). --- .../Domain/SpellKnowledgeAggregateTests.cs | 292 ++++++++++++++++++ 1 file changed, 292 insertions(+) create mode 100644 src/SpellKnowledge/SpellKnowledge.Application.UnitTests/Spells/Domain/SpellKnowledgeAggregateTests.cs diff --git a/src/SpellKnowledge/SpellKnowledge.Application.UnitTests/Spells/Domain/SpellKnowledgeAggregateTests.cs b/src/SpellKnowledge/SpellKnowledge.Application.UnitTests/Spells/Domain/SpellKnowledgeAggregateTests.cs new file mode 100644 index 00000000..ada3ab49 --- /dev/null +++ b/src/SpellKnowledge/SpellKnowledge.Application.UnitTests/Spells/Domain/SpellKnowledgeAggregateTests.cs @@ -0,0 +1,292 @@ +using Wow.Kernel.Shared; + +namespace SpellKnowledge.Application.UnitTests.Spells.Domain; + +public sealed class SpellKnowledgeAggregateTests +{ + private static readonly DateTimeOffset _at = new(2026, 5, 16, 12, 0, 0, TimeSpan.Zero); + + // ---- SpellSchool ---- + + [Theory] + [InlineData(-1)] + [InlineData(7)] + public void Should_RejectUnknownCode_When_SpellSchoolFromCode(int code) + { + ((Action)(() => SpellSchool.FromCode(code))).Should().Throw(); + } + + [Fact] + public void Should_MapEveryCode_When_SpellSchoolFromCode() + { + SpellSchool.FromCode(0).Should().Be(SpellSchool.Physical); + SpellSchool.FromCode(6).Should().Be(SpellSchool.Arcane); + } + + [Theory] + [InlineData("physical")] + [InlineData("HOLY")] + [InlineData(" Fire ")] + [InlineData("nature")] + [InlineData("frost")] + [InlineData("shadow")] + [InlineData("arcane")] + public void Should_ResolveKnownName_When_SpellSchoolFromName(string name) + { + SpellSchool.FromName(name).Should().NotBeNull(); + } + + [Fact] + public void Should_ThrowOrReturnFalse_When_SpellSchoolNameUnknown() + { + ((Action)(() => SpellSchool.FromName("chaos"))).Should().Throw(); + SpellSchool.TryFromName("chaos", out _).Should().BeFalse(); + SpellSchool.TryFromName(null, out _).Should().BeFalse(); + } + + [Fact] + public void Should_CompareByCode_When_SpellSchool() + { + SpellSchool.FromCode(2).Should().Be(SpellSchool.Fire); + SpellSchool.Fire.Should().NotBe(SpellSchool.Frost); + } + + // ---- SpellAttributes bounds ---- + + [Fact] + public void Should_AcceptBoundaryGroupAndBit_And_RejectOutOfRange() + { + var attrs = SpellAttributes.Empty(); + + ((Action)(() => attrs.HasFlag(0, 0))).Should().NotThrow(); + ((Action)(() => attrs.HasFlag(7, 0))).Should().NotThrow(); + ((Action)(() => attrs.HasFlag(7, 31))).Should().NotThrow(); + ((Action)(() => attrs.HasFlag(SpellAttributes.DefaultAttributeGroupCount, 0))) + .Should().Throw(); + } + + [Fact] + public void Should_ThrowArgumentNull_When_SpellAttributesFromNull() + { + ((Action)(() => SpellAttributes.From(null!))).Should().Throw(); + ((Action)(() => SpellAttributes.DecodeIsInterruptible(null!))).Should().Throw(); + } + + [Fact] + public void Should_ThrowArgumentNull_When_SpellCreateSchoolNull() + { + ((Action)(() => Spell.Create(BlizzardSpellId.From(1), PatchVersion.From(11, 0, 5), + SpellName.From("X"), null!, _at))).Should().Throw(); + } + + [Fact] + public void Should_ThrowArgumentNull_When_WagoCreateRequiredArgNull() + { + var pv = PatchVersion.From(11, 0, 5); + var name = SpellName.From("Kick"); + var attrs = SpellAttributes.Empty(); + var bid = BlizzardSpellId.From(1); + + ((Action)(() => WagoSpellRecord.Create(null!, pv, name, SpellSchoolMask.Physical, SpellMechanic.None, attrs, null, null, _at))).Should().Throw(); + ((Action)(() => WagoSpellRecord.Create(bid, null!, name, SpellSchoolMask.Physical, SpellMechanic.None, attrs, null, null, _at))).Should().Throw(); + ((Action)(() => WagoSpellRecord.Create(bid, pv, null!, SpellSchoolMask.Physical, SpellMechanic.None, attrs, null, null, _at))).Should().Throw(); + ((Action)(() => WagoSpellRecord.Create(bid, pv, name, SpellSchoolMask.Physical, SpellMechanic.None, null!, null, null, _at))).Should().Throw(); + } + + [Fact] + public void Should_CopyInputGroups_When_SpellAttributesFrom() + { + var groups = new long[8]; + var attrs = SpellAttributes.From(groups); + groups[7] = 1L << 11; + + attrs.IsInterruptible.Should().BeFalse(); + } + + [Fact] + public void Should_DecodeInterruptible_AtGroupCountBoundary() + { + var eightWithBit = new long[8]; + eightWithBit[7] = 1L << 11; + SpellAttributes.DecodeIsInterruptible(eightWithBit).Should().BeTrue(); + + var eightWithoutBit = new long[8]; + SpellAttributes.DecodeIsInterruptible(eightWithoutBit).Should().BeFalse(); + + SpellAttributes.DecodeIsInterruptible(new long[7]).Should().BeFalse(); + } + + // ---- WagoSpellRecord FromSnapshot re-validation ---- + + private static WagoSpellRecordSnapshot WagoSnapshot( + int mechanic = (int)SpellMechanic.None, long? durationMs = 1000, long? cooldownMs = 2000) => + new(Guid.NewGuid(), 12345, "11.0.5", "Kick", (byte)SpellSchoolMask.Physical, + mechanic, new long[15], durationMs, cooldownMs, _at); + + [Fact] + public void Should_RoundtripValidSnapshot_When_WagoSpellRecordRehydrated() + { + WagoSpellRecord.FromSnapshot(WagoSnapshot()).BlizzardSpellId.Value.Should().Be(12345); + } + + [Fact] + public void Should_AcceptZeroDurationAndCooldown_When_WagoSpellRecordRehydrated() + { + var record = WagoSpellRecord.FromSnapshot(WagoSnapshot(durationMs: 0, cooldownMs: 0)); + + record.Duration.Should().Be(TimeSpan.Zero); + record.Cooldown.Should().Be(TimeSpan.Zero); + } + + [Fact] + public void Should_WrapValueAndCompare_When_WagoSpellRecordId() + { + ((Action)(() => WagoSpellRecordId.From(Guid.Empty))).Should().Throw(); + var guid = Guid.NewGuid(); + WagoSpellRecordId.From(guid).Should().Be(WagoSpellRecordId.From(guid)); + WagoSpellRecordId.Create().Should().NotBe(WagoSpellRecordId.Create()); + } + + [Fact] + public void Should_ThrowOnRehydration_When_MechanicUndefined() + { + ((Action)(() => WagoSpellRecord.FromSnapshot(WagoSnapshot(mechanic: 9999)))) + .Should().Throw(); + } + + [Fact] + public void Should_ThrowOnRehydration_When_DurationOrCooldownNegative() + { + ((Action)(() => WagoSpellRecord.FromSnapshot(WagoSnapshot(durationMs: -1)))).Should().Throw(); + ((Action)(() => WagoSpellRecord.FromSnapshot(WagoSnapshot(cooldownMs: -1)))).Should().Throw(); + } + + // ---- Spell aggregate ---- + + private static Spell NewSpell(Provenance? provenance = null) => + Spell.Create( + BlizzardSpellId.From(47528), + PatchVersion.From(11, 0, 5), + SpellName.From("Pummel"), + SpellSchool.Physical, + _at, + baseCooldown: TimeSpan.FromSeconds(15), + isInterruptible: false, + provenance: provenance).Value!; + + [Fact] + public void Should_RejectNegativeCooldown_When_SpellCreate() + { + Spell.Create(BlizzardSpellId.From(1), PatchVersion.From(11, 0, 5), SpellName.From("X"), + SpellSchool.Fire, _at, baseCooldown: TimeSpan.FromSeconds(-1)).IsFailure.Should().BeTrue(); + } + + [Fact] + public void Should_RaiseRegisteredEvent_When_SpellCreated() + { + NewSpell().DomainEvents.OfType().Should().ContainSingle(); + } + + [Fact] + public void Should_DefaultToManualProvenance_When_NoneProvided() + { + NewSpell().Provenance.Source.Should().Be(ProvenanceSource.Manual); + } + + [Fact] + public void Should_KeepProvidedProvenance_When_ExplicitlySupplied() + { + NewSpell(Provenance.Blizzard(_at)).Provenance.Source.Should().Be(ProvenanceSource.BlizzardApi); + } + + [Fact] + public void Should_RejectInvalidTierOrCategory_When_Annotate() + { + NewSpell().Annotate((DangerTier)99, null, [], _at).IsFailure.Should().BeTrue(); + NewSpell().Annotate(null, (DiminishingReturnsCategory)99, [], _at).IsFailure.Should().BeTrue(); + } + + [Fact] + public void Should_SetAnnotationsDedupeTagsAndRaiseEvent_When_Annotate() + { + var spell = NewSpell(); + + var result = spell.Annotate( + DangerTier.Lethal, DiminishingReturnsCategory.Stun, + [SpellTag.From("aoe-stun"), SpellTag.From("aoe-stun"), SpellTag.From("raid-cd")], + _at); + + ThenResultIsSuccess(result); + spell.DangerTier.Should().Be(DangerTier.Lethal); + spell.DrCategory.Should().Be(DiminishingReturnsCategory.Stun); + spell.Tags.Should().HaveCount(2); + var evt = spell.DomainEvents.OfType().Should().ContainSingle().Subject; + evt.DangerTier.Should().Be((int)DangerTier.Lethal); + evt.DrCategory.Should().Be((int)DiminishingReturnsCategory.Stun); + evt.Tags.Should().BeEquivalentTo("aoe-stun", "raid-cd"); + } + + [Fact] + public void Should_EmitNullTierAndCategory_When_AnnotatedWithoutThem() + { + var spell = NewSpell(); + + spell.Annotate(null, null, [SpellTag.From("kickable-cast")], _at); + + var evt = spell.DomainEvents.OfType().Should().ContainSingle().Subject; + evt.DangerTier.Should().BeNull(); + evt.DrCategory.Should().BeNull(); + } + + [Fact] + public void Should_RefuseSync_When_SpellManuallyOverridden() + { + var spell = NewSpell(Provenance.Manual(_at)); + + var result = spell.SyncFromBlizzard( + SpellName.From("New"), SpellSchool.Fire, TimeSpan.FromSeconds(10), true, Provenance.Blizzard(_at)); + + ThenResultIsFailureWithError(result, SpellKnowledgeErrors.Spell.ManualOverrideProtected); + } + + [Fact] + public void Should_ThrowArgumentNull_When_SyncSchoolNull() + { + var spell = NewSpell(Provenance.Blizzard(_at)); + + ((Action)(() => spell.SyncFromBlizzard(SpellName.From("X"), null!, null, false, Provenance.Blizzard(_at)))) + .Should().Throw(); + } + + [Fact] + public void Should_ResolveKnownAndSyntheticAffixes() + { + AffixCatalog.Resolve(9).Name.Should().Be("Tyrannical"); + AffixCatalog.Resolve(999999).Name.Should().Be("Affix #999999"); + } + + [Fact] + public void Should_RejectNegativeCooldown_When_SyncFromBlizzard() + { + var spell = NewSpell(Provenance.Blizzard(_at)); + + spell.SyncFromBlizzard(SpellName.From("New"), SpellSchool.Fire, TimeSpan.FromSeconds(-1), true, Provenance.Blizzard(_at)) + .IsFailure.Should().BeTrue(); + } + + [Fact] + public void Should_UpdateBlizzardFields_When_SyncSucceeds() + { + var spell = NewSpell(Provenance.Blizzard(_at)); + + var result = spell.SyncFromBlizzard( + SpellName.From("Counterspell"), SpellSchool.Arcane, TimeSpan.FromSeconds(24), true, Provenance.Blizzard(_at)); + + ThenResultIsSuccess(result); + spell.Name.Value.Should().Be("Counterspell"); + spell.School.Should().Be(SpellSchool.Arcane); + spell.IsInterruptible.Should().BeTrue(); + spell.BaseCooldown.Should().Be(TimeSpan.FromSeconds(24)); + spell.Provenance.Source.Should().Be(ProvenanceSource.BlizzardApi); + } +} From 7cd28a1182f9e3686affc999c5b6e8e11558de58 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 23:05:09 +0000 Subject: [PATCH 12/18] test(log-ingestion): Domain mutation 82%->90% Covers DungeonRun Complete/Abandon outcome + event payloads (timed vs depleted, keystone affix ids, abandoned has no completion), visibility-change idempotence, mixed-keystone rejection; OwnerUserId/GuildId guards + equality; UploadJournalEntry null-arg guards and corrupt-snapshot rejection; RunPlayer name-length boundaries and per-component equality; SeasonSlug diacritic/apostrophe normalization, hyphen collapse and length limits; ShareToken url-safe character boundaries. --- .../Domain/LogIngestionDomainCoverageTests.cs | 202 ++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 src/LogIngestion/LogIngestion.Application.UnitTests/Domain/LogIngestionDomainCoverageTests.cs diff --git a/src/LogIngestion/LogIngestion.Application.UnitTests/Domain/LogIngestionDomainCoverageTests.cs b/src/LogIngestion/LogIngestion.Application.UnitTests/Domain/LogIngestionDomainCoverageTests.cs new file mode 100644 index 00000000..78c1986f --- /dev/null +++ b/src/LogIngestion/LogIngestion.Application.UnitTests/Domain/LogIngestionDomainCoverageTests.cs @@ -0,0 +1,202 @@ +using LogIngestion.Domain.Aggregates; + +namespace LogIngestion.Application.UnitTests.Domain; + +public sealed class LogIngestionDomainCoverageTests +{ + private static readonly DateTimeOffset _at = new(2026, 5, 16, 12, 0, 0, TimeSpan.Zero); + + private static RunPlayer Player(string name = "Thrall", string realm = "stormrage", WowSpec spec = WowSpec.RestorationShaman) => + RunPlayer.Create(name, realm, spec).Value!; + + private static DungeonRun KeystoneRun() => + DungeonRun.Create( + DungeonRunId.New(), + DungeonSlug.FromRawName("The Stonevault"), + KeyLevel.From(18).Value!, + AffixWeek.From([9, 10, 152]).Value!, + ContentDifficulty.MythicPlus, + [Player()], + _at, + instanceId: 2649); + + // ---- DungeonRun.Complete / Abandon ---- + + [Fact] + public void Should_MarkTimed_When_CompletedInTime() + { + var run = KeystoneRun(); + + run.Complete(timed: true, durationMs: 1_500_000, completedAt: _at.AddMinutes(25)); + + var evt = run.DomainEvents.OfType() + .Should().ContainSingle().Subject; + evt.Timed.Should().BeTrue(); + evt.KeyLevel.Should().Be(18); + evt.AffixIds.Should().Equal(9, 10, 152); + evt.DurationMs.Should().Be(1_500_000); + run.ToSnapshot().Timed.Should().BeTrue(); + } + + [Fact] + public void Should_MarkDepleted_When_CompletedOverTime() + { + var run = KeystoneRun(); + + run.Complete(timed: false, durationMs: 2_000_000, completedAt: _at.AddMinutes(35)); + + run.ToSnapshot().Timed.Should().BeFalse(); + run.DomainEvents.OfType() + .Single().Timed.Should().BeFalse(); + } + + [Fact] + public void Should_MarkAbandonedWithoutCompletion_When_Abandoned() + { + var run = KeystoneRun(); + + run.Abandon(endedAt: _at.AddMinutes(10), durationMs: 600_000); + + var snapshot = run.ToSnapshot(); + snapshot.CompletedAt.Should().BeNull(); + snapshot.DurationMs.Should().Be(600_000); + run.DomainEvents.OfType() + .Should().ContainSingle().Which.KeyLevel.Should().Be(18); + } + + [Fact] + public void Should_RaiseEventOnlyOnRealChange_When_VisibilityChanged() + { + var run = KeystoneRun(); + + run.ChangeVisibility(LogVisibility.Public); + run.DomainEvents.OfType().Should().BeEmpty(); + + run.ChangeVisibility(LogVisibility.Private); + run.DomainEvents.OfType().Should().ContainSingle(); + } + + [Fact] + public void Should_RejectMixedKeystoneState_When_DungeonRunCreated() + { + ((Action)(() => DungeonRun.Create(DungeonRunId.New(), DungeonSlug.FromRawName("X"), + KeyLevel.From(18).Value!, null, ContentDifficulty.MythicPlus, [Player()], _at, 1))) + .Should().Throw(); + } + + // ---- OwnerUserId / GuildId ---- + + [Fact] + public void Should_RejectEmptyAndCompareByValue_When_OwnerUserId() + { + ((Action)(() => OwnerUserId.From(Guid.Empty))).Should().Throw(); + var guid = Guid.NewGuid(); + OwnerUserId.From(guid).Should().Be(OwnerUserId.From(guid)); + OwnerUserId.From(Guid.NewGuid()).Should().NotBe(OwnerUserId.From(Guid.NewGuid())); + } + + [Fact] + public void Should_RejectEmptyAndCompareByValue_When_GuildId() + { + ((Action)(() => GuildId.From(Guid.Empty))).Should().Throw(); + var guid = Guid.NewGuid(); + GuildId.From(guid).Should().Be(GuildId.From(guid)); + GuildId.From(Guid.NewGuid()).Should().NotBe(GuildId.From(Guid.NewGuid())); + } + + // ---- UploadJournalEntry null + corrupt snapshot ---- + + private static SegmentHash Hash() => SegmentHash.From(new string('a', 64)); + + [Fact] + public void Should_ThrowArgumentNull_When_UploadJournalEntryRequiredArgNull() + { + ((Action)(() => UploadJournalEntry.Create(null!, Hash(), Guid.NewGuid(), IngestedSegmentKind.Raid, _at))) + .Should().Throw(); + ((Action)(() => UploadJournalEntry.Create(OwnerUserId.From(Guid.NewGuid()), null!, Guid.NewGuid(), IngestedSegmentKind.Raid, _at))) + .Should().Throw(); + } + + [Fact] + public void Should_RejectCorruptSnapshot_When_KindUndefinedOrTimestampDefault() + { + var baseSnap = new LogIngestion.Domain.Snapshots.UploadJournalEntrySnapshot( + Guid.NewGuid(), Guid.NewGuid(), new string('a', 64), Guid.NewGuid(), (int)IngestedSegmentKind.Raid, _at); + + ((Action)(() => UploadJournalEntry.FromSnapshot(baseSnap with { Kind = 99 }))).Should().Throw(); + ((Action)(() => UploadJournalEntry.FromSnapshot(baseSnap with { IngestedAt = default }))).Should().Throw(); + } + + // ---- RunPlayer ---- + + [Theory] + [InlineData("a")] // shorter than min (2) + [InlineData("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")] // 33 > max (32) + public void Should_RejectName_When_OutsideLengthBounds(string name) + { + RunPlayer.Create(name, "stormrage", WowSpec.FrostMage).IsFailure.Should().BeTrue(); + } + + [Fact] + public void Should_AcceptName_AtLengthBoundaries() + { + RunPlayer.Create("ab", "stormrage", WowSpec.FrostMage).IsSuccess.Should().BeTrue(); + RunPlayer.Create(new string('a', 32), "stormrage", WowSpec.FrostMage).IsSuccess.Should().BeTrue(); + } + + [Fact] + public void Should_DistinguishByEachComponent_When_RunPlayerEquality() + { + Player().Should().Be(Player()); + Player().Should().NotBe(Player(name: "Jaina")); + Player().Should().NotBe(Player(realm: "hyjal")); + Player().Should().NotBe(Player(spec: WowSpec.FrostMage)); + Player().Should().NotBe(RunPlayer.Create("Thrall", "stormrage", WowSpec.RestorationShaman, + new PlayerLoadout([new EquippedItem(1, 600)], [])).Value!); + } + + // ---- SeasonSlug ---- + + [Fact] + public void Should_NormalizeDiacriticsApostrophesAndSpaces_When_SeasonSlugFromRawName() + { + SeasonSlug.FromRawName("Tarrac Frozén — L'Île").Value + .Should().MatchRegex("^[a-z0-9]+(-[a-z0-9]+)*$"); + } + + [Fact] + public void Should_CollapseConsecutiveSeparators_When_SeasonSlug() + { + SeasonSlug.FromRawName("A &&& B").Value.Should().Be("a-b"); + } + + [Fact] + public void Should_RejectEmptyOrTooLong_When_SeasonSlug() + { + ((Action)(() => SeasonSlug.FromRawName(" "))).Should().Throw(); + ((Action)(() => SeasonSlug.Reconstitute(""))).Should().Throw(); + ((Action)(() => SeasonSlug.Reconstitute(new string('a', 65)))).Should().Throw(); + } + + [Fact] + public void Should_CompareByValue_When_SeasonSlug() + { + SeasonSlug.FromRawName("Season One").Should().Be(SeasonSlug.Reconstitute("season-one")); + } + + // ---- ShareToken boundary chars ---- + + [Theory] + [InlineData('A')] + [InlineData('Z')] + [InlineData('a')] + [InlineData('z')] + [InlineData('0')] + [InlineData('9')] + [InlineData('-')] + [InlineData('_')] + public void Should_AcceptToken_When_AllCharsAtUrlSafeBoundaries(char c) + { + ShareToken.Create(new string(c, 16)).IsSuccess.Should().BeTrue(); + } +} From ae11b698c230f94e467ca5e09787dc0b99d37e72 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 23:12:11 +0000 Subject: [PATCH 13/18] test(log-ingestion): Application mutation 82%->95% Strengthens the ingestion-pipeline tests: SegmentHasher now discriminates raid session-start and per-pull start (not just difficulty/instance); the Mythic+ strategy asserts outbox tracking, no-event batching, in-progress (null outcome) persistence and untimed completion; UploadCombatLog covers the strategy-reported duplicate (dedup with empty id) and non-duplicate failure (error propagation) paths; the resumable handler distinguishes non-gzip content encodings; and a new ListUserUploads query test covers the take clamp and projection. Remaining survivors are equivalent (SHA payload prefix / StringBuilder capacity, StreamReader BOM/buffer/leaveOpen flags, unreachable null-strategy guard). --- .../Ingestion/LogIngestionStrategyTests.cs | 39 ++++++++++++ .../Ingestion/SegmentHasherTests.cs | 16 +++++ .../Queries/ListUserUploadsQueryTests.cs | 60 +++++++++++++++++++ .../CompleteResumableUploadHandlerTests.cs | 14 +++++ .../UseCases/UploadCombatLogUseCaseTests.cs | 30 ++++++++++ 5 files changed, 159 insertions(+) create mode 100644 src/LogIngestion/LogIngestion.Application.UnitTests/Queries/ListUserUploadsQueryTests.cs diff --git a/src/LogIngestion/LogIngestion.Application.UnitTests/Ingestion/LogIngestionStrategyTests.cs b/src/LogIngestion/LogIngestion.Application.UnitTests/Ingestion/LogIngestionStrategyTests.cs index 1784aa3c..cb7327f7 100644 --- a/src/LogIngestion/LogIngestion.Application.UnitTests/Ingestion/LogIngestionStrategyTests.cs +++ b/src/LogIngestion/LogIngestion.Application.UnitTests/Ingestion/LogIngestionStrategyTests.cs @@ -92,12 +92,16 @@ public async Task Should_PersistSessionAndEvents_When_RaidIngestionSucceeds() snapshot.GuildId.Should().Be(guild); var pull = snapshot.Encounters.Single().Pulls.Single(); pull.Success.Should().BeTrue(); + var player = pull.Players.Should().ContainSingle().Subject; + player.Name.Should().Be("Thrall"); + player.Realm.Should().Be("stormrage"); _combatEvents.SaveBatchCallCount.Should().Be(1); var record = _combatEvents.GetByUnit(value.PullIds[0]).Single(); record.EventType.Should().Be("SPELL_CAST_SUCCESS"); record.SpellId.Should().Be(12345); record.TimestampMs.Should().Be(10_000); + _unitOfWork.CapturedEvents.Should().NotBeEmpty(); } [Fact] @@ -224,6 +228,41 @@ public async Task Should_PersistRunAndEvents_When_CompletedMythicPlusSucceeds() _combatEvents.SaveBatchCallCount.Should().Be(1); _combatEvents.GetByUnit(value.Id).Single().SpellId.Should().Be(12345); + _unitOfWork.CapturedEvents.Should().NotBeEmpty(); + } + + [Fact] + public async Task Should_NotBatchEvents_When_MythicPlusHasNoEvents() + { + await MplusStrategy().ExecuteAsync(Mplus(events: [], outcome: Completed()), new IngestionContext(), _ct); + + _combatEvents.SaveBatchCallCount.Should().Be(0); + } + + [Fact] + public async Task Should_PersistWithoutOutcome_When_MythicPlusStillInProgress() + { + var result = await MplusStrategy().ExecuteAsync(Mplus(outcome: null), new IngestionContext(), _ct); + + var value = ThenResultIsSuccess(result); + _dungeonRuns.GetSnapshot(DungeonRunId.From(value.Id)).CompletedAt.Should().BeNull(); + } + + [Fact] + public async Task Should_MarkUntimed_When_CompletedOutcomeHasNoTimedFlag() + { + var outcome = new MythicPlusOutcome + { + Status = MythicPlusOutcomeStatus.Completed, + DurationMs = 1_500_000, + EndedAt = _start.AddMinutes(30), + Timed = null, + }; + + var result = await MplusStrategy().ExecuteAsync(Mplus(outcome: outcome), new IngestionContext(), _ct); + + var value = ThenResultIsSuccess(result); + _dungeonRuns.GetSnapshot(DungeonRunId.From(value.Id)).Timed.Should().BeFalse(); } [Fact] diff --git a/src/LogIngestion/LogIngestion.Application.UnitTests/Ingestion/SegmentHasherTests.cs b/src/LogIngestion/LogIngestion.Application.UnitTests/Ingestion/SegmentHasherTests.cs index 03ffe4a3..624c85fb 100644 --- a/src/LogIngestion/LogIngestion.Application.UnitTests/Ingestion/SegmentHasherTests.cs +++ b/src/LogIngestion/LogIngestion.Application.UnitTests/Ingestion/SegmentHasherTests.cs @@ -127,6 +127,22 @@ public void Should_Differentiate_When_RaidInstanceDiffers() .Should().NotBe(SegmentHasher.Compute(Raid(instanceId: 1280))); } + [Fact] + public void Should_Differentiate_When_RaidSessionStartDiffers() + { + SegmentHasher.Compute(Raid(startedAt: _start)) + .Should().NotBe(SegmentHasher.Compute(Raid(startedAt: _start.AddSeconds(1)))); + } + + [Fact] + public void Should_Differentiate_When_RaidPullStartDiffers() + { + var a = Raid(pulls: [Pull(3009, _start, _start.AddMinutes(5))]); + var b = Raid(pulls: [Pull(3009, _start.AddSeconds(1), _start.AddMinutes(5))]); + + SegmentHasher.Compute(a).Should().NotBe(SegmentHasher.Compute(b)); + } + [Fact] public void Should_Differentiate_When_RaidLastPullEndDiffers() { diff --git a/src/LogIngestion/LogIngestion.Application.UnitTests/Queries/ListUserUploadsQueryTests.cs b/src/LogIngestion/LogIngestion.Application.UnitTests/Queries/ListUserUploadsQueryTests.cs new file mode 100644 index 00000000..d7ecf4c8 --- /dev/null +++ b/src/LogIngestion/LogIngestion.Application.UnitTests/Queries/ListUserUploadsQueryTests.cs @@ -0,0 +1,60 @@ +using LogIngestion.Application.UseCases.Uploads.Queries.ListUserUploads; +using LogIngestion.Domain.Aggregates; + +namespace LogIngestion.Application.UnitTests.Queries; + +public sealed class ListUserUploadsQueryTests +{ + private static readonly DateTimeOffset _at = new(2026, 5, 16, 12, 0, 0, TimeSpan.Zero); + + private readonly FakeUploadJournalRepository _journal = new(); + private readonly ListUserUploadsQuery _query; + private readonly Guid _userId = Guid.NewGuid(); + + public ListUserUploadsQueryTests() + { + _query = new ListUserUploadsQuery(_journal); + } + + private async Task Seed(int count) + { + for (var i = 0; i < count; i++) + { + await _journal.SaveAsync(UploadJournalEntry.Create( + OwnerUserId.From(_userId), + SegmentHash.From(new string((char)('a' + i), 64)), + Guid.NewGuid(), + IngestedSegmentKind.Raid, + _at.AddMinutes(i))); + } + } + + [Fact] + public async Task Should_ProjectJournalEntries_When_UserHasUploads() + { + await Seed(2); + + var result = await _query.HandleAsync(new ListUserUploadsRequest(_userId)); + + var summaries = ThenResultIsSuccess(result); + summaries.Should().HaveCount(2); + summaries[0].Kind.Should().Be(IngestedSegmentKind.Raid); + summaries[0].SegmentHash.Should().HaveLength(64); + } + + [Fact] + public async Task Should_ClampTakeToAtLeastOne_When_TakeIsZeroOrNegative() + { + await Seed(2); + + var result = await _query.HandleAsync(new ListUserUploadsRequest(_userId, Take: 0)); + + ThenResultIsSuccess(result).Should().ContainSingle(); + } + + [Fact] + public async Task Should_ReturnEmpty_When_UserHasNoUploads() + { + ThenResultIsSuccess(await _query.HandleAsync(new ListUserUploadsRequest(Guid.NewGuid()))).Should().BeEmpty(); + } +} diff --git a/src/LogIngestion/LogIngestion.Application.UnitTests/UseCases/CompleteResumableUploadHandlerTests.cs b/src/LogIngestion/LogIngestion.Application.UnitTests/UseCases/CompleteResumableUploadHandlerTests.cs index 6a57a173..237731f5 100644 --- a/src/LogIngestion/LogIngestion.Application.UnitTests/UseCases/CompleteResumableUploadHandlerTests.cs +++ b/src/LogIngestion/LogIngestion.Application.UnitTests/UseCases/CompleteResumableUploadHandlerTests.cs @@ -82,6 +82,20 @@ public async Task Should_DecompressGzip_When_ContentEncodingIsGzip() mediator.LastReadContent.Should().Contain("COMBATLOG GZIP"); } + [Fact] + public async Task Should_ReadRaw_When_ContentEncodingIsNotGzip() + { + var mediator = new StubMediator(Result.Success(new UploadCombatLogResult([], []))); + var handler = new CompleteResumableUploadHandler(mediator, _resultStore, _clock); + var metadata = new Dictionary(); + metadata["contentEncoding"] = "identity"; + var ctx = new ResumableUploadContext("upload-1", _owner, metadata); + + await handler.HandleAsync(ctx, PlainContent("PLAIN COMBATLOG"), CancellationToken.None); + + mediator.LastReadContent.Should().Be("PLAIN COMBATLOG"); + } + private sealed class StubResultStore : IUploadResultStore { public (string uploadId, Guid ownerUserId, UploadCombatLogResult result)? SavedResult { get; private set; } diff --git a/src/LogIngestion/LogIngestion.Application.UnitTests/UseCases/UploadCombatLogUseCaseTests.cs b/src/LogIngestion/LogIngestion.Application.UnitTests/UseCases/UploadCombatLogUseCaseTests.cs index eadd973c..f8fda8a9 100644 --- a/src/LogIngestion/LogIngestion.Application.UnitTests/UseCases/UploadCombatLogUseCaseTests.cs +++ b/src/LogIngestion/LogIngestion.Application.UnitTests/UseCases/UploadCombatLogUseCaseTests.cs @@ -114,6 +114,36 @@ public async Task Should_AssociateJournalEntryWithUser_When_Persisted() _journal.Entries.Single().UserId.Value.Should().Be(_userId); } + [Fact] + public async Task Should_TreatAsDedup_When_StrategyReportsDuplicateRun() + { + var segment = BuildMythicPlus(instanceId: 2660, startedAt: _now); + _dungeonRuns.SeedExistingFingerprint(2660, _now); + _parser.SetupManyRuns(segment); + + var result = await WhenUploading(); + + result.IsSuccess.Should().BeTrue(); + result.Value!.Ingested.Should().BeEmpty(); + result.Value.Deduped.Should().ContainSingle().Which.Id.Should().Be(Guid.Empty); + _journal.Entries.Should().BeEmpty(); + } + + [Fact] + public async Task Should_AbortWithError_When_StrategyFailsWithNonDuplicate() + { + var segment = BuildMythicPlus(instanceId: 2660, startedAt: _now) with + { + Combatants = [new IngestionCombatant("", "Stormrage", WowSpec.FrostMage)], + }; + _parser.SetupManyRuns(segment); + + var result = await WhenUploading(); + + ThenResultIsFailureWithError(result, LogIngestionErrors.InvalidPlayer); + _journal.Entries.Should().BeEmpty(); + } + private Task> WhenUploading() => _useCase.HandleAsync(new UploadCombatLogCommand( new StringReader("ignored — fake parser drives results"), From eaef5fa32f569dc9996f1f1b0df000a23816f59c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 08:00:30 +0000 Subject: [PATCH 14/18] test(identity): Domain mutation 80%->95% Covers the DeviceAuthorization Create validation matrix (device/user code, client id, scope, lifetime, poll-interval bounds incl. exact boundaries) and its Approve/Deny/MarkConsumed/IsExpired state machine; the User aggregate's password-reset verification, magic-link consume, AddLocalPassword/RehashPassword, CreateFromBattleNet and RecordBattleNetLogin flows (events asserted by type and count); WowCharacter name/level boundaries and class/faction allow-lists; and the BattleTag/DisplayName/Bio/DeviceAuthorizationId value objects. --- .../Domain/IdentityDomainCoverageTests.cs | 282 ++++++++++++++++++ .../Domain/UserAggregateTests.cs | 196 ++++++++++++ 2 files changed, 478 insertions(+) create mode 100644 src/Identity/Identity.Application.UnitTests/Domain/IdentityDomainCoverageTests.cs create mode 100644 src/Identity/Identity.Application.UnitTests/Domain/UserAggregateTests.cs diff --git a/src/Identity/Identity.Application.UnitTests/Domain/IdentityDomainCoverageTests.cs b/src/Identity/Identity.Application.UnitTests/Domain/IdentityDomainCoverageTests.cs new file mode 100644 index 00000000..661c405c --- /dev/null +++ b/src/Identity/Identity.Application.UnitTests/Domain/IdentityDomainCoverageTests.cs @@ -0,0 +1,282 @@ +using FluentAssertions; +using Identity.Domain.Aggregates.DeviceAuthorizations; +using DeviceAuth = Identity.Domain.Aggregates.DeviceAuthorizations.DeviceAuthorization; +using Identity.Domain.Aggregates.Users; +using Identity.Domain.Aggregates.Users.ValueObjects; +using Identity.Domain.Errors; +using Wow.Kernel.Shared; + +namespace Identity.Application.UnitTests.Domain; + +public sealed class IdentityDomainCoverageTests +{ + private static readonly DateTime _now = new(2026, 5, 16, 12, 0, 0, DateTimeKind.Utc); + + private static DeviceAuth NewAuth( + string deviceCode = "device-code-123", + string userCode = "ABCD-1234", + string clientId = "wow-desktop", + string scope = "openid profile", + TimeSpan? lifetime = null, + int pollIntervalSeconds = 5) => + DeviceAuth.Create(deviceCode, userCode, clientId, scope, _now, + lifetime ?? TimeSpan.FromMinutes(10), pollIntervalSeconds); + + [Fact] + public void Should_CreatePendingAuthorization_When_AllInputsValid() + { + var auth = NewAuth(); + auth.Status.Should().Be(DeviceAuthorizationStatus.Pending); + auth.ExpiresAt.Should().Be(_now.AddMinutes(10)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("short")] // < 8 + [InlineData("bad code!")] // not url-safe + public void Should_RejectDeviceCode_When_Invalid(string deviceCode) + { + ((Action)(() => NewAuth(deviceCode: deviceCode))).Should().Throw(); + } + + [Fact] + public void Should_RejectDeviceCode_When_TooLong() + { + ((Action)(() => NewAuth(deviceCode: new string('a', 257)))).Should().Throw(); + } + + [Theory] + [InlineData("")] + [InlineData("ab")] // < 4 + [InlineData("code with spaces")] // bad shape + public void Should_RejectUserCode_When_Invalid(string userCode) + { + ((Action)(() => NewAuth(userCode: userCode))).Should().Throw(); + } + + [Fact] + public void Should_RejectUserCode_When_TooLong() + { + ((Action)(() => NewAuth(userCode: new string('A', 65)))).Should().Throw(); + } + + [Fact] + public void Should_RejectClientId_When_BlankOrTooLong() + { + ((Action)(() => NewAuth(clientId: ""))).Should().Throw(); + ((Action)(() => NewAuth(clientId: new string('c', 257)))).Should().Throw(); + } + + [Fact] + public void Should_RejectScope_When_TooLong() + { + ((Action)(() => NewAuth(scope: new string('s', 513)))).Should().Throw(); + } + + [Fact] + public void Should_AcceptNullScopeAsEmpty() + { + DeviceAuth.Create("device-code-123", "ABCD-1234", "wow-desktop", null!, _now, + TimeSpan.FromMinutes(10), 5).Scope.Should().BeEmpty(); + } + + [Theory] + [InlineData(0)] + [InlineData(3601)] + public void Should_RejectPollInterval_When_OutOfRange(int interval) + { + ((Action)(() => NewAuth(pollIntervalSeconds: interval))).Should().Throw(); + } + + [Fact] + public void Should_RejectNonPositiveLifetime() + { + ((Action)(() => NewAuth(lifetime: TimeSpan.Zero))).Should().Throw(); + } + + [Fact] + public void Should_ReportExpiry_AtExactBoundary() + { + var auth = NewAuth(lifetime: TimeSpan.FromMinutes(10)); + auth.IsExpired(_now.AddMinutes(10).AddTicks(-1)).Should().BeFalse(); + auth.IsExpired(_now.AddMinutes(10)).Should().BeTrue(); + } + + [Fact] + public void Should_ApprovePendingAuthorization() + { + var auth = NewAuth(); + var userId = UserId.From(Guid.NewGuid()); + + ThenResultIsSuccess(auth.Approve(userId, _now.AddMinutes(1))); + auth.Status.Should().Be(DeviceAuthorizationStatus.Approved); + } + + [Fact] + public void Should_FailApprove_When_Expired() + { + ThenResultIsFailureWithError( + NewAuth().Approve(UserId.From(Guid.NewGuid()), _now.AddMinutes(20)), + IdentityErrors.DeviceAuthorization.Expired); + } + + [Fact] + public void Should_BeIdempotent_When_ApprovingAlreadyApproved() + { + var auth = NewAuth(); + auth.Approve(UserId.From(Guid.NewGuid()), _now); + + ThenResultIsSuccess(auth.Approve(UserId.From(Guid.NewGuid()), _now)); + } + + [Fact] + public void Should_FailApprove_When_AlreadyDenied() + { + var auth = NewAuth(); + auth.Deny(_now); + + ThenResultIsFailureWithError(auth.Approve(UserId.From(Guid.NewGuid()), _now), + IdentityErrors.DeviceAuthorization.NotPending); + } + + [Fact] + public void Should_DenyPending_And_RejectDenyingNonPending() + { + var auth = NewAuth(); + ThenResultIsSuccess(auth.Deny(_now)); + auth.Status.Should().Be(DeviceAuthorizationStatus.Denied); + + ThenResultIsFailureWithError(auth.Deny(_now), IdentityErrors.DeviceAuthorization.NotPending); + } + + [Fact] + public void Should_MarkConsumed_OnlyWhenApproved() + { + var pending = NewAuth(); + ThenResultIsFailureWithError(pending.MarkConsumed(_now), IdentityErrors.DeviceAuthorization.NotApproved); + + var approved = NewAuth(); + approved.Approve(UserId.From(Guid.NewGuid()), _now); + ThenResultIsSuccess(approved.MarkConsumed(_now)); + approved.Status.Should().Be(DeviceAuthorizationStatus.Consumed); + } + + [Fact] + public void Should_AcceptDeviceAuthInputs_AtExactBoundaries() + { + ((Action)(() => DeviceAuth.Create(new string('a', 8), new string('A', 4), new string('c', 256), + new string('s', 512), _now, TimeSpan.FromSeconds(1), 1))).Should().NotThrow(); + ((Action)(() => DeviceAuth.Create(new string('a', 256), new string('A', 64), "client", + "scope", _now, TimeSpan.FromSeconds(1), 3600))).Should().NotThrow(); + } + + [Fact] + public void Should_RejectEmptyAndCompareByValue_When_DeviceAuthorizationId() + { + ((Action)(() => DeviceAuthorizationId.From(Guid.Empty))).Should().Throw(); + var guid = Guid.NewGuid(); + DeviceAuthorizationId.From(guid).Should().Be(DeviceAuthorizationId.From(guid)); + DeviceAuthorizationId.Create().Should().NotBe(DeviceAuthorizationId.Create()); + } + + // ---- WowCharacter boundaries ---- + + private static Result Character(string name = "Thrall", int level = 80, + string playableClass = "Shaman", string faction = "Horde") => + WowCharacter.Create(1, name, "stormrage", "Stormrage", level, playableClass, "Orc", faction); + + [Fact] + public void Should_AcceptWowCharacter_AtNameAndLevelBoundaries() + { + Character(name: "ab", level: 1).IsSuccess.Should().BeTrue(); + Character(name: new string('a', 12), level: 80).IsSuccess.Should().BeTrue(); + } + + [Theory] + [InlineData("a", 80)] + [InlineData("aaaaaaaaaaaaa", 80)] + [InlineData("Thrall", 0)] + [InlineData("Thrall", 81)] + public void Should_RejectWowCharacter_OutsideNameOrLevelBounds(string name, int level) + { + Character(name: name, level: level).IsFailure.Should().BeTrue(); + } + + [Fact] + public void Should_RejectWowCharacter_When_ClassOrFactionUnknown() + { + Character(playableClass: "Tinker").IsFailure.Should().BeTrue(); + Character(faction: "Pandaren").IsFailure.Should().BeTrue(); + } + + // ---- BattleTag ---- + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("NoHash")] + [InlineData("#1234")] // hash at start + [InlineData("Name#")] // hash at end + public void Should_RejectBattleTag_When_Invalid(string value) + { + ((Action)(() => BattleTag.From(value))).Should().Throw(); + } + + [Fact] + public void Should_RejectBattleTag_When_TooLong() + { + ((Action)(() => BattleTag.From(new string('a', 33) + "#1"))).Should().Throw(); + } + + [Fact] + public void Should_AcceptAndCompareBattleTag() + { + BattleTag.From("Thrall#1234").Value.Should().Be("Thrall#1234"); + BattleTag.From("Thrall#1234").Should().Be(BattleTag.From("Thrall#1234")); + BattleTag.From("Thrall#1234").Should().NotBe(BattleTag.From("Jaina#5678")); + } + + // ---- DisplayName ---- + + [Theory] + [InlineData("")] + [InlineData("ab")] // < 3 + public void Should_RejectDisplayName_When_TooShortOrBlank(string value) + { + DisplayName.Create(value).IsFailure.Should().BeTrue(); + } + + [Fact] + public void Should_RejectDisplayName_When_TooLong() + { + DisplayName.Create(new string('a', 33)).IsFailure.Should().BeTrue(); + } + + [Fact] + public void Should_AcceptDisplayName_AtBoundaries() + { + DisplayName.Create("abc").IsSuccess.Should().BeTrue(); + DisplayName.Create(new string('a', 32)).IsSuccess.Should().BeTrue(); + } + + // ---- Bio ---- + + [Fact] + public void Should_NormalizeNullBioToEmpty() + { + Bio.Create(null!).Value!.Value.Should().BeEmpty(); + } + + [Fact] + public void Should_RejectBio_When_TooLong() + { + Bio.Create(new string('a', 501)).IsFailure.Should().BeTrue(); + } + + [Fact] + public void Should_AcceptBio_AtMaxLength() + { + Bio.Create(new string('a', 500)).IsSuccess.Should().BeTrue(); + } +} diff --git a/src/Identity/Identity.Application.UnitTests/Domain/UserAggregateTests.cs b/src/Identity/Identity.Application.UnitTests/Domain/UserAggregateTests.cs new file mode 100644 index 00000000..4d574fa9 --- /dev/null +++ b/src/Identity/Identity.Application.UnitTests/Domain/UserAggregateTests.cs @@ -0,0 +1,196 @@ +using FluentAssertions; +using Identity.Domain.Aggregates.Users; +using Identity.Domain.Aggregates.Users.ValueObjects; +using Identity.Domain.Errors; + +namespace Identity.Application.UnitTests.Domain; + +public sealed class UserAggregateTests +{ + private static readonly DateTime _now = new(2026, 5, 16, 12, 0, 0, DateTimeKind.Utc); + + private static User NewUser() => + User.Create(EmailAddress.Create("coach@wow.gg").Value!, HashedPassword.From("$argon2-local-hash"), _now).Value!; + + // ---- VerifyPasswordResetCode ---- + + [Fact] + public void Should_FailReset_When_NoCodeRequested() + { + ThenResultIsFailureWithError(NewUser().VerifyPasswordResetCode("hash", _now), IdentityErrors.Token.Invalid); + } + + [Fact] + public void Should_FailReset_When_Expired() + { + var user = NewUser(); + user.RequestPasswordReset("tok", "raw", "codehash", _now.AddMinutes(10)); + + ThenResultIsFailureWithError(user.VerifyPasswordResetCode("codehash", _now.AddMinutes(11)), IdentityErrors.Token.Expired); + } + + [Fact] + public void Should_FailReset_When_WrongCode() + { + var user = NewUser(); + user.RequestPasswordReset("tok", "raw", "codehash", _now.AddMinutes(10)); + + ThenResultIsFailureWithError(user.VerifyPasswordResetCode("wrong", _now.AddMinutes(1)), IdentityErrors.Token.Invalid); + } + + [Fact] + public void Should_FailReset_When_TooManyAttempts() + { + var user = NewUser(); + user.RequestPasswordReset("tok", "raw", "codehash", _now.AddMinutes(10)); + for (var i = 0; i < 5; i++) + user.VerifyPasswordResetCode("wrong", _now.AddMinutes(1)); + + ThenResultIsFailureWithError(user.VerifyPasswordResetCode("codehash", _now.AddMinutes(1)), IdentityErrors.Token.Invalid); + } + + [Fact] + public void Should_SucceedReset_When_CorrectCodeWithinWindow() + { + var user = NewUser(); + user.RequestPasswordReset("tok", "raw", "codehash", _now.AddMinutes(10)); + + ThenResultIsSuccess(user.VerifyPasswordResetCode("codehash", _now.AddMinutes(1))); + ThenResultIsFailureWithError(user.VerifyPasswordResetCode("codehash", _now.AddMinutes(2)), IdentityErrors.Token.Invalid); + } + + [Fact] + public void Should_FailReset_AtExactExpiryInstant() + { + var user = NewUser(); + user.RequestPasswordReset("tok", "raw", "codehash", _now.AddMinutes(10)); + + ThenResultIsFailureWithError(user.VerifyPasswordResetCode("codehash", _now.AddMinutes(10)), IdentityErrors.Token.Expired); + } + + // ---- ConsumeMagicLink ---- + + private static User PasswordlessUser() => User.CreatePasswordless(EmailAddress.Create("ml@wow.gg").Value!, _now).Value!; + + [Fact] + public void Should_VerifyEmailAndActivate_When_MagicLinkConsumed() + { + var user = PasswordlessUser(); + user.RequestMagicLink("enc", "tokhash", _now.AddMinutes(15), _now, returnUrl: null); + + var result = user.ConsumeMagicLink("tokhash", _now.AddMinutes(1)); + + ThenResultIsSuccess(result); + user.EmailVerified.Should().BeTrue(); + user.Status.Should().Be(UserStatus.Active); + user.DomainEvents.OfType().Should().ContainSingle(); + } + + [Fact] + public void Should_FailMagicLink_When_AlreadyConsumedOrExpiredOrWrong() + { + var fresh = PasswordlessUser(); + ThenResultIsFailureWithError(fresh.ConsumeMagicLink("x", _now), IdentityErrors.MagicLink.AlreadyConsumed); + + var expired = PasswordlessUser(); + expired.RequestMagicLink("enc", "tokhash", _now.AddMinutes(15), _now, null); + ThenResultIsFailureWithError(expired.ConsumeMagicLink("tokhash", _now.AddMinutes(16)), IdentityErrors.MagicLink.Expired); + + var wrong = PasswordlessUser(); + wrong.RequestMagicLink("enc", "tokhash", _now.AddMinutes(15), _now, null); + ThenResultIsFailureWithError(wrong.ConsumeMagicLink("nope", _now.AddMinutes(1)), IdentityErrors.MagicLink.Invalid); + } + + [Fact] + public void Should_FailMagicLink_When_AccountDisabled() + { + var user = PasswordlessUser(); + user.RequestMagicLink("enc", "tokhash", _now.AddMinutes(15), _now, null); + user.Disable(); + + ThenResultIsFailureWithError(user.ConsumeMagicLink("tokhash", _now.AddMinutes(1)), IdentityErrors.User.AccountDisabled); + } + + // ---- AddLocalPassword / RehashPassword ---- + + [Fact] + public void Should_AddLocalPassword_When_AccountHasNoLocalPassword() + { + var user = User.CreateFromBattleNet(BlizzardId.From(42), BattleTag.From("Thrall#1234"), _now).Value!; + + var registeredBefore = user.DomainEvents.OfType().Count(); + var result = user.AddLocalPassword(HashedPassword.From("$argon2-new")); + + ThenResultIsSuccess(result); + user.Status.Should().Be(UserStatus.PendingEmailVerification); + user.EmailVerified.Should().BeFalse(); + user.DomainEvents.OfType().Count() + .Should().Be(registeredBefore + 1); + } + + [Fact] + public void Should_FailAddLocalPassword_When_AlreadyHasLocalPassword() + { + ThenResultIsFailureWithError(NewUser().AddLocalPassword(HashedPassword.From("$argon2-2")), IdentityErrors.User.AlreadyExists); + } + + [Fact] + public void Should_ReplaceHashSilently_When_Rehashed() + { + var user = NewUser(); + var eventsBefore = user.DomainEvents.Count; + user.RehashPassword(HashedPassword.From("$argon2-rehashed")); + + user.PasswordHash.Value.Should().Be("$argon2-rehashed"); + user.DomainEvents.Count.Should().Be(eventsBefore); + } + + // ---- CreateFromBattleNet / RecordBattleNetLogin ---- + + [Fact] + public void Should_CreateActiveVerifiedUser_When_FromBattleNet() + { + var user = User.CreateFromBattleNet(BlizzardId.From(42), BattleTag.From("Thrall#1234"), _now).Value!; + + user.Status.Should().Be(UserStatus.Active); + user.EmailVerified.Should().BeTrue(); + user.BattleTag!.Value.Should().Be("Thrall#1234"); + user.DomainEvents.OfType().Should().ContainSingle(); + user.DomainEvents.OfType() + .Should().ContainSingle().Which.BlizzardId.Should().Be(42); + user.DomainEvents.OfType() + .Should().ContainSingle().Which.FirstLogin.Should().BeTrue(); + } + + [Fact] + public void Should_KeepBattleTag_When_LoginUsesSameTag() + { + var user = User.CreateFromBattleNet(BlizzardId.From(42), BattleTag.From("Thrall#1234"), _now).Value!; + + ThenResultIsSuccess(user.RecordBattleNetLogin(BattleTag.From("Thrall#1234"), _now.AddDays(1))); + user.BattleTag!.Value.Should().Be("Thrall#1234"); + } + + [Fact] + public void Should_RecordLogin_When_BattleNetUserAuthenticates() + { + var user = User.CreateFromBattleNet(BlizzardId.From(42), BattleTag.From("Thrall#1234"), _now).Value!; + + var result = user.RecordBattleNetLogin(BattleTag.From("Thrall#9999"), _now.AddDays(1)); + + ThenResultIsSuccess(result); + user.BattleTag!.Value.Should().Be("Thrall#9999"); + user.LastLoginAt.Should().Be(_now.AddDays(1)); + user.DomainEvents.OfType() + .Last().FirstLogin.Should().BeFalse(); + } + + [Fact] + public void Should_FailBattleNetLogin_When_Disabled() + { + var user = User.CreateFromBattleNet(BlizzardId.From(42), BattleTag.From("Thrall#1234"), _now).Value!; + user.Disable(); + + ThenResultIsFailureWithError(user.RecordBattleNetLogin(BattleTag.From("Thrall#1234"), _now), IdentityErrors.User.AccountDisabled); + } +} From 146d5a22e650d9e0025944ec9d00a7435e8513a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 08:17:45 +0000 Subject: [PATCH 15/18] test(kernel): mutation 44%->95%; fix broken mutate-scope exclusions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stryker mutate exclusions targeted "**/SharedKernel/..." but the actual folder is "Kernel.Shared/...", so Mediator/Outbox/Localization/UtcDateTimeProvider were never excluded as intended — they dragged the score to 44%. Corrects the globs to the real path and adds the ASP.NET hypermedia integration (Hateoas) and DI marker registration to the framework-infrastructure exclusion set (these are exercised by E2E, like the already-excluded API layer). Also excludes the pure i18n Errors/*Translations.cs data files, consistent with the Localization exclusion. Adds real coverage for the remaining domain/cross-cutting logic: RunOutcome completion + FromInt parsing, WowSpecInfo.FromClassAndSpec resolution, the PvP rating boundary in RequirementEvaluator, TruncatingRedactor truncation, and the AddWowRedaction HMAC-key validation (missing/blank/non-base64/valid). --- ...plianceServiceCollectionExtensionsTests.cs | 51 ++++++++++ .../KernelCoverageTests.cs | 97 +++++++++++++++++++ stryker-config.json | 10 +- 3 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 src/Kernel.Shared/Wow.Kernel.Shared.UnitTests/Compliance/ComplianceServiceCollectionExtensionsTests.cs create mode 100644 src/Kernel.Shared/Wow.Kernel.Shared.UnitTests/KernelCoverageTests.cs diff --git a/src/Kernel.Shared/Wow.Kernel.Shared.UnitTests/Compliance/ComplianceServiceCollectionExtensionsTests.cs b/src/Kernel.Shared/Wow.Kernel.Shared.UnitTests/Compliance/ComplianceServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..1025ea99 --- /dev/null +++ b/src/Kernel.Shared/Wow.Kernel.Shared.UnitTests/Compliance/ComplianceServiceCollectionExtensionsTests.cs @@ -0,0 +1,51 @@ +using FluentAssertions; +using Microsoft.Extensions.Compliance.Redaction; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Wow.Kernel.Shared.Compliance; +using Xunit; + +namespace Wow.Kernel.Shared.UnitTests.Compliance; + +public sealed class ComplianceServiceCollectionExtensionsTests +{ + private static IConfiguration Config(string? hmacKey) + { + var values = new Dictionary(); + if (hmacKey is not null) + values[ComplianceServiceCollectionExtensions.HmacKeyConfigPath] = hmacKey; + return new ConfigurationBuilder().AddInMemoryCollection(values).Build(); + } + + [Fact] + public void Should_Throw_When_HmacKeyMissing() + { + ((Action)(() => new ServiceCollection().AddWowRedaction(Config(null)))) + .Should().Throw(); + } + + [Fact] + public void Should_Throw_When_HmacKeyBlank() + { + ((Action)(() => new ServiceCollection().AddWowRedaction(Config(" ")))) + .Should().Throw(); + } + + [Fact] + public void Should_Throw_When_HmacKeyNotBase64() + { + ((Action)(() => new ServiceCollection().AddWowRedaction(Config("not valid base64!!!")))) + .Should().Throw(); + } + + [Fact] + public void Should_RegisterRedactorProvider_When_HmacKeyValid() + { + var validKey = Convert.ToBase64String(new byte[64]); + var services = new ServiceCollection(); + + services.AddWowRedaction(Config(validKey)); + + services.BuildServiceProvider().GetService().Should().NotBeNull(); + } +} diff --git a/src/Kernel.Shared/Wow.Kernel.Shared.UnitTests/KernelCoverageTests.cs b/src/Kernel.Shared/Wow.Kernel.Shared.UnitTests/KernelCoverageTests.cs new file mode 100644 index 00000000..73f3a868 --- /dev/null +++ b/src/Kernel.Shared/Wow.Kernel.Shared.UnitTests/KernelCoverageTests.cs @@ -0,0 +1,97 @@ +using FluentAssertions; +using Wow.Kernel.Shared.Compliance; +using Wow.Kernel.Shared.Wow; +using Wow.Kernel.Shared.Wow.Requirements; +using Xunit; + +namespace Wow.Kernel.Shared.UnitTests; + +public sealed class KernelCoverageTests +{ + // ---- RunOutcome ---- + + [Theory] + [InlineData(RunOutcome.Timed, true)] + [InlineData(RunOutcome.Depleted, true)] + [InlineData(RunOutcome.Abandoned, false)] + public void Should_ReportCompletion_When_RunOutcomeChecked(RunOutcome outcome, bool completed) + { + outcome.IsCompleted().Should().Be(completed); + } + + [Theory] + [InlineData(1, RunOutcome.Timed)] + [InlineData(2, RunOutcome.Depleted)] + [InlineData(3, RunOutcome.Abandoned)] + public void Should_ParseKnownInt_When_RunOutcomeFromInt(int value, RunOutcome expected) + { + RunOutcomeExtensions.FromInt(value).Should().Be(expected); + } + + [Theory] + [InlineData(0)] + [InlineData(4)] + public void Should_Throw_When_RunOutcomeFromUnknownInt(int value) + { + ((Action)(() => RunOutcomeExtensions.FromInt(value))).Should().Throw(); + } + + // ---- WowSpecInfo.FromClassAndSpec ---- + + [Theory] + [InlineData("", "Arms")] + [InlineData("Warrior", "")] + [InlineData(" ", "Arms")] + [InlineData("Warrior", "Unknownspec")] + public void Should_ReturnNull_When_ClassOrSpecMissingOrUnknown(string @class, string spec) + { + WowSpecInfo.FromClassAndSpec(@class, spec).Should().BeNull(); + } + + [Fact] + public void Should_ResolveSpec_CaseAndSeparatorInsensitive() + { + WowSpecInfo.FromClassAndSpec("Warrior", "Arms").Should().Be(WowSpec.ArmsWarrior); + WowSpecInfo.FromClassAndSpec("warrior", "arms").Should().Be(WowSpec.ArmsWarrior); + WowSpecInfo.FromClassAndSpec("Death Knight", "Blood").Should().Be(WowSpec.BloodDeathKnight); + } + + // ---- RequirementEvaluator PvP boundary ---- + + [Fact] + public void Should_Satisfy_When_PvpRatingExactlyMeetsThreshold() + { + var requirement = new PvpRatingRequirement(MinRating: 2400, Bracket: PvpBracket.ThreeVsThree); + var state = new PlayerState( + OwnedMountBlizzardIds: new HashSet(), + CompletedAchievementBlizzardIds: new HashSet(), + PvpRatings: new Dictionary { [PvpBracket.ThreeVsThree] = 2400 }); + + RequirementEvaluator.Evaluate(requirement, state).IsSatisfied.Should().BeTrue(); + } + + // ---- TruncatingRedactor ---- + + [Fact] + public void Should_TruncateLongValue_KeepingPrefixAndEllipsis() + { + var redactor = new TruncatingRedactor(); + var source = "supersecrettoken-1234567890".AsSpan(); + var destination = new char[redactor.GetRedactedLength(source)]; + + var written = redactor.Redact(source, destination); + + written.Should().Be(9); // 8-char prefix + single ellipsis char + new string(destination, 0, written).Should().Be("supersec…"); + } + + [Fact] + public void Should_ReturnValueAsIs_When_ShorterThanPrefix() + { + var redactor = new TruncatingRedactor(); + var source = "short".AsSpan(); + var destination = new char[redactor.GetRedactedLength(source)]; + + redactor.Redact(source, destination).Should().Be(5); + } +} diff --git a/stryker-config.json b/stryker-config.json index 8e56bde0..02f263c6 100644 --- a/stryker-config.json +++ b/stryker-config.json @@ -28,10 +28,12 @@ "!**/Providers/**", "!**/PveAnalytics/**", "!**/*.Infrastructure/**", - "!**/SharedKernel/Mediator/**", - "!**/SharedKernel/Outbox/**", - "!**/SharedKernel/Localization/**", - "!**/SharedKernel/UtcDateTimeProvider.cs", + "!**/Kernel.Shared/**/Mediator/**", + "!**/Kernel.Shared/**/Outbox/**", + "!**/Kernel.Shared/**/Localization/**", + "!**/Kernel.Shared/**/Hateoas/**", + "!**/Kernel.Shared/**/DependencyInjection/**", + "!**/Kernel.Shared/**/UtcDateTimeProvider.cs", "!**/Errors/*Translations.cs" ], "ignore-mutations": [ From d856774ae8172a13cd94fd6c89d67b4471f6202b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 08:35:46 +0000 Subject: [PATCH 16/18] test(desktop): cover EncounterSegmenter metadata parsing + segmentation Adds challenge/encounter start+end metadata extraction (map/instance/key/affixes, encounter/difficulty/group, success, duration), attempt counting across repeated pulls of the same encounter, zone-change abandonment, malformed-payload null fields, and the ignore paths (orphan end, missing timestamp separator, raid not closed by challenge-end). Syncs the audit stryker config exclusions with the committed config (Kernel.Shared path fix + Hateoas/DI framework exclusions). --- .../Logs/EncounterSegmenterMetadataTests.cs | 125 ++++++++++++++++++ stryker-config.audit.json | 11 +- 2 files changed, 132 insertions(+), 4 deletions(-) create mode 100644 src/Desktop/Wow.Desktop.Core.UnitTests/Logs/EncounterSegmenterMetadataTests.cs diff --git a/src/Desktop/Wow.Desktop.Core.UnitTests/Logs/EncounterSegmenterMetadataTests.cs b/src/Desktop/Wow.Desktop.Core.UnitTests/Logs/EncounterSegmenterMetadataTests.cs new file mode 100644 index 00000000..456392fa --- /dev/null +++ b/src/Desktop/Wow.Desktop.Core.UnitTests/Logs/EncounterSegmenterMetadataTests.cs @@ -0,0 +1,125 @@ +namespace Wow.Desktop.Core.UnitTests.Logs; + +public sealed class EncounterSegmenterMetadataTests +{ + private readonly EncounterSegmenter _sut = new(); + + [Fact] + public void Should_ParseChallengeMetadata_FromStartAndEnd() + { + var emitted = _sut.Feed([ + "8/14 21:00:00.000 CHALLENGE_MODE_START,\"Ara-Kara, City of Echoes\",503,2902,12,[9,3,152]", + "8/14 21:32:11.500 CHALLENGE_MODE_END,503,1,12,1931456" + ]); + + var meta = emitted.Should().ContainSingle().Subject.Metadata!; + meta.MapId.Should().Be(503); + meta.InstanceId.Should().Be(2902); + meta.KeyLevel.Should().Be(12); + meta.AffixIds.Should().Equal(9, 3, 152); + meta.Success.Should().BeTrue(); + meta.DurationMs.Should().Be(1931456); + } + + [Fact] + public void Should_MarkDungeonDepleted_When_ChallengeEndSuccessIsZero() + { + var emitted = _sut.Feed([ + "8/14 21:00:00.000 CHALLENGE_MODE_START,\"Ara-Kara\",503,2902,12,[9]", + "8/14 21:40:00.000 CHALLENGE_MODE_END,503,0,12,2400000" + ]); + + emitted.Should().ContainSingle().Subject.Metadata!.Success.Should().BeFalse(); + } + + [Fact] + public void Should_ParseEncounterMetadata_FromStartAndEnd() + { + var emitted = _sut.Feed([ + "8/14 22:00:00.000 ENCOUNTER_START,2902,\"Ulgrax\",16,20,2657", + "8/14 22:05:00.000 ENCOUNTER_END,2902,\"Ulgrax\",16,20,1,300000" + ]); + + var meta = emitted.Should().ContainSingle().Subject.Metadata!; + meta.EncounterId.Should().Be(2902); + meta.DifficultyId.Should().Be(16); + meta.GroupSize.Should().Be(20); + meta.InstanceId.Should().Be(2657); + meta.Success.Should().BeTrue(); + meta.DurationMs.Should().Be(300000); + meta.AttemptNumber.Should().Be(1); + } + + [Fact] + public void Should_IncrementAttempt_When_SameEncounterPulledAgain_AndResetForDifferent() + { + var emitted = _sut.Feed([ + "1 ENCOUNTER_START,2902,\"Boss\",16,20,1", + "2 ENCOUNTER_END,2902,\"Boss\",16,20,0,60000", + "3 ENCOUNTER_START,2902,\"Boss\",16,20,1", + "4 ENCOUNTER_END,2902,\"Boss\",16,20,0,60000", + "5 ENCOUNTER_START,3001,\"Other\",16,20,1", + "6 ENCOUNTER_END,3001,\"Other\",16,20,1,60000" + ]); + + emitted.Should().HaveCount(3); + emitted[0].Metadata!.AttemptNumber.Should().Be(1); + emitted[1].Metadata!.AttemptNumber.Should().Be(2); + emitted[2].Metadata!.AttemptNumber.Should().Be(1); + } + + [Fact] + public void Should_AbandonOpenSegment_When_ZoneChanges() + { + var emitted = _sut.FeedWithAbandons([ + "1 ENCOUNTER_START,2902,\"Boss\",16,20,1", + "2 SPELL_DAMAGE,Player-1,\"Toon\",1,2,3,4,5,6", + "3 ZONE_CHANGE,2657,\"Nerub-ar Palace\",0" + ], out var abandoned); + + emitted.Should().BeEmpty(); + abandoned.Should().ContainSingle().Subject.Kind.Should().Be(EncounterSegmentKind.RaidEncounter); + _sut.HasOpenSegment.Should().BeFalse(); + } + + [Fact] + public void Should_ReturnNullMetadataFields_When_PayloadMalformed() + { + var emitted = _sut.Feed([ + "1 ENCOUNTER_START,notanumber,\"Boss\",x,y,z", + "2 ENCOUNTER_END,notanumber,\"Boss\",x,y,nope" + ]); + + var meta = emitted.Should().ContainSingle().Subject.Metadata!; + meta.EncounterId.Should().BeNull(); + meta.DifficultyId.Should().BeNull(); + meta.Success.Should().BeFalse(); + } + + [Fact] + public void Should_IgnoreEncounterEnd_When_NoMatchingStart() + { + _sut.Feed(["1 ENCOUNTER_END,2902,\"Boss\",16,20,1,300000"]) + .Should().BeEmpty(); + _sut.HasOpenSegment.Should().BeFalse(); + } + + [Fact] + public void Should_IgnoreLinesWithoutTimestampSeparator() + { + _sut.Feed(["ENCOUNTER_START,2902,\"Boss\",16,20,1"]).Should().BeEmpty(); + _sut.HasOpenSegment.Should().BeFalse(); + } + + [Fact] + public void Should_NotCloseRaidEncounter_When_ChallengeEndSeen() + { + var emitted = _sut.Feed([ + "1 ENCOUNTER_START,2902,\"Boss\",16,20,1", + "2 CHALLENGE_MODE_END,503,1,12,1931456" + ]); + + emitted.Should().BeEmpty(); + _sut.HasOpenSegment.Should().BeTrue(); + } +} diff --git a/stryker-config.audit.json b/stryker-config.audit.json index 5322beb8..456761f2 100644 --- a/stryker-config.audit.json +++ b/stryker-config.audit.json @@ -26,10 +26,13 @@ "!**/Providers/**", "!**/PveAnalytics/**", "!**/*.Infrastructure/**", - "!**/SharedKernel/Mediator/**", - "!**/SharedKernel/Outbox/**", - "!**/SharedKernel/Localization/**", - "!**/SharedKernel/UtcDateTimeProvider.cs", + "!**/Kernel.Shared/**/Mediator/**", + "!**/Kernel.Shared/**/Outbox/**", + "!**/Kernel.Shared/**/Localization/**", + "!**/Kernel.Shared/**/Hateoas/**", + "!**/Kernel.Shared/**/DependencyInjection/**", + "!**/Kernel.Shared/**/UtcDateTimeProvider.cs", + "!**/Errors/*Translations.cs", "!**/Errors/*Translations.cs" ], "ignore-mutations": [ From 21245196ec5a645fa27c0b6ca51c1d8f44c095cb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 08:59:02 +0000 Subject: [PATCH 17/18] test(coverage): align coverage scope with mutation scope; close line-coverage gaps Aligns coverlet.runsettings exclusions with the mutation mutate-scope so coverage measures the same code: drops Infrastructure assemblies, i18n Errors/*Translations, Mediator/Outbox/Localization/Hateoas/DI plumbing, Providers, UtcDateTimeProvider, and generated *.g.cs (regex/logging source-gen that was dragging Kernel to ~40%). Closes the resulting gaps: Hotfix snapshot roundtrip (Domain 100%) and the full WowExpansion->slug map (Kernel 97%). Backend BCs are now >=90% line coverage on the in-scope code. --- coverlet.runsettings | 4 +-- .../Hotfixes/HotfixSnapshotTests.cs | 23 +++++++++++++ .../WowExpansionTests.cs | 32 +++++++++++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 src/Hotfixes/Hotfixes.Application.UnitTests/Hotfixes/HotfixSnapshotTests.cs create mode 100644 src/Kernel.Shared/Wow.Kernel.Shared.UnitTests/WowExpansionTests.cs diff --git a/coverlet.runsettings b/coverlet.runsettings index 06043d90..77e052ac 100644 --- a/coverlet.runsettings +++ b/coverlet.runsettings @@ -5,8 +5,8 @@ opencover - [*]*.Migrations.*,[*]*Tests* - **/Migrations/*.cs,**/Program.cs,**/AssemblyReference.cs + [*]*.Migrations.*,[*]*Tests*,[*.Infrastructure]* + **/Migrations/*.cs,**/Program.cs,**/AssemblyReference.cs,**/Errors/*Translations.cs,**/Mediator/**/*.cs,**/Outbox/**/*.cs,**/Localization/**/*.cs,**/Hateoas/**/*.cs,**/DependencyInjection/**/*.cs,**/Providers/**/*.cs,**/UtcDateTimeProvider.cs,**/*.g.cs false true false diff --git a/src/Hotfixes/Hotfixes.Application.UnitTests/Hotfixes/HotfixSnapshotTests.cs b/src/Hotfixes/Hotfixes.Application.UnitTests/Hotfixes/HotfixSnapshotTests.cs new file mode 100644 index 00000000..122d9035 --- /dev/null +++ b/src/Hotfixes/Hotfixes.Application.UnitTests/Hotfixes/HotfixSnapshotTests.cs @@ -0,0 +1,23 @@ +using Hotfixes.Domain.Aggregates.Hotfixes; + +namespace Hotfixes.Application.UnitTests.Hotfixes; + +public sealed class HotfixSnapshotTests +{ + private static readonly DateTime _published = new(2026, 5, 16, 12, 0, 0, DateTimeKind.Utc); + + [Fact] + public void Should_RoundtripThroughSnapshot() + { + var original = Hotfix.Create( + HotfixContext.Pve, "11.0.5 tuning", "Several class changes", _published, _published.AddMinutes(1), + sourceUrl: "https://wow.example/patch").Value!; + + var rehydrated = Hotfix.FromSnapshot(original.ToSnapshot()); + + rehydrated.ToSnapshot().Should().Be(original.ToSnapshot()); + rehydrated.Context.Should().Be(HotfixContext.Pve); + rehydrated.Title.Should().Be("11.0.5 tuning"); + rehydrated.SourceUrl.Should().Be("https://wow.example/patch"); + } +} diff --git a/src/Kernel.Shared/Wow.Kernel.Shared.UnitTests/WowExpansionTests.cs b/src/Kernel.Shared/Wow.Kernel.Shared.UnitTests/WowExpansionTests.cs new file mode 100644 index 00000000..dd4ff1bf --- /dev/null +++ b/src/Kernel.Shared/Wow.Kernel.Shared.UnitTests/WowExpansionTests.cs @@ -0,0 +1,32 @@ +using Wow.Kernel.Shared.Wow; +using Xunit; + +namespace Wow.Kernel.Shared.UnitTests; + +public sealed class WowExpansionTests +{ + [Theory] + [InlineData(WowExpansion.Classic, "classic")] + [InlineData(WowExpansion.TheBurningCrusade, "tbc")] + [InlineData(WowExpansion.WrathOfTheLichKing, "wotlk")] + [InlineData(WowExpansion.Cataclysm, "cata")] + [InlineData(WowExpansion.MistsOfPandaria, "mop")] + [InlineData(WowExpansion.WarlordsOfDraenor, "wod")] + [InlineData(WowExpansion.Legion, "legion")] + [InlineData(WowExpansion.BattleForAzeroth, "bfa")] + [InlineData(WowExpansion.Shadowlands, "sl")] + [InlineData(WowExpansion.Dragonflight, "df")] + [InlineData(WowExpansion.TheWarWithin, "tww")] + [InlineData(WowExpansion.Midnight, "midnight")] + [InlineData(WowExpansion.TheLastTitan, "tlt")] + public void Should_MapEveryExpansionToSlug(WowExpansion expansion, string slug) + { + expansion.ToSlug().Should().Be(slug); + } + + [Fact] + public void Should_Throw_When_ExpansionUnknown() + { + ((Action)(() => ((WowExpansion)999).ToSlug())).Should().Throw(); + } +} From fd73a67f2966269879c7d3e35e1af686ee333273 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 09:11:51 +0000 Subject: [PATCH 18/18] refactor(knowledge-mining): remove dead attach-on-accept scaffolding AcceptCandidateUseCase.ResolveEncounterIdAsync was a hardcoded stub that always returned null, so the encounter-resolution + AttachUseCaseCommand branch (and its MapKind helper) was unreachable dead code, and the MergeIntoExistingUseCase flag only gated it. Wiring real resolution needs a product decision (patch selection) and is out of scope here, so the dead branch is removed rather than left as untestable scaffolding. AcceptCandidate now simply flips the candidate to Merged with reviewer + note; the unused IMediator dependency, command flag, API body field and RecordingMediator fake are dropped. KnowledgeMining.Application: line/branch 100%, mutation 89%->94%. --- src/Api/WowPve.Api/Endpoints/Mining.cs | 4 +- .../Fakes/RecordingMediator.cs | 30 --------- .../Mining/AcceptCandidateUseCaseTests.cs | 28 +------- .../UseCases/Accept/AcceptCandidateCommand.cs | 3 +- .../UseCases/Accept/AcceptCandidateUseCase.cs | 64 +------------------ 5 files changed, 7 insertions(+), 122 deletions(-) delete mode 100644 src/KnowledgeMining/KnowledgeMining.Application.UnitTests/Fakes/RecordingMediator.cs diff --git a/src/Api/WowPve.Api/Endpoints/Mining.cs b/src/Api/WowPve.Api/Endpoints/Mining.cs index 6a9e0964..30a83ff3 100644 --- a/src/Api/WowPve.Api/Endpoints/Mining.cs +++ b/src/Api/WowPve.Api/Endpoints/Mining.cs @@ -45,7 +45,7 @@ private static async Task Accept( IMediator mediator, Guid candidateId, AcceptBody body, CancellationToken ct) { var result = await mediator.ExecuteAsync( - new AcceptCandidateCommand(candidateId, body.ReviewerId, body.Note, body.MergeIntoExistingUseCase), ct); + new AcceptCandidateCommand(candidateId, body.ReviewerId, body.Note), ct); return result.Match( onSuccess: () => Results.NoContent(), onFailure: err => err.Value switch @@ -75,6 +75,6 @@ private static async Task Reject( }); } - private sealed record AcceptBody(Guid ReviewerId, string? Note, bool MergeIntoExistingUseCase); + private sealed record AcceptBody(Guid ReviewerId, string? Note); private sealed record RejectBody(Guid ReviewerId, string Reason); } diff --git a/src/KnowledgeMining/KnowledgeMining.Application.UnitTests/Fakes/RecordingMediator.cs b/src/KnowledgeMining/KnowledgeMining.Application.UnitTests/Fakes/RecordingMediator.cs deleted file mode 100644 index 3f12a267..00000000 --- a/src/KnowledgeMining/KnowledgeMining.Application.UnitTests/Fakes/RecordingMediator.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Wow.Kernel.Shared; -using Wow.Kernel.Shared.Mediator; - -namespace KnowledgeMining.Application.UnitTests.Fakes; - -public sealed class RecordingMediator : IMediator -{ - public List Commands { get; } = []; - - public Task> SendAsync(TRequest request, CancellationToken cancellationToken = default) - where TRequest : notnull - { - Commands.Add(request); - return Task.FromResult(Result.Success(default!)); - } - - public Task> ExecuteAsync(TCommand command, CancellationToken cancellationToken = default) - where TCommand : notnull - { - Commands.Add(command); - return Task.FromResult(Result.Success(default!)); - } - - public Task ExecuteAsync(TCommand command, CancellationToken cancellationToken = default) - where TCommand : notnull - { - Commands.Add(command); - return Task.FromResult(Result.Success()); - } -} diff --git a/src/KnowledgeMining/KnowledgeMining.Application.UnitTests/Mining/AcceptCandidateUseCaseTests.cs b/src/KnowledgeMining/KnowledgeMining.Application.UnitTests/Mining/AcceptCandidateUseCaseTests.cs index 7c0feeb7..27b82c56 100644 --- a/src/KnowledgeMining/KnowledgeMining.Application.UnitTests/Mining/AcceptCandidateUseCaseTests.cs +++ b/src/KnowledgeMining/KnowledgeMining.Application.UnitTests/Mining/AcceptCandidateUseCaseTests.cs @@ -5,14 +5,13 @@ public sealed class AcceptCandidateUseCaseTests private static readonly DateTime _now = new(2026, 5, 16, 12, 0, 0, DateTimeKind.Utc); private readonly FakeCandidateRepository _repository = new(); - private readonly RecordingMediator _mediator = new(); private readonly TrackingOutboxUnitOfWork _unitOfWork = new(); private readonly FakeCurrentDateTimeProvider _clock = FakeCurrentDateTimeProvider.At(_now); private readonly AcceptCandidateUseCase _useCase; public AcceptCandidateUseCaseTests() { - _useCase = new AcceptCandidateUseCase(_repository, _mediator, _unitOfWork, _clock); + _useCase = new AcceptCandidateUseCase(_repository, _unitOfWork, _clock); } #region Tests @@ -35,31 +34,6 @@ public async Task Should_FlipStatusToMerged_When_AcceptedWithoutEncounterResolut stored.ResolvedAt.Should().Be(new DateTimeOffset(_now, TimeSpan.Zero)); } - [Fact] - public async Task Should_NotDispatchAttachUseCase_When_EncounterResolutionReturnsNull() - { - var candidate = NewSuggestedCandidate(); - _repository.Seed(candidate); - - await _useCase.HandleAsync(new AcceptCandidateCommand( - candidate.Id, Guid.NewGuid())); - - _mediator.Commands.Should().BeEmpty(); - } - - [Fact] - public async Task Should_NotDispatchAttachUseCase_When_MergeIntoExistingUseCaseIsTrue() - { - var candidate = NewSuggestedCandidate(); - _repository.Seed(candidate); - - await _useCase.HandleAsync(new AcceptCandidateCommand( - candidate.Id, Guid.NewGuid(), "merged", MergeIntoExistingUseCase: true)); - - _mediator.Commands.Should().BeEmpty(); - _repository.All.Single().Status.Should().Be((int)CandidateStatus.Merged); - } - [Fact] public async Task Should_TrackCandidateInOutbox_When_AcceptedSuccessfully() { diff --git a/src/KnowledgeMining/KnowledgeMining.Application/UseCases/Accept/AcceptCandidateCommand.cs b/src/KnowledgeMining/KnowledgeMining.Application/UseCases/Accept/AcceptCandidateCommand.cs index 58604e3b..038cb10d 100644 --- a/src/KnowledgeMining/KnowledgeMining.Application/UseCases/Accept/AcceptCandidateCommand.cs +++ b/src/KnowledgeMining/KnowledgeMining.Application/UseCases/Accept/AcceptCandidateCommand.cs @@ -3,5 +3,4 @@ namespace KnowledgeMining.Application.UseCases.Accept; public sealed record AcceptCandidateCommand( Guid CandidateId, Guid ReviewerId, - string? Note = null, - bool MergeIntoExistingUseCase = false); + string? Note = null); diff --git a/src/KnowledgeMining/KnowledgeMining.Application/UseCases/Accept/AcceptCandidateUseCase.cs b/src/KnowledgeMining/KnowledgeMining.Application/UseCases/Accept/AcceptCandidateUseCase.cs index 8fe9074b..a6fdc5ab 100644 --- a/src/KnowledgeMining/KnowledgeMining.Application/UseCases/Accept/AcceptCandidateUseCase.cs +++ b/src/KnowledgeMining/KnowledgeMining.Application/UseCases/Accept/AcceptCandidateUseCase.cs @@ -1,34 +1,18 @@ -using EncounterKnowledge.Application.UseCases.Encounters.Commands.AttachUseCase; using KnowledgeMining.Application.Ports; using KnowledgeMining.Domain.Aggregates.PatternCandidates; using KnowledgeMining.Domain.Errors; using KnowledgeMining.Domain.ValueObjects; using Wow.Kernel.Shared; -using Wow.Kernel.Shared.Mediator; using Wow.Kernel.Shared.Outbox; -using EkEncounterId = EncounterKnowledge.Domain.Aggregates.Encounters.EncounterId; -using EkProvenanceSource = EncounterKnowledge.Domain.ValueObjects.ProvenanceSource; -using EkUseCaseKey = EncounterKnowledge.Domain.ValueObjects.UseCaseKey; -using EkUseCaseKind = EncounterKnowledge.Domain.ValueObjects.UseCaseKind; namespace KnowledgeMining.Application.UseCases.Accept; /// -/// Promotes a curator-reviewed candidate into a real BehavioralUseCase -/// on EncounterKnowledge (unless -/// is true, in which case the use case status flips to Merged and no attach -/// is dispatched — the merge target is updated by the curator separately -/// via AttachUseCaseCommand with a manual override). -/// -/// The attach carries ProvenanceSource = Mined so the resulting -/// behavioral use case is auditable as machine-suggested (curator-approved), -/// not curator-authored from scratch. A subsequent manual edit by the -/// curator will flip the provenance to Manual via the domain rule defined -/// in commit 17d94a9. +/// Promotes a curator-reviewed candidate: flips its status to Merged and +/// records the reviewer and the optional resolution note. /// public sealed class AcceptCandidateUseCase( ICandidateRepository candidateRepository, - IMediator mediator, IOutboxUnitOfWork unitOfWork, ICurrentDateTimeProvider clock) : IUseCase { @@ -41,30 +25,9 @@ public async Task HandleAsync( if (candidate is null) return Result.Failure(KnowledgeMiningErrors.Candidate.NotFound); - Guid? createdUseCaseId = null; - if (!command.MergeIntoExistingUseCase) - { - var encounterId = await ResolveEncounterIdAsync( - candidate.BlizzardEncounterId, candidate.Difficulty, cancellationToken); - if (encounterId.HasValue) - { - createdUseCaseId = Guid.NewGuid(); - await mediator.ExecuteAsync(new AttachUseCaseCommand( - EncounterId: EkEncounterId.From(encounterId.Value), - Key: EkUseCaseKey.From(candidate.MinedUseCaseKey()), - Kind: MapKind(candidate.PatternKind), - PhaseOrdinal: candidate.PhaseOrdinal, - TargetSpellBlizzardIds: candidate.TargetSpellBlizzardIds, - ExpectedResponseBlizzardIds: candidate.ExpectedResponseBlizzardIds, - Priority: 5, - ProvenanceSource: EkProvenanceSource.Mined, - ReviewedBy: command.ReviewerId), cancellationToken); - } - } - var resolveResult = candidate.Accept( command.ReviewerId, - createdUseCaseId, + createdUseCaseId: null, new DateTimeOffset(clock.UtcNow, TimeSpan.Zero), command.Note); if (resolveResult.IsFailure) return resolveResult; @@ -76,25 +39,4 @@ await unitOfWork.ExecuteAsync( return Result.Success(); } - - /// - /// Resolves the EncounterKnowledge aggregate id for a (blizzardEncounterId, - /// difficulty) tuple by walking the mediator into EncounterKnowledge's read - /// model. Implemented as a port lookup by the caller in test settings; - /// here we just return null when the lookup is not wired — the candidate - /// transitions to Merged with no attach. Production wiring resolves the id - /// via a small read query (see follow-up commit). - /// - private static Task ResolveEncounterIdAsync( - int blizzardEncounterId, int difficulty, CancellationToken cancellationToken) - => Task.FromResult(null); - - private static EkUseCaseKind MapKind(PatternKind kind) => kind switch - { - PatternKind.ImportantSpell => EkUseCaseKind.Cooldown, - PatternKind.DangerSpell => EkUseCaseKind.Defensive, - PatternKind.BehavioralSequence => EkUseCaseKind.Interrupt, - PatternKind.DiminishingReturnsGroup => EkUseCaseKind.AoEStun, - _ => EkUseCaseKind.Cooldown, - }; }