using System.Globalization; using System.Text.Json; using LlamaApp.HuggingFace; using Xunit; namespace LlamaApp.Tests; /// /// Unit tests for the Hugging Face parsing layer: the /// family → size → build flattening into records and /// the pure helpers (byte formatting, quant extraction from GGUF filenames). /// public class CatalogTests { // ----- Flatten ---------------------------------------------------------- [Fact] public void Flatten_Produces_One_Repository_Per_Build() { var families = new[] { new Catalog.CatalogFamily { Name = "GPT-OSS", Brand = "OpenAI", Description = "Open-weight model", License = "Apache-2.0", Featured = true, Sizes = [ new Catalog.CatalogSize { Name = "GPT-OSS 20B", Params = "20B", Vision = false, Builds = [ new Catalog.CatalogBuild { Quant = "mxfp4", Size = "12.1 GB", SizeBytes = 12992276025UL, Repo = "ggml-org/gpt-oss-20b-GGUF" }, new Catalog.CatalogBuild { Quant = "Q4_0", Size = "11 GB", SizeBytes = 11811160064UL, Repo = "ggml-org/gpt-oss-20b-GGUF" }, ], }, new Catalog.CatalogSize { Name = "GPT-OSS 120B", Params = "120B", Vision = true, Builds = [ new Catalog.CatalogBuild { Quant = "mxfp4", Size = "60 GB", SizeBytes = 64424509440UL, Repo = "ggml-org/gpt-oss-120b-GGUF" }, ], }, ], }, }; var repos = Catalog.Flatten(families); Assert.Equal(3, repos.Count); } [Fact] public void Flatten_Maps_Family_Size_And_Build_Fields() { var families = new[] { new Catalog.CatalogFamily { Name = "GPT-OSS", Brand = "OpenAI", Description = "Open-weight model", License = "Apache-2.0", Featured = true, Sizes = [ new Catalog.CatalogSize { Name = "GPT-OSS 20B", Params = "20B", Vision = true, Builds = [ new Catalog.CatalogBuild { Quant = "mxfp4", Size = "12.1 GB", SizeBytes = 12992276025UL, Repo = "ggml-org/gpt-oss-20b-GGUF" }, ], }, ], }, }; var repo = Assert.Single(Catalog.Flatten(families)); Assert.Equal("ggml-org/gpt-oss-20b-GGUF", repo.Name); // build.Repo Assert.Equal("mxfp4", repo.Quant); // build.Quant Assert.Equal("12.1 GB", repo.Size); // build.Size Assert.Equal(12992276025UL, repo.SizeBytes); // build.SizeBytes Assert.Equal("GPT-OSS 20B", repo.DisplayName); // size.Name Assert.Equal("20B", repo.Parameters); // size.Params Assert.True(repo.Vision); // size.Vision Assert.Equal("OpenAI", repo.Brand); // family.Brand Assert.Equal("Open-weight model", repo.Description); // family.Description Assert.Equal("Apache-2.0", repo.License); // family.License Assert.True(repo.Featured); // family.Featured } [Fact] public void Flatten_Empty_Input_Returns_Empty_List() { Assert.Empty(Catalog.Flatten([])); } [Fact] public void Catalog_Json_Deserializes_And_Flattens() { // End-to-end: the catalog.json schema → DTOs → flat repositories. const string json = """ [ { "name": "Gemma 3", "brand": "Google", "description": "Multimodal model", "details": "...", "released": "2025-03", "license": "Gemma", "featured": false, "sizes": [ { "name": "Gemma 3 4B", "params": "4B", "vision": true, "builds": [ { "quant": "Q4_K_M", "size": "2.5 GB", "sizeBytes": 2526080992, "repo": "ggml-org/gemma-3-4b-it-GGUF" } ] } ] } ] """; var families = JsonSerializer.Deserialize(json); var repos = Catalog.Flatten(families!); var repo = Assert.Single(repos); Assert.Equal("ggml-org/gemma-3-4b-it-GGUF", repo.Name); Assert.Equal("Q4_K_M", repo.Quant); Assert.Equal("Gemma 3 4B", repo.DisplayName); Assert.Equal("Google", repo.Brand); Assert.True(repo.Vision); Assert.False(repo.Featured); // repo:quant is the id form POST /models/load requires. Assert.Equal("ggml-org/gemma-3-4b-it-GGUF:Q4_K_M", repo.ServerModelId); } // ----- FormatBytes ------------------------------------------------------- [Theory] [InlineData(0UL, "0 B")] [InlineData(512UL, "512 B")] [InlineData(1_000UL, "1 KB")] [InlineData(1_500UL, "2 KB")] [InlineData(1_000_000UL, "1 MB")] [InlineData(2_526_080_992UL, "2.5 GB")] [InlineData(1_000_000_000UL, "1 GB")] [InlineData(1_000_000_000_000UL, "1 TB")] // Boundaries: a hair under a unit step must not borrow the larger unit. [InlineData(999UL, "999 B")] [InlineData(999_999_999UL, "1000 MB")] public void FormatBytes_Formats_Human_Readable_Sizes(ulong bytes, string expected) { Assert.Equal(expected, Catalog.FormatBytes(bytes)); } // Regression guard for the base of the unit (1 GB = 1e9, not 1024^3). // FetchLocalAsync writes FormatBytes' output into the same Repository.Size // field the catalog fills with its own pre-formatted string, so the two must // agree byte-for-byte or a model's size changes when it becomes installed. // Pairs below are (sizeBytes, size) taken verbatim from llama.app/v1/catalog.json. [Theory] [InlineData(241_410_624UL, "241 MB")] [InlineData(811_843_488UL, "812 MB")] [InlineData(5_524_862_368UL, "5.5 GB")] [InlineData(12_109_566_560UL, "12.1 GB")] [InlineData(19_563_570_240UL, "19.6 GB")] [InlineData(31_842_799_232UL, "31.8 GB")] public void FormatBytes_Matches_Catalog_Size_Strings(ulong sizeBytes, string catalogSize) { Assert.Equal(catalogSize, Catalog.FormatBytes(sizeBytes)); } [Fact] public void FormatBytes_Uses_Invariant_Decimal_Separator() { // The catalog's strings are period-separated; a comma-decimal UI culture // must not make an installed model read "12,1 GB" next to them. var original = CultureInfo.CurrentCulture; try { CultureInfo.CurrentCulture = new CultureInfo("fr-FR"); Assert.Equal("12.1 GB", Catalog.FormatBytes(12_109_566_560UL)); } finally { CultureInfo.CurrentCulture = original; } } // ----- ExtractQuant ------------------------------------------------------ [Theory] [InlineData("gemma-3-4b-it-Q4_K_M", "Q4_K_M")] [InlineData("gpt-oss-20b-mxfp4", "mxfp4")] [InlineData("Q4_0", "Q4_0")] public void ExtractQuant_Finds_Quant_Label_In_File_Name(string fileName, string expected) { Assert.Equal(expected, Catalog.ExtractQuant(fileName)); } [Theory] [InlineData("model-q4_k_m")] // lowercase 'q' is not matched [InlineData("a-Q")] // single-char segment is not a quant [InlineData("gemma-3-4b-it")] public void ExtractQuant_Returns_Null_When_No_Quant_Segment(string fileName) { Assert.Null(Catalog.ExtractQuant(fileName)); } }