Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions OrchestratorIDE.Avalonia.HeadlessTests/Cf5TestHarness.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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).
/// </summary>
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 */ }
}
}

/// <summary>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.</summary>
internal sealed class FakeFabricRuntime(string answerJson) : IRoleRuntime
{
public string RuntimeName => "fake-fabric-runtime";

public async IAsyncEnumerable<string> StreamRoleCompletionAsync(
RuntimeRole role,
IEnumerable<AgentMessage> history,
IReadOnlyList<object>? tools = null,
double temperature = 0.1,
int maxTokens = 4096,
Action<ToolCall>? onToolCall = null,
Action<int, int>? 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)}}"}]}]}
""";
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
[TestFixture]
public class OrcChatCitationNavigationTests
{
private static T Required<T>(Control root, string name) where T : Control
=> root.FindControl<T>(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<SourcePreviewPanel>(panel, "SourcePreviewPanel");
var splitter = Required<GridSplitter>(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<GridSplitter>(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]);
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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).
/// </summary>
[TestFixture]
public class OrcChatContextFabricQueryTests
{
private static T Required<T>(Control root, string name) where T : Control
=> root.FindControl<T>(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<TextBox>(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<StackPanel>(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<Border>().Last();

Assert.Multiple(() =>
{
Assert.That(bubbleText, Does.Contain("Quick mode"));
Assert.That(bubbleText, Does.Contain("citations verified"));
Assert.That(assistantBubble.Tag, Is.EqualTo(quote));
});
}

/// <summary>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.</summary>
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);
}
}
}
Loading
Loading