933 lines
32 KiB
C#
933 lines
32 KiB
C#
#nullable enable
|
|
using System.Text;
|
|
using ImGuiNET;
|
|
using T3.Core.DataTypes.Vector;
|
|
using T3.Core.SystemUi;
|
|
using T3.Editor.Gui.Help;
|
|
using T3.Editor.Gui.Hub;
|
|
using T3.Editor.Gui.Input;
|
|
using T3.Editor.Gui.Styling;
|
|
using T3.Editor.Gui.Styling.Markdown;
|
|
|
|
namespace T3.Editor.Gui.Windows.TestRunner;
|
|
|
|
/// <summary>
|
|
/// In-editor manual test runner. Parses <c>.tests-manual/*.md</c>, walks through steps
|
|
/// recording Pass/Fail/Other + comment, summarizes in-memory.
|
|
/// State machine: Pick → Run → Summary → Pick.
|
|
/// </summary>
|
|
internal sealed class GuidedFeatureTestsWindow : Window
|
|
{
|
|
internal GuidedFeatureTestsWindow()
|
|
{
|
|
Config.Title = "Guided Feature Tests";
|
|
MenuTitle = "Guided Feature Tests...";
|
|
}
|
|
|
|
internal override IReadOnlyList<Window> GetInstances() => Array.Empty<Window>();
|
|
|
|
protected override void DrawContent()
|
|
{
|
|
if (_allSets == null)
|
|
ReloadSets();
|
|
|
|
switch (_state)
|
|
{
|
|
case State.Pick:
|
|
DrawPickState();
|
|
break;
|
|
case State.Run:
|
|
DrawRunState();
|
|
break;
|
|
case State.Summary:
|
|
DrawSummaryState();
|
|
break;
|
|
}
|
|
}
|
|
|
|
// ----- Pick state ---------------------------------------------------
|
|
|
|
private void DrawPickState()
|
|
{
|
|
var sets = _allSets!;
|
|
var displayDir = ShortenTestsDir(_testsDir);
|
|
var subtitle = $"Loaded {sets.Count} test set{(sets.Count == 1 ? "" : "s")} from {displayDir}";
|
|
|
|
ContentPanel.Begin("Guided Feature Tests", subtitle, drawTools: DrawPickHeaderTools);
|
|
|
|
DrawFilterRow(sets);
|
|
FormInputs.AddVerticalSpace(6);
|
|
|
|
// Reserve room at the bottom for the footer so the list scrolls
|
|
// independently while the action row stays pinned.
|
|
var scale = T3Ui.UiScaleFactor;
|
|
var footerH = ImGui.GetFrameHeight() + 16 * scale;
|
|
ImGui.BeginChild("##setlist", new Vector2(0, -footerH),
|
|
ImGuiChildFlags.None, ImGuiWindowFlags.NoBackground);
|
|
{
|
|
ImGui.PushStyleVar(ImGuiStyleVar.ItemSpacing, new Vector2(0, 4 * scale));
|
|
|
|
var visibleCount = 0;
|
|
foreach (var set in sets)
|
|
{
|
|
if (!Matches(set, _filter, _selectedTags))
|
|
continue;
|
|
|
|
visibleCount++;
|
|
DrawSetRow(set);
|
|
}
|
|
|
|
ImGui.PopStyleVar();
|
|
|
|
if (visibleCount == 0)
|
|
{
|
|
ImGui.PushStyleColor(ImGuiCol.Text, UiColors.TextMuted.Rgba);
|
|
ImGui.TextWrapped(sets.Count == 0
|
|
? $"No `.md` test sets found in {_testsDir}"
|
|
: "No sets match the current filter.");
|
|
ImGui.PopStyleColor();
|
|
}
|
|
}
|
|
ImGui.EndChild();
|
|
|
|
DrawPickFooter(sets);
|
|
|
|
ContentPanel.End();
|
|
}
|
|
|
|
private void DrawPickHeaderTools()
|
|
{
|
|
var iconSize = new Vector2(ImGui.GetFrameHeight(), ImGui.GetFrameHeight());
|
|
var spacing = ImGui.GetStyle().ItemSpacing.X;
|
|
CustomComponents.RightAlign(iconSize.X * 3 + spacing * 2, sameLine: false);
|
|
|
|
DocumentationButton.Draw("GuidedFeatureTests", DocumentationWikiUrl, iconSize);
|
|
|
|
ImGui.SameLine();
|
|
// BeginDisabled doesn't dim a transparent icon button (the glyph is drawn via the draw list,
|
|
// bypassing the disabled alpha), so use the Disabled button state for the dimmed look and just
|
|
// ignore the click when nothing is selected.
|
|
var anySelected = _selectedSetIds.Count > 0;
|
|
var openState = anySelected ? CustomComponents.ButtonStates.Emphasized : CustomComponents.ButtonStates.Disabled;
|
|
if (CustomComponents.TransparentIconButton(Icon.OpenExternally, iconSize, openState) && anySelected)
|
|
OpenSelectedSourceFiles();
|
|
if (ImGui.IsItemHovered())
|
|
CustomComponents.TooltipForLastItem("Open source files for the selected tests");
|
|
|
|
ImGui.SameLine();
|
|
if (CustomComponents.TransparentIconButton(Icon.Refresh, iconSize))
|
|
ReloadSets();
|
|
if (ImGui.IsItemHovered())
|
|
CustomComponents.TooltipForLastItem("Reload test sets from disk");
|
|
}
|
|
|
|
private void OpenSelectedSourceFiles()
|
|
{
|
|
if (_allSets == null)
|
|
return;
|
|
|
|
foreach (var set in _allSets)
|
|
{
|
|
if (_selectedSetIds.Contains(set.Id) && !string.IsNullOrEmpty(set.SourcePath))
|
|
CoreUi.Instance.OpenWithDefaultApplication(set.SourcePath);
|
|
}
|
|
}
|
|
|
|
private const string DocumentationWikiUrl = "https://github.com/tixl3d/tixl/wiki/dev.GuidedFeatureTests";
|
|
|
|
private void DrawFilterRow(List<TestSet> sets)
|
|
{
|
|
var scale = T3Ui.UiScaleFactor;
|
|
|
|
ImGui.SetNextItemWidth(220 * scale);
|
|
ImGui.InputTextWithHint("##filter", "Filter…", ref _filter, 256);
|
|
|
|
ImGui.SameLine();
|
|
ImGui.SetNextItemWidth(150 * scale);
|
|
var sortIdx = (int)_sortMode;
|
|
if (ImGui.Combo("##sort", ref sortIdx, "Recently added\0Alphabetical\0By scope\0"))
|
|
{
|
|
_sortMode = (TestSetSort)sortIdx;
|
|
TestSetParser.Sort(sets, _sortMode);
|
|
}
|
|
|
|
var allTags = CollectAllTags(sets);
|
|
if (allTags.Count == 0)
|
|
return;
|
|
|
|
// Tags on their own row (left-aligned) so they don't overlap the filter/sort controls.
|
|
for (var i = 0; i < allTags.Count; i++)
|
|
{
|
|
if (i > 0)
|
|
ImGui.SameLine();
|
|
var tag = allTags[i];
|
|
var active = _selectedTags.Contains(tag);
|
|
if (CustomComponents.TagFilterToggle(tag, active))
|
|
{
|
|
if (active) _selectedTags.Remove(tag);
|
|
else _selectedTags.Add(tag);
|
|
}
|
|
}
|
|
}
|
|
|
|
private void DrawPickFooter(List<TestSet> sets)
|
|
{
|
|
var scale = T3Ui.UiScaleFactor;
|
|
|
|
if (ImGui.Button("Select All"))
|
|
{
|
|
foreach (var s in sets)
|
|
if (Matches(s, _filter, _selectedTags))
|
|
_selectedSetIds.Add(s.Id);
|
|
}
|
|
ImGui.SameLine();
|
|
|
|
// Snapshot the disabled state so the matching pop runs even when the
|
|
// click clears the set in-between (otherwise EndDisabled fires without
|
|
// a matching Begin and ImGui's disabled stack underflows for one frame).
|
|
var clearDisabled = _selectedSetIds.Count == 0;
|
|
if (clearDisabled)
|
|
ImGui.BeginDisabled();
|
|
if (ImGui.Button("Clear"))
|
|
_selectedSetIds.Clear();
|
|
if (clearDisabled)
|
|
ImGui.EndDisabled();
|
|
|
|
// Start CTA right-aligned. Style follows the user's design proposal:
|
|
// bright filled CTA when something is selected, dimmed otherwise.
|
|
var startSize = CustomComponents.GetCtaButtonSize("Start");
|
|
ImGui.SameLine();
|
|
CustomComponents.RightAlign(startSize.X);
|
|
|
|
var anySelected = _selectedSetIds.Count > 0;
|
|
if (!anySelected)
|
|
ImGui.BeginDisabled();
|
|
|
|
var startBg = anySelected
|
|
? Color.Mix( UiColors.StatusActivated, UiColors.BackgroundFull, 0.2f)
|
|
: UiColors.BackgroundButton;
|
|
var startText = anySelected ? UiColors.ForegroundFull : UiColors.TextMuted;
|
|
if (CustomComponents.DrawCtaButton("Start", Icon.None, startText, startBg, Color.Transparent))
|
|
StartRun();
|
|
|
|
if (!anySelected)
|
|
ImGui.EndDisabled();
|
|
}
|
|
|
|
private void DrawSetRow(TestSet set)
|
|
{
|
|
var result = TestSetRow.Draw(set, _selectedSetIds.Contains(set.Id), height: 50);
|
|
|
|
if (result.Clicked)
|
|
{
|
|
if (!_selectedSetIds.Remove(set.Id))
|
|
_selectedSetIds.Add(set.Id);
|
|
}
|
|
|
|
// The shared row already shows a per-tick tooltip; otherwise show this set's intro markdown.
|
|
if (result.Hovered && !result.TickHovered)
|
|
DrawIntroTooltip(set);
|
|
}
|
|
|
|
private void DrawIntroTooltip(TestSet set)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(set.Intro) && set.ParseWarnings.Count == 0)
|
|
return;
|
|
|
|
var scale = T3Ui.UiScaleFactor;
|
|
|
|
// Pin the tooltip width BEFORE BeginTooltip so frame 1 already has the right ContentRegionAvail
|
|
// (otherwise the auto-resize window flashes at viewport width on its first appearance).
|
|
var tooltipWidth = 420 * scale;
|
|
ImGui.SetNextWindowSizeConstraints(new Vector2(tooltipWidth, 0), new Vector2(tooltipWidth, float.MaxValue));
|
|
|
|
ImGui.PushStyleVar(ImGuiStyleVar.WindowPadding, new Vector2(10, 8) * scale);
|
|
ImGui.BeginTooltip();
|
|
|
|
if (!string.IsNullOrWhiteSpace(set.Intro))
|
|
{
|
|
// Lazy-create so hot-reload that adds new fields doesn't NRE until the next editor restart.
|
|
_introMarkdown ??= new MarkdownView(new MarkdownView.Options());
|
|
// Op/topic refs suppress their own tooltip — the intro already renders inside one.
|
|
_introMarkdown.Draw(set.Intro,
|
|
onOperatorRef: static op => MarkdownOperatorLinks.HandleOperatorRef(op, suppressTooltip: true),
|
|
operatorColor: MarkdownOperatorLinks.GetOperatorColor);
|
|
}
|
|
|
|
if (set.ParseWarnings.Count > 0)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(set.Intro))
|
|
FormInputs.AddVerticalSpace(6);
|
|
ImGui.PushStyleColor(ImGuiCol.Text, UiColors.StatusWarning.Rgba);
|
|
ImGui.PushTextWrapPos(tooltipWidth);
|
|
foreach (var w in set.ParseWarnings)
|
|
ImGui.TextWrapped(w);
|
|
ImGui.PopTextWrapPos();
|
|
ImGui.PopStyleColor();
|
|
}
|
|
|
|
ImGui.EndTooltip();
|
|
ImGui.PopStyleVar();
|
|
}
|
|
|
|
private static string ShortenTestsDir(string fullPath)
|
|
{
|
|
if (string.IsNullOrEmpty(fullPath))
|
|
return fullPath;
|
|
// Show the .tests-manual folder name and one level above for context,
|
|
// not the full Windows path.
|
|
var name = System.IO.Path.GetFileName(fullPath);
|
|
return string.IsNullOrEmpty(name) ? fullPath : name;
|
|
}
|
|
|
|
// ----- Run state ----------------------------------------------------
|
|
|
|
private void DrawRunState()
|
|
{
|
|
var run = _run!;
|
|
if (_currentSetIdx >= run.Sets.Count)
|
|
{
|
|
FinishRun();
|
|
return;
|
|
}
|
|
|
|
var set = run.Sets[_currentSetIdx];
|
|
if (set.Steps.Count == 0)
|
|
{
|
|
AdvanceStep();
|
|
return;
|
|
}
|
|
|
|
var step = set.Steps[_currentStepIdx];
|
|
var globalIdx = ComputeGlobalStepIndex();
|
|
var globalCount = ComputeTotalStepCount();
|
|
|
|
var title = set.Title;
|
|
var subtitle = $"Step {globalIdx + 1}/{globalCount} — {step.Title}";
|
|
|
|
ContentPanel.Begin(title, subtitle, drawTools: DrawRunHeaderTools);
|
|
|
|
// The Abandon button inside DrawRunHeaderTools may have nulled _run;
|
|
// stop drawing the rest of this frame to avoid an NRE in the side panel.
|
|
if (_run == null || _state != State.Run)
|
|
{
|
|
ContentPanel.End();
|
|
return;
|
|
}
|
|
|
|
// Two-column layout: step content (left) + comment + outcome (right).
|
|
var scale = T3Ui.UiScaleFactor;
|
|
var gutter = 30f * scale;
|
|
var avail = ImGui.GetContentRegionAvail();
|
|
var rightWidth = MathF.Max(280 * scale, avail.X * 0.34f);
|
|
var leftWidth = MathF.Max(280 * scale, avail.X - rightWidth - gutter);
|
|
|
|
// The nav buttons in drawTools may have changed _currentSetIdx /
|
|
// _currentStepIdx. Re-resolve set and step now so the body cache is
|
|
// keyed and built from the CURRENT state, not the pre-click snapshot.
|
|
set = run.Sets[_currentSetIdx];
|
|
if (set.Steps.Count == 0)
|
|
{
|
|
ContentPanel.End();
|
|
return;
|
|
}
|
|
step = set.Steps[_currentStepIdx];
|
|
|
|
ImGui.BeginChild("##step-content", new Vector2(leftWidth, -1),
|
|
ImGuiChildFlags.None, ImGuiWindowFlags.NoBackground);
|
|
DrawStepBody(set, step);
|
|
ImGui.EndChild();
|
|
|
|
ImGui.SameLine(0, gutter);
|
|
ImGui.BeginChild("##step-side", new Vector2(rightWidth, -1),
|
|
ImGuiChildFlags.None, ImGuiWindowFlags.NoBackground);
|
|
DrawStepSide(set, step);
|
|
ImGui.EndChild();
|
|
|
|
ContentPanel.End();
|
|
}
|
|
|
|
private void DrawRunHeaderTools()
|
|
{
|
|
var scale = T3Ui.UiScaleFactor;
|
|
var iconSize = new Vector2(ImGui.GetFrameHeight(), ImGui.GetFrameHeight());
|
|
// 4 icons + 1 group gap + 1 group gap.
|
|
var toolsWidth = iconSize.X * 4 + 16 * scale;
|
|
CustomComponents.RightAlign(toolsWidth, sameLine: false);
|
|
|
|
// Edit this test set's source markdown — handy when the wording on a
|
|
// step is awkward and you want to fix it in place.
|
|
if (CustomComponents.TransparentIconButton(Icon.OpenExternally, iconSize))
|
|
OpenCurrentSetSource();
|
|
if (ImGui.IsItemHovered())
|
|
{
|
|
var set = _run?.Sets[_currentSetIdx];
|
|
CustomComponents.TooltipForLastItem(
|
|
"Open this test set's markdown source in the default editor",
|
|
set?.SourcePath ?? string.Empty);
|
|
}
|
|
|
|
ImGui.SameLine(0, 8 * scale);
|
|
if (CustomComponents.TransparentIconButton(Icon.ArrowLeft, iconSize))
|
|
StepBack();
|
|
if (ImGui.IsItemHovered())
|
|
CustomComponents.TooltipForLastItem("Previous step");
|
|
|
|
ImGui.SameLine(0, 0);
|
|
if (CustomComponents.TransparentIconButton(Icon.ArrowRight, iconSize))
|
|
AdvanceStep();
|
|
if (ImGui.IsItemHovered())
|
|
CustomComponents.TooltipForLastItem("Next step");
|
|
|
|
ImGui.SameLine(0, 8 * scale);
|
|
if (CustomComponents.TransparentIconButton(Icon.ChevronRight, iconSize))
|
|
SkipToNextSet();
|
|
if (ImGui.IsItemHovered())
|
|
CustomComponents.TooltipForLastItem("Skip to the next set\n(ends the run if this is the last set)");
|
|
}
|
|
|
|
private void OpenCurrentSetSource()
|
|
{
|
|
if (_run == null || _currentSetIdx >= _run.Sets.Count)
|
|
return;
|
|
var path = _run.Sets[_currentSetIdx].SourcePath;
|
|
if (string.IsNullOrEmpty(path))
|
|
return;
|
|
CoreUi.Instance.OpenWithDefaultApplication(path);
|
|
}
|
|
|
|
private void SkipToNextSet()
|
|
{
|
|
var run = _run!;
|
|
_currentSetIdx++;
|
|
_currentStepIdx = 0;
|
|
if (_currentSetIdx >= run.Sets.Count)
|
|
FinishRun();
|
|
}
|
|
|
|
private void DrawStepBody(TestSet set, TestStep step)
|
|
{
|
|
// Two markdown blocks per step (Context folds into Action). Caches
|
|
// invalidate on step transition; subsequent frames are layout-free.
|
|
if (_cachedStepKey != (set.Id, _currentStepIdx))
|
|
{
|
|
_cachedAction = BuildActionMarkdown(step);
|
|
_cachedExpected = string.IsNullOrEmpty(step.ExpectedMarkdown) ? null : step.ExpectedMarkdown;
|
|
_cachedStepKey = (set.Id, _currentStepIdx);
|
|
}
|
|
|
|
if (_cachedAction != null)
|
|
{
|
|
DrawSectionLabel("Please do the following");
|
|
_actionMarkdown.Draw(_cachedAction,
|
|
onOperatorRef: static op => MarkdownOperatorLinks.HandleOperatorRef(op),
|
|
operatorColor: MarkdownOperatorLinks.GetOperatorColor);
|
|
}
|
|
|
|
if (_cachedExpected != null)
|
|
{
|
|
DrawSectionLabel("Expected Results");
|
|
_expectedMarkdown.Draw(_cachedExpected,
|
|
onOperatorRef: static op => MarkdownOperatorLinks.HandleOperatorRef(op),
|
|
operatorColor: MarkdownOperatorLinks.GetOperatorColor);
|
|
}
|
|
}
|
|
|
|
private static void DrawSectionLabel(string label)
|
|
{
|
|
FormInputs.AddVerticalSpace(10);
|
|
CustomComponents.StylizedText(label, Fonts.FontSmall, UiColors.TextMuted);
|
|
}
|
|
|
|
private static string? BuildActionMarkdown(TestStep step)
|
|
{
|
|
var hasContext = !string.IsNullOrWhiteSpace(step.Context);
|
|
var hasAction = !string.IsNullOrWhiteSpace(step.ActionMarkdown);
|
|
if (!hasContext && !hasAction)
|
|
return null;
|
|
if (!hasContext)
|
|
return step.ActionMarkdown;
|
|
if (!hasAction)
|
|
return step.Context;
|
|
return step.Context + "\n\n" + step.ActionMarkdown;
|
|
}
|
|
|
|
private void DrawStepSide(TestSet set, TestStep step)
|
|
{
|
|
var result = GetOrCreateResult(set.Id, _currentStepIdx);
|
|
|
|
CustomComponents.StylizedText("Actual result", Fonts.FontSmall, UiColors.TextMuted);
|
|
|
|
var comment = result.Comment;
|
|
var inputPos = ImGui.GetCursorScreenPos();
|
|
if (ImGui.InputTextMultiline("##comment", ref comment, 1024,
|
|
new Vector2(-1, 160 * T3Ui.UiScaleFactor)))
|
|
{
|
|
result.Comment = comment;
|
|
}
|
|
|
|
// Manual placeholder — ImGui has no InputTextMultilineWithHint.
|
|
if (string.IsNullOrEmpty(result.Comment) && !ImGui.IsItemActive())
|
|
{
|
|
var dl = ImGui.GetWindowDrawList();
|
|
var color = ImGui.GetColorU32(UiColors.TextMuted.Fade(0.6f).Rgba);
|
|
var pad = ImGui.GetStyle().FramePadding;
|
|
dl.AddText(inputPos + pad, color, "Add comment…");
|
|
}
|
|
|
|
FormInputs.AddVerticalSpace(10);
|
|
|
|
// Outcome buttons via the project's standard CTA button helper.
|
|
if (CustomComponents.DrawCtaButton("Success", Icon.None,
|
|
UiColors.ForegroundFull,
|
|
UiColors.StatusControlled,
|
|
Color.Transparent))
|
|
RecordOutcomeAndAdvance(result, Outcome.Pass);
|
|
|
|
ImGui.SameLine();
|
|
if (CustomComponents.DrawCtaButton("Fail", Icon.None,
|
|
UiColors.ForegroundFull,
|
|
UiColors.StatusWarning,
|
|
Color.Transparent))
|
|
RecordOutcomeAndAdvance(result, Outcome.Fail);
|
|
|
|
ImGui.SameLine();
|
|
var otherEnabled = !string.IsNullOrWhiteSpace(result.Comment);
|
|
if (!otherEnabled)
|
|
ImGui.BeginDisabled();
|
|
if (CustomComponents.DrawCtaButton("Other…", Icon.None,
|
|
CustomComponents.ButtonStates.Default))
|
|
RecordOutcomeAndAdvance(result, Outcome.Other);
|
|
if (!otherEnabled)
|
|
ImGui.EndDisabled();
|
|
if (!otherEnabled && ImGui.IsItemHovered())
|
|
CustomComponents.TooltipForLastItem("Add a comment first");
|
|
}
|
|
|
|
private static void DrawOutcomeBadge(Outcome outcome)
|
|
{
|
|
var (icon, color) = outcome switch
|
|
{
|
|
Outcome.Pass => (Icon.Checkmark, UiColors.StatusControlled),
|
|
Outcome.Fail => (Icon.Error, UiColors.StatusWarning),
|
|
Outcome.Other => (Icon.Warning, UiColors.StatusAttention),
|
|
Outcome.Skipped => (Icon.Hidden, UiColors.TextMuted),
|
|
_ => (Icon.Hidden, UiColors.TextMuted),
|
|
};
|
|
icon.DrawAtCursor(color);
|
|
}
|
|
|
|
private void RecordOutcomeAndAdvance(StepResult result, Outcome outcome)
|
|
{
|
|
result.Outcome = outcome;
|
|
result.TimestampUtc = DateTime.UtcNow;
|
|
AdvanceStep();
|
|
}
|
|
|
|
private void AdvanceStep()
|
|
{
|
|
var run = _run!;
|
|
if (_currentSetIdx >= run.Sets.Count)
|
|
return;
|
|
|
|
var set = run.Sets[_currentSetIdx];
|
|
if (_currentStepIdx + 1 < set.Steps.Count)
|
|
{
|
|
_currentStepIdx++;
|
|
return;
|
|
}
|
|
|
|
_currentSetIdx++;
|
|
_currentStepIdx = 0;
|
|
|
|
if (_currentSetIdx >= run.Sets.Count)
|
|
FinishRun();
|
|
}
|
|
|
|
private void StepBack()
|
|
{
|
|
if (_currentStepIdx > 0)
|
|
{
|
|
_currentStepIdx--;
|
|
return;
|
|
}
|
|
|
|
if (_currentSetIdx > 0)
|
|
{
|
|
_currentSetIdx--;
|
|
_currentStepIdx = _run!.Sets[_currentSetIdx].Steps.Count - 1;
|
|
}
|
|
}
|
|
|
|
private void FinishRun()
|
|
{
|
|
var run = _run!;
|
|
run.FinishedUtc = DateTime.UtcNow;
|
|
TestRunExport.AutoSave(run);
|
|
TestRunResults.Invalidate(); // pick up this run's results for the checkmarks / status bars
|
|
_state = State.Summary;
|
|
}
|
|
|
|
// ----- Summary state -----------------------------------------------
|
|
|
|
private void DrawSummaryState()
|
|
{
|
|
var run = _run!;
|
|
var subtitle = $"Started {run.StartedUtc:HH:mm:ss} UTC, finished {run.FinishedUtc:HH:mm:ss} UTC " +
|
|
$"— {run.Sets.Count} set(s), {run.Results.Count} step(s).";
|
|
|
|
ContentPanel.Begin("Summary", subtitle);
|
|
|
|
var scale = T3Ui.UiScaleFactor;
|
|
var footerH = ImGui.GetFrameHeight() + 16 * scale;
|
|
|
|
DrawBlackSeparator();
|
|
|
|
ImGui.BeginChild("##summary-scroll", new Vector2(0, -footerH),
|
|
ImGuiChildFlags.None, ImGuiWindowFlags.NoBackground);
|
|
DrawSummaryScrollContent(run);
|
|
ImGui.EndChild();
|
|
|
|
DrawBlackSeparator();
|
|
|
|
DrawSummaryToolbar(run);
|
|
|
|
ContentPanel.End();
|
|
}
|
|
|
|
private static void DrawSummaryScrollContent(RunReport run)
|
|
{
|
|
foreach (var set in run.Sets)
|
|
{
|
|
var pass = 0;
|
|
var fail = 0;
|
|
var other = 0;
|
|
var skipped = 0;
|
|
foreach (var r in run.Results)
|
|
{
|
|
if (r.SetId != set.Id) continue;
|
|
switch (r.Outcome)
|
|
{
|
|
case Outcome.Pass: pass++; break;
|
|
case Outcome.Fail: fail++; break;
|
|
case Outcome.Other: other++; break;
|
|
default: skipped++; break;
|
|
}
|
|
}
|
|
|
|
CustomComponents.StylizedText(set.Title, Fonts.FontBold, UiColors.Text);
|
|
CustomComponents.StylizedText($" {pass} pass / {fail} fail / {other} other / {skipped} skipped",
|
|
Fonts.FontNormal, UiColors.TextMuted);
|
|
|
|
foreach (var r in run.Results)
|
|
{
|
|
if (r.SetId != set.Id) continue;
|
|
if (r.StepIndex >= set.Steps.Count) continue;
|
|
|
|
DrawOutcomeBadge(r.Outcome);
|
|
ImGui.SameLine();
|
|
ImGui.TextUnformatted(set.Steps[r.StepIndex].Title);
|
|
if (!string.IsNullOrEmpty(r.Comment))
|
|
{
|
|
CustomComponents.StylizedText($" {r.Comment}",
|
|
Fonts.FontNormal, UiColors.TextMuted);
|
|
}
|
|
}
|
|
|
|
FormInputs.AddVerticalSpace(8);
|
|
}
|
|
}
|
|
|
|
private void DrawSummaryToolbar(RunReport run)
|
|
{
|
|
// Copy Markdown is the one export wired in Phase 1; the others stay
|
|
// visibly disabled with a tooltip for later phases.
|
|
if (ImGui.Button("Copy Markdown"))
|
|
{
|
|
ImGui.SetClipboardText(BuildMarkdownReport(run));
|
|
}
|
|
|
|
ImGui.SameLine();
|
|
if (ImGui.Button("Copy JSON"))
|
|
{
|
|
ImGui.SetClipboardText(TestRunExport.BuildJson(run));
|
|
}
|
|
|
|
ImGui.SameLine();
|
|
if (ImGui.Button("Open Saved Runs"))
|
|
{
|
|
CoreUi.Instance.OpenWithDefaultApplication(TestRunExport.RunsDirectory);
|
|
}
|
|
|
|
if (ImGui.IsItemHovered())
|
|
CustomComponents.TooltipForLastItem("Each finished run is auto-saved here as JSON.");
|
|
|
|
ImGui.SameLine();
|
|
ImGui.BeginDisabled();
|
|
ImGui.Button("Open GitHub Issue");
|
|
ImGui.EndDisabled();
|
|
if (ImGui.IsItemHovered())
|
|
CustomComponents.TooltipForLastItem("Coming in a later phase");
|
|
|
|
// Right-align "New Run".
|
|
var newRunSize = ImGui.CalcTextSize("New Run") + ImGui.GetStyle().FramePadding * 2;
|
|
ImGui.SameLine();
|
|
CustomComponents.RightAlign(newRunSize.X);
|
|
if (ImGui.Button("New Run"))
|
|
{
|
|
_state = State.Pick;
|
|
_run = null;
|
|
}
|
|
}
|
|
|
|
private static void DrawBlackSeparator()
|
|
{
|
|
ImGui.PushStyleColor(ImGuiCol.Separator, UiColors.BackgroundFull.Rgba);
|
|
ImGui.Separator();
|
|
ImGui.PopStyleColor();
|
|
}
|
|
|
|
private static string BuildMarkdownReport(RunReport run)
|
|
{
|
|
var sb = new StringBuilder();
|
|
sb.Append("# Guided Feature Tests run\n\n");
|
|
sb.Append($"Started {run.StartedUtc:yyyy-MM-dd HH:mm:ss} UTC \n");
|
|
sb.Append($"Finished {run.FinishedUtc:yyyy-MM-dd HH:mm:ss} UTC \n");
|
|
sb.Append($"Sets: {run.Sets.Count}, steps: {run.Results.Count}\n\n");
|
|
|
|
foreach (var set in run.Sets)
|
|
{
|
|
var pass = 0;
|
|
var fail = 0;
|
|
var other = 0;
|
|
var skipped = 0;
|
|
foreach (var r in run.Results)
|
|
{
|
|
if (r.SetId != set.Id) continue;
|
|
switch (r.Outcome)
|
|
{
|
|
case Outcome.Pass: pass++; break;
|
|
case Outcome.Fail: fail++; break;
|
|
case Outcome.Other: other++; break;
|
|
default: skipped++; break;
|
|
}
|
|
}
|
|
|
|
sb.Append("## ").Append(set.Title)
|
|
.Append(" — ").Append(pass).Append(" pass")
|
|
.Append(" / ").Append(fail).Append(" fail")
|
|
.Append(" / ").Append(other).Append(" other")
|
|
.Append(" / ").Append(skipped).Append(" skipped\n");
|
|
|
|
foreach (var r in run.Results)
|
|
{
|
|
if (r.SetId != set.Id) continue;
|
|
if (r.StepIndex >= set.Steps.Count) continue;
|
|
|
|
var glyph = r.Outcome switch
|
|
{
|
|
Outcome.Pass => "✓",
|
|
Outcome.Fail => "✗",
|
|
Outcome.Other => "?",
|
|
_ => "·",
|
|
};
|
|
sb.Append("- ").Append(glyph).Append(' ')
|
|
.Append(set.Steps[r.StepIndex].Title).Append('\n');
|
|
|
|
// Keep each comment line as its own nested list item so multi-line
|
|
// notes stay readable instead of collapsing onto the title line.
|
|
if (!string.IsNullOrWhiteSpace(r.Comment))
|
|
{
|
|
foreach (var commentLine in r.Comment.Split('\n'))
|
|
{
|
|
var trimmed = commentLine.Trim();
|
|
if (trimmed.Length == 0)
|
|
continue;
|
|
|
|
sb.Append(" - ").Append(trimmed).Append('\n');
|
|
}
|
|
}
|
|
}
|
|
sb.Append('\n');
|
|
}
|
|
|
|
return sb.ToString();
|
|
}
|
|
|
|
// ----- Helpers ------------------------------------------------------
|
|
|
|
private void StartRun()
|
|
{
|
|
var sets = _allSets!;
|
|
var selected = new List<TestSet>();
|
|
foreach (var s in sets)
|
|
{
|
|
if (_selectedSetIds.Contains(s.Id) && Matches(s, _filter, _selectedTags))
|
|
selected.Add(s);
|
|
}
|
|
if (selected.Count == 0)
|
|
return;
|
|
|
|
_run = new RunReport
|
|
{
|
|
StartedUtc = DateTime.UtcNow,
|
|
FinishedUtc = DateTime.MinValue,
|
|
Sets = selected,
|
|
Results = new List<StepResult>(64),
|
|
};
|
|
_currentSetIdx = 0;
|
|
_currentStepIdx = 0;
|
|
_cachedStepKey = (string.Empty, -1);
|
|
_state = State.Run;
|
|
}
|
|
|
|
private StepResult GetOrCreateResult(string setId, int stepIdx)
|
|
{
|
|
var run = _run!;
|
|
for (var i = 0; i < run.Results.Count; i++)
|
|
{
|
|
if (run.Results[i].SetId == setId && run.Results[i].StepIndex == stepIdx)
|
|
return run.Results[i];
|
|
}
|
|
var created = new StepResult
|
|
{
|
|
SetId = setId,
|
|
StepIndex = stepIdx,
|
|
};
|
|
run.Results.Add(created);
|
|
return created;
|
|
}
|
|
|
|
private int ComputeGlobalStepIndex()
|
|
{
|
|
var run = _run!;
|
|
var idx = 0;
|
|
for (var i = 0; i < _currentSetIdx; i++)
|
|
idx += run.Sets[i].Steps.Count;
|
|
idx += _currentStepIdx;
|
|
return idx;
|
|
}
|
|
|
|
private int ComputeTotalStepCount()
|
|
{
|
|
var run = _run!;
|
|
var total = 0;
|
|
foreach (var s in run.Sets)
|
|
total += s.Steps.Count;
|
|
return total;
|
|
}
|
|
|
|
private static bool Matches(TestSet set, string filter, IReadOnlySet<string> requiredTags)
|
|
{
|
|
foreach (var tag in requiredTags)
|
|
{
|
|
if (!set.Tags.Contains(tag))
|
|
return false;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(filter))
|
|
return true;
|
|
if (set.Title.Contains(filter, StringComparison.OrdinalIgnoreCase))
|
|
return true;
|
|
if (set.Scope.Contains(filter, StringComparison.OrdinalIgnoreCase))
|
|
return true;
|
|
if (set.Id.Contains(filter, StringComparison.OrdinalIgnoreCase))
|
|
return true;
|
|
return false;
|
|
}
|
|
|
|
private static List<string> CollectAllTags(List<TestSet> sets)
|
|
{
|
|
var seen = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var s in sets)
|
|
foreach (var t in s.Tags)
|
|
seen.Add(t);
|
|
return new List<string>(seen);
|
|
}
|
|
|
|
private void ReloadSets()
|
|
{
|
|
_testsDir = TestSetParser.ResolveTestsDirectory();
|
|
_allSets = TestSetParser.LoadAll(_testsDir);
|
|
TestSetParser.Sort(_allSets, _sortMode);
|
|
_selectedSetIds.RemoveWhere(id => !ContainsId(_allSets, id));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Opens the runner and starts a single-set run for <paramref name="setId"/>. Used by the welcome
|
|
/// window's "Start Test" action. Falls back to the Pick state if the set can't be found.
|
|
/// </summary>
|
|
internal void StartSet(string setId)
|
|
{
|
|
Config.Visible = true;
|
|
|
|
if (_allSets == null)
|
|
ReloadSets();
|
|
|
|
TestSet? target = null;
|
|
foreach (var s in _allSets!)
|
|
{
|
|
if (s.Id == setId)
|
|
{
|
|
target = s;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (target == null)
|
|
{
|
|
_state = State.Pick;
|
|
return;
|
|
}
|
|
|
|
_run = new RunReport
|
|
{
|
|
StartedUtc = DateTime.UtcNow,
|
|
FinishedUtc = DateTime.MinValue,
|
|
Sets = new List<TestSet> { target },
|
|
Results = new List<StepResult>(32),
|
|
};
|
|
_currentSetIdx = 0;
|
|
_currentStepIdx = 0;
|
|
_cachedStepKey = (string.Empty, -1);
|
|
_state = State.Run;
|
|
}
|
|
|
|
private static bool ContainsId(List<TestSet> sets, string id)
|
|
{
|
|
foreach (var s in sets)
|
|
if (s.Id == id) return true;
|
|
return false;
|
|
}
|
|
|
|
private enum State
|
|
{
|
|
Pick,
|
|
Run,
|
|
Summary,
|
|
}
|
|
|
|
private State _state = State.Pick;
|
|
|
|
private List<TestSet>? _allSets;
|
|
private string _testsDir = string.Empty;
|
|
|
|
private readonly HashSet<string> _selectedSetIds = new(StringComparer.OrdinalIgnoreCase);
|
|
private string _filter = string.Empty;
|
|
private TestSetSort _sortMode = TestSetSort.RecentlyAdded;
|
|
private readonly HashSet<string> _selectedTags = new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
private RunReport? _run;
|
|
private int _currentSetIdx;
|
|
private int _currentStepIdx;
|
|
|
|
private readonly MarkdownView _actionMarkdown = new(new MarkdownView.Options());
|
|
private readonly MarkdownView _expectedMarkdown = new(new MarkdownView.Options());
|
|
private MarkdownView? _introMarkdown;
|
|
private (string SetId, int StepIdx) _cachedStepKey = (string.Empty, -1);
|
|
private string? _cachedAction;
|
|
private string? _cachedExpected;
|
|
|
|
private static readonly StringBuilder _scratch = new(128);
|
|
}
|