using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Moq; using NUnit.Framework; using SVSim.Database; using SVSim.Database.Entities.Story; using SVSim.Database.Models; using SVSim.Database.Repositories.Story; using SVSim.Database.Services; using SVSim.EmulatedEntrypoint.Models.Dtos.Story; using SVSim.EmulatedEntrypoint.Services; using SVSim.UnitTests.Infrastructure; namespace SVSim.UnitTests.Story; [TestFixture] public class StoryServiceTests { private Mock _master = null!; private Mock _viewer = null!; private StoryService _service = null!; [SetUp] public void SetUp() { _master = new Mock(); _viewer = new Mock(); // Non-reward tests never exercise the DB/reward path; use a stub InMemory context. var db = StoryServiceTestHelpers.NewInMemoryDb(nameof(SetUp)); var rewards = new RewardGrantService(db); _service = new StoryService( _master.Object, _viewer.Object, rewards: rewards, db: db, configService: StoryServiceTestHelpers.NewConfigService(), deckRepository: new Mock().Object, logger: NullLogger.Instance); } /// /// Creates a backed by a real from /// , seeds a viewer with RedEther reset to 0, and returns the /// service + viewer's actual ID. /// The caller owns the factory lifetime; keep it alive for post-call assertions. /// private StoryService NewServiceWithSeededViewer( SVSimTestFactory factory, out IServiceScope scope, out long viewerId) { viewerId = factory.SeedViewerAsync().GetAwaiter().GetResult(); // Reset RedEther to 0 so tests can assert literal post-state totals (spec requirement). var seedId = viewerId; using (var resetScope = factory.Services.CreateScope()) { var resetDb = resetScope.ServiceProvider.GetRequiredService(); var v = resetDb.Viewers.First(x => x.Id == seedId); v.Currency.RedEther = 0; resetDb.SaveChanges(); } scope = factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var rewards = scope.ServiceProvider.GetRequiredService(); return new StoryService( _master.Object, _viewer.Object, rewards: rewards, db: db, configService: StoryServiceTestHelpers.NewConfigService(), deckRepository: new Mock().Object, logger: NullLogger.Instance); } private static StoryChapter Ch(int storyId, int section, int chara, string chapId, string nextId, bool battle = true, int? sbsId = null) => new() { StoryId = storyId, SectionId = section, CharaId = chara, ChapterId = chapId, NextChapterId = nextId, BattleExists = battle, SpecialBattleSettingId = sbsId, IsSkipEnabled = true }; [Test] public async Task GetInfoAsync_chapter1_always_released_chapter2_locked_when_no_progress() { var chapters = new List { Ch(100, 1, 2, "1", "2"), Ch(101, 1, 2, "2", "3"), }; _master.Setup(m => m.GetChaptersBySectionCharaAsync(1, 2)).ReturnsAsync(chapters); _viewer.Setup(v => v.GetProgressForChaptersAsync(7L, It.IsAny>())) .ReturnsAsync(new Dictionary()); _viewer.Setup(v => v.GetBranchUnlockedStoryIdsAsync(7L, It.IsAny>())) .ReturnsAsync(new HashSet()); var resp = await _service.GetInfoAsync(StoryApiType.Main, 1, 2, viewerId: 7L); var ch1 = resp.StoryMasterList.Single(c => c.ChapterId == "1"); var ch2 = resp.StoryMasterList.Single(c => c.ChapterId == "2"); Assert.That(ch1.IsReleased, Is.True); Assert.That(ch2.IsReleased, Is.False); } [Test] public async Task GetInfoAsync_chapter2_released_after_chapter1_finished() { var chapters = new List { Ch(100, 1, 2, "1", "2"), Ch(101, 1, 2, "2", "3"), }; _master.Setup(m => m.GetChaptersBySectionCharaAsync(1, 2)).ReturnsAsync(chapters); _viewer.Setup(v => v.GetProgressForChaptersAsync(7L, It.IsAny>())) .ReturnsAsync(new Dictionary { { 100, new ViewerStoryProgress { ViewerId = 7, StoryId = 100, IsFinish = true } } }); _viewer.Setup(v => v.GetBranchUnlockedStoryIdsAsync(7L, It.IsAny>())) .ReturnsAsync(new HashSet()); var resp = await _service.GetInfoAsync(StoryApiType.Main, 1, 2, viewerId: 7L); Assert.That(resp.StoryMasterList.Single(c => c.ChapterId == "2").IsReleased, Is.True); } [Test] public async Task GetInfoAsync_chapter2_released_after_skip_clear_too() { var chapters = new List { Ch(100, 1, 2, "1", "2"), Ch(101, 1, 2, "2", "3"), }; _master.Setup(m => m.GetChaptersBySectionCharaAsync(1, 2)).ReturnsAsync(chapters); _viewer.Setup(v => v.GetProgressForChaptersAsync(7L, It.IsAny>())) .ReturnsAsync(new Dictionary { { 100, new ViewerStoryProgress { StoryId = 100, IsSkipped = true } } }); _viewer.Setup(v => v.GetBranchUnlockedStoryIdsAsync(7L, It.IsAny>())) .ReturnsAsync(new HashSet()); var resp = await _service.GetInfoAsync(StoryApiType.Main, 1, 2, viewerId: 7L); Assert.That(resp.StoryMasterList.Single(c => c.ChapterId == "2").IsReleased, Is.True); } [Test] public async Task GetInfoAsync_branch_children_locked_without_explicit_unlock() { var chapters = new List { Ch(200, 17, 500901, "2", "3a 3b 3c"), Ch(201, 17, 500901, "3a", "4a"), Ch(202, 17, 500901, "3b", "4b"), }; _master.Setup(m => m.GetChaptersBySectionCharaAsync(17, 500901)).ReturnsAsync(chapters); _viewer.Setup(v => v.GetProgressForChaptersAsync(7L, It.IsAny>())) .ReturnsAsync(new Dictionary { { 200, new ViewerStoryProgress { StoryId = 200, IsFinish = true } } }); _viewer.Setup(v => v.GetBranchUnlockedStoryIdsAsync(7L, It.IsAny>())) .ReturnsAsync(new HashSet()); var resp = await _service.GetInfoAsync(StoryApiType.Main, 17, 500901, viewerId: 7L); Assert.That(resp.StoryMasterList.Single(c => c.ChapterId == "3a").IsReleased, Is.False); Assert.That(resp.StoryMasterList.Single(c => c.ChapterId == "3b").IsReleased, Is.False); } [Test] public async Task GetInfoAsync_branch_child_released_when_unlock_exists() { var chapters = new List { Ch(200, 17, 500901, "2", "3a 3b 3c"), Ch(201, 17, 500901, "3a", "4a"), Ch(202, 17, 500901, "3b", "4b"), }; _master.Setup(m => m.GetChaptersBySectionCharaAsync(17, 500901)).ReturnsAsync(chapters); _viewer.Setup(v => v.GetProgressForChaptersAsync(7L, It.IsAny>())) .ReturnsAsync(new Dictionary { { 200, new ViewerStoryProgress { StoryId = 200, IsFinish = true } } }); _viewer.Setup(v => v.GetBranchUnlockedStoryIdsAsync(7L, It.IsAny>())) .ReturnsAsync(new HashSet { 201 }); // only 3a unlocked var resp = await _service.GetInfoAsync(StoryApiType.Main, 17, 500901, viewerId: 7L); Assert.That(resp.StoryMasterList.Single(c => c.ChapterId == "3a").IsReleased, Is.True); Assert.That(resp.StoryMasterList.Single(c => c.ChapterId == "3b").IsReleased, Is.False); } [Test] public async Task GetLeaderSelectAsync_untouched_chara_has_current_chapter_1() { _master.Setup(m => m.GetSectionsByFamilyAsync(StoryApiType.Main)) .ReturnsAsync(new List { new() { Id = 1, IsLeaderSelect = true } }); foreach (int chara in new[] { 1, 2, 3, 4, 5, 6, 7, 8 }) { _master.Setup(m => m.GetChaptersBySectionCharaAsync(1, chara)) .ReturnsAsync(new List { Ch(100 + chara, 1, chara, "1", "2") }); } _viewer.Setup(v => v.GetProgressForChaptersAsync(7L, It.IsAny>())) .ReturnsAsync(new Dictionary()); var resp = await _service.GetLeaderSelectAsync(StoryApiType.Main, 1, viewerId: 7L); Assert.That(resp.LeaderList, Has.Count.EqualTo(8)); Assert.That(resp.LeaderList.All(l => l.CurrentChapter == 1)); Assert.That(resp.LeaderCount, Is.EqualTo(8)); } [Test] public async Task GetLeaderSelectAsync_after_clearing_chapter5_current_chapter_is_6() { _master.Setup(m => m.GetSectionsByFamilyAsync(StoryApiType.Main)) .ReturnsAsync(new List { new() { Id = 1, IsLeaderSelect = true } }); _master.Setup(m => m.GetChaptersBySectionCharaAsync(1, 2)).ReturnsAsync(new List { Ch(101, 1, 2, "1", "2"), Ch(102, 1, 2, "2", "3"), Ch(103, 1, 2, "3", "4"), Ch(104, 1, 2, "4", "5"), Ch(105, 1, 2, "5", "6"), Ch(106, 1, 2, "6", "0"), }); foreach (int chara in new[] { 1, 3, 4, 5, 6, 7, 8 }) _master.Setup(m => m.GetChaptersBySectionCharaAsync(1, chara)).ReturnsAsync(new List()); _viewer.Setup(v => v.GetProgressForChaptersAsync(7L, It.IsAny>())) .ReturnsAsync(new Dictionary { { 101, new ViewerStoryProgress { StoryId = 101, IsFinish = true } }, { 102, new ViewerStoryProgress { StoryId = 102, IsFinish = true } }, { 103, new ViewerStoryProgress { StoryId = 103, IsFinish = true } }, { 104, new ViewerStoryProgress { StoryId = 104, IsFinish = true } }, { 105, new ViewerStoryProgress { StoryId = 105, IsFinish = true } }, }); var resp = await _service.GetLeaderSelectAsync(StoryApiType.Main, 1, viewerId: 7L); var chara2 = resp.LeaderList.Single(l => l.CharaId == 2); Assert.That(chara2.CurrentChapter, Is.EqualTo(6)); Assert.That(chara2.IsFinished, Is.False); // chapter 6 not done yet } [Test] public async Task StartAsync_returns_sbs_payload_for_chapter_with_sbs_id() { var chapter = Ch(101, 1, 2, "2", "3", sbsId: 8); var sbs = new SpecialBattleSetting { Id = 8, PlayerStartLife = 20, EnemyStartLife = 10, Note = "Disaster Tree ch2&3" }; _master.Setup(m => m.GetChapterByIdAsync(101)).ReturnsAsync(chapter); _master.Setup(m => m.GetSbsByIdAsync(8)).ReturnsAsync(sbs); var resp = await _service.StartAsync(StoryApiType.Main, new[] { 101 }, viewerId: 7L); Assert.That(resp.ContainsKey("0"), Is.True); var slot = (StartSlotWithSbs)resp["0"]; Assert.That(slot.SpecialBattleSetting.Id, Is.EqualTo("8")); Assert.That(slot.SpecialBattleSetting.EnemyStartLife, Is.EqualTo("10")); Assert.That(resp.ContainsKey("mission_parameter"), Is.True); Assert.That(((Array)resp["mission_parameter"]).Length, Is.EqualTo(0)); } [Test] public async Task StartAsync_returns_empty_slot_for_chapter_without_sbs_id() { var chapter = Ch(100, 1, 2, "1", "2", sbsId: null); _master.Setup(m => m.GetChapterByIdAsync(100)).ReturnsAsync(chapter); var resp = await _service.StartAsync(StoryApiType.Main, new[] { 100 }, viewerId: 7L); Assert.That(resp["0"], Is.InstanceOf()); Assert.That(((Array)resp["0"]).Length, Is.EqualTo(0)); } [Test] public async Task FinishAsync_skip_shape_sets_isSkipped_and_grants_nothing() { var chapter = Ch(100, 1, 2, "1", "2"); chapter.Rewards.Add(new StoryChapterReward { RewardType = 1, RewardNumber = 20 }); _master.Setup(m => m.GetChapterByIdAsync(100)).ReturnsAsync(chapter); _viewer.Setup(v => v.GetProgressForChaptersAsync(7L, It.IsAny>())) .ReturnsAsync(new Dictionary()); var req = new FinishRequest { StoryId = 100, IsFinish = 1, SelectionChapterId = null, IsSelectAnotherEnd = false, ClassId = null, // play-shape absence → skip }; var resp = await _service.FinishAsync(StoryApiType.Main, req, viewerId: 7L); Assert.That(resp.RewardList, Is.Empty); Assert.That(resp.StoryRewardList, Is.Empty); Assert.That(resp.GetClassExperience, Is.EqualTo("0")); _viewer.Verify(v => v.UpsertProgressAsync(7L, 100, null, true), Times.Once); } [Test] public async Task FinishAsync_skip_shape_with_selection_unlocks_branch() { var parent = Ch(200, 17, 500901, "2", "3a 3b 3c"); var branch3b = Ch(202, 17, 500901, "3b", "4b"); _master.Setup(m => m.GetChapterByIdAsync(200)).ReturnsAsync(parent); _master.Setup(m => m.GetChaptersBySectionCharaAsync(17, 500901)) .ReturnsAsync(new List { parent, branch3b }); _viewer.Setup(v => v.GetProgressForChaptersAsync(7L, It.IsAny>())) .ReturnsAsync(new Dictionary()); var req = new FinishRequest { StoryId = 200, IsFinish = 1, SelectionChapterId = "3b", IsSelectAnotherEnd = false, }; await _service.FinishAsync(StoryApiType.Main, req, viewerId: 7L); _viewer.Verify(v => v.UpsertBranchUnlockAsync(7L, 202), Times.Once); } [Test] public async Task FinishAsync_play_shape_first_clear_grants_rewards_and_xp() { using var factory = new SVSimTestFactory(); var svc = NewServiceWithSeededViewer(factory, out var scope, out var viewerId); using (scope) { var chapter = Ch(100, 1, 2, "1", "2"); chapter.Rewards.Add(new StoryChapterReward { RewardType = 1, RewardDetailId = 0, RewardNumber = 100 }); _master.Setup(m => m.GetChapterByIdAsync(100)).ReturnsAsync(chapter); _viewer.Setup(v => v.GetProgressForChaptersAsync(viewerId, It.IsAny>())) .ReturnsAsync(new Dictionary()); var req = new FinishRequest { StoryId = 100, IsFinish = 1, ClassId = 2, EvolveCount = 0, TotalTurn = 5, DeckNo = 1, UseBuildDeck = 0, DeckFormat = 1, Mission = new(), RecoveryData = null, }; var resp = await svc.FinishAsync(StoryApiType.Main, req, viewerId: viewerId); // Viewer started at RedEther=0; grant of 100 → post-state total = 100. Assert.That(resp.RewardList, Has.Count.EqualTo(1)); Assert.That(resp.RewardList[0].RewardNum, Is.EqualTo("100")); Assert.That(resp.GetClassExperience, Is.EqualTo("200")); _viewer.Verify(v => v.UpsertProgressAsync(viewerId, 100, true, null), Times.Once); // Confirm currency persisted: fetch fresh viewer from a new scope. using var verifyScope = factory.Services.CreateScope(); var db2 = verifyScope.ServiceProvider.GetRequiredService(); var freshViewer = await db2.Viewers.FirstAsync(v => v.Id == viewerId); Assert.That(freshViewer.Currency.RedEther, Is.EqualTo(100UL)); } } [Test] public async Task FinishAsync_play_shape_replay_does_not_double_grant() { using var factory = new SVSimTestFactory(); var svc = NewServiceWithSeededViewer(factory, out var scope, out var viewerId); using (scope) { var chapter = Ch(100, 1, 2, "1", "2"); chapter.Rewards.Add(new StoryChapterReward { RewardType = 1, RewardDetailId = 0, RewardNumber = 100 }); _master.Setup(m => m.GetChapterByIdAsync(100)).ReturnsAsync(chapter); _viewer.Setup(v => v.GetProgressForChaptersAsync(viewerId, It.IsAny>())) .ReturnsAsync(new Dictionary { { 100, new ViewerStoryProgress { StoryId = 100, IsFinish = true } } }); var req = new FinishRequest { StoryId = 100, IsFinish = 1, ClassId = 2 }; var resp = await svc.FinishAsync(StoryApiType.Main, req, viewerId: viewerId); Assert.That(resp.RewardList, Is.Empty); // Currency must not have changed from its seed value of 0. using var verifyScope = factory.Services.CreateScope(); var db2 = verifyScope.ServiceProvider.GetRequiredService(); var freshViewer = await db2.Viewers.FirstAsync(v => v.Id == viewerId); Assert.That(freshViewer.Currency.RedEther, Is.EqualTo(0UL)); } } [Test] public async Task FinishAsync_battle_after_skip_still_grants_rewards() { using var factory = new SVSimTestFactory(); var svc = NewServiceWithSeededViewer(factory, out var scope, out var viewerId); using (scope) { var chapter = Ch(100, 1, 2, "1", "2"); chapter.Rewards.Add(new StoryChapterReward { RewardType = 1, RewardDetailId = 0, RewardNumber = 100 }); _master.Setup(m => m.GetChapterByIdAsync(100)).ReturnsAsync(chapter); // Previously skipped but never finished — should be treated as first clear. _viewer.Setup(v => v.GetProgressForChaptersAsync(viewerId, It.IsAny>())) .ReturnsAsync(new Dictionary { { 100, new ViewerStoryProgress { StoryId = 100, IsFinish = false, IsSkipped = true } } }); var req = new FinishRequest { StoryId = 100, IsFinish = 1, ClassId = 2 }; var resp = await svc.FinishAsync(StoryApiType.Main, req, viewerId: viewerId); Assert.That(resp.RewardList, Has.Count.EqualTo(1)); Assert.That(resp.RewardList[0].RewardNum, Is.EqualTo("100")); using var verifyScope = factory.Services.CreateScope(); var db2 = verifyScope.ServiceProvider.GetRequiredService(); var freshViewer = await db2.Viewers.FirstAsync(v => v.Id == viewerId); Assert.That(freshViewer.Currency.RedEther, Is.EqualTo(100UL)); } } } internal static class StoryServiceTestHelpers { public static SVSim.Database.Services.IGameConfigService NewConfigService() { var mock = new Mock(); mock.Setup(s => s.Get()) .Returns(new SVSim.Database.Models.Config.StoryConfig { ClassXpPerClear = 200 }); return mock.Object; } /// /// Returns a minimal backed by the EF InMemory provider. /// Safe for non-reward tests that never actually query the DB. /// Each call should use a unique to prevent test bleed-through. /// public static SVSimDbContext NewInMemoryDb(string dbName) { var options = new Microsoft.EntityFrameworkCore.DbContextOptionsBuilder() .UseInMemoryDatabase(dbName) .Options; return new SVSimDbContext( Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance, options); } }