using MeetingAssistant.MeetingNotes; using MeetingAssistant.Workflow; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; namespace MeetingAssistant.Tests; public sealed class MeetingWorkflowEngineTests { [Fact] public async Task CreatedRuleAddsDefaultAttendeeOnceWhenMeetingHasNoAttendees() { var fixture = await WorkflowFixture.CreateAsync( """ rules: - name: add-me on: - created: {} if: - condition: meeting.attendees.count = 0 steps: - uses: add_attendee value: Manuel """); await fixture.Engine.RunAsync( MeetingWorkflowEvent.Created(fixture.Artifacts), fixture.Options, CancellationToken.None); await fixture.Engine.RunAsync( MeetingWorkflowEvent.Created(fixture.Artifacts), fixture.Options, CancellationToken.None); var meeting = await fixture.NoteStore.ReadAsync(fixture.Artifacts.MeetingNotePath, CancellationToken.None); Assert.Equal(["Manuel"], meeting.Frontmatter.Attendees); } [Fact] public async Task StateTransitionRuleCanCleanTitleAndAddProjectWithNestedConditions() { var fixture = await WorkflowFixture.CreateAsync( """ rules: - name: clean-teams-title on: - state_transition: from: collecting metadata to: transcribing if: - and: - condition: contains(meeting.title, '[Teams]') - not: condition: contains(meeting.projects, 'Architecture') steps: - uses: set_property property: title value: '@Model.Meeting.Title.Replace("[Teams]", "").Trim()' - uses: add_project value: Architecture """, title: "[Teams] Architecture Sync"); await fixture.Engine.RunAsync( MeetingWorkflowEvent.StateTransition( fixture.Artifacts, AssistantContextState.CollectingMetadata, AssistantContextState.Transcribing), fixture.Options, CancellationToken.None); var meeting = await fixture.NoteStore.ReadAsync(fixture.Artifacts.MeetingNotePath, CancellationToken.None); Assert.Equal("Architecture Sync", meeting.Frontmatter.Title); Assert.Equal(["Architecture"], meeting.Frontmatter.Projects); } [Fact] public async Task SpeakerIdentifiedRuleFiltersByNameAndWritesRazorContext() { var fixture = await WorkflowFixture.CreateAsync( """ rules: - name: ada-context on: - speaker_identified: name: Ada if: - or: - condition: contains(meeting.title, 'Architecture') - condition: meeting.attendees.count > 3 steps: - uses: add_context value: 'Speaker @Model.Speaker.Name was identified in @Model.Meeting.Title.' """, title: "Architecture Sync"); await fixture.Engine.RunAsync( MeetingWorkflowEvent.SpeakerIdentified(fixture.Artifacts, "Grace"), fixture.Options, CancellationToken.None); await fixture.Engine.RunAsync( MeetingWorkflowEvent.SpeakerIdentified(fixture.Artifacts, "Ada"), fixture.Options, CancellationToken.None); var context = await File.ReadAllTextAsync(fixture.Artifacts.AssistantContextPath); Assert.DoesNotContain("Grace", context); Assert.Contains("Speaker Ada was identified in Architecture Sync.", context); } [Fact] public async Task RuleCanRemoveAttendeeWhenNestedConditionMatches() { var fixture = await WorkflowFixture.CreateAsync( """ rules: - name: remove-placeholder on: - created: {} if: - and: - condition: contains(meeting.attendees, 'Placeholder') - not: condition: contains(meeting.title, 'Keep Placeholder') steps: - uses: remove_attendee value: Placeholder """, attendees: ["Placeholder", "Ada"]); await fixture.Engine.RunAsync( MeetingWorkflowEvent.Created(fixture.Artifacts), fixture.Options, CancellationToken.None); var meeting = await fixture.NoteStore.ReadAsync(fixture.Artifacts.MeetingNotePath, CancellationToken.None); Assert.Equal(["Ada"], meeting.Frontmatter.Attendees); } [Fact] public async Task MissingRulesFileDoesNothing() { var fixture = await WorkflowFixture.CreateAsync("", rulesPath: Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"), "missing.yaml")); await fixture.Engine.RunAsync( MeetingWorkflowEvent.Created(fixture.Artifacts), fixture.Options, CancellationToken.None); var meeting = await fixture.NoteStore.ReadAsync(fixture.Artifacts.MeetingNotePath, CancellationToken.None); Assert.Empty(meeting.Frontmatter.Attendees); } private sealed class WorkflowFixture { private WorkflowFixture( MeetingAssistantOptions options, MarkdownMeetingNoteStore noteStore, MeetingSessionArtifacts artifacts, MeetingWorkflowEngine engine) { Options = options; NoteStore = noteStore; Artifacts = artifacts; Engine = engine; } public MeetingAssistantOptions Options { get; } public MarkdownMeetingNoteStore NoteStore { get; } public MeetingSessionArtifacts Artifacts { get; } public MeetingWorkflowEngine Engine { get; } public static async Task CreateAsync( string rulesYaml, string title = "Planning Sync", IReadOnlyList? attendees = null, string? rulesPath = null) { var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); rulesPath ??= Path.Combine(root, "rules.yaml"); if (!string.IsNullOrEmpty(rulesYaml)) { await File.WriteAllTextAsync(rulesPath, rulesYaml); } var options = new MeetingAssistantOptions { Vault = new VaultOptions { BaseFolder = root, MeetingNotesFolder = "Notes", TranscriptsFolder = "Transcripts", AssistantContextFolder = "Context", SummariesFolder = "Summaries", ProjectsFolder = "Projects" }, Automation = new AutomationOptions { RulesPath = rulesPath } }; var noteStore = new MarkdownMeetingNoteStore( Microsoft.Extensions.Options.Options.Create(options), NullLogger.Instance); var artifactStore = new MarkdownMeetingArtifactStore( NullLogger.Instance); var artifacts = new MeetingSessionArtifacts( Path.Combine(root, "Notes", "meeting.md"), Path.Combine(root, "Transcripts", "transcript.md"), Path.Combine(root, "Context", "context.md"), Path.Combine(root, "Summaries", "summary.md")); var meeting = await noteStore.SaveAsync( MeetingNoteTemplate.Create( title, DateTimeOffset.Parse("2026-05-26T10:00:00+02:00"), attendees: attendees ?? [], transcriptPath: artifacts.TranscriptPath, assistantContextPath: artifacts.AssistantContextPath, summaryPath: artifacts.SummaryPath) with { Path = artifacts.MeetingNotePath }, options, CancellationToken.None); await artifactStore.CreateAssistantContextAsync( artifacts, meeting, "", null, CancellationToken.None); var engine = new MeetingWorkflowEngine( new FileMeetingWorkflowRulesProvider( NullLogger.Instance), noteStore, artifactStore, NullLogger.Instance); return new WorkflowFixture(options, noteStore, artifacts, engine); } } }