using LlamaApp.Common;
using LlamaApp.Views;
using Xunit;
namespace LlamaApp.Tests;
///
/// Unit tests for the row state machine — the derived
/// glyph-visibility properties that drive the Available-row action cell
/// (play / download ring / load ring / open / failure affordance), the
/// download-progress captions, the change notifications, and the display-name
/// / server-id helpers.
///
/// These only touch pure managed logic: no XAML objects (SvgImageSource etc.)
/// are instantiated, so they run in a plain test host.
///
public class ModelItemStateMachineTests
{
// ----- Glyph visibility state machine ----------------------------------
[Fact]
public void Default_State_Shows_Play_Glyph()
{
var item = new ModelItem();
Assert.True(item.PlayGlyphVisible);
Assert.False(item.ProgressRingVisible);
Assert.False(item.LoadingRingVisible);
Assert.False(item.OpenGlyphVisible);
}
[Fact]
public void Downloading_Shows_Progress_Ring_And_Hides_Play()
{
var item = new ModelItem { IsDownloading = true };
Assert.True(item.ProgressRingVisible);
Assert.False(item.PlayGlyphVisible);
Assert.False(item.LoadingRingVisible);
Assert.False(item.OpenGlyphVisible);
}
[Fact]
public void Loading_Shows_Loading_Ring()
{
var item = new ModelItem { IsLoading = true };
Assert.True(item.LoadingRingVisible);
Assert.False(item.PlayGlyphVisible);
Assert.False(item.ProgressRingVisible);
Assert.False(item.OpenGlyphVisible);
}
[Fact]
public void Download_Takes_Priority_Over_Load()
{
// A row that's mid-download shows the download ring even if a load
// request is also in flight (a model can't load before it's cached).
var item = new ModelItem { IsDownloading = true, IsLoading = true };
Assert.True(item.ProgressRingVisible);
Assert.False(item.LoadingRingVisible);
}
[Fact]
public void Loaded_Shows_Open_Glyph()
{
var item = new ModelItem { IsLoaded = true };
Assert.True(item.OpenGlyphVisible);
Assert.False(item.PlayGlyphVisible);
Assert.False(item.ProgressRingVisible);
Assert.False(item.LoadingRingVisible);
}
[Fact]
public void Failed_Download_Hides_Play_Glyph()
{
// Regression: a failed row must show the warning + retry affordance,
// not the play glyph — the model isn't fully cached, so loading it
// would just be rejected by the server.
var item = new ModelItem { DownloadFailed = true };
Assert.False(item.PlayGlyphVisible);
item.DownloadFailed = false;
Assert.True(item.PlayGlyphVisible);
}
// ----- Cancel-download button visibility -------------------------------
[Fact]
public void Cancel_Button_Hidden_When_Idle()
{
var item = new ModelItem();
Assert.False(item.CancelDownloadVisible);
}
[Fact]
public void Cancel_Button_Shown_Only_For_App_Driven_Downloads()
{
// An externally-triggered download (WebUI / CLI) has no cancellation
// source — the button must hide rather than offer a no-op cancel.
var item = new ModelItem { IsDownloading = true };
Assert.False(item.CancelDownloadVisible);
// The driver assigns the source when the download starts...
item.DownloadCancellation = new CancellationTokenSource();
Assert.True(item.CancelDownloadVisible);
// ...and clears it when the download ends (before IsDownloading flips).
item.DownloadCancellation = null;
Assert.False(item.CancelDownloadVisible);
}
[Fact]
public void Cancel_Button_Hides_When_Download_Ends()
{
var item = new ModelItem
{
IsDownloading = true,
DownloadCancellation = new CancellationTokenSource(),
};
Assert.True(item.CancelDownloadVisible);
item.IsDownloading = false;
Assert.False(item.CancelDownloadVisible);
}
[Fact]
public void DownloadCancellation_Raises_Cancel_Visibility_Notification()
{
var item = new ModelItem { IsDownloading = true };
var raised = new List();
item.PropertyChanged += (_, e) => raised.Add(e.PropertyName);
item.DownloadCancellation = new CancellationTokenSource();
Assert.Contains(nameof(ModelItem.CancelDownloadVisible), raised);
raised.Clear();
item.DownloadCancellation = null;
Assert.Contains(nameof(ModelItem.CancelDownloadVisible), raised);
}
[Fact]
public void IsDownloading_Raises_Cancel_Visibility_Notification()
{
var item = new ModelItem();
var raised = new List();
item.PropertyChanged += (_, e) => raised.Add(e.PropertyName);
item.IsDownloading = true;
Assert.Contains(nameof(ModelItem.CancelDownloadVisible), raised);
}
// ----- Load failure affordance -----------------------------------------
[Fact]
public void Failed_Load_Hides_Play_Glyph()
{
// A rejected load must show the warning + retry affordance, not the
// play glyph — otherwise the failure is silent (the ring just vanishes).
var item = new ModelItem { LoadFailed = true };
Assert.False(item.PlayGlyphVisible);
Assert.False(item.LoadingRingVisible);
Assert.False(item.OpenGlyphVisible);
item.LoadFailed = false;
Assert.True(item.PlayGlyphVisible);
}
[Fact]
public void LoadFailed_Raises_Play_Glyph_Notification()
{
var item = new ModelItem();
var raised = new List();
item.PropertyChanged += (_, e) => raised.Add(e.PropertyName);
item.LoadFailed = true;
// The UI swaps play ↔ warning+retry on this transition.
Assert.Contains(nameof(ModelItem.LoadFailed), raised);
Assert.Contains(nameof(ModelItem.PlayGlyphVisible), raised);
}
[Fact]
public void Loading_Again_After_Failure_Shows_Ring_Not_Affordance()
{
// The retry path clears LoadFailed and sets IsLoading: the load ring
// shows and the play glyph stays hidden.
var item = new ModelItem { LoadFailed = true };
item.LoadFailed = false;
item.IsLoading = true;
Assert.True(item.LoadingRingVisible);
Assert.False(item.PlayGlyphVisible);
}
// ----- Download progress captions --------------------------------------
[Fact]
public void Download_Is_Indeterminate_Until_First_Bytes()
{
var item = new ModelItem { IsDownloading = true };
Assert.True(item.IsIndeterminateDownload);
Assert.False(item.DownloadPercentTextVisible);
item.DownloadFraction = 0.5;
Assert.False(item.IsIndeterminateDownload);
Assert.True(item.DownloadPercentTextVisible);
}
[Fact]
public void Progress_Percent_And_Text_Are_Derived_From_Fraction()
{
var item = new ModelItem { DownloadFraction = 0.423 };
Assert.Equal(42.3, item.DownloadProgressPercent, precision: 3);
Assert.Equal("42%", item.DownloadProgressText);
}
[Fact]
public void Percent_Caption_Hides_When_Not_Downloading()
{
var item = new ModelItem { DownloadFraction = 0.5 };
Assert.False(item.DownloadPercentTextVisible); // not downloading
}
// ----- Download detail line / subtitle ---------------------------------
[Fact]
public void Subtitle_Is_Params_And_Size_At_Rest()
{
var item = new ModelItem { Parameters = "20B", Size = "12.1 GB" };
Assert.Equal("20B · 12.1 GB", item.SubtitleText);
}
[Fact]
public void Subtitle_Drops_Empty_Parts()
{
// An uncataloged model can lack params/size — no dangling " · ".
var item = new ModelItem { Parameters = "", Size = "12.1 GB" };
Assert.Equal("12.1 GB", item.SubtitleText);
}
[Fact]
public void Subtitle_Shows_Download_Detail_While_Downloading()
{
var item = new ModelItem
{
Parameters = "20B",
Size = "12.1 GB",
IsDownloading = true,
DownloadedBytes = 3_200_000_000,
DownloadTotalBytes = 12_100_000_000,
};
Assert.Equal("3.2 GB of 12.1 GB", item.SubtitleText);
// Size unknown yet → the rest subtitle stays (no "0 B of 0 B").
var early = new ModelItem { Parameters = "20B", Size = "12.1 GB", IsDownloading = true };
Assert.Equal("20B · 12.1 GB", early.SubtitleText);
}
[Fact]
public void Subtitle_Returns_To_Params_Size_When_Download_Ends()
{
var item = new ModelItem
{
Parameters = "20B",
Size = "12.1 GB",
IsDownloading = true,
DownloadedBytes = 3_200_000_000,
DownloadTotalBytes = 12_100_000_000,
};
item.IsDownloading = false;
Assert.Equal("20B · 12.1 GB", item.SubtitleText);
}
[Fact]
public void Byte_Updates_Raise_Subtitle_Notifications()
{
var item = new ModelItem { IsDownloading = true };
var raised = new List();
item.PropertyChanged += (_, e) => raised.Add(e.PropertyName);
item.DownloadTotalBytes = 12_100_000_000;
item.DownloadedBytes = 3_200_000_000;
item.DownloadBytesPerSecond = 45_000_000;
Assert.Contains(nameof(ModelItem.SubtitleText), raised);
Assert.Contains(nameof(ModelItem.DownloadDetailText), raised);
}
// ----- Load progress captions -------------------------------------------
[Fact]
public void Load_Is_Indeterminate_Until_First_Progress_Event()
{
var item = new ModelItem { IsLoading = true };
Assert.True(item.IsIndeterminateLoad);
Assert.False(item.LoadPercentTextVisible);
item.LoadFraction = 0.5;
Assert.False(item.IsIndeterminateLoad);
Assert.True(item.LoadPercentTextVisible);
}
[Fact]
public void Load_Percent_And_Text_Are_Derived_From_Fraction()
{
var item = new ModelItem { LoadFraction = 0.423 };
Assert.Equal(42.3, item.LoadProgressPercent, precision: 3);
Assert.Equal("42%", item.LoadProgressText);
}
[Fact]
public void Load_Percent_Caption_Hides_When_Not_Loading()
{
var item = new ModelItem { LoadFraction = 0.5 };
Assert.False(item.LoadPercentTextVisible); // not loading
}
[Fact]
public void IsLoading_Raises_Load_Progress_Notifications()
{
var item = new ModelItem();
var raised = new List();
item.PropertyChanged += (_, e) => raised.Add(e.PropertyName);
item.IsLoading = true;
// The ring swaps indeterminate ↔ determinate on this transition.
Assert.Contains(nameof(ModelItem.IsIndeterminateLoad), raised);
Assert.Contains(nameof(ModelItem.LoadPercentTextVisible), raised);
}
// ----- Change notifications --------------------------------------------
[Fact]
public void State_Changes_Raise_Derived_Property_Notifications()
{
var item = new ModelItem();
var raised = new List();
item.PropertyChanged += (_, e) => raised.Add(e.PropertyName);
item.IsDownloading = true;
Assert.Contains(nameof(ModelItem.IsDownloading), raised);
Assert.Contains(nameof(ModelItem.PlayGlyphVisible), raised);
Assert.Contains(nameof(ModelItem.ProgressRingVisible), raised);
Assert.Contains(nameof(ModelItem.LoadingRingVisible), raised);
Assert.Contains(nameof(ModelItem.OpenGlyphVisible), raised);
Assert.Contains(nameof(ModelItem.IsIndeterminateDownload), raised);
Assert.Contains(nameof(ModelItem.DownloadPercentTextVisible), raised);
}
[Fact]
public void DownloadFailed_Raises_Play_Glyph_Notification()
{
var item = new ModelItem();
var raised = new List();
item.PropertyChanged += (_, e) => raised.Add(e.PropertyName);
item.DownloadFailed = true;
// The UI swaps play ↔ warning+retry on this transition.
Assert.Contains(nameof(ModelItem.PlayGlyphVisible), raised);
}
[Fact]
public void No_Op_Assignments_Do_Not_Raise()
{
var item = new ModelItem();
var raised = new List();
item.PropertyChanged += (_, e) => raised.Add(e.PropertyName);
item.IsLoaded = false; // already false
item.IsDownloading = false; // already false
Assert.Empty(raised);
}
[Fact]
public void Fraction_Noise_Below_Granularity_Does_Not_Raise()
{
// The setter guards at 0.001 granularity so a high-frequency progress
// stream doesn't flood the UI with no-op updates.
var item = new ModelItem { DownloadFraction = 0.5 };
var raised = new List();
item.PropertyChanged += (_, e) => raised.Add(e.PropertyName);
item.DownloadFraction = 0.5005; // below the 0.001 guard
Assert.Empty(raised);
item.DownloadFraction = 0.502; // above it
Assert.Contains(nameof(ModelItem.DownloadProgressPercent), raised);
Assert.Contains(nameof(ModelItem.DownloadProgressText), raised);
}
// ----- Display name / server id helpers --------------------------------
[Theory]
[InlineData("ggml-org/gpt-oss-20b-GGUF", "gpt-oss-20b-GGUF")]
[InlineData("GPT-OSS 20B (mxfp4)", "GPT-OSS 20B (mxfp4)")]
[InlineData("a/b/c", "c")]
public void DisplayName_Takes_Last_Path_Segment(string name, string expected)
{
var item = new ModelItem { Name = name };
Assert.Equal(expected, item.DisplayName);
}
[Fact]
public void IModel_Name_Prefers_Repo_Name_Over_Display_Name()
{
var item = new ModelItem { Name = "GPT-OSS 20B (mxfp4)", RepoName = "ggml-org/gpt-oss-20b-GGUF" };
Assert.Equal("ggml-org/gpt-oss-20b-GGUF", ((IModel)item).Name);
var fallback = new ModelItem { Name = "some/name" };
Assert.Equal("some/name", ((IModel)fallback).Name);
}
[Fact]
public void Server_Model_Id_Appends_Quant_When_Present()
{
var withQuant = new ModelItem { RepoName = "ggml-org/gpt-oss-20b-GGUF", Quant = "mxfp4" };
Assert.Equal("ggml-org/gpt-oss-20b-GGUF:mxfp4", ((IModel)withQuant).ServerModelId);
var withoutQuant = new ModelItem { RepoName = "ggml-org/gpt-oss-20b-GGUF" };
Assert.Equal("ggml-org/gpt-oss-20b-GGUF", ((IModel)withoutQuant).ServerModelId);
}
// ----- Row tooltip (name + description) --------------------------------
[Fact]
public void RowToolTip_Is_The_Name_When_No_Description()
{
var item = new ModelItem { Name = "GPT-OSS 20B (mxfp4)" };
Assert.Equal("GPT-OSS 20B (mxfp4)", item.RowToolTip);
}
[Fact]
public void RowToolTip_Appends_The_Description_On_A_Second_Line()
{
// The catalog's description was populated but bound nowhere — the
// tooltip is how you tell what a model is before downloading it.
var item = new ModelItem
{
Name = "GPT-OSS 20B (mxfp4)",
Description = "OpenAI's open-weight reasoning model",
};
Assert.Equal("GPT-OSS 20B (mxfp4)\nOpenAI's open-weight reasoning model", item.RowToolTip);
}
// ----- Logo resolution (null paths only — no SvgImageSource created) ----
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("unknown-brand")]
public void ResolveLogo_Returns_Null_For_Null_Or_Unknown_Brand(string? brand)
{
Assert.Null(ModelItem.ResolveLogo(brand));
}
}