feat(progression): remaining slice 1 value objects

Lives (floors at zero, exhaustion observable), LevelId, SkillId,
PlaythroughId (never self-generates, keeps tests deterministic),
StatName enum, LevelTime rejects NaN/infinity.

14 tests. Arithmetic/comparison members deferred to the slices whose
behaviour needs them rather than added speculatively.
This commit is contained in:
2026-07-28 18:27:26 +02:00
parent 313a5315b1
commit 9767941c65
12 changed files with 196 additions and 3 deletions

View File

@@ -14,4 +14,10 @@ public class CoinsTests
// which is the safe outcome; clamping would silently consume what wasn't there.
Assert.Equal(Coins.Of(10), remaining);
}
[Fact]
public void A_negative_quantity_of_coins_cannot_exist()
{
Assert.Throws<ArgumentOutOfRangeException>(() => Coins.Of(-1));
}
}

View File

@@ -0,0 +1,14 @@
namespace Mr.BrickAdventures.Progression.Tests;
public class LevelIdTests
{
[Theory]
[InlineData("")]
[InlineData(" ")]
public void A_blank_id_is_not_a_level(string value)
{
// Levels are addressed by a stable authored id, never by position in a list. An unset
// field in a .tres would otherwise become a valid id that silently collides with itself.
Assert.Throws<ArgumentException>(() => LevelId.Of(value));
}
}

View File

@@ -10,4 +10,14 @@ public class LevelTimeTests
// files. Rejecting zero makes that unrepresentable rather than merely unlikely.
Assert.Throws<ArgumentOutOfRangeException>(() => LevelTime.Of(0));
}
[Theory]
[InlineData(double.NaN)]
[InlineData(double.PositiveInfinity)]
public void A_time_that_is_not_a_finite_number_is_rejected(double seconds)
{
// Times are derived from subtracting tick counts, so a stale or unset start time can
// produce NaN or infinity rather than a plausible-but-wrong number.
Assert.Throws<ArgumentOutOfRangeException>(() => LevelTime.Of(seconds));
}
}

View File

@@ -0,0 +1,21 @@
namespace Mr.BrickAdventures.Progression.Tests;
public class LivesTests
{
[Fact]
public void Losing_the_last_life_exhausts_them()
{
var remaining = Lives.Of(1).Decrement();
Assert.Equal(0, remaining.Value);
Assert.True(remaining.IsExhausted);
}
[Fact]
public void Decrementing_when_already_exhausted_stays_at_zero()
{
var remaining = Lives.Of(0).Decrement();
Assert.Equal(0, remaining.Value);
}
}

View File

@@ -0,0 +1,13 @@
namespace Mr.BrickAdventures.Progression.Tests;
public class PlaythroughIdTests
{
[Fact]
public void The_empty_guid_is_not_a_playthrough()
{
// Ids arrive from an adapter, never from Guid.NewGuid() inside the domain — otherwise every
// test touching identity would be non-deterministic. That makes a default-valued guid a
// realistic mistake worth refusing.
Assert.Throws<ArgumentException>(() => PlaythroughId.Of(Guid.Empty));
}
}

View File

@@ -0,0 +1,20 @@
namespace Mr.BrickAdventures.Progression.Tests;
public class SkillIdTests
{
[Theory]
[InlineData("")]
[InlineData(" ")]
public void A_blank_id_is_not_a_skill(string value)
{
Assert.Throws<ArgumentException>(() => SkillId.Of(value));
}
[Fact]
public void Ids_with_the_same_text_are_the_same_skill()
{
// Owned skills are held in a dictionary keyed by id, so value equality is what makes the
// type usable at all rather than a convenience.
Assert.Equal(SkillId.Of("brick_throw"), SkillId.Of("brick_throw"));
}
}