diff --git a/OrchestratorIDE.Avalonia.HeadlessTests/Cf5TestHarness.cs b/OrchestratorIDE.Avalonia.HeadlessTests/Cf5TestHarness.cs new file mode 100644 index 00000000..cc301e05 --- /dev/null +++ b/OrchestratorIDE.Avalonia.HeadlessTests/Cf5TestHarness.cs @@ -0,0 +1,145 @@ +// Copyright (C) 2025-present hardcoreerik / TheOrc contributors +// SPDX-License-Identifier: AGPL-3.0-or-later +using System.Runtime.CompilerServices; +using OrchestratorIDE.Core.Runtime; +using OrchestratorIDE.Models; +using OrchestratorIDE.Services.ContextFabric; +using OrchestratorIDE.Services.Data; +using OrchestratorIDE.UI.ViewModels; + +namespace OrchestratorIDE.Avalonia.HeadlessTests; + +/// +/// Shared CF-5 wiring for headless ChatPanel tests — builds a real in-memory corpus/document +/// plus the full FabricAskService/FabricIndexingOrchestrator/LibraryViewModel stack against a +/// scripted IRoleRuntime, mirroring OrchestratorIDE.UnitTests' ContextFabricAskServiceTests +/// harness shape (kept local here since HeadlessTests can't reference UnitTests' internal +/// fakes). +/// +internal sealed class Cf5TestHarness : IDisposable +{ + public SqliteStore Store { get; } + public FabricLibraryRepository Library { get; } + public DocumentGraphRepository Graph { get; } + public FabricCorpusEntry Corpus { get; } + public FabricDocumentEntry Document { get; } + public string WorkspaceRoot { get; } + + private Cf5TestHarness( + SqliteStore store, FabricLibraryRepository library, DocumentGraphRepository graph, + FabricCorpusEntry corpus, FabricDocumentEntry document, string workspaceRoot) + { + Store = store; + Library = library; + Graph = graph; + Corpus = corpus; + Document = document; + WorkspaceRoot = workspaceRoot; + } + + public static Cf5TestHarness Create(string quote = "LANTERN is the assigned call sign.") + { + var store = new SqliteStore(":memory:"); + store.Initialize(); + var library = new FabricLibraryRepository(store); + var graph = new DocumentGraphRepository(store); + var corpus = library.CreateCorpus("cf5-headless-corpus", "CF-5 headless lane"); + var now = DateTimeOffset.UtcNow; + var document = new FabricDocumentEntry( + "cf5-headless-doc", + corpus.CorpusId, + "source-digest", + "normalized-digest", + "CF-5 Headless Notes", + "text/plain", + FabricIngestionVersions.TextMarkdownParser, + FabricIngestionVersions.TextMarkdownParser, + "ready", + [], + now, + now); + + var texts = new[] { quote, "Emergency frequency is 17.4 MHz." }; + var start = 0; + library.ReplaceDocument(document, texts.Select((text, index) => + { + var draft = new FabricSegmentDraft( + $"seg-{index}", + index, + $"Section {index}", + start, + start + text.Length, + Math.Max(6, text.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length), + FabricHashing.Sha256(text), + text, + index > 0 ? $"seg-{index - 1}" : null, + index < texts.Length - 1 ? $"seg-{index + 1}" : null, + FabricIngestionVersions.Segmenter); + start += text.Length + 1; + return draft; + }).ToArray()); + + var workspaceRoot = Path.Combine(Path.GetTempPath(), "orc-cf5-headless-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(workspaceRoot); + + return new Cf5TestHarness(store, library, graph, corpus, document, workspaceRoot); + } + + public FabricAskService NewAskService(IRoleRuntime runtime) + { + var search = new FabricSearchService(Library, Graph); + var planner = new FabricQueryPlanner(search, Graph); + var packBuilder = new EvidencePackBuilder(Library, Graph); + var verifier = new FabricCitationVerifier(Library); + return new FabricAskService(planner, packBuilder, verifier, Library, runtime); + } + + public FabricIndexingOrchestrator NewOrchestrator(IRoleRuntime runtime) + { + var reader = new FabricNativeReaderService(Library, Graph, runtime); + var reducer = new FabricReducer(Library, Graph); + return new FabricIndexingOrchestrator(reader, reducer, Library); + } + + public LibraryViewModel NewLibraryViewModel() + { + var artifacts = new Services.Hive.ContentAddressedStore(Path.Combine(WorkspaceRoot, "objects")); + var libraryService = new FabricLibraryService(Library, artifacts); + return new LibraryViewModel(libraryService, Library, Path.Combine(WorkspaceRoot, "fabric")); + } + + public void Dispose() + { + Store.Dispose(); + try { Directory.Delete(WorkspaceRoot, recursive: true); } catch { /* best-effort cleanup */ } + } +} + +/// Minimal IRoleRuntime stand-in — returns one canned JSON answer regardless of +/// prompt content, for tests that only assert on the ChatPanel UI wiring, not retrieval. +internal sealed class FakeFabricRuntime(string answerJson) : IRoleRuntime +{ + public string RuntimeName => "fake-fabric-runtime"; + + public async IAsyncEnumerable StreamRoleCompletionAsync( + RuntimeRole role, + IEnumerable history, + IReadOnlyList? tools = null, + double temperature = 0.1, + int maxTokens = 4096, + Action? onToolCall = null, + Action? onUsage = null, + [EnumeratorCancellation] CancellationToken ct = default) + { + await Task.Yield(); + yield return answerJson; + } + + public RuntimeHealth GetHealth(RuntimeRole? role = null) => new(true, RuntimeName, "fake.gguf"); + public RuntimeStats GetStats(RuntimeRole? role = null) => new(RuntimeName, "fake.gguf"); + + public static string BuildAnswerJson(string quote, string segmentId) => + $$""" + {"schemaVersion":"cf0-answer-1.0","answer":"{{quote}}","abstained":false,"claims":[{"text":"{{quote}}","citations":[{"segmentId":"{{segmentId}}","charStart":0,"charEnd":{{quote.Length}},"quote":"{{quote}}","quoteDigest":"{{FabricHashing.Sha256(quote)}}"}]}]} + """; +} diff --git a/OrchestratorIDE.Avalonia.HeadlessTests/OrcChatCitationNavigationTests.cs b/OrchestratorIDE.Avalonia.HeadlessTests/OrcChatCitationNavigationTests.cs new file mode 100644 index 00000000..a8163a1b --- /dev/null +++ b/OrchestratorIDE.Avalonia.HeadlessTests/OrcChatCitationNavigationTests.cs @@ -0,0 +1,85 @@ +// Copyright (C) 2025-present hardcoreerik / TheOrc contributors +// SPDX-License-Identifier: AGPL-3.0-or-later +using System.Reflection; +using Avalonia.Controls; +using Avalonia.Headless.NUnit; +using Avalonia.Interactivity; +using NUnit.Framework; +using OrchestratorIDE.UI.Controls; +using OrchestratorIDE.UI.Panels; +using OrchestratorIDE.UI.ViewModels; + +namespace OrchestratorIDE.Avalonia.HeadlessTests; + +/// +/// CF-5: clicking a citation footnote opens the source-preview rail with the right segment +/// text and verification badge; closing it hides the rail again. OpenSourcePreview/the +/// footnote click handler are private, so this invokes them via reflection — same convention +/// ChatPanelModeToggleTests uses for SendAsync/OnToolStart. +/// +[TestFixture] +public class OrcChatCitationNavigationTests +{ + private static T Required(Control root, string name) where T : Control + => root.FindControl(name) + ?? throw new AssertionException($"Expected to find control named '{name}'."); + + [AvaloniaTest] + public void OpenSourcePreview_ShowsPanelWithCitationDetails() + { + using var harness = Cf5TestHarness.Create(); + var panel = new ChatPanel(); + var libraryVm = harness.NewLibraryViewModel(); + panel.SetFabricServices( + harness.NewAskService(new FakeFabricRuntime("{}")), + harness.NewOrchestrator(new FakeFabricRuntime("{}")), + libraryVm, + webImporter: null, + harness.WorkspaceRoot); + + var citation = new CitationViewModel( + 1, "seg-0", harness.Document.DocumentId, "Section 0", + 0, "LANTERN is the assigned call sign.".Length, + "LANTERN is the assigned call sign.", "Supported"); + + InvokeOpenSourcePreview(panel, citation); + + var preview = Required(panel, "SourcePreviewPanel"); + var splitter = Required(panel, "SourcePreviewSplitter"); + + Assert.Multiple(() => + { + Assert.That(preview.IsVisible, Is.True); + Assert.That(splitter.IsVisible, Is.True); + }); + } + + [AvaloniaTest] + public void ClosingSourcePreview_HidesSplitter() + { + using var harness = Cf5TestHarness.Create(); + var panel = new ChatPanel(); + panel.SetFabricServices( + harness.NewAskService(new FakeFabricRuntime("{}")), + harness.NewOrchestrator(new FakeFabricRuntime("{}")), + harness.NewLibraryViewModel(), + webImporter: null, + harness.WorkspaceRoot); + + var citation = new CitationViewModel(1, "seg-0", harness.Document.DocumentId, "Section 0", 0, 5, "LANTERN", "Supported"); + InvokeOpenSourcePreview(panel, citation); + + var closeMethod = typeof(ChatPanel).GetMethod("OnSourcePreviewClosed", BindingFlags.NonPublic | BindingFlags.Instance) + ?? throw new InvalidOperationException("OnSourcePreviewClosed not found via reflection."); + closeMethod.Invoke(panel, null); + + Assert.That(Required(panel, "SourcePreviewSplitter").IsVisible, Is.False); + } + + private static void InvokeOpenSourcePreview(ChatPanel panel, CitationViewModel citation) + { + var method = typeof(ChatPanel).GetMethod("OpenSourcePreview", BindingFlags.NonPublic | BindingFlags.Instance) + ?? throw new InvalidOperationException("OpenSourcePreview not found via reflection."); + method.Invoke(panel, [citation]); + } +} diff --git a/OrchestratorIDE.Avalonia.HeadlessTests/OrcChatContextFabricQueryTests.cs b/OrchestratorIDE.Avalonia.HeadlessTests/OrcChatContextFabricQueryTests.cs new file mode 100644 index 00000000..d58da4ac --- /dev/null +++ b/OrchestratorIDE.Avalonia.HeadlessTests/OrcChatContextFabricQueryTests.cs @@ -0,0 +1,91 @@ +// Copyright (C) 2025-present hardcoreerik / TheOrc contributors +// SPDX-License-Identifier: AGPL-3.0-or-later +using Avalonia.Controls; +using Avalonia.Headless.NUnit; +using Avalonia.Interactivity; +using Avalonia.Threading; +using Avalonia.VisualTree; +using NUnit.Framework; +using OrchestratorIDE.UI.Panels; + +namespace OrchestratorIDE.Avalonia.HeadlessTests; + +/// +/// CF-5: sending a question while a corpus is attached must route through FabricAskService +/// (not the plain ChatEngine/Ollama path) and render a cited answer bubble with the coverage +/// line. Uses reflection to invoke SendAsync directly, matching ChatPanelModeToggleTests' +/// ClearDuringInFlightSend_doesNotThrow convention (BtnSend_Click is fire-and-forget, so +/// exceptions inside it would otherwise become unobserved task exceptions). +/// +[TestFixture] +public class OrcChatContextFabricQueryTests +{ + private static T Required(Control root, string name) where T : Control + => root.FindControl(name) + ?? throw new AssertionException($"Expected to find control named '{name}'."); + + [AvaloniaTest] + public async Task SendingWithAttachedCorpus_RoutesThroughFabricAskService_AndRendersCoverageLine() + { + const string quote = "LANTERN is the assigned call sign."; + using var harness = Cf5TestHarness.Create(quote); + var panel = new ChatPanel(); + panel.SetFabricServices( + harness.NewAskService(new FakeFabricRuntime(FakeFabricRuntime.BuildAnswerJson(quote, "seg-0"))), + harness.NewOrchestrator(new FakeFabricRuntime("{}")), + harness.NewLibraryViewModel(), + webImporter: null, + harness.WorkspaceRoot); + + OrcChatLibraryTests.InvokeAttach(panel, harness.Corpus.CorpusId); + Dispatcher.UIThread.RunJobs(); + + Required(panel, "TbInput").Text = "What is the call sign?"; + + var sendMethod = typeof(ChatPanel).GetMethod("SendAsync", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) + ?? throw new InvalidOperationException("SendAsync method not found via reflection."); + var sendTask = (Task)sendMethod.Invoke(panel, null)!; + + for (var i = 0; i < 200 && !sendTask.IsCompleted; i++) + { + Dispatcher.UIThread.RunJobs(); + await Task.Delay(5); + } + + Assert.That(sendTask.IsCompleted, Is.True, "SendAsync (fabric path) never completed."); + Assert.That(sendTask.IsFaulted, Is.False, $"SendAsync threw: {sendTask.Exception?.InnerException}"); + + var chatStack = Required(panel, "ChatStack"); + var bubbleText = ExtractAllText(chatStack); + // The answer bubble's Tag carries the raw answer text (same "Copy as Markdown" seam + // OnTurnComplete uses for the plain-chat path) -- a reliable check independent of how + // MarkdownView happens to materialize the prose into its own control tree. + var assistantBubble = chatStack.Children.OfType().Last(); + + Assert.Multiple(() => + { + Assert.That(bubbleText, Does.Contain("Quick mode")); + Assert.That(bubbleText, Does.Contain("citations verified")); + Assert.That(assistantBubble.Tag, Is.EqualTo(quote)); + }); + } + + /// Walks the visual tree collecting every TextBlock's Text — the cited-answer + /// bubble is built from plain Avalonia controls (no single string property to read back), + /// same constraint ChatPanel's own MarkdownView-based bubbles have. + private static string ExtractAllText(Control root) + { + var sb = new System.Text.StringBuilder(); + Walk(root); + return sb.ToString(); + + void Walk(Control control) + { + if (control is TextBlock tb && tb.Text is not null) + sb.Append(tb.Text).Append(' '); + foreach (var child in control.GetVisualChildren()) + if (child is Control c) Walk(c); + } + } +} diff --git a/OrchestratorIDE.Avalonia.HeadlessTests/OrcChatLibraryTests.cs b/OrchestratorIDE.Avalonia.HeadlessTests/OrcChatLibraryTests.cs new file mode 100644 index 00000000..08f5d0c7 --- /dev/null +++ b/OrchestratorIDE.Avalonia.HeadlessTests/OrcChatLibraryTests.cs @@ -0,0 +1,144 @@ +// Copyright (C) 2025-present hardcoreerik / TheOrc contributors +// SPDX-License-Identifier: AGPL-3.0-or-later +using System.Reflection; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Headless.NUnit; +using Avalonia.Interactivity; +using Avalonia.Threading; +using NUnit.Framework; +using OrchestratorIDE.UI.Controls; +using OrchestratorIDE.UI.Panels; + +namespace OrchestratorIDE.Avalonia.HeadlessTests; + +/// +/// CF-5: covers the library drawer toggle and corpus attach/detach state in ChatPanel's +/// corpus bar. Mirrors ChatPanelModeToggleTests' Required<T>/reflection conventions. +/// +[TestFixture] +public class OrcChatLibraryTests +{ + private static T Required(Control root, string name) where T : Control + => root.FindControl(name) + ?? throw new AssertionException($"Expected to find control named '{name}'."); + + private static void Click(Button button) => + button.RaiseEvent(new RoutedEventArgs(Button.ClickEvent)); + + [AvaloniaTest] + public void LibraryDrawer_And_SourcePreview_AreHidden_ByDefault() + { + var panel = new ChatPanel(); + + Assert.Multiple(() => + { + Assert.That(Required(panel, "LibraryDrawer").IsVisible, Is.False); + Assert.That(Required(panel, "SourcePreviewPanel").IsVisible, Is.False); + Assert.That(Required(panel, "BdrCorpusBadge").IsVisible, Is.False); + }); + } + + [AvaloniaTest] + public void ToggleLibraryButton_ShowsAndHidesDrawer() + { + var panel = new ChatPanel(); + var toggle = Required(panel, "BtnToggleLibrary"); + + Click(toggle); + Dispatcher.UIThread.RunJobs(); + Assert.That(Required(panel, "LibraryDrawer").IsVisible, Is.True); + + Click(toggle); + Dispatcher.UIThread.RunJobs(); + Assert.That(Required(panel, "LibraryDrawer").IsVisible, Is.False); + } + + [AvaloniaTest] + public void AttachingCorpus_ShowsBadgeAndModeToggle() + { + using var harness = Cf5TestHarness.Create(); + var panel = new ChatPanel(); + panel.SetFabricServices( + harness.NewAskService(new FakeFabricRuntime("{}")), + harness.NewOrchestrator(new FakeFabricRuntime("{}")), + harness.NewLibraryViewModel(), + webImporter: null, + harness.WorkspaceRoot); + + InvokeAttach(panel, harness.Corpus.CorpusId); + Dispatcher.UIThread.RunJobs(); + + Assert.Multiple(() => + { + Assert.That(Required(panel, "BdrCorpusBadge").IsVisible, Is.True); + Assert.That(Required(panel, "TxtCorpusName").Text, Is.EqualTo(harness.Corpus.Name)); + Assert.That(Required(panel, "StackModeToggle").IsVisible, Is.True); + }); + } + + [AvaloniaTest] + public void DetachingCorpus_HidesBadgeAndSourcePreview() + { + using var harness = Cf5TestHarness.Create(); + var panel = new ChatPanel(); + panel.SetFabricServices( + harness.NewAskService(new FakeFabricRuntime("{}")), + harness.NewOrchestrator(new FakeFabricRuntime("{}")), + harness.NewLibraryViewModel(), + webImporter: null, + harness.WorkspaceRoot); + + InvokeAttach(panel, harness.Corpus.CorpusId); + Dispatcher.UIThread.RunJobs(); + Required(panel, "SourcePreviewPanel").IsVisible = true; + + InvokeDetach(panel); + Dispatcher.UIThread.RunJobs(); + + Assert.Multiple(() => + { + Assert.That(Required(panel, "BdrCorpusBadge").IsVisible, Is.False); + Assert.That(Required(panel, "StackModeToggle").IsVisible, Is.False); + Assert.That(Required(panel, "SourcePreviewPanel").IsVisible, Is.False); + }); + } + + [AvaloniaTest] + public void ModeToggle_SwitchesHintText() + { + using var harness = Cf5TestHarness.Create(); + var panel = new ChatPanel(); + panel.SetFabricServices( + harness.NewAskService(new FakeFabricRuntime("{}")), + harness.NewOrchestrator(new FakeFabricRuntime("{}")), + harness.NewLibraryViewModel(), + webImporter: null, + harness.WorkspaceRoot); + + InvokeAttach(panel, harness.Corpus.CorpusId); + Dispatcher.UIThread.RunJobs(); + + Click(Required