diff --git a/.github/workflows/desktop-tests.yml b/.github/workflows/desktop-tests.yml
new file mode 100644
index 0000000..2b23557
--- /dev/null
+++ b/.github/workflows/desktop-tests.yml
@@ -0,0 +1,30 @@
+name: Desktop tests
+
+# Автозапуск юнит-тестов десктопа при изменениях кода.
+# Работает и в GitHub Actions, и в Gitea Actions (если настроены раннеры).
+on:
+ push:
+ paths:
+ - 'src/Elfisa.Core/**'
+ - 'src/Elfisa.Avalonia/**'
+ - 'tests/**'
+ - '.github/workflows/desktop-tests.yml'
+ pull_request:
+ workflow_dispatch:
+
+jobs:
+ test:
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup .NET 8
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '8.0.x'
+
+ - name: Restore
+ run: dotnet restore tests/Elfisa.Tests/Elfisa.Tests.csproj
+
+ - name: Test
+ run: dotnet test tests/Elfisa.Tests/Elfisa.Tests.csproj -c Release --no-restore --logger "console;verbosity=minimal"
diff --git a/Elfisa.sln b/Elfisa.sln
index fc6d9da..dc4add0 100644
--- a/Elfisa.sln
+++ b/Elfisa.sln
@@ -13,6 +13,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elfisa.ButtonSkillDemo", "s
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elfisa.GradientButtonDemo", "src\Elfisa.GradientButtonDemo\Elfisa.GradientButtonDemo.csproj", "{19457083-89B8-4C5B-BCA1-BFB66C273502}"
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elfisa.Tests", "tests\Elfisa.Tests\Elfisa.Tests.csproj", "{4712D7E7-6A3A-48E8-9CF9-96A70AFB8670}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elfisa.Core", "src\Elfisa.Core\Elfisa.Core.csproj", "{40529B23-A30E-4BCF-B706-254DB547A643}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elfisa.Avalonia", "src\Elfisa.Avalonia\Elfisa.Avalonia.csproj", "{396E6D2E-F2F2-4E3B-8CFB-7F7C74C7E423}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -83,6 +91,42 @@ Global
{19457083-89B8-4C5B-BCA1-BFB66C273502}.Release|x64.Build.0 = Release|Any CPU
{19457083-89B8-4C5B-BCA1-BFB66C273502}.Release|x86.ActiveCfg = Release|Any CPU
{19457083-89B8-4C5B-BCA1-BFB66C273502}.Release|x86.Build.0 = Release|Any CPU
+ {4712D7E7-6A3A-48E8-9CF9-96A70AFB8670}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {4712D7E7-6A3A-48E8-9CF9-96A70AFB8670}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {4712D7E7-6A3A-48E8-9CF9-96A70AFB8670}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {4712D7E7-6A3A-48E8-9CF9-96A70AFB8670}.Debug|x64.Build.0 = Debug|Any CPU
+ {4712D7E7-6A3A-48E8-9CF9-96A70AFB8670}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {4712D7E7-6A3A-48E8-9CF9-96A70AFB8670}.Debug|x86.Build.0 = Debug|Any CPU
+ {4712D7E7-6A3A-48E8-9CF9-96A70AFB8670}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {4712D7E7-6A3A-48E8-9CF9-96A70AFB8670}.Release|Any CPU.Build.0 = Release|Any CPU
+ {4712D7E7-6A3A-48E8-9CF9-96A70AFB8670}.Release|x64.ActiveCfg = Release|Any CPU
+ {4712D7E7-6A3A-48E8-9CF9-96A70AFB8670}.Release|x64.Build.0 = Release|Any CPU
+ {4712D7E7-6A3A-48E8-9CF9-96A70AFB8670}.Release|x86.ActiveCfg = Release|Any CPU
+ {4712D7E7-6A3A-48E8-9CF9-96A70AFB8670}.Release|x86.Build.0 = Release|Any CPU
+ {40529B23-A30E-4BCF-B706-254DB547A643}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {40529B23-A30E-4BCF-B706-254DB547A643}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {40529B23-A30E-4BCF-B706-254DB547A643}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {40529B23-A30E-4BCF-B706-254DB547A643}.Debug|x64.Build.0 = Debug|Any CPU
+ {40529B23-A30E-4BCF-B706-254DB547A643}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {40529B23-A30E-4BCF-B706-254DB547A643}.Debug|x86.Build.0 = Debug|Any CPU
+ {40529B23-A30E-4BCF-B706-254DB547A643}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {40529B23-A30E-4BCF-B706-254DB547A643}.Release|Any CPU.Build.0 = Release|Any CPU
+ {40529B23-A30E-4BCF-B706-254DB547A643}.Release|x64.ActiveCfg = Release|Any CPU
+ {40529B23-A30E-4BCF-B706-254DB547A643}.Release|x64.Build.0 = Release|Any CPU
+ {40529B23-A30E-4BCF-B706-254DB547A643}.Release|x86.ActiveCfg = Release|Any CPU
+ {40529B23-A30E-4BCF-B706-254DB547A643}.Release|x86.Build.0 = Release|Any CPU
+ {396E6D2E-F2F2-4E3B-8CFB-7F7C74C7E423}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {396E6D2E-F2F2-4E3B-8CFB-7F7C74C7E423}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {396E6D2E-F2F2-4E3B-8CFB-7F7C74C7E423}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {396E6D2E-F2F2-4E3B-8CFB-7F7C74C7E423}.Debug|x64.Build.0 = Debug|Any CPU
+ {396E6D2E-F2F2-4E3B-8CFB-7F7C74C7E423}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {396E6D2E-F2F2-4E3B-8CFB-7F7C74C7E423}.Debug|x86.Build.0 = Debug|Any CPU
+ {396E6D2E-F2F2-4E3B-8CFB-7F7C74C7E423}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {396E6D2E-F2F2-4E3B-8CFB-7F7C74C7E423}.Release|Any CPU.Build.0 = Release|Any CPU
+ {396E6D2E-F2F2-4E3B-8CFB-7F7C74C7E423}.Release|x64.ActiveCfg = Release|Any CPU
+ {396E6D2E-F2F2-4E3B-8CFB-7F7C74C7E423}.Release|x64.Build.0 = Release|Any CPU
+ {396E6D2E-F2F2-4E3B-8CFB-7F7C74C7E423}.Release|x86.ActiveCfg = Release|Any CPU
+ {396E6D2E-F2F2-4E3B-8CFB-7F7C74C7E423}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -91,5 +135,8 @@ Global
{7974F646-2F23-46E3-A614-A3D0C5D8C844} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{1A28299F-5E9B-4E2E-AEC1-EB35FB911F85} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{19457083-89B8-4C5B-BCA1-BFB66C273502} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {4712D7E7-6A3A-48E8-9CF9-96A70AFB8670} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
+ {40529B23-A30E-4BCF-B706-254DB547A643} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {396E6D2E-F2F2-4E3B-8CFB-7F7C74C7E423} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
EndGlobalSection
EndGlobal
diff --git a/src/Elfisa.Avalonia/Elfisa.Avalonia.csproj b/src/Elfisa.Avalonia/Elfisa.Avalonia.csproj
index baf0393..c812fad 100644
--- a/src/Elfisa.Avalonia/Elfisa.Avalonia.csproj
+++ b/src/Elfisa.Avalonia/Elfisa.Avalonia.csproj
@@ -14,6 +14,10 @@
ru;en
+
+
+
+
diff --git a/src/Elfisa.Avalonia/ViewModels/PriceListViewModel.cs b/src/Elfisa.Avalonia/ViewModels/PriceListViewModel.cs
index 8f20957..22a0b31 100644
--- a/src/Elfisa.Avalonia/ViewModels/PriceListViewModel.cs
+++ b/src/Elfisa.Avalonia/ViewModels/PriceListViewModel.cs
@@ -208,14 +208,14 @@ public partial class PriceListViewModel : ViewModelBase
SelectedOffer = Offers.FirstOrDefault();
}
- private static string ExtractDosage(string? drugName)
+ internal static string ExtractDosage(string? drugName)
{
if (string.IsNullOrWhiteSpace(drugName)) return string.Empty;
var m = DosageRegex.Match(drugName);
return m.Success ? m.Value.Trim() : string.Empty;
}
- private static string BuildBaseName(string? drugName, string? tradeName, string dosage)
+ internal static string BuildBaseName(string? drugName, string? tradeName, string dosage)
{
if (!string.IsNullOrWhiteSpace(tradeName)) return tradeName.Trim();
diff --git a/src/Elfisa.Core/ApiClient.cs b/src/Elfisa.Core/ApiClient.cs
index c810fe6..b74936e 100644
--- a/src/Elfisa.Core/ApiClient.cs
+++ b/src/Elfisa.Core/ApiClient.cs
@@ -25,13 +25,20 @@ public sealed class ApiClient
_http = new HttpClient { Timeout = TimeSpan.FromSeconds(120) };
}
+ // Тест-конструктор: подмена HTTP-обработчика (мок) для юнит-тестов.
+ internal ApiClient(HttpMessageHandler handler, string baseUrl)
+ {
+ _baseUrl = baseUrl.TrimEnd('/');
+ _http = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(120) };
+ }
+
public string BaseUrl => _baseUrl;
public string? Token => _token;
public bool IsAuthenticated => !string.IsNullOrEmpty(_token);
public void SetToken(string? token) => _token = string.IsNullOrWhiteSpace(token) ? null : token;
- private string Url(string path) => new Uri(new Uri(_baseUrl + "/"), path.TrimStart('/')).ToString();
+ internal string Url(string path) => new Uri(new Uri(_baseUrl + "/"), path.TrimStart('/')).ToString();
/// Логин. Возвращает (token, role) либо бросает с понятным сообщением.
public async Task LoginAsync(string username, string password)
diff --git a/src/Elfisa.Core/Elfisa.Core.csproj b/src/Elfisa.Core/Elfisa.Core.csproj
index fa71b7a..47b4598 100644
--- a/src/Elfisa.Core/Elfisa.Core.csproj
+++ b/src/Elfisa.Core/Elfisa.Core.csproj
@@ -6,4 +6,8 @@
enable
+
+
+
+
diff --git a/src/Elfisa.Core/Session.cs b/src/Elfisa.Core/Session.cs
index 4c09845..31ae125 100644
--- a/src/Elfisa.Core/Session.cs
+++ b/src/Elfisa.Core/Session.cs
@@ -47,7 +47,7 @@ public sealed class Session
}
/// Проверяет, истёк ли JWT (по claim exp), с запасом в 1 минуту.
- private static bool IsJwtExpired(string token)
+ internal static bool IsJwtExpired(string token)
{
try
{
diff --git a/src/Elfisa.Core/UpdateService.cs b/src/Elfisa.Core/UpdateService.cs
index 2401054..83f31a8 100644
--- a/src/Elfisa.Core/UpdateService.cs
+++ b/src/Elfisa.Core/UpdateService.cs
@@ -124,7 +124,7 @@ public static class UpdateService
}
/// Разбирает тег вида v2.0.4 / 2.0.4 / 2.0.4-beta в Version.
- private static Version? ParseVersion(string? tag)
+ internal static Version? ParseVersion(string? tag)
{
if (string.IsNullOrWhiteSpace(tag)) return null;
tag = tag.Trim();
diff --git a/tests/Elfisa.Tests/ApiClientTests.cs b/tests/Elfisa.Tests/ApiClientTests.cs
new file mode 100644
index 0000000..19af307
--- /dev/null
+++ b/tests/Elfisa.Tests/ApiClientTests.cs
@@ -0,0 +1,100 @@
+using System.Net;
+using System.Net.Http;
+using Elfisa.Core;
+using Xunit;
+
+namespace Elfisa.Tests;
+
+public class ApiClientTests
+{
+ private static ApiClient Client(HttpMessageHandler h) => new(h, "https://api.test");
+
+ [Fact]
+ public void Url_builds_absolute_paths()
+ {
+ var c = new ApiClient("https://api.test/");
+ Assert.Equal("https://api.test/auth/login", c.Url("/auth/login"));
+ Assert.Equal("https://api.test/x/y", c.Url("x/y"));
+ }
+
+ [Fact]
+ public async Task Login_success_returns_token_and_role()
+ {
+ var h = new FakeHttpHandler(HttpStatusCode.OK, "{\"token\":\"abc\",\"role\":\"buyer\"}");
+ var resp = await Client(h).LoginAsync("u", "p");
+ Assert.Equal("abc", resp.Token);
+ Assert.Equal("buyer", resp.Role);
+ }
+
+ [Fact]
+ public async Task Login_401_maps_to_friendly_message()
+ {
+ var h = new FakeHttpHandler(HttpStatusCode.Unauthorized, "{\"error\":\"x\"}");
+ var ex = await Assert.ThrowsAsync(() => Client(h).LoginAsync("u", "p"));
+ Assert.Contains("Неверный логин", ex.Message);
+ }
+
+ [Fact]
+ public async Task Login_429_maps_to_ratelimit_message()
+ {
+ var h = new FakeHttpHandler((HttpStatusCode)429, "{}");
+ var ex = await Assert.ThrowsAsync(() => Client(h).LoginAsync("u", "p"));
+ Assert.Contains("Слишком много", ex.Message);
+ }
+
+ [Fact]
+ public async Task Login_retries_transient_then_succeeds()
+ {
+ var h = new FakeHttpHandler((req, i) =>
+ i < 2 ? throw new HttpRequestException("transient")
+ : FakeHttpHandler.Json(HttpStatusCode.OK, "{\"token\":\"ok\"}"));
+ var resp = await Client(h).LoginAsync("u", "p");
+ Assert.Equal("ok", resp.Token);
+ Assert.Equal(3, h.Calls); // 2 сбоя + 1 успех
+ }
+
+ [Fact]
+ public async Task Login_gives_up_after_retries()
+ {
+ var h = new FakeHttpHandler((req, i) => throw new HttpRequestException("down"));
+ await Assert.ThrowsAsync(() => Client(h).LoginAsync("u", "p"));
+ Assert.Equal(3, h.Calls); // 1 попытка + 2 повтора
+ }
+
+ [Fact]
+ public async Task Summary_parses_response_and_builds_query()
+ {
+ var body = "{\"total_drugs\":2,\"summary\":[{\"supplier_price_id\":\"1\",\"drug_name\":\"Аспирин\",\"price\":10.5}]}";
+ var h = new FakeHttpHandler(HttpStatusCode.OK, body);
+ var c = Client(h);
+ c.SetToken("t");
+ var resp = await c.GetPriceSummaryAsync("асп", limit: 50, offset: 20);
+ Assert.Equal(2, resp.TotalDrugs);
+ Assert.Single(resp.Summary!);
+ Assert.Equal("Аспирин", resp.Summary![0].DrugName);
+ Assert.Equal(10.5m, resp.Summary![0].Price);
+
+ var url = h.LastRequest!.RequestUri!.ToString();
+ Assert.Contains("limit=50", url);
+ Assert.Contains("offset=20", url);
+ Assert.Contains("q=", url);
+ }
+
+ [Fact]
+ public async Task Summary_short_query_omits_q()
+ {
+ var h = new FakeHttpHandler(HttpStatusCode.OK, "{\"total_drugs\":0,\"summary\":[]}");
+ var c = Client(h);
+ c.SetToken("t");
+ await c.GetPriceSummaryAsync("a", limit: 10, offset: 0); // 1 символ < 2 → без q
+ Assert.DoesNotContain("q=", h.LastRequest!.RequestUri!.ToString());
+ }
+
+ [Fact]
+ public async Task Authorized_call_without_token_throws()
+ {
+ var h = new FakeHttpHandler(HttpStatusCode.OK, "{}");
+ var c = Client(h); // токен не задан
+ await Assert.ThrowsAsync(() => c.GetPriceSummaryAsync("асп"));
+ }
+}
diff --git a/tests/Elfisa.Tests/CartServiceTests.cs b/tests/Elfisa.Tests/CartServiceTests.cs
new file mode 100644
index 0000000..2d9d83d
--- /dev/null
+++ b/tests/Elfisa.Tests/CartServiceTests.cs
@@ -0,0 +1,69 @@
+using Elfisa.Avalonia.ViewModels;
+using Elfisa.Core;
+using Xunit;
+
+namespace Elfisa.Tests;
+
+// Все тесты корзины в одном классе → xUnit гонит их последовательно (синглтон CartService).
+public class CartServiceTests
+{
+ private static PriceItem Item(string id, decimal price, string name = "X")
+ => new() { SupplierPriceId = id, Price = price, DrugName = name, SupplierName = "S" };
+
+ [Fact]
+ public void Add_items_and_total()
+ {
+ var cart = CartService.Instance;
+ cart.Clear();
+ cart.Add(Item("1", 10m), 2);
+ cart.Add(Item("2", 5m), 3);
+ Assert.Equal(2, cart.Count);
+ Assert.Equal(35m, cart.Total); // 20 + 15
+ cart.Clear();
+ }
+
+ [Fact]
+ public void Add_same_id_merges_quantity()
+ {
+ var cart = CartService.Instance;
+ cart.Clear();
+ cart.Add(Item("1", 10m), 2);
+ cart.Add(Item("1", 10m), 3); // тот же supplier_price_id → qty += 3
+ Assert.Equal(1, cart.Count);
+ Assert.Equal(50m, cart.Total); // qty 5 * 10
+ cart.Clear();
+ }
+
+ [Fact]
+ public void Add_ignores_empty_id()
+ {
+ var cart = CartService.Instance;
+ cart.Clear();
+ cart.Add(Item("", 10m), 1);
+ Assert.Equal(0, cart.Count);
+ }
+
+ [Fact]
+ public void Remove_and_clear()
+ {
+ var cart = CartService.Instance;
+ cart.Clear();
+ cart.Add(Item("1", 10m), 1);
+ cart.Remove(cart.Items[0]);
+ Assert.Equal(0, cart.Count);
+ }
+
+ [Fact]
+ public void Changed_event_fires_on_add()
+ {
+ var cart = CartService.Instance;
+ cart.Clear();
+ int fired = 0;
+ Action handler = () => fired++;
+ cart.Changed += handler;
+ cart.Add(Item("1", 10m), 1);
+ cart.Changed -= handler;
+ cart.Clear();
+ Assert.True(fired >= 1);
+ }
+}
diff --git a/tests/Elfisa.Tests/DraftTests.cs b/tests/Elfisa.Tests/DraftTests.cs
new file mode 100644
index 0000000..3c37fbe
--- /dev/null
+++ b/tests/Elfisa.Tests/DraftTests.cs
@@ -0,0 +1,36 @@
+using Elfisa.Core;
+using Xunit;
+
+namespace Elfisa.Tests;
+
+public class DraftTests
+{
+ [Fact]
+ public void DraftItem_line_sum_multiplies()
+ {
+ var it = new DraftItem { Price = 10.5m, Qty = 3 };
+ Assert.Equal(31.5m, it.LineSum);
+ }
+
+ [Fact]
+ public void DraftOrder_total_and_count()
+ {
+ var o = new DraftOrder();
+ o.Items.Add(new DraftItem { Price = 10m, Qty = 2 }); // 20
+ o.Items.Add(new DraftItem { Price = 5.5m, Qty = 4 }); // 22
+ Assert.Equal(42m, o.Total);
+ Assert.Equal(2, o.ItemsCount);
+ }
+
+ [Fact]
+ public void DraftOrder_generates_id()
+ => Assert.False(string.IsNullOrWhiteSpace(new DraftOrder().Id));
+
+ [Fact]
+ public void Empty_order_totals_zero()
+ {
+ var o = new DraftOrder();
+ Assert.Equal(0m, o.Total);
+ Assert.Equal(0, o.ItemsCount);
+ }
+}
diff --git a/tests/Elfisa.Tests/DtoTests.cs b/tests/Elfisa.Tests/DtoTests.cs
new file mode 100644
index 0000000..d9fc920
--- /dev/null
+++ b/tests/Elfisa.Tests/DtoTests.cs
@@ -0,0 +1,60 @@
+using System.Text.Json;
+using Elfisa.Core;
+using Xunit;
+
+namespace Elfisa.Tests;
+
+public class DtoTests
+{
+ [Fact]
+ public void PriceItem_deserializes_snake_case()
+ {
+ var json = "{\"supplier_price_id\":\"sp1\",\"drug_name\":\"Аспирин\",\"barcode\":\"123\"," +
+ "\"price\":12.30,\"quantity\":5,\"es_code\":777,\"trade_name\":\"Асп\"}";
+ var it = JsonSerializer.Deserialize(json)!;
+ Assert.Equal("sp1", it.SupplierPriceId);
+ Assert.Equal("Аспирин", it.DrugName);
+ Assert.Equal("123", it.Barcode);
+ Assert.Equal(12.30m, it.Price);
+ Assert.Equal(5m, it.Quantity);
+ Assert.Equal(777, it.EsCode);
+ Assert.Equal("Асп", it.TradeName);
+ }
+
+ [Fact]
+ public void BuyerOrderCreateRequest_serializes_snake_case()
+ {
+ var req = new BuyerOrderCreateRequest
+ {
+ LocationId = "loc1",
+ GlobalSign = "EX-1",
+ Items = { new BuyerOrderItemReq { SupplierPriceId = "sp1", Qty = 2, ItemName = "Товар", Barcode = "111" } }
+ };
+ var json = JsonSerializer.Serialize(req);
+ Assert.Contains("\"location_id\":\"loc1\"", json);
+ Assert.Contains("\"supplier_price_id\":\"sp1\"", json);
+ Assert.Contains("\"qty\":2", json);
+ }
+
+ [Fact]
+ public void LoginResponse_round_trip()
+ {
+ var r = JsonSerializer.Deserialize(
+ "{\"token\":\"t\",\"role\":\"manager\",\"redirect_url\":\"/x\"}")!;
+ Assert.Equal("t", r.Token);
+ Assert.Equal("manager", r.Role);
+ Assert.Equal("/x", r.RedirectUrl);
+ }
+
+ [Fact]
+ public void AppSettings_round_trip_preserves_fields()
+ {
+ var s = new AppSettings { ApiBaseUrl = "https://x", Theme = "Dark", FontFamily = "Arial", UiScale = 1.3, Username = "u" };
+ var back = JsonSerializer.Deserialize(JsonSerializer.Serialize(s))!;
+ Assert.Equal("https://x", back.ApiBaseUrl);
+ Assert.Equal("Dark", back.Theme);
+ Assert.Equal("Arial", back.FontFamily);
+ Assert.Equal(1.3, back.UiScale);
+ Assert.Equal("u", back.Username);
+ }
+}
diff --git a/tests/Elfisa.Tests/Elfisa.Tests.csproj b/tests/Elfisa.Tests/Elfisa.Tests.csproj
new file mode 100644
index 0000000..a4d256f
--- /dev/null
+++ b/tests/Elfisa.Tests/Elfisa.Tests.csproj
@@ -0,0 +1,23 @@
+
+
+
+ net8.0
+ enable
+ enable
+ false
+ Elfisa.Tests
+ Elfisa.Tests
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Elfisa.Tests/PriceLogicTests.cs b/tests/Elfisa.Tests/PriceLogicTests.cs
new file mode 100644
index 0000000..55a6208
--- /dev/null
+++ b/tests/Elfisa.Tests/PriceLogicTests.cs
@@ -0,0 +1,36 @@
+using Elfisa.Avalonia.ViewModels;
+using Xunit;
+
+namespace Elfisa.Tests;
+
+// Логика дрилдаула прайса: извлечение дозировки (МГ) и базового наименования.
+public class PriceLogicTests
+{
+ [Theory]
+ [InlineData("Нурофен таб. 200мг №10", "200мг")]
+ [InlineData("Парацетамол 500 мг таб", "500 мг")]
+ [InlineData("Витамин D3 2000 МЕ", "2000 МЕ")]
+ [InlineData("Настойка 25мл", "25мл")]
+ [InlineData("Метформин 1000мг", "1000мг")]
+ [InlineData("Бинт стерильный", "")]
+ [InlineData("", "")]
+ [InlineData(null, "")]
+ public void ExtractDosage_finds_unit(string? name, string expected)
+ => Assert.Equal(expected, PriceListViewModel.ExtractDosage(name));
+
+ [Fact]
+ public void BuildBaseName_prefers_trade_name()
+ => Assert.Equal("Нурофен", PriceListViewModel.BuildBaseName("Нурофен таб 200мг", "Нурофен", "200мг"));
+
+ [Fact]
+ public void BuildBaseName_strips_dosage()
+ => Assert.Equal("Аспирин", PriceListViewModel.BuildBaseName("Аспирин 500мг", null, "500мг"));
+
+ [Fact]
+ public void BuildBaseName_keeps_name_without_dosage()
+ => Assert.Equal("Бинт стерильный", PriceListViewModel.BuildBaseName("Бинт стерильный", null, ""));
+
+ [Fact]
+ public void BuildBaseName_empty_returns_empty()
+ => Assert.Equal("", PriceListViewModel.BuildBaseName("", null, ""));
+}
diff --git a/tests/Elfisa.Tests/README.md b/tests/Elfisa.Tests/README.md
new file mode 100644
index 0000000..220f4aa
--- /dev/null
+++ b/tests/Elfisa.Tests/README.md
@@ -0,0 +1,39 @@
+# Тесты десктопа (Elfisa.Tests)
+
+Автотесты для кросс-платформенного клиента ЭльФиСА (`Elfisa.Core` + `Elfisa.Avalonia`).
+
+## Запуск
+
+```bash
+dotnet test tests/Elfisa.Tests/Elfisa.Tests.csproj
+```
+
+или всё решение:
+
+```bash
+dotnet test Elfisa.sln
+```
+
+CI (`.github/workflows/desktop-tests.yml`) гоняет их автоматически при пуше в `src/**` или `tests/**`.
+
+## Что покрыто (56 тестов)
+
+| Файл | Область |
+|---|---|
+| `UpdateServiceTests` | разбор версий тегов (`v2.0.4`→`2.0.4.0`), сравнение «новее», URL установщика |
+| `SessionTests` | срок JWT-сессии (`IsJwtExpired`): будущий/просроченный/битый токен, запас 60с |
+| `ApiClientTests` | сборка URL, маппинг ошибок (401/429), ретраи на транзиентных сбоях, парсинг сводного прайса, требование токена |
+| `DtoTests` | JSON-контракты (snake_case) DTO: `PriceItem`, `BuyerOrderCreateRequest`, `LoginResponse`, `AppSettings` |
+| `DraftTests` | суммы черновиков заказа (`LineSum`, `Total`, `ItemsCount`) |
+| `CartServiceTests` | корзина: добавление, слияние по `supplier_price_id`, удаление, очистка, событие Changed |
+| `PriceLogicTests` | дрилдаун прайса: извлечение дозировки (МГ) и базового наименования |
+
+## Как устроены моки
+
+- `FakeHttpHandler` (в `TestHelpers.cs`) — подменяет HTTP у `ApiClient` через internal-конструктор `ApiClient(HttpMessageHandler, baseUrl)`. Позволяет тестировать ретраи/ошибки/парсинг без сети.
+- `Jwt.Make(exp)` — генерирует JWT с нужным `exp` для тестов сессии.
+- Тестируемые внутренние методы открыты через `InternalsVisibleTo("Elfisa.Tests")` (в csproj `Elfisa.Core` и `Elfisa.Avalonia`).
+
+## Задел (не сделано)
+
+Headless UI-тесты (рендер вьюх без экрана) — `Avalonia.Headless.XUnit` под Avalonia 12 требует **xUnit v3**, что конфликтует с текущим v2. Добавить после миграции тест-проекта на xUnit v3.
diff --git a/tests/Elfisa.Tests/SessionTests.cs b/tests/Elfisa.Tests/SessionTests.cs
new file mode 100644
index 0000000..b8cc2b9
--- /dev/null
+++ b/tests/Elfisa.Tests/SessionTests.cs
@@ -0,0 +1,32 @@
+using Elfisa.Core;
+using Xunit;
+
+namespace Elfisa.Tests;
+
+public class SessionTests
+{
+ [Fact]
+ public void Future_token_is_not_expired()
+ => Assert.False(Session.IsJwtExpired(Jwt.Make(Jwt.Now + 3600)));
+
+ [Fact]
+ public void Past_token_is_expired()
+ => Assert.True(Session.IsJwtExpired(Jwt.Make(Jwt.Now - 10)));
+
+ [Fact]
+ public void Within_60s_skew_is_treated_expired()
+ => Assert.True(Session.IsJwtExpired(Jwt.Make(Jwt.Now + 30)));
+
+ [Theory]
+ [InlineData("")]
+ [InlineData("onlyonepart")]
+ [InlineData("has.two")]
+ [InlineData("too.many.parts.here")]
+ [InlineData("a.!!!notbase64!!!.c")]
+ public void Malformed_token_is_treated_expired(string token)
+ => Assert.True(Session.IsJwtExpired(token));
+
+ [Fact]
+ public void No_exp_claim_is_not_expired()
+ => Assert.False(Session.IsJwtExpired(Jwt.MakeNoExp()));
+}
diff --git a/tests/Elfisa.Tests/TestHelpers.cs b/tests/Elfisa.Tests/TestHelpers.cs
new file mode 100644
index 0000000..2a53035
--- /dev/null
+++ b/tests/Elfisa.Tests/TestHelpers.cs
@@ -0,0 +1,41 @@
+using System.Net;
+using System.Net.Http;
+using System.Text;
+
+namespace Elfisa.Tests;
+
+/// Мок HttpMessageHandler: отдаёт заданные ответы или бросает ошибки.
+internal sealed class FakeHttpHandler : HttpMessageHandler
+{
+ private readonly Func _responder;
+ public int Calls { get; private set; }
+ public HttpRequestMessage? LastRequest { get; private set; }
+
+ public FakeHttpHandler(Func responder) => _responder = responder;
+
+ public FakeHttpHandler(HttpStatusCode code, string body)
+ : this((_, __) => Json(code, body)) { }
+
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ LastRequest = request;
+ var i = Calls++;
+ return Task.FromResult(_responder(request, i));
+ }
+
+ public static HttpResponseMessage Json(HttpStatusCode code, string body) =>
+ new(code) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
+}
+
+/// Генератор JWT для тестов срока действия сессии.
+internal static class Jwt
+{
+ public static long Now => DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+
+ private static string B64(string s) =>
+ Convert.ToBase64String(Encoding.UTF8.GetBytes(s)).TrimEnd('=').Replace('+', '-').Replace('/', '_');
+
+ public static string Make(long exp) => $"{B64("{\"alg\":\"HS256\"}")}.{B64($"{{\"exp\":{exp}}}")}.sig";
+
+ public static string MakeNoExp() => $"{B64("{\"alg\":\"HS256\"}")}.{B64("{\"sub\":\"x\"}")}.sig";
+}
diff --git a/tests/Elfisa.Tests/UpdateServiceTests.cs b/tests/Elfisa.Tests/UpdateServiceTests.cs
new file mode 100644
index 0000000..b6fa550
--- /dev/null
+++ b/tests/Elfisa.Tests/UpdateServiceTests.cs
@@ -0,0 +1,39 @@
+using Elfisa.Core;
+using Xunit;
+
+namespace Elfisa.Tests;
+
+public class UpdateServiceTests
+{
+ [Theory]
+ [InlineData("v2.0.4", "2.0.4.0")]
+ [InlineData("2.0.4", "2.0.4.0")]
+ [InlineData("2.0", "2.0.0.0")]
+ [InlineData("2", "2.0.0.0")]
+ [InlineData("2.0.4-beta", "2.0.4.0")]
+ [InlineData("V10.2.1", "10.2.1.0")]
+ [InlineData(" 2.0.9 ", "2.0.9.0")]
+ public void ParseVersion_valid(string tag, string expected)
+ => Assert.Equal(Version.Parse(expected), UpdateService.ParseVersion(tag));
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(null)]
+ [InlineData("garbage")]
+ [InlineData("v.x.y")]
+ public void ParseVersion_invalid_returns_null(string? tag)
+ => Assert.Null(UpdateService.ParseVersion(tag));
+
+ [Fact]
+ public void Newer_version_compares_greater()
+ {
+ Assert.True(UpdateService.ParseVersion("v2.0.5") > UpdateService.ParseVersion("v2.0.4"));
+ Assert.True(UpdateService.ParseVersion("2.1.0") > UpdateService.ParseVersion("2.0.9"));
+ Assert.False(UpdateService.ParseVersion("2.0.4") > UpdateService.ParseVersion("2.0.4"));
+ Assert.True(UpdateService.ParseVersion("v3") > UpdateService.ParseVersion("2.9.9"));
+ }
+
+ [Fact]
+ public void SetupUrl_points_to_cdn()
+ => Assert.Equal("https://cdn.24pharmdata.ru/ElfisaPharmacy-Setup.exe", UpdateService.SetupUrl);
+}