using System.Text.Json; using Microsoft.Extensions.Logging.Abstractions; using Novelly.Api.Agent; using Novelly.Api.Beats; using Novelly.Api.Characters; using Novelly.Api.Novels; namespace Novelly.Api.Tests; [TestFixture] public class NovelAgentToolsetTests : ServiceTestFixture { private NovelAgentToolset _toolset = null!; protected override void OnSetUp() => _toolset = new NovelAgentToolset(Novels, Characters, Arcs, Chapters, ChapterLabels, Beats, Tags, Locations, Questions, NullLogger.Instance); private static JsonElement Input(object value) => JsonSerializer.SerializeToElement(value); [Test] public void The_toolset_offers_every_tool_the_stdio_server_offered() { var expected = new[] { "list_novels", "create_novel", "get_novel_brief", "update_novel_brief", "list_characters", "get_character", "create_character", "update_character", "get_character_beats", "get_character_arc", "add_arc_stage", "update_arc_stage", "delete_arc_stage", "reorder_arc_stages", "set_arc_stage_beats", "relate_characters", "link_character_identity", "unlink_character_identity", "get_chapter_outline", "create_beat", "update_beat", "delete_beat", "assign_character_to_beats", "reorder_beats", "move_beats", "list_chapters", "get_chapter", "create_chapter", "update_chapter", "list_tags", "get_tag_references", "create_tag", "update_tag", "delete_tag", "list_locations", "get_location_references", "create_location", "update_location", "delete_location", "list_open_questions", "raise_open_question", "update_open_question", "resolve_open_question", "reopen_question", "delete_open_question" }; Assert.That(_toolset.Definitions.Select(t => t.Name), Is.EquivalentTo(expected)); } [Test] public void No_novel_scoped_tools_schema_already_declares_a_novel_id() { Assert.Multiple(() => { foreach (var tool in _toolset.Definitions.Where(t => t.RequiresNovelId)) { Assert.That(tool.InputSchema.TryGetProperty("novelId", out _), Is.False, tool.Name); } }); } [Test] public async Task Creating_a_novel_through_a_tool_makes_it_appear_in_the_novel_list() { var result = await _toolset.ExecuteAsync( "create_novel", Guid.Empty, Input(new { title = "The Ash Ledger" })); var novels = await Novels.ListAsync(); Assert.Multiple(() => { Assert.That(result.IsError, Is.False); Assert.That(novels.Select(n => n.Title), Has.Member("The Ash Ledger")); }); } [Test] public async Task Reading_a_character_by_id_returns_its_dossier() { var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id; var characterId = (await Characters.CreateAsync(novelId, new CreateCharacterRequest("Ines"))).Id; var result = await _toolset.ExecuteAsync( "get_character", novelId, Input(new { characterId })); Assert.Multiple(() => { Assert.That(result.IsError, Is.False); Assert.That(result.Content, Does.Contain("Ines")); }); } [Test] public async Task Relating_two_characters_shows_the_pair_on_both_dossiers() { var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id; var inesId = (await Characters.CreateAsync(novelId, new CreateCharacterRequest("Ines"))).Id; var marcoId = (await Characters.CreateAsync(novelId, new CreateCharacterRequest("Marco"))).Id; var result = await _toolset.ExecuteAsync( "relate_characters", novelId, Input(new { characterId = inesId, relatedCharacterId = marcoId, relationshipType = "sister" })); var ines = await Characters.GetAsync(inesId); var marco = await Characters.GetAsync(marcoId); Assert.Multiple(() => { Assert.That(result.IsError, Is.False); Assert.That(ines!.Relationships.Select(r => r.RelatedCharacterId), Has.Member(marcoId)); Assert.That(marco!.Relationships.Select(r => r.RelatedCharacterId), Has.Member(inesId)); }); } [Test] public async Task Setting_an_arc_stages_beats_replaces_whatever_was_there() { var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id; var characterId = (await Characters.CreateAsync(novelId, new CreateCharacterRequest("Ines", Importance: CharacterImportance.Main))).Id; var chapterId = (await Chapters.CreateAsync(novelId, new Novelly.Api.Chapters.CreateChapterRequest("Chapter One"))).Id; var beatId = (await Beats.CreateAsync(chapterId, new CreateBeatRequest("Arrival", CharacterIds: [characterId]))).Id; var stageId = (await Arcs.CreateAsync(characterId, new CreateArcStageRequest("Doubt"))).Id; var result = await _toolset.ExecuteAsync( "set_arc_stage_beats", novelId, Input(new { arcStageId = stageId, beatIds = new[] { beatId.ToString() } })); Assert.That(result.IsError, Is.False); } [Test] public async Task Creating_a_tag_with_a_colour_then_renaming_it_keeps_it_on_what_carried_it() { var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id; var created = await _toolset.ExecuteAsync( "create_tag", novelId, Input(new { name = "Foreshadowing", color = "#9a4a2f" })); var tagsAfterCreate = await Tags.ListAsync(novelId); var tagId = tagsAfterCreate.Single(t => t.Name == "Foreshadowing").Id; var renamed = await _toolset.ExecuteAsync( "update_tag", novelId, Input(new { tagId, name = "Setup" })); var tagsAfterRename = await Tags.ListAsync(novelId); Assert.Multiple(() => { Assert.That(created.IsError, Is.False); Assert.That(renamed.IsError, Is.False); Assert.That(tagsAfterRename.Select(t => t.Name), Has.Member("Setup")); Assert.That(tagsAfterRename.Select(t => t.Name), Has.No.Member("Foreshadowing")); }); } [Test] public async Task Deleting_a_tag_leaves_the_characters_that_carried_it_alone() { var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id; var characterId = (await Characters.CreateAsync( novelId, new CreateCharacterRequest("Ines", Tags: ["Foreshadowing"]))).Id; var tagId = (await Tags.ListAsync(novelId)).Single(t => t.Name == "Foreshadowing").Id; var result = await _toolset.ExecuteAsync("delete_tag", novelId, Input(new { tagId })); var character = await Characters.GetAsync(characterId); var tagsAfter = await Tags.ListAsync(novelId); Assert.Multiple(() => { Assert.That(result.IsError, Is.False); Assert.That(tagsAfter, Is.Empty); Assert.That(character, Is.Not.Null); }); } [Test] public async Task Creating_and_renaming_a_location_updates_it_everywhere() { var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id; await _toolset.ExecuteAsync("create_location", novelId, Input(new { name = "The Salt Flats" })); var locationId = (await Locations.ListAsync(novelId)).Single(l => l.Name == "The Salt Flats").Id; var renamed = await _toolset.ExecuteAsync( "update_location", novelId, Input(new { locationId, name = "The Great Salt Flats" })); var locationsAfter = await Locations.ListAsync(novelId); Assert.Multiple(() => { Assert.That(renamed.IsError, Is.False); Assert.That(locationsAfter.Select(l => l.Name), Has.Member("The Great Salt Flats")); }); } [Test] public async Task Updating_an_open_question_can_detach_it_from_its_chapter() { var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id; var chapterId = (await Chapters.CreateAsync(novelId, new Novelly.Api.Chapters.CreateChapterRequest("Chapter One"))).Id; var questionId = (await Questions.CreateAsync( novelId, new Novelly.Api.Questions.CreateOpenQuestionRequest("Who sent the letter?", ChapterId: chapterId)))!.Id; var result = await _toolset.ExecuteAsync( "update_open_question", novelId, Input(new { questionId, clearChapter = true })); var question = await Questions.GetAsync(questionId); Assert.Multiple(() => { Assert.That(result.IsError, Is.False); Assert.That(question!.ChapterId, Is.Null); }); } [Test] public async Task Asking_for_an_unknown_id_comes_back_as_an_error_result() { var novelId = (await Novels.CreateAsync(new CreateNovelRequest("The Salt Road"))).Id; var missingId = Guid.NewGuid(); Assert.Multiple(async () => { var character = await _toolset.ExecuteAsync("get_character", novelId, Input(new { characterId = missingId })); Assert.That(character.IsError, Is.True); var tag = await _toolset.ExecuteAsync("update_tag", novelId, Input(new { tagId = missingId, name = "x" })); Assert.That(tag.IsError, Is.True); var location = await _toolset.ExecuteAsync("delete_location", novelId, Input(new { locationId = missingId })); Assert.That(location.IsError, Is.True); var question = await _toolset.ExecuteAsync("update_open_question", novelId, Input(new { questionId = missingId })); Assert.That(question.IsError, Is.True); }); } }