Skip to content
This repository was archived by the owner on Jun 5, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
2aa0e54
chore(mutation): add per-BC Stryker tooling and exclude i18n registra…
claude May 23, 2026
2995f9b
test(coaching): raise Domain mutation score 53%->95%, Application 92%…
claude May 23, 2026
0a333d7
test(hotfixes): cover HotfixId boundary invariant, Domain mutation 64…
claude May 23, 2026
b2f3f1b
test(encounter-knowledge): Domain mutation 43%->94% via sociable + bo…
claude May 23, 2026
b69a005
test(knowledge-mining): Domain mutation 56%->97%
claude May 23, 2026
95514a2
test(comparison): Domain 54%->96%, Application 77%->97%
claude May 23, 2026
d9aa66d
test(log-ingestion): Application mutation 45%->82%
claude May 23, 2026
e50296f
test(log-ingestion): Domain mutation 69%->82% via boundary VO tests
claude May 23, 2026
95e69c6
test(spell-knowledge): Domain mutation 47%->65% via boundary VO tests
claude May 23, 2026
b2ee937
test(analysis): Domain mutation 61%->90%; document quality bar in CLA…
claude May 23, 2026
2d483a3
test(spell-knowledge): Domain mutation 65%->91%, Application 92%
claude May 23, 2026
7cd28a1
test(log-ingestion): Domain mutation 82%->90%
claude May 23, 2026
ae11b69
test(log-ingestion): Application mutation 82%->95%
claude May 23, 2026
eaef5fa
test(identity): Domain mutation 80%->95%
claude May 24, 2026
146d5a2
test(kernel): mutation 44%->95%; fix broken mutate-scope exclusions
claude May 24, 2026
d856774
test(desktop): cover EncounterSegmenter metadata parsing + segmentation
claude May 24, 2026
2124519
test(coverage): align coverage scope with mutation scope; close line-…
claude May 24, 2026
fd73a67
refactor(knowledge-mining): remove dead attach-on-accept scaffolding
claude May 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions coverlet.runsettings
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
<DataCollector friendlyName="XPlat code coverage">
<Configuration>
<Format>opencover</Format>
<Exclude>[*]*.Migrations.*,[*]*Tests*</Exclude>
<ExcludeByFile>**/Migrations/*.cs,**/Program.cs,**/AssemblyReference.cs</ExcludeByFile>
<Exclude>[*]*.Migrations.*,[*]*Tests*,[*.Infrastructure]*</Exclude>
<ExcludeByFile>**/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</ExcludeByFile>
<SingleHit>false</SingleHit>
<UseSourceLink>true</UseSourceLink>
<IncludeTestAssembly>false</IncludeTestAssembly>
Expand Down
13 changes: 13 additions & 0 deletions dotnet-tools.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-stryker": {
"version": "4.14.2",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

What is the latest version of dotnet-stryker NuGet package and is version 4.14.2 available?

💡 Result:

Latest version of the dotnet-stryker NuGet package is 4.14.1 [1]. Yes—version 4.14.2 is available: I could not find dotnet-stryker 4.14.2 on NuGet; the NuGet Gallery page lists 4.14.1 as the latest version, and no 4.14.2 entry was surfaced in the results I checked [1].

Citations:


🌐 Web query:

dotnet-stryker 4.14.2 NuGet available?

💡 Result:

No. As of 2026-05-23, the NuGet package for dotnet-stryker shows 4.14.1 as the latest available version; I could not find a NuGet package entry for dotnet-stryker 4.14.2 on nuget.org. What I found: - NuGet Gallery lists dotnet-stryker version 4.14.1, with install commands for the.NET tool. [1] - The Stryker repository release/tag I found for dotnet-stryker is 4.14.1 (not 4.14.2). [2] A GitHub PR exists titled “Bump dotnet-stryker from 4.14.1 to 4.14.2”, but that is not proof that 4.14.2 has been published to NuGet. [3]

Citations:


Update dotnet-stryker in dotnet-tools.json—NuGet doesn’t publish 4.14.2

NuGet Gallery lists dotnet-stryker latest as 4.14.1 and there’s no published package entry for 4.14.2, so dotnet-tools.json specifying 4.14.2 will likely fail tool installation. Update version to 4.14.1 (or another version that’s actually on nuget.org). [1]

[1] https://www.nuget.org/packages/dotnet-stryker

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dotnet-tools.json` at line 6, The dotnet-tools.json entry for dotnet-stryker
specifies an unavailable version; update the "version" field for dotnet-stryker
from "4.14.2" to a valid published version (e.g., "4.14.1") so the tool can be
installed; locate the dotnet-stryker entry and change the "version" value
accordingly.

"commands": [
"dotnet-stryker"
],
"rollForward": false
}
}
}
Original file line number Diff line number Diff line change
@@ -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<ArgumentException>();
}

[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<ArgumentException>();
}

[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!);
}
}
Original file line number Diff line number Diff line change
@@ -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<Analysis.Domain.Events.RunAnalysisComputedEvent>().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);
}
}
Loading
Loading