Files
przygody-pana-cegly/progression.tests/ProgressionServiceTests.cs
Gabriel Kaszewski a1c9042652 feat: level progression moves to the new domain (slice 10b, step 1)
narrowest safe step: only level identity and progression move. Coins,
lives, skills and death stay entirely on GameStateStore and behave as
before, so nothing is tracked in two places.

GameManager addresses levels by stable LevelId via the catalogue;
LevelScenes is gone, so reordering levels no longer invalidates saves.
Every level entry goes through EnterLevel, which fixes LevelStarted never
firing on the first level or after Continue - ghost recording and splits
have been dead outside mid-campaign.

GoTo announces after a frame, once the incoming scene is actually
current. ChangeSceneToPacked is deferred, so the old code handed
GhostEventHandler the scene being torn down.

FinishLevel takes LevelTime? - GetCurrentLevelTime can legitimately be 0
and inventing a value is exactly the bad data LevelTime refuses.

Continue distinguishes absent from corrupt and refuses to overwrite a
corrupt save. Statistics move to the Profile. 69 tests.
2026-07-28 19:57:01 +02:00

262 lines
10 KiB
C#

namespace Mr.BrickAdventures.Progression.Tests;
public class ProgressionServiceTests
{
private static readonly LevelId First = LevelId.Of("village_1");
private static readonly LevelId Second = LevelId.Of("village_2");
private static readonly PlaythroughId Id =
PlaythroughId.Of(new Guid("44444444-4444-4444-4444-444444444444"));
private static Campaign TwoLevels() => Campaign.Of(new[] { First, Second });
private static ProgressionService Service()
{
var service = new ProgressionService(
Profile.Empty(),
new InMemoryPlaythroughStore(),
new InMemoryProfileStore(),
TwoLevels(),
SkillCatalogue.Empty(),
startingLives: Lives.Of(3));
service.StartPlaythrough(Id);
service.DrainEvents(); // setup announces; tests assert about the operation under test
return service;
}
[Fact]
public void Entering_a_level_always_announces_that_it_started()
{
// Today LevelStarted is emitted only by TryToGoToNextLevel, so it never fires for the first
// level of a new game or for anything after a Continue. GhostManager.StartRecording and the
// speedrun timer both hang off it, which is why neither works except mid-campaign.
var service = Service();
service.EnterLevel(First);
Assert.Contains(service.DrainEvents(), e => e is LevelStarted started && started.Level.Equals(First));
}
[Fact]
public void Entering_a_level_the_playthrough_cannot_reach_is_refused()
{
var service = Service();
Assert.False(service.EnterLevel(Second));
Assert.Empty(service.DrainEvents());
}
[Fact]
public void Two_death_events_in_one_attempt_cost_one_life_and_persist_the_result()
{
// End to end this time. Also note the save: today nothing persists on death, so quitting
// after dying gives the lives back.
var store = new InMemoryPlaythroughStore();
var service = new ProgressionService(
Profile.Empty(), store, new InMemoryProfileStore(),
TwoLevels(), SkillCatalogue.Empty(), Lives.Of(3));
service.StartPlaythrough(Id);
service.EnterLevel(First);
service.HandlePlayerDeath();
service.HandlePlayerDeath();
var reloaded = store.Load(Id);
Assert.Equal(LoadOutcome.Loaded, reloaded.Outcome);
Assert.Equal(2, reloaded.Value!.Lives.Value);
}
private static readonly SkillId BrickThrow = SkillId.Of("brick_throw");
private static readonly SkillId BrickShield = SkillId.Of("brick_shield");
/// <summary>brick_throw costs 10 then 25; brick_shield has no authored upgrades at all.</summary>
private static SkillCatalogue Skills() => SkillCatalogue.Of(
new Dictionary<SkillId, IReadOnlyList<Coins>>
{
[BrickThrow] = new[] { Coins.Of(10), Coins.Of(25) },
[BrickShield] = Array.Empty<Coins>(),
});
private static ProgressionService ServiceWithSkills(IPlaythroughStore? store = null)
{
var service = new ProgressionService(
Profile.Empty(), store ?? new InMemoryPlaythroughStore(), new InMemoryProfileStore(),
TwoLevels(), Skills(), Lives.Of(3));
service.StartPlaythrough(Id);
service.EnterLevel(First);
service.DrainEvents(); // setup announces; tests assert about the operation under test
return service;
}
[Fact]
public void A_skill_that_cannot_be_afforded_is_refused_and_records_nothing()
{
// The bank is empty and the attempt has found nothing, so there is no way to pay. Refusing
// here is what keeps SettleCompletion's negative-bank assertion unreachable.
var service = ServiceWithSkills();
Assert.Equal(PurchaseOutcome.NotAffordable, service.PurchaseSkill(BrickThrow));
}
[Fact]
public void A_skill_with_no_authored_upgrades_cannot_be_bought()
{
// Five skills currently have no upgrades/ entries. TryUnlockSkill reads Upgrades[0].Cost on
// them, which would throw — it is unreachable today only because MarketplaceButton hides
// them. Here it is a stated outcome instead of a hidden landmine.
var service = ServiceWithSkills();
Assert.Equal(PurchaseOutcome.NotPurchasable, service.PurchaseSkill(BrickShield));
}
[Fact]
public void A_skill_already_at_its_top_level_cannot_be_upgraded_further()
{
// TryUpgradeSkill guards with Level >= MaxLevel but then indexes Upgrades[skill.Level], and
// BrickArmorSkillComponent indexes Upgrades[Level - 1] unguarded.
var service = ServiceWithSkills();
service.RecordCoinsFound(Coins.Of(35));
// Unlock to level 1 for 10, upgrade to level 2 for 25 — both within one Attempt, so this
// also covers reading the current level from the ledger rather than only from the Playthrough.
Assert.Equal(PurchaseOutcome.Purchased, service.PurchaseSkill(BrickThrow));
Assert.Equal(PurchaseOutcome.Purchased, service.PurchaseSkill(BrickThrow));
Assert.Equal(PurchaseOutcome.AlreadyAtTopLevel, service.PurchaseSkill(BrickThrow));
}
[Fact]
public void Finishing_a_level_for_the_first_time_banks_advances_and_persists_both_stores()
{
var playthroughStore = new InMemoryPlaythroughStore();
var profileStore = new InMemoryProfileStore();
var service = new ProgressionService(
Profile.Empty(), playthroughStore, profileStore, TwoLevels(), Skills(), Lives.Of(3));
service.StartPlaythrough(Id);
service.EnterLevel(First);
service.RecordCoinsFound(Coins.Of(50));
service.FinishLevel(LevelTime.Of(31.5));
var reloaded = playthroughStore.Load(Id).Value!;
Assert.Equal(Coins.Of(50), reloaded.Bank);
Assert.Equal(Second, reloaded.CurrentLevel);
Assert.True(reloaded.HasCleared(First));
Assert.Equal(LevelTime.Of(31.5), profileStore.Load().Value!.BestTimeFor(First));
}
[Fact]
public void Replaying_a_cleared_level_improves_the_best_time_but_banks_nothing()
{
var playthroughStore = new InMemoryPlaythroughStore();
var profileStore = new InMemoryProfileStore();
var service = new ProgressionService(
Profile.Empty(), playthroughStore, profileStore, TwoLevels(), Skills(), Lives.Of(3));
service.StartPlaythrough(Id);
service.EnterLevel(First);
service.RecordCoinsFound(Coins.Of(50));
service.FinishLevel(LevelTime.Of(40.0));
// Back for a faster run, picking up coins along the way.
service.EnterLevel(First);
service.RecordCoinsFound(Coins.Of(99));
service.FinishLevel(LevelTime.Of(30.0));
var reloaded = playthroughStore.Load(Id).Value!;
Assert.Equal(Coins.Of(50), reloaded.Bank);
Assert.Equal(Second, reloaded.CurrentLevel);
Assert.Equal(LevelTime.Of(30.0), profileStore.Load().Value!.BestTimeFor(First));
}
[Fact]
public void Resuming_a_saved_playthrough_restores_where_it_had_got_to()
{
var store = new InMemoryPlaythroughStore();
var first = new ProgressionService(
Profile.Empty(), store, new InMemoryProfileStore(), TwoLevels(), Skills(), Lives.Of(3));
first.StartPlaythrough(Id);
first.EnterLevel(First);
first.FinishLevel(LevelTime.Of(20.0));
// A fresh service, as though the game had been restarted.
var resumed = new ProgressionService(
Profile.Empty(), store, new InMemoryProfileStore(), TwoLevels(), Skills(), Lives.Of(3));
Assert.True(resumed.LoadPlaythrough(Id));
Assert.Equal(Second, resumed.CurrentLevel);
Assert.True(resumed.HasCleared(First));
Assert.True(resumed.CanEnter(First));
Assert.True(resumed.CanEnter(Second));
}
[Fact]
public void Resuming_when_there_is_no_save_leaves_the_service_not_playing()
{
var service = new ProgressionService(
Profile.Empty(), new InMemoryPlaythroughStore(), new InMemoryProfileStore(),
TwoLevels(), Skills(), Lives.Of(3));
Assert.False(service.LoadPlaythrough(Id));
Assert.False(service.IsPlaying);
}
[Fact]
public void Finishing_without_a_measured_time_still_settles_the_level_but_records_no_best_time()
{
// GetCurrentLevelTime returns 0 when the speedrun timer was never started. Passing null says
// so honestly rather than banking a zero-second record.
var profileStore = new InMemoryProfileStore();
var service = new ProgressionService(
Profile.Empty(), new InMemoryPlaythroughStore(), profileStore,
TwoLevels(), Skills(), Lives.Of(3));
service.StartPlaythrough(Id);
service.EnterLevel(First);
service.FinishLevel(null);
Assert.True(service.HasCleared(First));
Assert.Equal(Second, service.CurrentLevel);
Assert.Null(profileStore.Load().Value!.BestTimeFor(First));
}
[Fact]
public void An_unreachable_level_cannot_be_entered_even_though_it_is_in_the_campaign()
{
var service = Service();
Assert.True(service.CanEnter(First));
Assert.False(service.CanEnter(Second));
}
[Fact]
public void Picking_up_a_coin_announces_the_new_total_so_the_hud_never_polls()
{
// CoinsChanged is emitted from four places today and subscribed by nobody, while Hud rebuilds
// three translated strings every frame in _Process. One announcement, one read path.
var service = ServiceWithSkills();
service.RecordCoinsFound(Coins.Of(7));
var announced = service.DrainEvents().OfType<TotalCoinsChanged>().Single();
Assert.Equal(Coins.Of(7), announced.Total);
Assert.Equal(Coins.Of(7), service.TotalCoins);
}
[Fact]
public void Dying_announces_both_the_remaining_lives_and_the_coins_reverting_to_the_bank()
{
var service = ServiceWithSkills();
service.RecordCoinsFound(Coins.Of(40));
service.HandlePlayerDeath();
var events = service.DrainEvents();
Assert.Equal(2, events.OfType<LivesChanged>().Single().Remaining.Value);
// The attempt is discarded, so the 40 found coins were never banked.
Assert.Equal(Coins.Zero, events.OfType<TotalCoinsChanged>().Last().Total);
Assert.Equal(Coins.Zero, service.TotalCoins);
}
}