using MeetingAssistant.Workflow; using MeetingAssistant.Speakers; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using System.Text.Json; namespace MeetingAssistant.Tests; public sealed class WorkflowRulesEditorTests { [Fact] public void EditorOptionsInheritSummarizerDefaultsAndOverrideConfiguredValues() { var defaults = new AgentOptions { Endpoint = "https://summary.example", Key = "summary-key", KeyEnv = "SUMMARY_KEY", Model = "summary-model", EnableThinking = true, ReasoningEffort = ReasoningEffortOption.High, ReconnectionAttempts = 7, ReconnectionDelay = TimeSpan.FromSeconds(7), ContextWindowTokens = 1000, MaxOutputTokens = 100, EnableCompaction = true, CompactionRemainingRatio = 0.2, ResponsesCompactPath = "responses/compact" }; var editor = new WorkflowRulesEditorOptions { Model = "editor-model", EnableThinking = false, MaxOutputTokens = 50 }; var effective = editor.ToEffectiveAgentOptions(defaults); Assert.Equal("https://summary.example", effective.Endpoint); Assert.Equal("summary-key", effective.Key); Assert.Equal("SUMMARY_KEY", effective.KeyEnv); Assert.Equal("editor-model", effective.Model); Assert.False(effective.EnableThinking); Assert.Equal(ReasoningEffortOption.High, effective.ReasoningEffort); Assert.Equal(7, effective.ReconnectionAttempts); Assert.Equal(TimeSpan.FromSeconds(7), effective.ReconnectionDelay); Assert.Equal(1000, effective.ContextWindowTokens); Assert.Equal(50, effective.MaxOutputTokens); Assert.True(effective.EnableCompaction); Assert.Equal(0.2, effective.CompactionRemainingRatio); Assert.Equal("responses/compact", effective.ResponsesCompactPath); } [Fact] public async Task RulesEditorToolsReadWriteAndSearchOnlyConfiguredRulesFile() { var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); var rulesPath = Path.Combine(root, "rules.yaml"); var unrelatedPath = Path.Combine(root, "other.yaml"); await File.WriteAllTextAsync(unrelatedPath, "rules:\n- name: do-not-find\n"); var tools = new WorkflowRulesEditorTools(new MeetingAssistantOptions { Automation = new AutomationOptions { RulesPath = rulesPath } }); var writeResult = await tools.WriteRules(""" rules: - name: add-me on: - created: {} steps: - uses: add_attendee value: Manuel """); var readResult = await tools.ReadRules(); var searchResult = await tools.Search("add-me"); Assert.Equal(rulesPath, writeResult); Assert.Contains("name: add-me", readResult); Assert.Contains("rules.yaml:", searchResult); Assert.DoesNotContain("other.yaml", searchResult); } [Fact] public async Task RulesEditorToolsAppendByDefaultAndReplaceOnlyWhenRequested() { var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); var rulesPath = Path.Combine(root, "rules.yaml"); await File.WriteAllTextAsync( rulesPath, """ rules: - name: existing on: - created: {} steps: - uses: add_attendee value: Ada """); var tools = new WorkflowRulesEditorTools(new MeetingAssistantOptions { Automation = new AutomationOptions { RulesPath = rulesPath } }); await tools.WriteRules(""" - name: appended on: - created: {} steps: - uses: add_attendee value: Grace """); var appended = await File.ReadAllTextAsync(rulesPath); Assert.Contains("name: existing", appended); Assert.Contains("name: appended", appended); await tools.WriteRules("rules: []", replace_file: true); Assert.Equal("rules: []", await File.ReadAllTextAsync(rulesPath)); } [Fact] public async Task RulesEditorToolsRefuseInvalidYamlWithoutOverwritingFile() { var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); var rulesPath = Path.Combine(root, "rules.yaml"); await File.WriteAllTextAsync(rulesPath, "rules: []"); var tools = new WorkflowRulesEditorTools(new MeetingAssistantOptions { Automation = new AutomationOptions { RulesPath = rulesPath } }); var result = await tools.WriteRules("rules:\n- name: [", replace_file: true); Assert.StartsWith("Refused: workflow rules YAML is invalid.", result); Assert.Equal("rules: []", await File.ReadAllTextAsync(rulesPath)); } [Fact] public async Task InstructionBuilderIncludesConfiguredRulesPathAndWorkflowDocs() { var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); var rulesPath = Path.Combine(root, "rules.yaml"); var builder = new WorkflowRulesEditorInstructionBuilder( NullLogger.Instance); var instructions = await builder.BuildAsync(new MeetingAssistantOptions { Automation = new AutomationOptions { RulesPath = rulesPath } }, CancellationToken.None); Assert.Contains(rulesPath, instructions); Assert.Contains("Meeting Workflow Engine", instructions); Assert.Contains("Workflow rules reference documentation", instructions); Assert.Contains("speaker identity database", instructions); } [Fact] public async Task RulesEditorToolsCrudAndSearchSpeakerIdentities() { await using var fixture = await WorkflowRulesEditorIdentityFixture.CreateAsync(); var tools = fixture.CreateTools(); var createResult = await tools.CreateIdentity( "Sabrina", ["Sabi"], ["Guest-01"]); using var created = JsonDocument.Parse(createResult); var identityId = created.RootElement .GetProperty("identity") .GetProperty("id") .GetInt32(); var searchResult = await tools.SearchIdentities("sabi"); var readResult = await tools.ReadIdentity(identityId); var updateResult = await tools.UpdateIdentity( identityId, "Sabrina M.", ["Sabrina"], ["Guest-02"]); var deleteResult = await tools.DeleteIdentity(identityId); Assert.Contains("\"displayName\": \"Sabrina\"", searchResult); Assert.Contains("\"canonicalName\": \"Sabrina\"", readResult); Assert.Contains("\"canonicalName\": \"Sabrina M.\"", updateResult); Assert.Equal($"Deleted identity {identityId}.", deleteResult); Assert.Empty(await fixture.Context.SpeakerIdentities.ToListAsync()); } [Fact] public async Task RulesEditorToolsMergeSpeakerIdentitiesIntoTarget() { await using var fixture = await WorkflowRulesEditorIdentityFixture.CreateAsync(); var target = await fixture.AddIdentityAsync("Sabrina", aliases: ["Sabi"], sample: [1]); var source = await fixture.AddIdentityAsync("Sabine", aliases: ["Guest Name"], candidates: ["Guest-01"], sample: [2, 3]); var tools = fixture.CreateTools(); var result = await tools.MergeIdentities(target.Id, source.Id); Assert.Contains("\"canonicalName\": \"Sabrina\"", result); var saved = await fixture.LoadIdentityAsync(target.Id); Assert.Equal(["Guest Name", "Guest-01", "Sabi", "Sabine"], saved.Aliases.Select(alias => alias.Name).Order()); var snippets = saved.Snippets.Select(sample => sample.WavBytes).OrderBy(bytes => bytes.Length).ToArray(); Assert.Equal([1], snippets[0]); Assert.Equal([2, 3], snippets[1]); Assert.False(await fixture.Context.SpeakerIdentities.AnyAsync(identity => identity.Id == source.Id)); } [Fact] public async Task RulesEditorToolsListReadQueueAndDeleteSpeakerSamples() { await using var fixture = await WorkflowRulesEditorIdentityFixture.CreateAsync(); var identity = await fixture.AddIdentityAsync("Sabrina", sample: [4, 5, 6]); var sampleId = identity.Snippets.Single().Id; var queue = new CapturingSamplePlaybackQueue(); var tools = fixture.CreateTools(queue); var listResult = await tools.ListIdentitySamples(identity.Id); var readResult = await tools.ReadIdentitySample(sampleId); var playResult = await tools.QueuePlayIdentitySample(sampleId); var deleteResult = await tools.DeleteIdentitySample(sampleId); Assert.Contains($"\"id\": {sampleId}", listResult); Assert.Contains(Convert.ToBase64String([4, 5, 6]), readResult); Assert.Equal($"Queued sample {sampleId}.", playResult); var queued = queue.Queued.Single(); Assert.Equal(sampleId, queued.SampleId); Assert.Equal([4, 5, 6], queued.WavBytes); Assert.Equal($"Deleted sample {sampleId}.", deleteResult); Assert.Empty(await fixture.Context.SpeakerSnippets.ToListAsync()); } [Fact] public async Task ViewModelShowsUserMessageThinkingStateAndFinalAgentMessage() { var pipeline = new BlockingRulesEditorPipeline("Updated rules."); var viewModel = new WorkflowRulesEditorChatViewModel(pipeline) { Draft = "Rename ABS Daily" }; var sendTask = viewModel.SendAsync(); await pipeline.Started.Task.WaitAsync(TimeSpan.FromSeconds(2)); Assert.True(viewModel.IsThinking); Assert.Equal("", viewModel.Draft); Assert.Equal( [new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, "Rename ABS Daily")], viewModel.Messages.ToArray()); pipeline.Release.SetResult(); await sendTask.WaitAsync(TimeSpan.FromSeconds(2)); Assert.False(viewModel.IsThinking); Assert.Equal(2, viewModel.Messages.Count); Assert.Equal(WorkflowRulesEditorChatRole.Agent, viewModel.Messages[1].Role); Assert.Equal("Updated rules.", viewModel.Messages[1].Content); } [Theory] [InlineData(0, 500, 0, true)] [InlineData(0, 500, 400, true)] [InlineData(492, 500, 1000, true)] [InlineData(300, 500, 1000, false)] public void ScrollPolicyAutoScrollsOnlyWhenAlreadyNearBottom( double verticalOffset, double viewportHeight, double contentHeight, bool expected) { Assert.Equal( expected, WorkflowRulesEditorScrollPolicy.ShouldAutoScroll( verticalOffset, viewportHeight, contentHeight)); } [Fact] public void ScrollPolicyAutoScrollsWhenPreviousContentFitBeforeNewCardOverflows() { Assert.True(WorkflowRulesEditorScrollPolicy.ShouldAutoScroll( verticalOffset: 0, viewportHeight: 500, previousContentHeight: 500)); } [Fact] public void ScrollPolicyComputesNonNegativeBottomOffset() { Assert.Equal(500, WorkflowRulesEditorScrollPolicy.GetBottomOffset(500, 1000)); Assert.Equal(0, WorkflowRulesEditorScrollPolicy.GetBottomOffset(500, 400)); } [Fact] public void MarkdownParserRecognizesRequestedInlineStyles() { var inlines = WorkflowRulesEditorMarkdown.ParseInline( "Normal *italic* **bold** and `code`."); Assert.Equal( [ new WorkflowRulesEditorMarkdownInline("Normal ", WorkflowRulesEditorMarkdownInlineStyle.Normal), new WorkflowRulesEditorMarkdownInline("italic", WorkflowRulesEditorMarkdownInlineStyle.Italic), new WorkflowRulesEditorMarkdownInline(" ", WorkflowRulesEditorMarkdownInlineStyle.Normal), new WorkflowRulesEditorMarkdownInline("bold", WorkflowRulesEditorMarkdownInlineStyle.Bold), new WorkflowRulesEditorMarkdownInline(" and ", WorkflowRulesEditorMarkdownInlineStyle.Normal), new WorkflowRulesEditorMarkdownInline("code", WorkflowRulesEditorMarkdownInlineStyle.Code), new WorkflowRulesEditorMarkdownInline(".", WorkflowRulesEditorMarkdownInlineStyle.Normal) ], inlines); } [Fact] public void MarkdownParserPreservesParagraphLineBreaks() { var blocks = WorkflowRulesEditorMarkdown.Parse(""" First line Second line with `code` """); var paragraph = Assert.IsType(Assert.Single(blocks)); Assert.Contains(paragraph.Inlines, inline => inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Normal && inline.Text.Contains('\n')); Assert.Contains(paragraph.Inlines, inline => inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Code && inline.Text == "code"); } [Fact] public void MarkdownParserRecognizesPipeTables() { var blocks = WorkflowRulesEditorMarkdown.Parse(""" Current identities: | ID | Name | Samples | |----|------|---------| | 1 | Manuel | 2 | | 2 | Sabrina | 1 | """); Assert.Collection( blocks, block => Assert.IsType(block), block => { var table = Assert.IsType(block); Assert.Equal(["ID", "Name", "Samples"], table.Header); Assert.Equal(["1", "Manuel", "2"], table.Rows[0]); Assert.Equal(["2", "Sabrina", "1"], table.Rows[1]); }); } [Fact] public void MarkdownParserRecognizesFencedCodeBlocks() { var blocks = WorkflowRulesEditorMarkdown.Parse(""" Before ```yaml rules: - name: example ``` After **bold** """); Assert.Collection( blocks, block => Assert.IsType(block), block => { var code = Assert.IsType(block); Assert.Equal("rules:" + Environment.NewLine + "- name: example", code.Text); }, block => { var paragraph = Assert.IsType(block); Assert.Contains(paragraph.Inlines, inline => inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Bold); }); } private sealed class BlockingRulesEditorPipeline : IWorkflowRulesEditorChatPipeline { private readonly string response; public BlockingRulesEditorPipeline(string response) { this.response = response; } public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); public async Task SendAsync( IReadOnlyList conversation, string userMessage, CancellationToken cancellationToken) { Started.SetResult(); await Release.Task.WaitAsync(cancellationToken); return new WorkflowRulesEditorChatResult( response, conversation .Append(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.User, userMessage)) .Append(new WorkflowRulesEditorChatMessage(WorkflowRulesEditorChatRole.Agent, response)) .ToList()); } } private sealed class WorkflowRulesEditorIdentityFixture : IAsyncDisposable { private readonly string tempDirectory; private readonly string dbPath; private WorkflowRulesEditorIdentityFixture( string tempDirectory, string dbPath, SpeakerIdentityDbContext context) { this.tempDirectory = tempDirectory; this.dbPath = dbPath; Context = context; } public SpeakerIdentityDbContext Context { get; } public static async Task CreateAsync() { var tempDirectory = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(tempDirectory); var dbPath = Path.Combine(tempDirectory, "speaker-identities.db"); var context = new SpeakerIdentityDbContext(new DbContextOptionsBuilder() .UseSqlite($"Data Source={dbPath};Pooling=False") .Options); await SpeakerIdentitySchema.EnsureCreatedOrUpdatedAsync(context, CancellationToken.None); return new WorkflowRulesEditorIdentityFixture(tempDirectory, dbPath, context); } public WorkflowRulesEditorTools CreateTools( IWorkflowRulesEditorSamplePlaybackQueue? samplePlaybackQueue = null) { return new WorkflowRulesEditorTools( new MeetingAssistantOptions(), new TestSpeakerIdentityDbContextFactory(dbPath), samplePlaybackQueue); } public async Task AddIdentityAsync( string canonicalName, IReadOnlyList? aliases = null, IReadOnlyList? candidates = null, byte[]? sample = null) { var now = DateTimeOffset.UtcNow; var identity = new SpeakerIdentity { CanonicalName = canonicalName, CreatedAt = now, UpdatedAt = now, Aliases = aliases?.Select(alias => new SpeakerAlias { Name = alias }).ToList() ?? [], CandidateNames = candidates?.Select(candidate => new SpeakerCandidateName { Name = candidate }).ToList() ?? [], Snippets = sample is null ? [] : [ new SpeakerSnippet { WavBytes = sample, CreatedAt = now } ] }; Context.SpeakerIdentities.Add(identity); await Context.SaveChangesAsync(); return identity; } public Task LoadIdentityAsync(int id) { Context.ChangeTracker.Clear(); return Context.SpeakerIdentities .Include(identity => identity.Aliases) .Include(identity => identity.CandidateNames) .Include(identity => identity.Snippets) .SingleAsync(identity => identity.Id == id); } public async ValueTask DisposeAsync() { await Context.DisposeAsync(); if (Directory.Exists(tempDirectory)) { Directory.Delete(tempDirectory, recursive: true); } } } private sealed class TestSpeakerIdentityDbContextFactory : IDbContextFactory { private readonly string dbPath; public TestSpeakerIdentityDbContextFactory(string dbPath) { this.dbPath = dbPath; } public SpeakerIdentityDbContext CreateDbContext() { return new SpeakerIdentityDbContext(new DbContextOptionsBuilder() .UseSqlite($"Data Source={dbPath};Pooling=False") .Options); } } private sealed class CapturingSamplePlaybackQueue : IWorkflowRulesEditorSamplePlaybackQueue { public List<(int SampleId, byte[] WavBytes)> Queued { get; } = []; public Task QueueAsync(int sampleId, byte[] wavBytes, CancellationToken cancellationToken = default) { Queued.Add((sampleId, wavBytes)); return Task.FromResult($"Queued sample {sampleId}."); } } }