Story
This commit is contained in:
@@ -25,12 +25,12 @@ public class GameConfigurationJsonbTests
|
||||
var rows = await db.GameConfigs.AsNoTracking().ToListAsync();
|
||||
var byName = rows.ToDictionary(r => r.SectionName);
|
||||
|
||||
// One row per [ConfigSection]-marked POCO (7 sections today: Player, DefaultGrants,
|
||||
// DefaultLoadout, Challenge, Rotation, PackRates, MyRotationSchedule).
|
||||
// One row per [ConfigSection]-marked POCO (8 sections today: Player, DefaultGrants,
|
||||
// DefaultLoadout, Challenge, Rotation, PackRates, MyRotationSchedule, Story).
|
||||
Assert.That(byName.Keys, Is.EquivalentTo(new[]
|
||||
{
|
||||
"Player", "DefaultGrants", "DefaultLoadout", "Challenge", "Rotation", "PackRates",
|
||||
"MyRotationSchedule",
|
||||
"MyRotationSchedule", "Story",
|
||||
}));
|
||||
|
||||
var mrSchedule = JsonSerializer.Deserialize<MyRotationScheduleConfig>(byName["MyRotationSchedule"].ValueJson)!;
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.8" />
|
||||
<PackageReference Include="Moq" Version="4.20.70" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -49,6 +50,10 @@
|
||||
<Content Include="..\SVSim.Bootstrap\Data\test-fixtures\*.json" Link="Data\prod-captures\%(Filename)%(Extension)">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<!-- StoryImporter integration test fixtures — copied to Story\Fixtures\ in test output. -->
|
||||
<Content Include="Story\Fixtures\*.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
34
SVSim.UnitTests/Story/Fixtures/importer-chapters.json
Normal file
34
SVSim.UnitTests/Story/Fixtures/importer-chapters.json
Normal file
@@ -0,0 +1,34 @@
|
||||
[
|
||||
{"story_id": 100, "section_id": 1, "chara_id": 2, "chapter_id": "1",
|
||||
"next_chapter_id": "2", "required_chapter_id": null,
|
||||
"selection_display_position": null, "selection_text_id": null,
|
||||
"show_coordinate": 1, "x_coordinate": 100, "y_coordinate": -100,
|
||||
"is_camera_movable": 1, "show_subtitles": 0,
|
||||
"battle_exists": true, "enemy_chara_id": 500010, "enemy_class": 2, "enemy_ai_id": 2001,
|
||||
"bg_file_name": "6", "chapter_effect_path": null, "chapter_clear_text_id": null,
|
||||
"battle3dfield_id": 4, "bgm_id": "0", "special_battle_setting_id": null,
|
||||
"release_point": 0, "is_maintenance_chapter": false,
|
||||
"is_play_another_end_appearance_animation": false, "is_released_another_end": false,
|
||||
"is_skip_enabled": true,
|
||||
"battle_settings": [{"deck_class_id": 2, "player_emotion_override": 0,
|
||||
"enemy_emotion_override": 0, "skin_id_override": 0, "battle3dfield_id_override": 0,
|
||||
"bgm_id_override": 0, "deck_skin_id_override": 0}],
|
||||
"story_reward": [{"reward_type": 5, "reward_detail_id": 100222010, "reward_number": 3}],
|
||||
"sub_chapters": []},
|
||||
{"story_id": 101, "section_id": 1, "chara_id": 2, "chapter_id": "2",
|
||||
"next_chapter_id": "3", "required_chapter_id": null,
|
||||
"selection_display_position": null, "selection_text_id": null,
|
||||
"show_coordinate": 1, "x_coordinate": 200, "y_coordinate": -100,
|
||||
"is_camera_movable": 1, "show_subtitles": 0,
|
||||
"battle_exists": true, "enemy_chara_id": 6, "enemy_class": 6, "enemy_ai_id": 2002,
|
||||
"bg_file_name": "4", "chapter_effect_path": null, "chapter_clear_text_id": null,
|
||||
"battle3dfield_id": 2, "bgm_id": "0", "special_battle_setting_id": 8,
|
||||
"release_point": 0, "is_maintenance_chapter": false,
|
||||
"is_play_another_end_appearance_animation": false, "is_released_another_end": false,
|
||||
"is_skip_enabled": true,
|
||||
"battle_settings": [{"deck_class_id": 2, "player_emotion_override": 0,
|
||||
"enemy_emotion_override": 0, "skin_id_override": 0, "battle3dfield_id_override": 0,
|
||||
"bgm_id_override": 0, "deck_skin_id_override": 0}],
|
||||
"story_reward": [{"reward_type": 1, "reward_detail_id": 0, "reward_number": 20}],
|
||||
"sub_chapters": []}
|
||||
]
|
||||
9
SVSim.UnitTests/Story/Fixtures/importer-sbs.json
Normal file
9
SVSim.UnitTests/Story/Fixtures/importer-sbs.json
Normal file
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{"id": 8, "player_first_turn": 0, "player_start_pp": 0, "enemy_start_pp": 0,
|
||||
"player_start_life": 20, "enemy_start_life": 10,
|
||||
"player_attach_skill": "", "enemy_attach_skill": "",
|
||||
"id_override_in_battle_log": "", "banish_effect_override": "",
|
||||
"token_draw_effect_override": "", "special_token_draw_effect_override": "",
|
||||
"result_skip": 0, "vs_effect_override": 0, "class_destroy_effect_override": 0,
|
||||
"note": "test sbs"}
|
||||
]
|
||||
6
SVSim.UnitTests/Story/Fixtures/importer-sections.json
Normal file
6
SVSim.UnitTests/Story/Fixtures/importer-sections.json
Normal file
@@ -0,0 +1,6 @@
|
||||
[
|
||||
{"id": 1, "world_id": 1, "story_api_type": "Main", "order_id": 1, "all_story_order_id": 1,
|
||||
"name_text_key": "section_1", "image_name": "btn_1", "is_leader_select": true,
|
||||
"back_ground_id": 1, "chapter_select_type": 1, "story_type_overwrite": 1,
|
||||
"is_under_maintenance": false, "is_play_another_end_appearance_animation": false}
|
||||
]
|
||||
3
SVSim.UnitTests/Story/Fixtures/importer-worlds.json
Normal file
3
SVSim.UnitTests/Story/Fixtures/importer-worlds.json
Normal file
@@ -0,0 +1,3 @@
|
||||
[
|
||||
{"id": 1, "title_text_key": "world_1", "panel_image_name": "panel_1", "ribbon_text": ""}
|
||||
]
|
||||
12
SVSim.UnitTests/Story/Fixtures/snapshot-finish-response.json
Normal file
12
SVSim.UnitTests/Story/Fixtures/snapshot-finish-response.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"get_class_experience":"0","class_experience":0,"class_level":"0",
|
||||
"achieved_info":{},"reward_list":[],"story_reward_list":[],
|
||||
"quest":{"is_display_badge":false},
|
||||
"story_notification":{"is_display_badge":false},
|
||||
"basic_puzzle":{"is_display_badge":false},
|
||||
"shop_notification":{"card_pack":false,"build_deck":false,"sleeve":false,"leader_skin":false},
|
||||
"receive_friend_apply_count":0,
|
||||
"gathering_info":{"has_invite":0,"is_entry":0},
|
||||
"competition_info":{"is_competition_period":false},
|
||||
"is_available_colosseum_free_entry":false
|
||||
}
|
||||
18
SVSim.UnitTests/Story/Fixtures/snapshot-info-response.json
Normal file
18
SVSim.UnitTests/Story/Fixtures/snapshot-info-response.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"story_master_list":[
|
||||
{"story_id":"100","section_id":"1","chara_id":"2","chapter_id":"1","is_lock":false,
|
||||
"next_chapter_id":"2","required_chapter_id":"","selection_display_position":"",
|
||||
"selection_text_id":"","show_coordinate":"1","x_coordinate":"100","y_coordinate":"-100",
|
||||
"is_camera_ movable":"1","show_subtitles":"0","battle_exists":true,
|
||||
"enemy_chara_id":"500010","enemy_class":"2","enemy_ai_id":"2001","bg_file_name":"6",
|
||||
"chapter_effect_path":"","chapter_clear_text_id":"","battle3dfield_id":"4","bgm_id":"0",
|
||||
"special_battle_setting_id":"","release_point":"0",
|
||||
"battle_settings":[{"deck_class_id":2,"player_emotion_override":0,"enemy_emotion_override":0,
|
||||
"skin_id_override":0,"battle3dfield_id_override":0,"bgm_id_override":0,"deck_skin_id_override":0}],
|
||||
"story_reward":[{"reward_type":"1","reward_detail_id":"0","reward_number":"100"}],
|
||||
"is_maintenance_chapter":false,"is_released":true,"is_skipped":false,"is_finish":false,
|
||||
"unlock_text":"","is_play_another_end_appearance_animation":false,
|
||||
"is_released_another_end":false,"is_skip_enabled":true}
|
||||
],
|
||||
"maintenance_card_list":[]
|
||||
}
|
||||
68
SVSim.UnitTests/Story/RoutingSmokeTests.Story.cs
Normal file
68
SVSim.UnitTests/Story/RoutingSmokeTests.Story.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NUnit.Framework;
|
||||
using SVSim.Database;
|
||||
using SVSim.EmulatedEntrypoint;
|
||||
|
||||
namespace SVSim.UnitTests.Story;
|
||||
|
||||
/// <summary>
|
||||
/// Smoke tests for the 21 story URLs. We assert the framework matched the route
|
||||
/// (status != 404). Auth-required routes return 401, which is fine — that still means routing matched.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class RoutingSmokeTestsStory
|
||||
{
|
||||
private sealed class TestFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder)
|
||||
{
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<SVSimDbContext>));
|
||||
if (descriptor != null) services.Remove(descriptor);
|
||||
services.AddDbContext<SVSimDbContext>(opt => opt.UseInMemoryDatabase("RoutingSmokeStory"));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private const string ValidBaseRequestJson =
|
||||
"""{"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""";
|
||||
|
||||
[TestCase("/story/section")]
|
||||
[TestCase("/main_story/section")]
|
||||
[TestCase("/limited_story/section")]
|
||||
[TestCase("/event_story/section")]
|
||||
[TestCase("/main_story/leader_select")]
|
||||
[TestCase("/limited_story/leader_select")]
|
||||
[TestCase("/event_story/leader_select")]
|
||||
[TestCase("/main_story/info")]
|
||||
[TestCase("/limited_story/info")]
|
||||
[TestCase("/event_story/info")]
|
||||
[TestCase("/main_story/get_deck_list")]
|
||||
[TestCase("/event_story/get_deck_list")]
|
||||
[TestCase("/main_story/start")]
|
||||
[TestCase("/limited_story/start")]
|
||||
[TestCase("/event_story/start")]
|
||||
[TestCase("/main_story/finish")]
|
||||
[TestCase("/limited_story/finish")]
|
||||
[TestCase("/event_story/finish")]
|
||||
[TestCase("/main_story/all_finish")]
|
||||
[TestCase("/limited_story/all_finish")]
|
||||
[TestCase("/event_story/all_finish")]
|
||||
public async Task Story_route_resolves(string path)
|
||||
{
|
||||
using var factory = new TestFactory();
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var response = await client.PostAsync(path,
|
||||
new StringContent(ValidBaseRequestJson, Encoding.UTF8, "application/json"));
|
||||
|
||||
Assert.That(response.StatusCode, Is.Not.EqualTo(HttpStatusCode.NotFound),
|
||||
$"Route {path} did not match — route registration broken.");
|
||||
}
|
||||
}
|
||||
58
SVSim.UnitTests/Story/StoryImporterTests.cs
Normal file
58
SVSim.UnitTests/Story/StoryImporterTests.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NUnit.Framework;
|
||||
using SVSim.Bootstrap.Importers;
|
||||
using SVSim.Database;
|
||||
|
||||
namespace SVSim.UnitTests.Story;
|
||||
|
||||
[TestFixture]
|
||||
public class StoryImporterTests
|
||||
{
|
||||
private static string FixturesDir =>
|
||||
Path.Combine(AppContext.BaseDirectory, "Story", "Fixtures");
|
||||
|
||||
private static SVSimDbContext NewInMemoryContext(string name)
|
||||
{
|
||||
var opts = new DbContextOptionsBuilder<SVSimDbContext>()
|
||||
.UseInMemoryDatabase(name)
|
||||
.Options;
|
||||
return new SVSimDbContext(NullLogger<SVSimDbContext>.Instance, opts);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ImportAsync_inserts_worlds_sections_chapters_sbs_from_fixtures()
|
||||
{
|
||||
await using var ctx = NewInMemoryContext(nameof(ImportAsync_inserts_worlds_sections_chapters_sbs_from_fixtures));
|
||||
|
||||
var importer = new StoryImporter();
|
||||
await importer.ImportAsync(ctx, FixturesDir);
|
||||
|
||||
Assert.That(await ctx.StoryWorlds.CountAsync(), Is.EqualTo(1));
|
||||
Assert.That(await ctx.StorySections.CountAsync(), Is.EqualTo(1));
|
||||
Assert.That(await ctx.StoryChapters.CountAsync(), Is.EqualTo(2));
|
||||
Assert.That(await ctx.SpecialBattleSettings.CountAsync(), Is.EqualTo(1));
|
||||
|
||||
var chapter2 = await ctx.StoryChapters
|
||||
.Include(c => c.BattleSettings).Include(c => c.Rewards)
|
||||
.FirstAsync(c => c.StoryId == 101);
|
||||
Assert.That(chapter2.SpecialBattleSettingId, Is.EqualTo(8));
|
||||
Assert.That(chapter2.Rewards.Count, Is.EqualTo(1));
|
||||
Assert.That(chapter2.BattleSettings.Count, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ImportAsync_is_idempotent_no_changes_on_rerun()
|
||||
{
|
||||
await using var ctx = NewInMemoryContext(nameof(ImportAsync_is_idempotent_no_changes_on_rerun));
|
||||
|
||||
var importer = new StoryImporter();
|
||||
await importer.ImportAsync(ctx, FixturesDir);
|
||||
var afterFirst = await ctx.StoryChapters.CountAsync();
|
||||
|
||||
await importer.ImportAsync(ctx, FixturesDir);
|
||||
var afterSecond = await ctx.StoryChapters.CountAsync();
|
||||
|
||||
Assert.That(afterSecond, Is.EqualTo(afterFirst));
|
||||
}
|
||||
}
|
||||
423
SVSim.UnitTests/Story/StoryServiceTests.cs
Normal file
423
SVSim.UnitTests/Story/StoryServiceTests.cs
Normal file
@@ -0,0 +1,423 @@
|
||||
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<IStoryMasterRepository> _master = null!;
|
||||
private Mock<IViewerStoryProgressRepository> _viewer = null!;
|
||||
private StoryService _service = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_master = new Mock<IStoryMasterRepository>();
|
||||
_viewer = new Mock<IViewerStoryProgressRepository>();
|
||||
// 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<SVSim.Database.Repositories.Deck.IDeckRepository>().Object,
|
||||
logger: NullLogger<StoryService>.Instance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="StoryService"/> backed by a real <see cref="SVSimDbContext"/> from
|
||||
/// <paramref name="factory"/>, 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.
|
||||
/// </summary>
|
||||
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<SVSimDbContext>();
|
||||
var v = resetDb.Viewers.First(x => x.Id == seedId);
|
||||
v.Currency.RedEther = 0;
|
||||
resetDb.SaveChanges();
|
||||
}
|
||||
|
||||
scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
|
||||
var rewards = scope.ServiceProvider.GetRequiredService<RewardGrantService>();
|
||||
|
||||
return new StoryService(
|
||||
_master.Object,
|
||||
_viewer.Object,
|
||||
rewards: rewards,
|
||||
db: db,
|
||||
configService: StoryServiceTestHelpers.NewConfigService(),
|
||||
deckRepository: new Mock<SVSim.Database.Repositories.Deck.IDeckRepository>().Object,
|
||||
logger: NullLogger<StoryService>.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<StoryChapter> {
|
||||
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<IEnumerable<int>>()))
|
||||
.ReturnsAsync(new Dictionary<int, ViewerStoryProgress>());
|
||||
_viewer.Setup(v => v.GetBranchUnlockedStoryIdsAsync(7L, It.IsAny<IEnumerable<int>>()))
|
||||
.ReturnsAsync(new HashSet<int>());
|
||||
|
||||
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<StoryChapter> {
|
||||
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<IEnumerable<int>>()))
|
||||
.ReturnsAsync(new Dictionary<int, ViewerStoryProgress> {
|
||||
{ 100, new ViewerStoryProgress { ViewerId = 7, StoryId = 100, IsFinish = true } } });
|
||||
_viewer.Setup(v => v.GetBranchUnlockedStoryIdsAsync(7L, It.IsAny<IEnumerable<int>>()))
|
||||
.ReturnsAsync(new HashSet<int>());
|
||||
|
||||
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<StoryChapter> {
|
||||
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<IEnumerable<int>>()))
|
||||
.ReturnsAsync(new Dictionary<int, ViewerStoryProgress> {
|
||||
{ 100, new ViewerStoryProgress { StoryId = 100, IsSkipped = true } } });
|
||||
_viewer.Setup(v => v.GetBranchUnlockedStoryIdsAsync(7L, It.IsAny<IEnumerable<int>>()))
|
||||
.ReturnsAsync(new HashSet<int>());
|
||||
|
||||
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<StoryChapter> {
|
||||
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<IEnumerable<int>>()))
|
||||
.ReturnsAsync(new Dictionary<int, ViewerStoryProgress> {
|
||||
{ 200, new ViewerStoryProgress { StoryId = 200, IsFinish = true } } });
|
||||
_viewer.Setup(v => v.GetBranchUnlockedStoryIdsAsync(7L, It.IsAny<IEnumerable<int>>()))
|
||||
.ReturnsAsync(new HashSet<int>());
|
||||
|
||||
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<StoryChapter> {
|
||||
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<IEnumerable<int>>()))
|
||||
.ReturnsAsync(new Dictionary<int, ViewerStoryProgress> {
|
||||
{ 200, new ViewerStoryProgress { StoryId = 200, IsFinish = true } } });
|
||||
_viewer.Setup(v => v.GetBranchUnlockedStoryIdsAsync(7L, It.IsAny<IEnumerable<int>>()))
|
||||
.ReturnsAsync(new HashSet<int> { 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<StorySection> { 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<StoryChapter> { Ch(100 + chara, 1, chara, "1", "2") });
|
||||
}
|
||||
_viewer.Setup(v => v.GetProgressForChaptersAsync(7L, It.IsAny<IEnumerable<int>>()))
|
||||
.ReturnsAsync(new Dictionary<int, ViewerStoryProgress>());
|
||||
|
||||
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<StorySection> { new() { Id = 1, IsLeaderSelect = true } });
|
||||
_master.Setup(m => m.GetChaptersBySectionCharaAsync(1, 2)).ReturnsAsync(new List<StoryChapter> {
|
||||
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<StoryChapter>());
|
||||
_viewer.Setup(v => v.GetProgressForChaptersAsync(7L, It.IsAny<IEnumerable<int>>()))
|
||||
.ReturnsAsync(new Dictionary<int, ViewerStoryProgress> {
|
||||
{ 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<Array>());
|
||||
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<IEnumerable<int>>()))
|
||||
.ReturnsAsync(new Dictionary<int, ViewerStoryProgress>());
|
||||
|
||||
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<StoryChapter> { parent, branch3b });
|
||||
_viewer.Setup(v => v.GetProgressForChaptersAsync(7L, It.IsAny<IEnumerable<int>>()))
|
||||
.ReturnsAsync(new Dictionary<int, ViewerStoryProgress>());
|
||||
|
||||
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<IEnumerable<int>>()))
|
||||
.ReturnsAsync(new Dictionary<int, ViewerStoryProgress>());
|
||||
|
||||
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<SVSimDbContext>();
|
||||
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<IEnumerable<int>>()))
|
||||
.ReturnsAsync(new Dictionary<int, ViewerStoryProgress> {
|
||||
{ 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<SVSimDbContext>();
|
||||
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<IEnumerable<int>>()))
|
||||
.ReturnsAsync(new Dictionary<int, ViewerStoryProgress> {
|
||||
{ 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<SVSimDbContext>();
|
||||
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<SVSim.Database.Services.IGameConfigService>();
|
||||
mock.Setup(s => s.Get<SVSim.Database.Models.Config.StoryConfig>())
|
||||
.Returns(new SVSim.Database.Models.Config.StoryConfig { ClassXpPerClear = 200 });
|
||||
return mock.Object;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a minimal <see cref="SVSimDbContext"/> backed by the EF InMemory provider.
|
||||
/// Safe for non-reward tests that never actually query the DB.
|
||||
/// Each call should use a unique <paramref name="dbName"/> to prevent test bleed-through.
|
||||
/// </summary>
|
||||
public static SVSimDbContext NewInMemoryDb(string dbName)
|
||||
{
|
||||
var options = new Microsoft.EntityFrameworkCore.DbContextOptionsBuilder<SVSimDbContext>()
|
||||
.UseInMemoryDatabase(dbName)
|
||||
.Options;
|
||||
return new SVSimDbContext(
|
||||
Microsoft.Extensions.Logging.Abstractions.NullLogger<SVSimDbContext>.Instance,
|
||||
options);
|
||||
}
|
||||
}
|
||||
91
SVSim.UnitTests/Story/StoryWireShapeTests.cs
Normal file
91
SVSim.UnitTests/Story/StoryWireShapeTests.cs
Normal file
@@ -0,0 +1,91 @@
|
||||
using System.Text.Json;
|
||||
using NUnit.Framework;
|
||||
using SVSim.EmulatedEntrypoint.Models.Dtos.Story;
|
||||
|
||||
namespace SVSim.UnitTests.Story;
|
||||
|
||||
[TestFixture]
|
||||
public class StoryWireShapeTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions Opts = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.Never,
|
||||
WriteIndented = false,
|
||||
};
|
||||
|
||||
[Test]
|
||||
public void InfoResponse_serializes_to_expected_shape()
|
||||
{
|
||||
var dto = new InfoResponse
|
||||
{
|
||||
StoryMasterList = new()
|
||||
{
|
||||
new StoryMasterEntry
|
||||
{
|
||||
StoryId = "100", SectionId = "1", CharaId = "2", ChapterId = "1",
|
||||
IsLock = false, NextChapterId = "2",
|
||||
ShowCoordinate = "1", XCoordinate = "100", YCoordinate = "-100",
|
||||
IsCameraMovable = "1", ShowSubtitles = "0",
|
||||
BattleExists = true, EnemyCharaId = "500010", EnemyClass = "2",
|
||||
EnemyAiId = "2001", BgFileName = "6",
|
||||
Battle3dFieldId = "4", BgmId = "0", ReleasePoint = "0",
|
||||
BattleSettings = new() { new BattleSettingDto { DeckClassId = 2 } },
|
||||
StoryReward = new() { new RewardDto {
|
||||
RewardType = "1", RewardDetailId = "0", RewardNumber = "100" } },
|
||||
IsReleased = true, IsSkipEnabled = true,
|
||||
}
|
||||
}
|
||||
};
|
||||
var actual = JsonSerializer.Serialize(dto, Opts);
|
||||
var expectedPath = Path.Combine(AppContext.BaseDirectory, "Story", "Fixtures",
|
||||
"snapshot-info-response.json");
|
||||
var expectedJson = File.ReadAllText(expectedPath);
|
||||
|
||||
AssertJsonEquivalent(actual, expectedJson);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void FinishResponse_default_serializes_to_expected_shape()
|
||||
{
|
||||
var dto = new FinishResponse();
|
||||
var actual = JsonSerializer.Serialize(dto, Opts);
|
||||
var expectedPath = Path.Combine(AppContext.BaseDirectory, "Story", "Fixtures",
|
||||
"snapshot-finish-response.json");
|
||||
var expectedJson = File.ReadAllText(expectedPath);
|
||||
AssertJsonEquivalent(actual, expectedJson);
|
||||
}
|
||||
|
||||
private static void AssertJsonEquivalent(string actualJson, string expectedJson)
|
||||
{
|
||||
var a = JsonDocument.Parse(actualJson).RootElement;
|
||||
var e = JsonDocument.Parse(expectedJson).RootElement;
|
||||
Assert.That(JsonDeepEquals(a, e), Is.True,
|
||||
$"JSON mismatch.\nExpected: {expectedJson}\nActual: {actualJson}");
|
||||
}
|
||||
|
||||
private static bool JsonDeepEquals(JsonElement a, JsonElement b)
|
||||
{
|
||||
if (a.ValueKind != b.ValueKind) return false;
|
||||
switch (a.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
var ap = a.EnumerateObject().OrderBy(p => p.Name).ToList();
|
||||
var bp = b.EnumerateObject().OrderBy(p => p.Name).ToList();
|
||||
if (ap.Count != bp.Count) return false;
|
||||
for (int i = 0; i < ap.Count; i++)
|
||||
if (ap[i].Name != bp[i].Name || !JsonDeepEquals(ap[i].Value, bp[i].Value))
|
||||
return false;
|
||||
return true;
|
||||
case JsonValueKind.Array:
|
||||
var ae = a.EnumerateArray().ToList();
|
||||
var be = b.EnumerateArray().ToList();
|
||||
if (ae.Count != be.Count) return false;
|
||||
for (int i = 0; i < ae.Count; i++)
|
||||
if (!JsonDeepEquals(ae[i], be[i])) return false;
|
||||
return true;
|
||||
default:
|
||||
return a.GetRawText() == b.GetRawText();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user