Číselníky ve dvou vrstvách: dodávaná a uživatelská

Dosud byl ciselniky.json jediný soubor vedle .exe, který si aplikace zároveň
dodávala i přepisovala. Přeinstalace novou verzí by uživateli přepsala
záznamy, které si sám nastřádal – ručně v záložce Číselníky i „samoučením“
jmen z projektů. Nově:

- dodávaná vrstva  ciselniky.vychozi.json vedle .exe, jen ke čtení; každá
  instalace ji obnoví, takže s novou verzí smí růst a měnit se,
- uživatelská vrstva  ciselniky.user.json v %APPDATA%\Rozpisky, kde je
  uložený jen rozdíl proti dodávané: Vlastni (přidané a upravené položky)
  a Skryte (klíče dodávaných položek, které si uživatel odklidil).

Slučování zachovává pořadí dodávaných položek, upravenou položku přepíše
na jejím místě a vlastní řadí za ně. Klíč se bere podle typu (Nazev /
Hodnota / Oznaceni), porovnává se bez ohledu na velikost písmen – stejně
jako to už dělá samoučení jmen. Obsah se porovnává serializací, takže
nová pole položek jsou pokrytá automaticky.

Migrace: chybí-li uživatelská vrstva a vedle .exe leží starý ciselniky.json,
vezme se jeho obsah jako aktuální stav, dopočítá se z něj rozdíl a originál
se přejmenuje na .migrovano. Nic se nemaže.

Backfill krajů v MainViewModelu tím ztratil smysl (kraje přicházejí
z dodávané vrstvy) a je odstraněný.

Slučování a dopočet rozdílu jsou zpřístupněné testům (InternalsVisibleTo) –
chyba v nich se projeví tichou ztrátou dat, takže je pokrývá 14 nových testů
včetně kolečka uložit→načíst a scénáře aktualizace programu.

Ověřeno i ručně: migrace 7,6 kB souboru na 708 B rozdílu, přežití smazání
celého bin/ a přebuildu, běh s nezapisovatelnou instalační složkou.

Build 0 chyb / 0 varování, 50 testů zelených.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-07 10:48:43 +02:00
co-authored by Claude Opus 5
parent 3c0e30bf35
commit 6cd41ee5a3
5 changed files with 432 additions and 60 deletions
+191
View File
@@ -0,0 +1,191 @@
using System.Collections.ObjectModel;
using Rozpisky.Data;
using Rozpisky.Models;
using Xunit;
namespace Rozpisky.Tests;
/// <summary>
/// Slučování dodávané a uživatelské vrstvy číselníků a dopočet rozdílu
/// (<see cref="CiselnikyStore.Sluc{T}"/> / <see cref="CiselnikyStore.Rozdil{T}"/>).
/// Na téhle logice stojí, jestli uživatel po aktualizaci programu najde svoje záznamy
/// chyba tady se projeví tichou ztrátou dat, kterou nikdo nenahlásí.
/// </summary>
public class CiselnikyVrstvyTesty
{
private static List<Jmeno> Vychozi(params string[] jmena)
=> jmena.Select(j => new Jmeno { Hodnota = j }).ToList();
private static ObservableCollection<Jmeno> Sluc(
List<Jmeno> vychozi, CiselnikyStore.Vrstva<Jmeno>? uziv)
{
var cil = new ObservableCollection<Jmeno>();
CiselnikyStore.Sluc(cil, vychozi, uziv);
return cil;
}
[Fact]
public void BezUzivatelskeVrstvyZustaneDodavanaBezeZmeny()
{
var vysledek = Sluc(Vychozi("Novák", "Svoboda"), null);
Assert.Equal(new[] { "Novák", "Svoboda" }, vysledek.Select(j => j.Hodnota));
}
[Fact]
public void VlastniPolozkySeRadiZaDodavane()
{
var uziv = new CiselnikyStore.Vrstva<Jmeno> { Vlastni = Vychozi("Dvořák") };
var vysledek = Sluc(Vychozi("Novák", "Svoboda"), uziv);
Assert.Equal(new[] { "Novák", "Svoboda", "Dvořák" }, vysledek.Select(j => j.Hodnota));
}
[Fact]
public void SkrytaDodavanaPolozkaSeNeobjevi()
{
var uziv = new CiselnikyStore.Vrstva<Jmeno> { Skryte = { "Svoboda" } };
var vysledek = Sluc(Vychozi("Novák", "Svoboda"), uziv);
Assert.Equal(new[] { "Novák" }, vysledek.Select(j => j.Hodnota));
}
[Fact]
public void UpravenaDodavanaPolozkaSePrepiseNaSvemMiste()
{
// Uživatel opravil adresu dodávané organizace musí zůstat na původní pozici,
// jinak by se mu po každé aktualizaci přeházelo pořadí v našeptávači.
var vychozi = new List<Organizace>
{
new() { Nazev = "SŽ Praha", Adresa = "stará" },
new() { Nazev = "SŽ Brno", Adresa = "Kounicova 26" },
};
var uziv = new CiselnikyStore.Vrstva<Organizace>
{
Vlastni = { new Organizace { Nazev = "SŽ Praha", Adresa = "Dlážděná 1003/7" } },
};
var cil = new ObservableCollection<Organizace>();
CiselnikyStore.Sluc(cil, vychozi, uziv);
Assert.Equal(new[] { "SŽ Praha", "SŽ Brno" }, cil.Select(o => o.Nazev));
Assert.Equal("Dlážděná 1003/7", cil[0].Adresa);
}
[Fact]
public void ParovaniIgnorujeVelikostPismen()
{
var uziv = new CiselnikyStore.Vrstva<Jmeno> { Vlastni = Vychozi("NOVÁK") };
var vysledek = Sluc(Vychozi("Novák"), uziv);
Assert.Single(vysledek);
Assert.Equal("NOVÁK", vysledek[0].Hodnota);
}
[Fact]
public void RozepsaneRadkySeNeslijouDoJednoho()
{
// Dva prázdné řádky přidané tlačítkem „Přidat záznam“ mají stejný (prázdný) klíč
// nesmí se spárovat, jinak by uživateli jeden zmizel dřív, než ho stihne vyplnit.
var uziv = new CiselnikyStore.Vrstva<Jmeno> { Vlastni = Vychozi("", "") };
var vysledek = Sluc(Vychozi("Novák"), uziv);
Assert.Equal(3, vysledek.Count);
}
[Fact]
public void RozdilZachytiPridaniUpravuISkryti()
{
var vychozi = Vychozi("Novák", "Svoboda", "Černý");
// Uživatel: Svobodu smazal, přidal Dvořáka, Novák zůstal beze změny.
var stav = Vychozi("Novák", "Černý", "Dvořák");
var rozdil = CiselnikyStore.Rozdil(stav, vychozi);
Assert.Equal(new[] { "Dvořák" }, rozdil.Vlastni.Select(j => j.Hodnota));
Assert.Equal(new[] { "Svoboda" }, rozdil.Skryte);
}
[Fact]
public void RozdilNepovazujeNezmenenouPolozkuZaVlastni()
{
var vychozi = Vychozi("Novák", "Svoboda");
var rozdil = CiselnikyStore.Rozdil(Vychozi("Novák", "Svoboda"), vychozi);
Assert.Empty(rozdil.Vlastni);
Assert.Empty(rozdil.Skryte);
}
[Fact]
public void RozdilZachytiZmenuJinehoNezKlicovehoPole()
{
var vychozi = new List<Firma>
{
new() { Nazev = "EXprojekt s.r.o.", Telefon = "+420 533 312 000" },
};
var stav = new List<Firma>
{
new() { Nazev = "EXprojekt s.r.o.", Telefon = "+420 111 222 333" },
};
var rozdil = CiselnikyStore.Rozdil(stav, vychozi);
Assert.Single(rozdil.Vlastni);
Assert.Equal("+420 111 222 333", rozdil.Vlastni[0].Telefon);
Assert.Empty(rozdil.Skryte); // klíč zůstal, nejde o skrytí
}
[Fact]
public void PrejmenovaniDodavanePolozkySeUloziJakoSkrytiAPridani()
{
var vychozi = Vychozi("Nowak");
var rozdil = CiselnikyStore.Rozdil(Vychozi("Novák"), vychozi);
Assert.Equal(new[] { "Novák" }, rozdil.Vlastni.Select(j => j.Hodnota));
Assert.Equal(new[] { "Nowak" }, rozdil.Skryte);
}
[Fact]
public void RozdilASlouceniDavajiZpetPuvodniStav()
{
// Kolečko uložit → načíst nesmí nic ztratit ani přidat, ať se s číselníkem stalo cokoli.
var vychozi = Vychozi("Novák", "Svoboda", "Černý");
var stav = Vychozi("Novák", "Černý", "Dvořák", "Horák");
var vysledek = Sluc(vychozi, CiselnikyStore.Rozdil(stav, vychozi));
Assert.Equal(stav.Select(j => j.Hodnota), vysledek.Select(j => j.Hodnota));
}
[Fact]
public void NovaDodavanaPolozkaSePoAktualizaciObjeviAVlastniZustanou()
{
// Jádro celé změny: program vydá novou verzi s doplněným číselníkem, uživatelův
// rozdíl je pořád ten starý nová položka musí přibýt, vlastní nesmí zmizet.
var stareVychozi = Vychozi("Novák");
var rozdilUzivatele = CiselnikyStore.Rozdil(Vychozi("Novák", "Dvořák"), stareVychozi);
var noveVychozi = Vychozi("Novák", "Svoboda");
var vysledek = Sluc(noveVychozi, rozdilUzivatele);
Assert.Equal(new[] { "Novák", "Svoboda", "Dvořák" }, vysledek.Select(j => j.Hodnota));
}
[Fact]
public void SkrytiPrezijeAktualizaciDodavaneVrstvy()
{
var stareVychozi = Vychozi("Novák", "Svoboda");
var rozdilUzivatele = CiselnikyStore.Rozdil(Vychozi("Novák"), stareVychozi);
// Nová verze programu Svobodu pořád dodává uživatel ho ale jednou odklidil.
var vysledek = Sluc(Vychozi("Novák", "Svoboda", "Černý"), rozdilUzivatele);
Assert.Equal(new[] { "Novák", "Černý" }, vysledek.Select(j => j.Hodnota));
}
[Fact]
public void KlicSeBerePodleTypuPolozky()
{
Assert.Equal("SŽ", CiselnikyStore.Klic(new Organizace { Nazev = "SŽ" }));
Assert.Equal("EXprojekt", CiselnikyStore.Klic(new Firma { Nazev = "EXprojekt" }));
Assert.Equal("Novák", CiselnikyStore.Klic(new Jmeno { Hodnota = "Novák" }));
Assert.Equal("DPS", CiselnikyStore.Klic(new VerzeDokumentace { Hodnota = "DPS" }));
Assert.Equal("D", CiselnikyStore.Klic(new Cast { Oznaceni = "D", Nazev = "Mosty" }));
Assert.Equal("Jihomoravský", CiselnikyStore.Klic(new Kraj { Nazev = "Jihomoravský" }));
}
}