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 StepValuesWithEmailAddressesAreTreatedAsLiteralText() { var fixture = await WorkflowFixture.CreateAsync( """ rules: - name: normalize-sew-sofi-daily on: - state_transition: from: collecting metadata to: transcribing if: - condition: "meeting.title = 'WG: Sofi - Daily - Team Velocity Vanguards'" steps: - uses: set_property property: title value: 'SEW Sofi - Daily' - uses: add_project value: 'SEW Solution Finder' - uses: add_attendee value: 'niklas.link.e@sew-eurodrive.de' """, title: "WG: Sofi - Daily - Team Velocity Vanguards"); 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("SEW Sofi - Daily", meeting.Frontmatter.Title); Assert.Equal(["SEW Solution Finder"], meeting.Frontmatter.Projects); Assert.Equal(["niklas.link.e@sew-eurodrive.de"], meeting.Frontmatter.Attendees); } [Fact] public async Task StepValuesCanMixEmailAddressesAndRazorTemplates() { var fixture = await WorkflowFixture.CreateAsync( """ rules: - name: mixed-template on: - created: {} steps: - uses: add_context value: 'Contact Support@contoso.com about @Model.Meeting.Title.' """, title: "Architecture Sync"); await RunCreatedAsync(fixture); var context = await fixture.ReadContextAsync(); Assert.Contains("Contact Support@contoso.com about Architecture Sync.", context); } [Fact] public async Task StepValuesRenderRazorTransitionsThatAreNotModelMarkers() { var fixture = await WorkflowFixture.CreateAsync( """ rules: - name: razor-transition on: - created: {} steps: - uses: add_context value: 'Generated in @System.DateTime.UtcNow.Year' """); await RunCreatedAsync(fixture); var context = await fixture.ReadContextAsync(); Assert.Contains($"Generated in {DateTime.UtcNow.Year}", context); } [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); } [Theory] [InlineData("created: {}", "created", null, null, null, true)] [InlineData("created: {}", "speaker", null, null, "Ada", false)] [InlineData("state_transition:\n from: collecting metadata", "state", "collecting metadata", "speaker recognition", null, true)] [InlineData("state_transition:\n to: transcribing", "state", "collecting metadata", "transcribing", null, true)] [InlineData("state_transition:\n from: collecting metadata\n to: transcribing", "state", "collecting metadata", "transcribing", null, true)] [InlineData("state_transition:\n from: transcribing\n to: summarizing", "state", "collecting metadata", "transcribing", null, false)] [InlineData("state_transition:\n from: COLLECTING METADATA\n to: TRANSCRIBING", "state", "collecting metadata", "transcribing", null, true)] [InlineData("speaker_identified:\n name: Ada", "speaker", null, null, "Ada", true)] [InlineData("speaker_identified:\n name: ADA", "speaker", null, null, "ada", true)] [InlineData("speaker_identified:\n name: Ada", "speaker", null, null, "Grace", false)] [InlineData("speaker_identified: {}", "speaker", null, null, "Grace", true)] public async Task TriggerMatchingScenarios( string triggerYaml, string eventKind, string? from, string? to, string? speaker, bool shouldRun) { var fixture = await WorkflowFixture.CreateAsync( $$""" rules: - name: trigger-scenario on: - {{triggerYaml}} steps: - uses: add_project value: Triggered """); await fixture.Engine.RunAsync( CreateEvent(eventKind, fixture.Artifacts, from, to, speaker), fixture.Options, CancellationToken.None); var meeting = await fixture.ReadMeetingAsync(); Assert.Equal(shouldRun, meeting.Frontmatter.Projects.Contains("Triggered")); } [Theory] [InlineData("event.type = 'SpeakerIdentified'", "speaker", null, null, "Ada", true)] [InlineData("state.from = 'collecting metadata' and state.to = 'transcribing'", "state", "collecting metadata", "transcribing", null, true)] [InlineData("meeting.state = 'transcribing'", "state", "collecting metadata", "transcribing", null, true)] [InlineData("speaker.name = 'Ada'", "speaker", null, null, "Ada", true)] [InlineData("speaker.name = 'Ada'", "speaker", null, null, "Grace", false)] [InlineData("starts_with(meeting.title, 'planning')", "created", null, null, null, true)] [InlineData("ends_with(meeting.title, 'sync')", "created", null, null, null, true)] [InlineData("contains(meeting.title, 'ANNING S')", "created", null, null, null, true)] [InlineData("contains(meeting.attendees, 'Ada')", "created", null, null, null, true)] [InlineData("contains(meeting.projects, 'Architecture')", "created", null, null, null, true)] [InlineData("meeting.attendees.count >= 2", "created", null, null, null, true)] public async Task ExpressionConditionParameterScenarios( string condition, string eventKind, string? from, string? to, string? speaker, bool shouldRun) { var fixture = await WorkflowFixture.CreateAsync( $$""" rules: - name: condition-scenario on: - created: {} - state_transition: {} - speaker_identified: {} if: - condition: {{condition}} steps: - uses: add_project value: ConditionMatched """, title: "Planning Sync", attendees: ["Ada", "Grace"], projects: ["Architecture"]); await fixture.Engine.RunAsync( CreateEvent(eventKind, fixture.Artifacts, from, to, speaker), fixture.Options, CancellationToken.None); var meeting = await fixture.ReadMeetingAsync(); Assert.Equal(shouldRun, meeting.Frontmatter.Projects.Contains("ConditionMatched")); } [Fact] public async Task TopLevelConditionsAreCombinedWithAnd() { var fixture = await WorkflowFixture.CreateAsync( """ rules: - name: all-top-level-conditions on: - created: {} if: - condition: contains(meeting.title, 'Planning') - condition: contains(meeting.attendees, 'Ada') - condition: contains(meeting.projects, 'Missing') steps: - uses: add_project value: ShouldNotRun """, attendees: ["Ada"], projects: ["Architecture"]); await RunCreatedAsync(fixture); var meeting = await fixture.ReadMeetingAsync(); Assert.DoesNotContain("ShouldNotRun", meeting.Frontmatter.Projects); } [Fact] public async Task OrGroupSkipsRuleWhenAllBranchesAreFalse() { var fixture = await WorkflowFixture.CreateAsync( """ rules: - name: false-or on: - created: {} if: - or: - condition: contains(meeting.title, 'Budget') - condition: contains(meeting.attendees, 'Grace') steps: - uses: add_project value: ShouldNotRun """, attendees: ["Ada"]); await RunCreatedAsync(fixture); var meeting = await fixture.ReadMeetingAsync(); Assert.DoesNotContain("ShouldNotRun", meeting.Frontmatter.Projects); } [Fact] public async Task EmptyConditionListRunsWhenTriggerMatches() { var fixture = await WorkflowFixture.CreateAsync( """ rules: - name: no-if on: - created: {} steps: - uses: add_project value: NoConditionNeeded """); await RunCreatedAsync(fixture); var meeting = await fixture.ReadMeetingAsync(); Assert.Contains("NoConditionNeeded", meeting.Frontmatter.Projects); } [Fact] public async Task RuleWithMultipleTriggersRunsWhenAnyTriggerMatches() { var fixture = await WorkflowFixture.CreateAsync( """ rules: - name: multi-trigger on: - created: {} - speaker_identified: name: Ada steps: - uses: add_project value: MultiTrigger """); await fixture.Engine.RunAsync( MeetingWorkflowEvent.SpeakerIdentified(fixture.Artifacts, "Ada"), fixture.Options, CancellationToken.None); var meeting = await fixture.ReadMeetingAsync(); Assert.Contains("MultiTrigger", meeting.Frontmatter.Projects); } [Fact] public async Task MultipleRulesRunInFileOrderAndLaterRulesSeeEarlierMutations() { var fixture = await WorkflowFixture.CreateAsync( """ rules: - name: first on: - created: {} steps: - uses: add_attendee value: Ada - name: second on: - created: {} if: - condition: contains(meeting.attendees, 'Ada') steps: - uses: add_project value: Architecture """); await RunCreatedAsync(fixture); var meeting = await fixture.ReadMeetingAsync(); Assert.Equal(["Ada"], meeting.Frontmatter.Attendees); Assert.Equal(["Architecture"], meeting.Frontmatter.Projects); } [Fact] public async Task LaterStepsInSameRuleSeeEarlierStepMutationsInRazorModel() { var fixture = await WorkflowFixture.CreateAsync( """ rules: - name: step-order on: - created: {} steps: - uses: add_attendee value: Ada - uses: add_context value: 'Attendees: @Model.Meeting.Attendees.Count' """); await RunCreatedAsync(fixture); var context = await fixture.ReadContextAsync(); Assert.Contains("Attendees: 1", context); } [Fact] public async Task SpeakerIdentifiedTemplateCanUseRecognizedSpeakerName() { var fixture = await WorkflowFixture.CreateAsync( """ rules: - name: speaker-template on: - speaker_identified: {} steps: - uses: add_context value: '@Model.Speaker.Name joined @Model.Meeting.Title' """, title: "Design Review"); await fixture.Engine.RunAsync( MeetingWorkflowEvent.SpeakerIdentified(fixture.Artifacts, "Manuel"), fixture.Options, CancellationToken.None); var context = await fixture.ReadContextAsync(); Assert.Contains("Manuel joined Design Review", context); } [Fact] public async Task StateTransitionTemplateCanUseFromAndToStates() { var fixture = await WorkflowFixture.CreateAsync( """ rules: - name: transition-template on: - state_transition: to: transcribing steps: - uses: add_context value: '@Model.Event.From -> @Model.Event.To' """); await fixture.Engine.RunAsync( MeetingWorkflowEvent.StateTransition( fixture.Artifacts, AssistantContextState.CollectingMetadata, AssistantContextState.Transcribing), fixture.Options, CancellationToken.None); var context = await fixture.ReadContextAsync(); Assert.Contains("collecting metadata -> transcribing", context); } [Fact] public async Task AddAttendeeNormalizesDisplayNameFromEmailAddress() { var fixture = await WorkflowFixture.CreateAsync( """ rules: - name: normalize-attendee on: - created: {} steps: - uses: add_attendee value: 'Ada Lovelace ' """); await RunCreatedAsync(fixture); var meeting = await fixture.ReadMeetingAsync(); Assert.Equal(["Ada Lovelace"], meeting.Frontmatter.Attendees); } [Fact] public async Task AddAttendeeDoesNotDuplicateExistingDisplayNameFromEmailAddress() { var fixture = await WorkflowFixture.CreateAsync( """ rules: - name: dedupe-attendee on: - created: {} steps: - uses: add_attendee value: Ada """, attendees: ["Ada "]); await RunCreatedAsync(fixture); var meeting = await fixture.ReadMeetingAsync(); Assert.Equal(["Ada "], meeting.Frontmatter.Attendees); } [Fact] public async Task RemoveAttendeeMatchesExistingDisplayNameFromEmailAddress() { var fixture = await WorkflowFixture.CreateAsync( """ rules: - name: remove-email-attendee on: - created: {} steps: - uses: remove_attendee value: Ada """, attendees: ["Ada ", "Grace"]); await RunCreatedAsync(fixture); var meeting = await fixture.ReadMeetingAsync(); Assert.Equal(["Grace"], meeting.Frontmatter.Attendees); } [Fact] public async Task AddProjectDoesNotDuplicateCaseInsensitiveProject() { var fixture = await WorkflowFixture.CreateAsync( """ rules: - name: dedupe-project on: - created: {} steps: - uses: add_project value: architecture """, projects: ["Architecture"]); await RunCreatedAsync(fixture); var meeting = await fixture.ReadMeetingAsync(); Assert.Equal(["Architecture"], meeting.Frontmatter.Projects); } [Fact] public async Task SetPropertyCanUseNameAliasForTitle() { var fixture = await WorkflowFixture.CreateAsync( """ rules: - name: title-by-name on: - created: {} steps: - uses: set_property name: meeting.title value: Updated Title """); await RunCreatedAsync(fixture); var meeting = await fixture.ReadMeetingAsync(); Assert.Equal("Updated Title", meeting.Frontmatter.Title); } [Fact] public async Task AddContextDoesNotRequireMeetingNoteMutationToPersist() { var fixture = await WorkflowFixture.CreateAsync( """ rules: - name: context-only on: - created: {} steps: - uses: add_context value: Context-only note. """); await RunCreatedAsync(fixture); var context = await fixture.ReadContextAsync(); Assert.Contains("Context-only note.", context); } [Fact] public async Task RulesFileChangesAreReadForTheNextWorkflowEvent() { var root = Path.Combine(Path.GetTempPath(), "meeting-assistant-tests", Guid.NewGuid().ToString("N")); var rulesPath = Path.Combine(root, "rules.yaml"); Directory.CreateDirectory(root); var fixture = await WorkflowFixture.CreateAsync( """ rules: - name: first-version on: - created: {} steps: - uses: add_project value: First """, rulesPath: rulesPath); await RunCreatedAsync(fixture); await File.WriteAllTextAsync( rulesPath, """ rules: - name: second-version on: - speaker_identified: {} steps: - uses: add_project value: Second """); await fixture.Engine.RunAsync( MeetingWorkflowEvent.SpeakerIdentified(fixture.Artifacts, "Ada"), fixture.Options, CancellationToken.None); var meeting = await fixture.ReadMeetingAsync(); Assert.Equal(["First", "Second"], meeting.Frontmatter.Projects); } [Fact] public async Task BlankRulesFileDoesNothing() { var fixture = await WorkflowFixture.CreateAsync(" "); await RunCreatedAsync(fixture); var meeting = await fixture.ReadMeetingAsync(); Assert.Empty(meeting.Frontmatter.Attendees); Assert.Empty(meeting.Frontmatter.Projects); } [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 static Task RunCreatedAsync(WorkflowFixture fixture) { return fixture.Engine.RunAsync( MeetingWorkflowEvent.Created(fixture.Artifacts), fixture.Options, CancellationToken.None); } private static MeetingWorkflowEvent CreateEvent( string eventKind, MeetingSessionArtifacts artifacts, string? from, string? to, string? speaker) { return eventKind switch { "created" => MeetingWorkflowEvent.Created(artifacts), "speaker" => MeetingWorkflowEvent.SpeakerIdentified(artifacts, speaker ?? "Ada"), "state" => MeetingWorkflowEvent.StateTransition( artifacts, ParseState(from ?? "collecting metadata"), ParseState(to ?? "transcribing")), _ => throw new InvalidOperationException($"Unknown workflow event kind '{eventKind}'.") }; } private static AssistantContextState ParseState(string state) { return state.Trim().ToLowerInvariant() switch { "collecting metadata" => AssistantContextState.CollectingMetadata, "transcribing" => AssistantContextState.Transcribing, "speaker recognition" => AssistantContextState.SpeakerRecognition, "summarizing" => AssistantContextState.Summarizing, "finished" => AssistantContextState.Finished, "error" => AssistantContextState.Error, _ => throw new InvalidOperationException($"Unknown workflow state '{state}'.") }; } 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, IReadOnlyList? projects = 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 ?? [], projects: projects ?? [], 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); } public Task ReadMeetingAsync() { return NoteStore.ReadAsync(Artifacts.MeetingNotePath, CancellationToken.None); } public Task ReadContextAsync() { return File.ReadAllTextAsync(Artifacts.AssistantContextPath); } } }