Files
meeting-assistant/MeetingAssistant.Tests/MeetingWorkflowEngineTests.cs

1007 lines
35 KiB
C#

using MeetingAssistant.MeetingNotes;
using MeetingAssistant.Workflow;
using Microsoft.Extensions.Logging;
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 TranscriptLineRuleCanReplaceMaskedProfanityBeforeWriting()
{
var fixture = await WorkflowFixture.CreateAsync(MeetingWorkflowTestRules.MaskedProfanityRedactionYaml);
var line = await fixture.Engine.TransformTranscriptLineAsync(
MeetingWorkflowEvent.TranscriptLine(
fixture.Artifacts,
"[00:00:04] Guest-1: Azure returned ***** here.",
"Guest-1"),
fixture.Options,
CancellationToken.None);
Assert.Equal("[00:00:04] Guest-1: Azure returned [redacted] here.", line);
}
[Fact]
public async Task TranscriptLineRulePreservesUtf8CharactersWhenRenderingRazor()
{
var fixture = await WorkflowFixture.CreateAsync(MeetingWorkflowTestRules.MaskedProfanityRedactionYaml);
var line = await fixture.Engine.TransformTranscriptLineAsync(
MeetingWorkflowEvent.TranscriptLine(
fixture.Artifacts,
"[00:00:57] Guest-1: Das heißt, Schimpfwörter wie ***** müssen als nächstes richtig bleiben.",
"Guest-1"),
fixture.Options,
CancellationToken.None);
Assert.Equal(
"[00:00:57] Guest-1: Das heißt, Schimpfwörter wie [redacted] müssen als nächstes richtig bleiben.",
line);
}
[Fact]
public async Task TriggeredWorkflowRuleLogsStartAndCompletion()
{
var logger = new ListLogger<MeetingWorkflowEngine>();
var fixture = await WorkflowFixture.CreateAsync(
"""
rules:
- name: logged-rule
on:
- created: {}
steps:
- uses: add_project
value: Diagnostics
""",
logger: logger);
await RunCreatedAsync(fixture);
Assert.Contains(logger.Messages, message =>
message.Contains("Triggered meeting workflow rule logged-rule for event Created", StringComparison.Ordinal));
Assert.Contains(logger.Messages, message =>
message.Contains("Completed meeting workflow rule logged-rule for event Created", StringComparison.Ordinal));
}
[Fact]
public async Task WorkflowRuleFailureLogsRuleNameAndEventType()
{
var logger = new ListLogger<MeetingWorkflowEngine>();
var fixture = await WorkflowFixture.CreateAsync(
"""
rules:
- name: broken-rule
on:
- created: {}
steps:
- uses: set_property
property: unsupported
value: Diagnostics
""",
logger: logger);
await Assert.ThrowsAsync<InvalidOperationException>(() => RunCreatedAsync(fixture));
Assert.Contains(logger.Messages, message =>
message.Contains("Meeting workflow rule broken-rule failed for event Created", StringComparison.Ordinal));
}
[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)]
[InlineData("attendee_added:\n equals: Ada Lovelace", "attendee", null, null, "ada lovelace", true)]
[InlineData("attendee_added:\n contains: contoso", "attendee", null, null, "Ada (Contoso)", true)]
[InlineData("attendee_added:\n regex: '^Ada .+Contoso\\)$'", "attendee", null, null, "Ada (Contoso)", true)]
[InlineData("attendee_added:\n regex: '^Grace'", "attendee", null, null, "Ada (Contoso)", false)]
public async Task TriggerMatchingScenarios(
string triggerYaml,
string eventKind,
string? from,
string? to,
string? speaker,
bool shouldRun)
{
var stepYaml = eventKind == "attendee"
? """
- uses: set_property
property: attendee.name
value: Triggered
"""
: """
- uses: add_project
value: Triggered
""";
var fixture = await WorkflowFixture.CreateAsync(
$$"""
rules:
- name: trigger-scenario
on:
- {{triggerYaml}}
steps:
{{stepYaml}}
""");
var workflowEvent = CreateEvent(eventKind, fixture.Artifacts, from, to, speaker);
if (eventKind == "attendee")
{
var transformed = await fixture.Engine.TransformAttendeeAsync(
workflowEvent,
fixture.Options,
CancellationToken.None);
Assert.Equal(shouldRun ? "Triggered" : speaker, transformed);
return;
}
await fixture.Engine.RunAsync(workflowEvent, 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 <ada@example.com>'
""");
await RunCreatedAsync(fixture);
var meeting = await fixture.ReadMeetingAsync();
Assert.Equal(["Ada Lovelace"], meeting.Frontmatter.Attendees);
}
[Fact]
public async Task AttendeeAddedRuleCanTransformWorkflowAddedAttendee()
{
var fixture = await WorkflowFixture.CreateAsync(
"""
rules:
- name: add-raw-attendee
on:
- created: {}
steps:
- uses: add_attendee
value: 'Ada Lovelace (Contoso)'
- name: clean-contoso-attendee
on:
- attendee_added:
contains: 'Contoso'
steps:
- uses: set_property
property: attendee.name
value: '@Model.Attendee.Name.Replace(" (Contoso)", "")'
""");
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 <ada@example.com>"]);
await RunCreatedAsync(fixture);
var meeting = await fixture.ReadMeetingAsync();
Assert.Equal(["Ada <ada@example.com>"], 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 <ada@example.com>", "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"),
"attendee" => MeetingWorkflowEvent.AttendeeAdded(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<WorkflowFixture> CreateAsync(
string rulesYaml,
string title = "Planning Sync",
IReadOnlyList<string>? attendees = null,
IReadOnlyList<string>? projects = null,
string? rulesPath = null,
ILogger<MeetingWorkflowEngine>? logger = 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<MarkdownMeetingNoteStore>.Instance);
var artifactStore = new MarkdownMeetingArtifactStore(
NullLogger<MarkdownMeetingArtifactStore>.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<FileMeetingWorkflowRulesProvider>.Instance),
noteStore,
artifactStore,
logger ?? NullLogger<MeetingWorkflowEngine>.Instance);
return new WorkflowFixture(options, noteStore, artifacts, engine);
}
public Task<MeetingNote> ReadMeetingAsync()
{
return NoteStore.ReadAsync(Artifacts.MeetingNotePath, CancellationToken.None);
}
public Task<string> ReadContextAsync()
{
return File.ReadAllTextAsync(Artifacts.AssistantContextPath);
}
}
private sealed class ListLogger<T> : ILogger<T>
{
public List<string> Messages { get; } = [];
public IDisposable BeginScope<TState>(TState state)
where TState : notnull
{
return NullScope.Instance;
}
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 sealed class NullScope : IDisposable
{
public static NullScope Instance { get; } = new();
public void Dispose()
{
}
}
}
}