Files
meeting-assistant/MeetingAssistant.Tests/WorkflowRulesEditorTests.cs
T
codex 351ed2eb50
PR and Push Build/Test / build-and-test (push) Failing after 9m19s
Fix workflow rule validation logging
2026-06-03 17:33:12 +02:00

1330 lines
55 KiB
C#

using MeetingAssistant.Workflow;
using MeetingAssistant.Logging;
using MeetingAssistant.Speakers;
using Microsoft.Extensions.Configuration;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
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 SettingsToolsReadWriteAndValidateConfiguredAppsettingsFile()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
var configPath = Path.Combine(root, "appsettings.json");
await File.WriteAllTextAsync(
configPath,
"""
{
"MeetingAssistant": {
"Agent": {
"Model": "old-model"
}
}
}
""");
var tools = new WorkflowRulesEditorTools(
new MeetingAssistantOptions(),
configPath: configPath);
var readResult = await tools.ReadConfig(from: 3, to: 5);
var writeResult = await tools.WriteConfig("""
{
"MeetingAssistant": {
"Agent": {
"Model": "new-model"
}
}
}
""");
var invalidResult = await tools.WriteConfig("{ invalid json");
Assert.Contains("\"Agent\"", readResult);
Assert.Equal(configPath, writeResult);
Assert.StartsWith("Refused: appsettings JSON is invalid.", invalidResult);
Assert.Contains("new-model", await File.ReadAllTextAsync(configPath));
Assert.DoesNotContain("invalid json", await File.ReadAllTextAsync(configPath));
}
[Fact]
public async Task SettingsToolsReadConfigurationDocumentation()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
var docsPath = Path.Combine(root, "meeting-assistant-configuration.md");
await File.WriteAllTextAsync(docsPath, "# Config Docs\n\nAgent model settings.");
var tools = new WorkflowRulesEditorTools(
new MeetingAssistantOptions(),
configDocsPath: docsPath);
var docs = await tools.ReadConfigDocs();
Assert.Contains("Agent model settings", docs);
}
[Fact]
public async Task SettingsToolsReadAndSearchApplicationLogs()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
await File.WriteAllTextAsync(
Path.Combine(root, MeetingAssistantLogFiles.CurrentLogFileName),
"first line\nsecond needle\nthird line\n");
await File.WriteAllTextAsync(
Path.Combine(root, MeetingAssistantLogFiles.GetRotatedLogFileName(1)),
"rotated needle\n");
var tools = new WorkflowRulesEditorTools(
new MeetingAssistantOptions(),
logDirectory: root);
using var provider = new MeetingAssistantFileLoggerProvider(root);
provider.CreateLogger("Test").LogInformation("locked needle");
var tail = await tools.ReadLogs(tail: 2);
var range = await tools.ReadLogs(from: 1, to: 1);
var search = await tools.SearchLogs("needle");
Assert.Contains("locked needle", tail);
Assert.Contains("locked needle", search);
Assert.Contains("locked needle", range);
Assert.Contains("meeting-assistant.log:1", search);
Assert.Contains("meeting-assistant.log.2:1 rotated needle", search);
}
[Fact]
public async Task SettingsToolsReadAndSearchOnlyCopiedSpecFiles()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var sessionSpec = Path.Combine(root, "meeting-session", "spec.md");
Directory.CreateDirectory(Path.GetDirectoryName(sessionSpec)!);
await File.WriteAllTextAsync(
sessionSpec,
"""
# meeting-session Specification
Meeting Assistant creates notes.
""");
await File.WriteAllTextAsync(
Path.Combine(root, "root-spec.md"),
"Root spec mentions Assistant.");
var outside = Path.Combine(Path.GetDirectoryName(root)!, "outside.md");
await File.WriteAllTextAsync(outside, "outside Assistant");
var tools = new WorkflowRulesEditorTools(
new MeetingAssistantOptions(),
specRootPath: root);
var read = await tools.ReadSpecFile("meeting-session/spec.md", from: 3, to: 3);
var escaped = await tools.ReadSpecFile("../outside.md");
var search = await tools.SearchSpec("Assistant");
Assert.Equal("Meeting Assistant creates notes.", read.Trim());
Assert.StartsWith("Refused: spec path must stay inside", escaped);
Assert.Contains("meeting-session/spec.md:3 Meeting Assistant creates notes.", search);
Assert.Contains("root-spec.md:1 Root spec mentions Assistant.", search);
Assert.DoesNotContain("outside.md", search);
}
[Fact]
public async Task SettingsToolsReadTailAndSearchSpecsWithFilePattern()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var sessionSpec = Path.Combine(root, "meeting-session", "spec.md");
var otherSpec = Path.Combine(root, "other", "notes.md");
Directory.CreateDirectory(Path.GetDirectoryName(sessionSpec)!);
Directory.CreateDirectory(Path.GetDirectoryName(otherSpec)!);
await File.WriteAllTextAsync(sessionSpec, "one\nsession needle\nthree\nfour");
await File.WriteAllTextAsync(otherSpec, "other needle");
var tools = new WorkflowRulesEditorTools(
new MeetingAssistantOptions(),
specRootPath: root);
var tail = await tools.ReadSpecFile("meeting-session/spec.md", tail: 2);
var search = await tools.SearchSpec("needle", file_pattern: "meeting-session/*.md");
Assert.Equal("three\nfour", tail);
Assert.Contains("meeting-session/spec.md:2 session needle", search);
Assert.DoesNotContain("other/notes.md", search);
}
[Fact]
public async Task SettingsToolsCreateProjectWithRecommendedSeedOrDirectAgentsFile()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var projectsRoot = Path.Combine(root, "Projects");
var agentsTemplatePath = Path.Combine(root, "Project-AGENTS.md");
Directory.CreateDirectory(root);
await File.WriteAllTextAsync(agentsTemplatePath, "Recommended project instructions.");
var tools = new WorkflowRulesEditorTools(
new MeetingAssistantOptions
{
Vault =
{
ProjectsFolder = projectsRoot
}
},
projectAgentsTemplatePath: agentsTemplatePath);
var recommended = await tools.CreateProject("Alpha Project");
var direct = await tools.CreateProject(
"Beta Project",
seed: "agents_md",
agents_md: "Direct project instructions.");
var alphaRoot = Path.Combine(projectsRoot, "Alpha Project");
var betaRoot = Path.Combine(projectsRoot, "Beta Project");
Assert.Equal("Alpha Project", recommended);
Assert.Contains("Alpha Project", await tools.ListProjects());
Assert.Equal("Recommended project instructions.", await File.ReadAllTextAsync(Path.Combine(alphaRoot, "AGENTS.md")));
Assert.Contains("# Executive Summary", await File.ReadAllTextAsync(Path.Combine(alphaRoot, "PROJECT.md")));
Assert.Contains("project folder was created", await File.ReadAllTextAsync(Path.Combine(alphaRoot, "JOURNAL.md")));
Assert.Contains("Important decisions", await File.ReadAllTextAsync(Path.Combine(alphaRoot, "DECISIONS.md")));
Assert.Equal("Direct project instructions.", await File.ReadAllTextAsync(Path.Combine(betaRoot, "AGENTS.md")));
Assert.False(File.Exists(Path.Combine(betaRoot, "PROJECT.md")));
Assert.StartsWith("Refused:", await tools.CreateProject("../Escape"));
var missingTemplateTools = new WorkflowRulesEditorTools(
new MeetingAssistantOptions
{
Vault =
{
ProjectsFolder = projectsRoot
}
},
projectAgentsTemplatePath: Path.Combine(root, "Missing-Project-AGENTS.md"));
var missingTemplate = await missingTemplateTools.CreateProject("Gamma Project");
Assert.StartsWith("Refused: recommended project AGENTS.md template was not found:", missingTemplate);
Assert.False(Directory.Exists(Path.Combine(projectsRoot, "Gamma Project")));
}
[Fact]
public async Task SettingsToolsReadWriteAndSearchExistingProjectFiles()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var projectsRoot = Path.Combine(root, "Projects");
var alphaRoot = Path.Combine(projectsRoot, "Alpha");
var betaRoot = Path.Combine(projectsRoot, "Beta");
Directory.CreateDirectory(alphaRoot);
Directory.CreateDirectory(betaRoot);
await File.WriteAllTextAsync(Path.Combine(alphaRoot, "PROJECT.md"), "alpha one\nalpha needle\nalpha three");
await File.WriteAllTextAsync(Path.Combine(alphaRoot, "notes.txt"), "alpha needle hidden from md search");
await File.WriteAllTextAsync(Path.Combine(betaRoot, "PROJECT.md"), "beta needle");
var tools = new WorkflowRulesEditorTools(
new MeetingAssistantOptions
{
Vault =
{
ProjectsFolder = projectsRoot
}
});
var files = await tools.ListProjectFiles("Alpha");
var tail = await tools.ReadProjectFile("Alpha", "PROJECT.md", tail: 2);
var write = await tools.WriteProjectFile("Alpha", "JOURNAL.md", "created");
var search = await tools.SearchProjects("needle", file_pattern: "*.md");
Assert.Equal("notes.txt\nPROJECT.md", files);
Assert.Equal("alpha needle\nalpha three", tail);
Assert.Equal("Alpha/JOURNAL.md", write);
Assert.Equal("created", await File.ReadAllTextAsync(Path.Combine(alphaRoot, "JOURNAL.md")));
Assert.Contains("Alpha/PROJECT.md:2 alpha needle", search);
Assert.Contains("Beta/PROJECT.md:1 beta needle", search);
Assert.DoesNotContain("notes.txt", search);
Assert.StartsWith("Refused:", await tools.ReadProjectFile("Missing", "PROJECT.md"));
Assert.StartsWith("Refused:", await tools.WriteProjectFile("Alpha", "../escape.md", "bad"));
}
[Fact]
public async Task SettingsToolsListReadAndSearchMeetingArtifacts()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var summariesRoot = Path.Combine(root, "Summaries");
var transcriptsRoot = Path.Combine(root, "Transcripts");
var notesRoot = Path.Combine(root, "Notes");
var contextRoot = Path.Combine(root, "Context");
Directory.CreateDirectory(summariesRoot);
Directory.CreateDirectory(transcriptsRoot);
Directory.CreateDirectory(notesRoot);
Directory.CreateDirectory(contextRoot);
await File.WriteAllTextAsync(
Path.Combine(summariesRoot, "20260530-1000-summary.md"),
"""
---
title: Older Meeting
start_time: "2026-05-30T10:00:00+02:00"
meeting: "[[20260530-1000-note|Meeting Note]]"
transcript: "[[20260530-1000-transcript|Transcript]]"
assistant_context: "[[20260530-1000-context|Assistant Context]]"
projects:
- Alpha
---
older summary needle
""");
await File.WriteAllTextAsync(
Path.Combine(summariesRoot, "20260531-0900-summary.md"),
"""
---
title: Newer Meeting
start_time: "2026-05-31T09:00:00+02:00"
meeting: "[[20260531-0900-note|Meeting Note]]"
transcript: "[[20260531-0900-transcript|Transcript]]"
assistant_context: "[[20260531-0900-context|Assistant Context]]"
projects:
- Beta
---
newer summary needle
""");
await File.WriteAllTextAsync(Path.Combine(notesRoot, "20260531-0900-note.md"), "note one\nnote needle\nnote three");
await File.WriteAllTextAsync(Path.Combine(transcriptsRoot, "20260531-0900-transcript.md"), "transcript one\ntranscript needle\ntranscript three");
await File.WriteAllTextAsync(Path.Combine(contextRoot, "20260531-0900-context.md"), "context one\ncontext needle\ncontext three");
var tools = new WorkflowRulesEditorTools(new MeetingAssistantOptions
{
Vault =
{
SummariesFolder = summariesRoot,
TranscriptsFolder = transcriptsRoot,
MeetingNotesFolder = notesRoot,
AssistantContextFolder = contextRoot
}
});
var recent = await tools.ListRecentSummaries(limit: 2);
var summary = await tools.ReadSummary("20260531-0900-summary.md", tail: 1);
var transcript = await tools.ReadTranscript("20260531-0900-transcript.md", from: 2, to: 2);
var meetingNote = await tools.ReadMeetingNote("20260531-0900-note.md", tail: 2);
var context = await tools.ReadContext("20260531-0900-context.md", tail: 2);
var summarySearch = await tools.SearchSummaries("needle");
var transcriptSearch = await tools.SearchTranscripts("needle");
var noteSearch = await tools.SearchMeetingNotes("needle");
var contextSearch = await tools.SearchContext("needle");
Assert.True(recent.IndexOf("20260531-0900-summary.md", StringComparison.Ordinal) <
recent.IndexOf("20260530-1000-summary.md", StringComparison.Ordinal));
Assert.Contains("title: Newer Meeting", recent);
Assert.Contains("meeting_note: 20260531-0900-note.md", recent);
Assert.Contains("transcript: 20260531-0900-transcript.md", recent);
Assert.Contains("context: 20260531-0900-context.md", recent);
Assert.Equal("newer summary needle", summary.Trim());
Assert.Equal("transcript needle", transcript.Trim());
Assert.Equal("note needle\nnote three", meetingNote);
Assert.Equal("context needle\ncontext three", context);
Assert.Contains("20260531-0900-summary.md:", summarySearch);
Assert.Contains("20260531-0900-transcript.md:2 transcript needle", transcriptSearch);
Assert.Contains("20260531-0900-note.md:2 note needle", noteSearch);
Assert.Contains("20260531-0900-context.md:2 context needle", contextSearch);
Assert.StartsWith("Refused:", await tools.ReadSummary("../escape.md"));
}
[Fact]
public async Task SettingsToolsWriteMeetingArtifactsAndFrontmatter()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
var summariesRoot = Path.Combine(root, "summaries");
var transcriptsRoot = Path.Combine(root, "transcripts");
var notesRoot = Path.Combine(root, "meetings");
var contextRoot = Path.Combine(root, "context");
Directory.CreateDirectory(summariesRoot);
Directory.CreateDirectory(transcriptsRoot);
Directory.CreateDirectory(notesRoot);
Directory.CreateDirectory(contextRoot);
await File.WriteAllTextAsync(
Path.Combine(notesRoot, "daily.md"),
"""
---
title: Old
---
Existing body.
""");
await File.WriteAllTextAsync(Path.Combine(transcriptsRoot, "daily-transcript.md"), "one\nthree");
var tools = new WorkflowRulesEditorTools(new MeetingAssistantOptions
{
Vault =
{
SummariesFolder = summariesRoot,
TranscriptsFolder = transcriptsRoot,
MeetingNotesFolder = notesRoot,
AssistantContextFolder = contextRoot
}
});
Assert.Equal("daily-summary.md", await tools.WriteSummary("daily-summary.md", "summary body", replace_file: true));
Assert.Equal("daily-transcript.md", await tools.WriteTranscript("daily-transcript.md", "two", insert: 2));
Assert.Equal("daily.md", await tools.WriteMeetingNoteFrontmatter(
"daily.md",
"""
title: Fixed
projects:
- Alpha
transcript: "[[daily-transcript|Transcript]]"
"""));
Assert.Equal("repair/context.md", await tools.WriteContext("repair/context.md", "context body"));
Assert.Equal("summary body", await File.ReadAllTextAsync(Path.Combine(summariesRoot, "daily-summary.md")));
Assert.Equal("one\ntwo\nthree", await File.ReadAllTextAsync(Path.Combine(transcriptsRoot, "daily-transcript.md")));
var note = await File.ReadAllTextAsync(Path.Combine(notesRoot, "daily.md"));
Assert.Contains("title: Fixed", note);
Assert.Contains("projects:\n- Alpha", note);
Assert.Contains("Existing body.", note);
Assert.Equal("context body", await File.ReadAllTextAsync(Path.Combine(contextRoot, "repair", "context.md")));
Assert.StartsWith("Refused:", await tools.WriteSummary("../escape.md", "bad"));
Assert.StartsWith("Refused:", await tools.WriteMeetingNoteFrontmatter("daily.md", "title: ["));
}
[Fact]
public async Task SettingsToolsExposeDiagnosticsWithoutHttp()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["MeetingAssistant:Automation:RulesPath"] = "rules.yaml"
})
.Build();
var tools = new WorkflowRulesEditorTools(
new MeetingAssistantOptions(),
configuration: config);
Assert.Contains("meeting-assistant", tools.GetHealth());
Assert.Equal("Recording coordinator is not configured.", tools.GetRecordingStatus());
Assert.Contains("rules.yaml", tools.ReloadWorkflowConfiguration());
Assert.Equal("Meeting metadata provider is not configured.", await tools.DiagnoseCurrentMeeting());
Assert.Equal("Speaker identity merge service is not configured.", await tools.MergeRecentSpeakerIdentities());
Assert.Equal("ASR diagnostic service is not configured.", await tools.DiagnoseAsrTranscribeFile("missing.wav"));
Assert.Equal("ASR diagnostic service is not configured.", await tools.DiagnoseAsrDiarizeFile("missing.wav"));
}
[Fact]
public void FileLoggerRotatesCurrentLogAndKeepsFourOlderFiles()
{
var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(root);
File.WriteAllText(Path.Combine(root, MeetingAssistantLogFiles.CurrentLogFileName), "current");
File.WriteAllText(Path.Combine(root, MeetingAssistantLogFiles.GetRotatedLogFileName(1)), "old1");
File.WriteAllText(Path.Combine(root, MeetingAssistantLogFiles.GetRotatedLogFileName(2)), "old2");
File.WriteAllText(Path.Combine(root, MeetingAssistantLogFiles.GetRotatedLogFileName(3)), "old3");
File.WriteAllText(Path.Combine(root, MeetingAssistantLogFiles.GetRotatedLogFileName(4)), "old4");
using (var provider = new MeetingAssistantFileLoggerProvider(root))
{
provider.CreateLogger("Test").LogInformation("fresh");
}
Assert.Contains("fresh", File.ReadAllText(Path.Combine(root, MeetingAssistantLogFiles.CurrentLogFileName)));
Assert.Equal("current", File.ReadAllText(Path.Combine(root, MeetingAssistantLogFiles.GetRotatedLogFileName(1))));
Assert.Equal("old3", File.ReadAllText(Path.Combine(root, MeetingAssistantLogFiles.GetRotatedLogFileName(4))));
Assert.False(File.Exists(Path.Combine(root, "meeting-assistant.log.5")));
}
[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 fixture = await CreateRulesEditorFixtureAsync();
var result = await fixture.Tools.WriteRules("rules:\n- name: [", replace_file: true);
Assert.StartsWith("Refused: workflow rules YAML is invalid.", result);
Assert.Equal("rules: []", await File.ReadAllTextAsync(fixture.RulesPath));
}
[Fact]
public async Task RulesEditorToolsRefuseWorkflowRulesThatWouldFailAtRuntime()
{
var fixture = await CreateRulesEditorFixtureAsync();
var result = await fixture.Tools.WriteRules("""
rules:
- name: broken-step
on:
- created: {}
steps:
- uses: send_email
value: Manuel
""", replace_file: true);
Assert.StartsWith("Refused: workflow rules are invalid.", result);
Assert.Contains("broken-step", result);
Assert.Contains("send_email", result);
Assert.Contains("add_attendee", result);
Assert.Equal("rules: []", await File.ReadAllTextAsync(fixture.RulesPath));
}
[Fact]
public async Task RulesEditorToolsLogWorkflowRuleValidationFailures()
{
var logger = new CapturingLogger();
var fixture = await CreateRulesEditorFixtureAsync(logger);
var result = await fixture.Tools.WriteRules("""
rules:
- name: broken-step
on:
- created: {}
steps:
- uses: send_email
value: Manuel
""", replace_file: true);
Assert.StartsWith("Refused: workflow rules are invalid.", result);
Assert.Contains(logger.Messages, message =>
message.Contains(nameof(WorkflowRulesEditorTools.WriteRules), StringComparison.Ordinal) &&
message.Contains("send_email", StringComparison.Ordinal));
}
[Fact]
public async Task RulesEditorToolsExplainUnsupportedWorkflowProperties()
{
var fixture = await CreateRulesEditorFixtureAsync();
var result = await fixture.Tools.WriteRules("""
rules:
- name: broken-property
on:
- created: {}
steps:
- uses: set_property
property: meeting.status
value: active
""", replace_file: true);
Assert.StartsWith("Refused: workflow rules are invalid.", result);
Assert.Contains("broken-property", result);
Assert.Contains("meeting.status", result);
Assert.Contains("title", result);
Assert.Equal("rules: []", await File.ReadAllTextAsync(fixture.RulesPath));
}
[Fact]
public async Task RulesEditorToolsRefuseTranscriptLinePropertyOutsideTranscriptLineTrigger()
{
var fixture = await CreateRulesEditorFixtureAsync();
var result = await fixture.Tools.WriteRules("""
rules:
- name: broken-transcript-property
on:
- created: {}
steps:
- uses: set_property
property: transcript.line
value: redacted
""", replace_file: true);
Assert.StartsWith("Refused: workflow rules are invalid.", result);
Assert.Contains("broken-transcript-property", result);
Assert.Contains("transcript.line", result);
Assert.Contains("transcript_line", result);
Assert.Equal("rules: []", await File.ReadAllTextAsync(fixture.RulesPath));
}
[Fact]
public async Task RulesEditorToolsAcceptAndParseMaskedProfanityRedactionRule()
{
var fixture = await CreateRulesEditorFixtureAsync();
var result = await fixture.Tools.WriteRules(MeetingWorkflowTestRules.MaskedProfanityRedactionYaml, replace_file: true);
Assert.Equal(fixture.RulesPath, result);
var provider = new FileMeetingWorkflowRulesProvider(
NullLogger<FileMeetingWorkflowRulesProvider>.Instance);
var rule = Assert.Single(await provider.GetRulesAsync(fixture.Options, CancellationToken.None));
Assert.Equal("redact-masked-profanity", rule.Name);
Assert.NotNull(Assert.Single(rule.On).TranscriptLine);
Assert.Equal("contains(transcript.line, '*****')", Assert.Single(rule.If).Condition);
var step = Assert.Single(rule.Steps);
Assert.Equal("set_property", step.Uses);
Assert.Equal("transcript.line", step.Property);
Assert.Equal(
"""@Model.Transcript.Line.Replace("*****", "[redacted]")""",
step.Value);
}
[Fact]
public async Task RulesEditorToolsRefuseMalformedRazorStepValues()
{
var fixture = await CreateRulesEditorFixtureAsync();
var result = await fixture.Tools.WriteRules("""
rules:
- name: broken-razor
on:
- created: {}
steps:
- uses: add_context
value: "Invalid template: @ThisIsNotAModelVar"
""", replace_file: true);
Assert.StartsWith("Refused: workflow rules are invalid.", result);
Assert.Contains("broken-razor", result);
Assert.Contains("Razor", result);
Assert.Contains("ThisIsNotAModelVar", result);
Assert.Equal("rules: []", await File.ReadAllTextAsync(fixture.RulesPath));
}
[Fact]
public async Task RulesEditorToolsAllowValidRazorAndEmailStepValues()
{
var fixture = await CreateRulesEditorFixtureAsync(initialContent: "");
var result = await fixture.Tools.WriteRules("""
rules:
- name: valid-razor
on:
- speaker_identified: {}
steps:
- uses: add_context
value: "Speaker @Model.Speaker.Name can contact Support@example.com"
""", replace_file: true);
Assert.Equal(fixture.RulesPath, result);
Assert.Contains("@Model.Speaker.Name", await File.ReadAllTextAsync(fixture.RulesPath));
Assert.Contains("Support@example.com", await File.ReadAllTextAsync(fixture.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);
Assert.Contains("read_config", instructions);
Assert.Contains("read_logs", instructions);
Assert.Contains("read_spec_file", instructions);
Assert.Contains("list_recent_summaries", instructions);
}
[Fact]
public async Task RulesEditorToolsCrudAndSearchSpeakerIdentities()
{
await using var fixture = await WorkflowRulesEditorIdentityFixture.CreateAsync();
var identity = await fixture.AddIdentityAsync("Sabrina", aliases: ["Sabi"], candidates: ["Guest-01"], sample: [1]);
var tools = fixture.CreateTools();
var searchResult = await tools.SearchIdentities("sabi");
var readResult = await tools.ReadIdentity(identity.Id);
var updateResult = await tools.UpdateIdentity(
identity.Id,
"Sabrina M.",
["Sabrina"],
["Guest-02"]);
var deleteResult = await tools.DeleteIdentity(identity.Id);
Assert.Contains("\"displayName\": \"Sabrina\"", searchResult);
Assert.Contains("\"canonicalName\": \"Sabrina\"", readResult);
Assert.Contains("\"canonicalName\": \"Sabrina M.\"", updateResult);
Assert.Equal($"Deleted identity {identity.Id}.", deleteResult);
Assert.Empty(await fixture.Context.SpeakerIdentities.ToListAsync());
}
[Fact]
public async Task RulesEditorToolsRefusesSamplelessSpeakerIdentityCreation()
{
await using var fixture = await WorkflowRulesEditorIdentityFixture.CreateAsync();
var tools = fixture.CreateTools();
var result = await tools.CreateIdentity("Sabrina", ["Sabi"], ["Guest-01"]);
Assert.StartsWith("Refused: speaker identities require at least one audio sample.", result);
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;
fixture.Context.SpeakerSnippets.Add(new SpeakerSnippet
{
SpeakerIdentityId = identity.Id,
WavBytes = [7, 8, 9],
CreatedAt = DateTimeOffset.UtcNow
});
await fixture.Context.SaveChangesAsync();
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);
var remaining = await fixture.Context.SpeakerSnippets.ToListAsync();
Assert.Single(remaining);
Assert.NotEqual(sampleId, remaining.Single().Id);
}
[Fact]
public async Task RulesEditorToolsRefusesDeletingLastSpeakerIdentitySample()
{
await using var fixture = await WorkflowRulesEditorIdentityFixture.CreateAsync();
var identity = await fixture.AddIdentityAsync("Sabrina", sample: [4, 5, 6]);
var sampleId = identity.Snippets.Single().Id;
var tools = fixture.CreateTools();
var result = await tools.DeleteIdentitySample(sampleId);
Assert.Equal(
$"Refused: sample {sampleId} is the last sample for identity {identity.Id}. Delete the identity instead.",
result);
Assert.Single(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** ***both*** ~~gone~~ [docs](https://example.test) <https://auto.test> <test@example.com> ![logo](C:/tmp/logo.png) 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(" ", WorkflowRulesEditorMarkdownInlineStyle.Normal),
new WorkflowRulesEditorMarkdownInline("both", WorkflowRulesEditorMarkdownInlineStyle.BoldItalic),
new WorkflowRulesEditorMarkdownInline(" ", WorkflowRulesEditorMarkdownInlineStyle.Normal),
new WorkflowRulesEditorMarkdownInline("gone", WorkflowRulesEditorMarkdownInlineStyle.Strikethrough),
new WorkflowRulesEditorMarkdownInline(" ", WorkflowRulesEditorMarkdownInlineStyle.Normal),
new WorkflowRulesEditorMarkdownInline(
"docs",
WorkflowRulesEditorMarkdownInlineStyle.Link,
"https://example.test"),
new WorkflowRulesEditorMarkdownInline(" ", WorkflowRulesEditorMarkdownInlineStyle.Normal),
new WorkflowRulesEditorMarkdownInline(
"https://auto.test",
WorkflowRulesEditorMarkdownInlineStyle.Link,
"https://auto.test"),
new WorkflowRulesEditorMarkdownInline(" ", WorkflowRulesEditorMarkdownInlineStyle.Normal),
new WorkflowRulesEditorMarkdownInline(
"test@example.com",
WorkflowRulesEditorMarkdownInlineStyle.Link,
"mailto:test@example.com"),
new WorkflowRulesEditorMarkdownInline(" ", WorkflowRulesEditorMarkdownInlineStyle.Normal),
new WorkflowRulesEditorMarkdownInline(
"logo",
WorkflowRulesEditorMarkdownInlineStyle.Image,
"C:/tmp/logo.png"),
new WorkflowRulesEditorMarkdownInline(" and ", WorkflowRulesEditorMarkdownInlineStyle.Normal),
new WorkflowRulesEditorMarkdownInline("code", WorkflowRulesEditorMarkdownInlineStyle.Code),
new WorkflowRulesEditorMarkdownInline(".", WorkflowRulesEditorMarkdownInlineStyle.Normal)
],
inlines);
}
[Fact]
public void MarkdownParserRecognizesHeadingsBlockquotesAndCheckboxes()
{
var blocks = WorkflowRulesEditorMarkdown.Parse("""
# Main
## Detail
> quoted **line**
> second
- [x] done
- [ ] open
""");
Assert.Collection(
blocks,
block =>
{
var heading = Assert.IsType<WorkflowRulesEditorMarkdownHeading>(block);
Assert.Equal(1, heading.Level);
Assert.Equal("# Main", Assert.Single(heading.Inlines).Text);
},
block =>
{
var heading = Assert.IsType<WorkflowRulesEditorMarkdownHeading>(block);
Assert.Equal(2, heading.Level);
Assert.Equal("## Detail", Assert.Single(heading.Inlines).Text);
},
block =>
{
var quote = Assert.IsType<WorkflowRulesEditorMarkdownBlockQuote>(block);
Assert.Contains(quote.Inlines, inline => inline.Text.Contains("quoted", StringComparison.Ordinal));
Assert.Contains(quote.Inlines, inline => inline.Style == WorkflowRulesEditorMarkdownInlineStyle.Bold);
},
block =>
{
var tasks = Assert.IsType<WorkflowRulesEditorMarkdownTaskList>(block);
Assert.Collection(
tasks.Items,
item =>
{
Assert.True(item.IsChecked);
Assert.Equal("done", Assert.Single(item.Inlines).Text);
},
item =>
{
Assert.False(item.IsChecked);
Assert.Equal("open", Assert.Single(item.Inlines).Text);
});
});
}
[Fact]
public void MarkdownParserRecognizesStandaloneImageEmbeds()
{
var blocks = WorkflowRulesEditorMarkdown.Parse("""
Before
![Architecture diagram](C:/tmp/architecture.png)
After
""");
Assert.Collection(
blocks,
block => Assert.IsType<WorkflowRulesEditorMarkdownParagraph>(block),
block =>
{
var image = Assert.IsType<WorkflowRulesEditorMarkdownImage>(block);
Assert.Equal("Architecture diagram", image.AltText);
Assert.Equal("C:/tmp/architecture.png", image.Target);
},
block => Assert.IsType<WorkflowRulesEditorMarkdownParagraph>(block));
}
[Fact]
public void MarkdownLinkResolverFindsScreenshotAttachmentsUnderArtifactRoots()
{
var temp = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
try
{
var contextRoot = Path.Combine(temp, "Meetings", "Assistant Context");
var attachmentPath = Path.Combine(
contextRoot,
"2026",
"06",
"Attachments",
"20260601-120000-001.png");
Directory.CreateDirectory(Path.GetDirectoryName(attachmentPath)!);
File.WriteAllBytes(attachmentPath, [1, 2, 3]);
var resolver = new WorkflowRulesEditorMarkdownLinkResolver([contextRoot], [contextRoot]);
Assert.True(resolver.TryResolveImageUri("Attachments/20260601-120000-001.png", out var uri));
Assert.Equal(new Uri(attachmentPath), uri);
Assert.Equal(attachmentPath, resolver.ResolveOpenTarget("Attachments/20260601-120000-001.png"));
}
finally
{
if (Directory.Exists(temp))
{
Directory.Delete(temp, recursive: true);
}
}
}
[Fact]
public void MarkdownLinkResolverDoesNotCacheMissingFiles()
{
var temp = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N"));
try
{
var contextRoot = Path.Combine(temp, "Meetings", "Assistant Context");
var attachmentPath = Path.Combine(contextRoot, "Attachments", "late.png");
var resolver = new WorkflowRulesEditorMarkdownLinkResolver([contextRoot], [contextRoot]);
Assert.False(resolver.TryResolveImageUri("Attachments/late.png", out _));
Directory.CreateDirectory(Path.GetDirectoryName(attachmentPath)!);
File.WriteAllBytes(attachmentPath, [1, 2, 3]);
Assert.True(resolver.TryResolveImageUri("Attachments/late.png", out var uri));
Assert.Equal(new Uri(attachmentPath), uri);
}
finally
{
if (Directory.Exists(temp))
{
Directory.Delete(temp, recursive: true);
}
}
}
[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 CapturingLogger : ILogger
{
public List<string> Messages { get; } = [];
public IDisposable? BeginScope<TState>(TState state)
where TState : notnull
{
return null;
}
public bool IsEnabled(LogLevel logLevel)
{
return true;
}
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
Messages.Add(formatter(state, exception));
}
}
private static async Task<RulesEditorFixture> CreateRulesEditorFixtureAsync(
ILogger? logger = null,
string initialContent = "rules: []")
{
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, initialContent);
var options = new MeetingAssistantOptions
{
Automation = new AutomationOptions { RulesPath = rulesPath }
};
return new RulesEditorFixture(
rulesPath,
options,
new WorkflowRulesEditorTools(options, logger: logger));
}
private sealed record RulesEditorFixture(
string RulesPath,
MeetingAssistantOptions Options,
WorkflowRulesEditorTools Tools);
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}.");
}
}
}