using System.Text; using System.Text.Json; using LlamaApp.Common; using LlamaApp.Llama; using Xunit; namespace LlamaApp.LlamaCpp.Tests; /// /// Unit tests for the JSON schemas exchanged with the local llama.cpp server. /// Keeps us honest when the server's /models, /models/sse, or /// status payloads evolve. /// public class LlamaManagerSchemaTests { // ----- GET /models -> ModelsResponseDto -> ServerModel ------------------- [Fact] public void Map_Loaded_Vision_Model_From_Dto() { var dto = new LlamaManager.ServerModelDto { Id = "ggml-org/gemma-3-4b-it-GGUF:Q4_K_M", Path = "/models/gemma-3-4b-it-qat.q4_k_m.gguf", Status = new LlamaManager.ModelStatusDto { Value = "loaded" }, Architecture = new LlamaManager.ArchitectureDto { InputModalities = ["text", "image"], OutputModalities = ["text"], }, Source = "cache", CanRemove = true, }; var model = LlamaManager.Map(dto); Assert.Equal("ggml-org/gemma-3-4b-it-GGUF:Q4_K_M", model.Id); Assert.Equal("/models/gemma-3-4b-it-qat.q4_k_m.gguf", model.Path); Assert.True(model.IsLoaded); Assert.False(model.IsLoading); Assert.True(model.SupportsImage); Assert.Equal(new[] { "text", "image" }, model.InputModalities); Assert.Equal("cache", model.Source); Assert.True(model.CanRemove); } [Fact] public void Map_Unloading_Model_Has_No_Vision() { var dto = new LlamaManager.ServerModelDto { Id = "ggml-org/gpt-oss-20b-GGUF:Q4_0", Status = new LlamaManager.ModelStatusDto { Value = "loading" }, Source = "cache", }; var model = LlamaManager.Map(dto); Assert.True(model.IsLoading); Assert.False(model.IsLoaded); Assert.False(model.SupportsImage); Assert.Empty(model.InputModalities); } [Fact] public void Map_Downloading_Model_From_Dto() { // Real /models shape for a mid-download model: the id is the bare repo // (the quant is resolved only on completion) and the status value is // "downloading" — neither loading nor loaded. var dto = new LlamaManager.ServerModelDto { Id = "mistralai/Ministral-3-3B-Instruct-2512-GGUF", Status = new LlamaManager.ModelStatusDto { Value = "downloading" }, Architecture = new LlamaManager.ArchitectureDto { InputModalities = ["text"] }, Source = "cache", CanRemove = true, }; var model = LlamaManager.Map(dto); Assert.True(model.IsDownloading); Assert.False(model.IsLoading); Assert.False(model.IsLoaded); } [Fact] public void Map_Null_Status_And_Architecture_Default_To_Unloaded() { var model = LlamaManager.Map(new LlamaManager.ServerModelDto()); Assert.Equal("", model.Id); Assert.Equal("", model.Status); Assert.False(model.IsLoaded); Assert.False(model.IsLoading); Assert.False(model.SupportsImage); Assert.Empty(model.InputModalities); } [Fact] public void Deserialize_ModelsResponse_And_Map_All_Entries() { const string json = """ {"data":[ {"id":"a/b:Q4","status":{"value":"unloaded"},"source":"cache","can_remove":false}, {"id":"c/d:Q8","status":{"value":"loaded"},"architecture":{"input_modalities":["text"]},"can_remove":true} ]} """; var doc = JsonSerializer.Deserialize(json); Assert.NotNull(doc); Assert.NotNull(doc.Data); Assert.Equal(2, doc.Data.Count); var models = doc.Data.Select(LlamaManager.Map).ToList(); Assert.Equal("a/b:Q4", models[0].Id); Assert.Equal("unloaded", models[0].Status); Assert.Equal("c/d:Q8", models[1].Id); Assert.True(models[1].IsLoaded); } // ----- SSE parse + progress aggregation ---------------------------------- [Fact] public async Task ParseSseStream_Yields_Event_Model_And_Data() { const string payload = """ data: {"model":"ggml-org/gemma-3-4b-it-GGUF:Q4_K_M","event":"download_finished","data":{}} """; await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(payload)); using var reader = new StreamReader(stream); var events = await CollectAsync(reader); Assert.Single(events); var (evt, model, data) = events[0]; Assert.Equal("download_finished", evt); Assert.Equal("ggml-org/gemma-3-4b-it-GGUF:Q4_K_M", model); Assert.Equal(JsonValueKind.Object, data.ValueKind); } [Fact] public async Task ParseSseStream_Ignores_Non_Data_Lines() { const string payload = """ event: download_progress id: 1 data: {"model":"a/b","event":"download_progress","data":{"progress":{}}} """; await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(payload)); using var reader = new StreamReader(stream); var events = await CollectAsync(reader); Assert.Single(events); Assert.Equal("download_progress", events[0].Event); } [Fact] public async Task ParseSseStream_Dispatches_Multiple_Events_On_Blank_Lines() { const string payload = """ data: {"model":"a/b","event":"download_started","data":{}} data: {"model":"a/b","event":"download_finished","data":{}} """; await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(payload)); using var reader = new StreamReader(stream); var events = await CollectAsync(reader); Assert.Equal(2, events.Count); Assert.Equal("download_started", events[0].Event); Assert.Equal("download_finished", events[1].Event); } [Fact] public async Task ParseSseStream_Flushes_Trailing_Event_At_Eof() { // No trailing blank line — a server that drops the connection // mid-stream must not silently lose the last event. const string payload = "data: {\"model\":\"a/b\",\"event\":\"download_finished\",\"data\":{}}"; await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(payload)); using var reader = new StreamReader(stream); var events = await CollectAsync(reader); Assert.Single(events); Assert.Equal("download_finished", events[0].Event); } [Fact] public async Task ParseSseStream_Handles_Crlf_Line_Endings() { const string payload = "data: {\"model\":\"a/b\",\"event\":\"download_finished\",\"data\":{}}\r\n\r\n"; await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(payload)); using var reader = new StreamReader(stream); var events = await CollectAsync(reader); Assert.Single(events); Assert.Equal("download_finished", events[0].Event); } [Fact] public async Task ParseSseStream_Joins_Multi_Line_Data() { // SSE allows one event's data to span several data: lines; they are // joined with '\n' before parsing. const string payload = """ data: {"model":"a/b","event":"download_progress", data: "data":{}} """; await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(payload)); using var reader = new StreamReader(stream); var events = await CollectAsync(reader); Assert.Single(events); Assert.Equal("download_progress", events[0].Event); Assert.Equal("a/b", events[0].Model); } [Fact] public async Task ParseSseStream_Skips_Payload_Without_Event_Field() { const string payload = """ data: {"model":"a/b","data":{}} """; await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(payload)); using var reader = new StreamReader(stream); var events = await CollectAsync(reader); Assert.Empty(events); } [Fact] public void SumProgress_Sums_Per_Url_Done_And_Total() { const string dataJson = """ {"progress":{ "https://huggingface.co/ggml-org/gemma-4-E4B-it-GGUF/resolve/2714b5519c6c3516b1000e7c5e1eba998dfe1fe8/mmproj-gemma-4-E4B-it-Q8_0.gguf":{"done":93584632,"total":559874528}, "https://huggingface.co/ggml-org/gemma-4-E4B-it-GGUF/resolve/2714b5519c6c3516b1000e7c5e1eba998dfe1fe8/gemma-4-E4B-it-Q4_K_M.gguf":{"done":0,"total":5335289824} }} """; using var doc = JsonDocument.Parse(dataJson); var (done, total) = LlamaManager.SumProgress(doc.RootElement); Assert.Equal(93584632L, done); Assert.Equal(559874528L + 5335289824L, total); } [Fact] public void SumProgress_Returns_Zero_When_Progress_Missing() { using var doc = JsonDocument.Parse("{}"); var (done, total) = LlamaManager.SumProgress(doc.RootElement); Assert.Equal(0, done); Assert.Equal(0, total); } [Fact] public void SumProgress_Skips_Non_Object_Entries_And_Missing_Fields() { const string dataJson = """ {"progress":{ "url1":{"done":10}, "url2":"not-an-object", "url3":{"total":100}, "url4":{"done":"oops","total":50} }} """; using var doc = JsonDocument.Parse(dataJson); var (done, total) = LlamaManager.SumProgress(doc.RootElement); Assert.Equal(10, done); Assert.Equal(150, total); } // ----- status_change data payload (load progress) ---------------------- [Fact] public void ParseStatusChange_Loading_Reports_Value_As_Fraction() { // Real payload captured from /models/sse while loading. A single-stage // load reports the stage's value directly as the overall fraction. const string dataJson = """ {"status":"loading","progress":{"stages":["text_model"],"current":"text_model","value":0.9664499163627625}} """; using var doc = JsonDocument.Parse(dataJson); var (status, fraction) = LlamaManager.ParseStatusChange(doc.RootElement); Assert.Equal("loading", status); Assert.Equal(0.9664499163627625, fraction, precision: 6); } [Fact] public void ParseStatusChange_Loaded_Carries_No_Progress() { // Real payload captured from /models/sse on completion (the info body // is trimmed) — no progress object, so the fraction stays 0; callers // report the terminal 1.0 themselves. const string dataJson = """ {"status":"loaded","info":{"id":"ggml-org/gemma-3-1b-it-qat-GGUF:Q4_0"}} """; using var doc = JsonDocument.Parse(dataJson); var (status, fraction) = LlamaManager.ParseStatusChange(doc.RootElement); Assert.Equal("loaded", status); Assert.Equal(0, fraction); } [Fact] public void ParseStatusChange_MultiStage_Weights_Value_By_Stage_Position() { // Second of two stages at 50% -> (1 + 0.5) / 2 = 0.75 overall. const string dataJson = """ {"status":"loading","progress":{"stages":["text_model","mmproj"],"current":"mmproj","value":0.5}} """; using var doc = JsonDocument.Parse(dataJson); var (status, fraction) = LlamaManager.ParseStatusChange(doc.RootElement); Assert.Equal("loading", status); Assert.Equal(0.75, fraction, precision: 6); } [Theory] [InlineData("{}")] [InlineData("[]")] [InlineData("{\"status\":42}")] public void ParseStatusChange_Missing_Or_Non_String_Status_Yields_Empty(string dataJson) { using var doc = JsonDocument.Parse(dataJson); var (status, fraction) = LlamaManager.ParseStatusChange(doc.RootElement); Assert.Equal("", status); Assert.Equal(0, fraction); } [Fact] public void ParseStatusChange_Non_Number_Value_Yields_Zero_Fraction() { // Regression: TryGetDouble throws on a String element (same as // TryGetInt64) — the ValueKind guard must run before it. const string dataJson = """ {"status":"loading","progress":{"value":"oops"}} """; using var doc = JsonDocument.Parse(dataJson); var (status, fraction) = LlamaManager.ParseStatusChange(doc.RootElement); Assert.Equal("loading", status); Assert.Equal(0, fraction); } [Fact] public void ParseStatusChange_Clamps_Out_Of_Range_Value() { const string dataJson = """ {"status":"loading","progress":{"stages":["text_model"],"current":"text_model","value":1.7}} """; using var doc = JsonDocument.Parse(dataJson); var (_, fraction) = LlamaManager.ParseStatusChange(doc.RootElement); Assert.Equal(1.0, fraction); } [Fact] public void ModelDownloadProgress_Fraction_Computes_Correctly() { var p = new ModelDownloadProgress( Model: "a/b", DownloadedBytes: 250, TotalBytes: 1000, Done: false, Failed: false); Assert.Equal(0.25, p.Fraction, precision: 5); } [Fact] public void ModelDownloadProgress_Fraction_Is_Zero_When_Total_Unknown() { var p = new ModelDownloadProgress( Model: "a/b", DownloadedBytes: 100, TotalBytes: 0, Done: false, Failed: false); Assert.Equal(0.0, p.Fraction); } // ----- helpers ----------------------------------------------------------- private static async Task> CollectAsync(StreamReader reader) { var list = new List<(string, string, JsonElement)>(); await foreach (var tuple in LlamaManager.ParseSseStreamAsync(reader, CancellationToken.None)) list.Add(tuple); return list; } }