Files
meeting-assistant/MeetingAssistant.Tests/WorkflowRulesEditorTests.cs
T
codex 693f52afee
PR and Push Build/Test / build-and-test (push) Successful in 7m0s
Add tray rules and identities editor
2026-05-28 01:28:16 +02:00

493 lines
19 KiB
C#

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 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: [");
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<WorkflowRulesEditorInstructionBuilder>.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<WorkflowRulesEditorMarkdownParagraph>(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<WorkflowRulesEditorMarkdownParagraph>(block),
block =>
{
var table = Assert.IsType<WorkflowRulesEditorMarkdownTable>(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<WorkflowRulesEditorMarkdownParagraph>(block),
block =>
{
var code = Assert.IsType<WorkflowRulesEditorMarkdownCodeBlock>(block);
Assert.Equal("rules:" + Environment.NewLine + "- name: example", code.Text);
},
block =>
{
var paragraph = Assert.IsType<WorkflowRulesEditorMarkdownParagraph>(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<WorkflowRulesEditorChatResult> SendAsync(
IReadOnlyList<WorkflowRulesEditorChatMessage> 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<WorkflowRulesEditorIdentityFixture> 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<SpeakerIdentityDbContext>()
.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<SpeakerIdentity> AddIdentityAsync(
string canonicalName,
IReadOnlyList<string>? aliases = null,
IReadOnlyList<string>? 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<SpeakerIdentity> 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<SpeakerIdentityDbContext>
{
private readonly string dbPath;
public TestSpeakerIdentityDbContextFactory(string dbPath)
{
this.dbPath = dbPath;
}
public SpeakerIdentityDbContext CreateDbContext()
{
return new SpeakerIdentityDbContext(new DbContextOptionsBuilder<SpeakerIdentityDbContext>()
.UseSqlite($"Data Source={dbPath};Pooling=False")
.Options);
}
}
private sealed class CapturingSamplePlaybackQueue : IWorkflowRulesEditorSamplePlaybackQueue
{
public List<(int SampleId, byte[] WavBytes)> Queued { get; } = [];
public Task<string> QueueAsync(int sampleId, byte[] wavBytes, CancellationToken cancellationToken = default)
{
Queued.Add((sampleId, wavBytes));
return Task.FromResult($"Queued sample {sampleId}.");
}
}
}