From 29eddd71ea55a364b4e95bbf84082a73287e3a15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= <1005065+DeepDiver1975@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:23:55 +0200 Subject: [PATCH 01/18] feat(domain): add Building, Dwelling, DwellingStatus, CoMeasurementLabels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com> --- src/LageBuch.Domain/CoMeasurement/Building.cs | 65 +++++++++++++++++++ .../CoMeasurement/CoMeasurementLabels.cs | 29 +++++++++ src/LageBuch.Domain/CoMeasurement/Dwelling.cs | 38 +++++++++++ .../CoMeasurement/DwellingStatus.cs | 8 +++ 4 files changed, 140 insertions(+) create mode 100644 src/LageBuch.Domain/CoMeasurement/Building.cs create mode 100644 src/LageBuch.Domain/CoMeasurement/CoMeasurementLabels.cs create mode 100644 src/LageBuch.Domain/CoMeasurement/Dwelling.cs create mode 100644 src/LageBuch.Domain/CoMeasurement/DwellingStatus.cs diff --git a/src/LageBuch.Domain/CoMeasurement/Building.cs b/src/LageBuch.Domain/CoMeasurement/Building.cs new file mode 100644 index 0000000..f0db1c1 --- /dev/null +++ b/src/LageBuch.Domain/CoMeasurement/Building.cs @@ -0,0 +1,65 @@ +namespace LageBuch.Domain.CoMeasurement; + +public sealed record Building +{ + public Guid Id { get; private init; } + public string Name { get; private init; } = string.Empty; + public int FloorCount { get; private init; } + public int ApartmentsPerFloor { get; private init; } + public IReadOnlyDictionary FloorDescriptions { get; private init; } = + new Dictionary(); + public int Ordinal { get; private init; } + + private Building() { } + + public static Building Create(string name, int floorCount, int apartmentsPerFloor, int ordinal) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Hausname darf nicht leer sein.", nameof(name)); + if (floorCount < 1 || floorCount > 50) + throw new ArgumentOutOfRangeException(nameof(floorCount), "Obergeschosse müssen zwischen 1 und 50 liegen."); + if (apartmentsPerFloor < 1 || apartmentsPerFloor > 30) + throw new ArgumentOutOfRangeException(nameof(apartmentsPerFloor), "Wohnungen je Geschoss müssen zwischen 1 und 30 liegen."); + + return new Building + { + Id = Guid.NewGuid(), + Name = name.Trim(), + FloorCount = floorCount, + ApartmentsPerFloor = apartmentsPerFloor, + Ordinal = ordinal + }; + } + + public static Building Rehydrate( + Guid id, string name, int floorCount, int apartmentsPerFloor, + IReadOnlyDictionary floorDescriptions, int ordinal) + => new() + { + Id = id, + Name = name, + FloorCount = floorCount, + ApartmentsPerFloor = apartmentsPerFloor, + FloorDescriptions = floorDescriptions, + Ordinal = ordinal + }; + + public Building WithStructure(int floorCount, int apartmentsPerFloor) + { + if (floorCount < 1 || floorCount > 50) + throw new ArgumentOutOfRangeException(nameof(floorCount)); + if (apartmentsPerFloor < 1 || apartmentsPerFloor > 30) + throw new ArgumentOutOfRangeException(nameof(apartmentsPerFloor)); + return this with { FloorCount = floorCount, ApartmentsPerFloor = apartmentsPerFloor }; + } + + public Building WithFloorDescription(int ordinal, string? description) + { + var dict = new Dictionary(FloorDescriptions.ToDictionary(kv => kv.Key, kv => kv.Value)); + if (string.IsNullOrWhiteSpace(description)) + dict.Remove(ordinal); + else + dict[ordinal] = description.Trim(); + return this with { FloorDescriptions = dict }; + } +} \ No newline at end of file diff --git a/src/LageBuch.Domain/CoMeasurement/CoMeasurementLabels.cs b/src/LageBuch.Domain/CoMeasurement/CoMeasurementLabels.cs new file mode 100644 index 0000000..619e792 --- /dev/null +++ b/src/LageBuch.Domain/CoMeasurement/CoMeasurementLabels.cs @@ -0,0 +1,29 @@ +namespace LageBuch.Domain.CoMeasurement; + +public static class CoMeasurementLabels +{ + public static string FloorLabel(int ordinal) => + ordinal == 0 ? "EG" : $"{ordinal}. OG"; + + public static string ApartmentLabel(int apartmentNumber) => + $"Whg. {apartmentNumber}"; + + public static string DwellingLocation(Building building, int floorOrdinal, int apartmentNumber) => + $"{building.Name}, {FloorLabel(floorOrdinal)}, {ApartmentLabel(apartmentNumber)}"; + + public static string StatusText(DwellingStatus status) => status switch + { + DwellingStatus.NotSearched => "noch nicht abgesucht", + DwellingStatus.Searched => "abgesucht – keine Personen betroffen", + DwellingStatus.Affected => "Person(en) betroffen", + _ => throw new ArgumentOutOfRangeException(nameof(status)) + }; + + public static string StatusChip(DwellingStatus status) => status switch + { + DwellingStatus.NotSearched => "GELB", + DwellingStatus.Searched => "GRÜN", + DwellingStatus.Affected => "ROT", + _ => throw new ArgumentOutOfRangeException(nameof(status)) + }; +} \ No newline at end of file diff --git a/src/LageBuch.Domain/CoMeasurement/Dwelling.cs b/src/LageBuch.Domain/CoMeasurement/Dwelling.cs new file mode 100644 index 0000000..ca2606f --- /dev/null +++ b/src/LageBuch.Domain/CoMeasurement/Dwelling.cs @@ -0,0 +1,38 @@ +namespace LageBuch.Domain.CoMeasurement; + +public sealed record Dwelling +{ + public Guid Id { get; private init; } + public Guid BuildingId { get; private init; } + public int FloorOrdinal { get; private init; } + public int ApartmentNumber { get; private init; } + public string? ResidentName { get; private init; } + public DwellingStatus Status { get; private init; } + public bool? KeyAvailable { get; private init; } + public int? CoValue { get; private init; } + + public static Dwelling Create(Guid buildingId, int floorOrdinal, int apartmentNumber) + => new() + { + Id = Guid.NewGuid(), + BuildingId = buildingId, + FloorOrdinal = floorOrdinal, + ApartmentNumber = apartmentNumber, + Status = DwellingStatus.NotSearched + }; + + public static Dwelling Rehydrate( + Guid id, Guid buildingId, int floorOrdinal, int apartmentNumber, + string? residentName, DwellingStatus status, bool? keyAvailable, int? coValue) + => new() + { + Id = id, + BuildingId = buildingId, + FloorOrdinal = floorOrdinal, + ApartmentNumber = apartmentNumber, + ResidentName = residentName, + Status = status, + KeyAvailable = keyAvailable, + CoValue = coValue + }; +} \ No newline at end of file diff --git a/src/LageBuch.Domain/CoMeasurement/DwellingStatus.cs b/src/LageBuch.Domain/CoMeasurement/DwellingStatus.cs new file mode 100644 index 0000000..ec5d090 --- /dev/null +++ b/src/LageBuch.Domain/CoMeasurement/DwellingStatus.cs @@ -0,0 +1,8 @@ +namespace LageBuch.Domain.CoMeasurement; + +public enum DwellingStatus +{ + NotSearched = 0, // Gelb + Searched = 1, // Grün + Affected = 2 // Rot +} \ No newline at end of file From 24760cc217be4eb888ad8d269ab36ad972be7445 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= <1005065+DeepDiver1975@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:38:23 +0200 Subject: [PATCH 02/18] test(domain): add CoMeasurement tests for Building, Dwelling, labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com> --- .../CoMeasurementTests.cs | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 tests/LageBuch.Domain.Tests/CoMeasurementTests.cs diff --git a/tests/LageBuch.Domain.Tests/CoMeasurementTests.cs b/tests/LageBuch.Domain.Tests/CoMeasurementTests.cs new file mode 100644 index 0000000..95c94ec --- /dev/null +++ b/tests/LageBuch.Domain.Tests/CoMeasurementTests.cs @@ -0,0 +1,123 @@ +using LageBuch.Domain.CoMeasurement; + +namespace LageBuch.Domain.Tests; + +public class CoMeasurementTests +{ + [Fact] + public void Building_Create_SetsProperties() + { + var building = Building.Create("Haus A", 8, 10, 0); + Assert.Equal("Haus A", building.Name); + Assert.Equal(8, building.FloorCount); + Assert.Equal(10, building.ApartmentsPerFloor); + Assert.Equal(0, building.Ordinal); + Assert.NotEqual(Guid.Empty, building.Id); + } + + [Fact] + public void Building_Create_TrimsName() + { + var building = Building.Create(" Haus A ", 8, 10, 0); + Assert.Equal("Haus A", building.Name); + } + + [Theory] + [InlineData(0)] + [InlineData(51)] + public void Building_Create_InvalidFloorCount_Throws(int floorCount) + { + Assert.Throws(() => + Building.Create("Haus A", floorCount, 10, 0)); + } + + [Theory] + [InlineData(0)] + [InlineData(31)] + public void Building_Create_InvalidApartments_Throws(int apartments) + { + Assert.Throws(() => + Building.Create("Haus A", 8, apartments, 0)); + } + + [Fact] + public void Building_Create_EmptyName_Throws() + { + Assert.Throws(() => + Building.Create("", 8, 10, 0)); + } + + [Fact] + public void Dwelling_Create_SetsProperties() + { + var buildingId = Guid.NewGuid(); + var dwelling = Dwelling.Create(buildingId, 2, 3); + Assert.Equal(buildingId, dwelling.BuildingId); + Assert.Equal(2, dwelling.FloorOrdinal); + Assert.Equal(3, dwelling.ApartmentNumber); + Assert.Equal(DwellingStatus.NotSearched, dwelling.Status); + Assert.Null(dwelling.CoValue); + Assert.Null(dwelling.ResidentName); + Assert.Null(dwelling.KeyAvailable); + } + + [Fact] + public void CoMeasurementLabels_FloorLabel_EG() + { + Assert.Equal("EG", CoMeasurementLabels.FloorLabel(0)); + } + + [Fact] + public void CoMeasurementLabels_FloorLabel_OG() + { + Assert.Equal("3. OG", CoMeasurementLabels.FloorLabel(3)); + } + + [Fact] + public void CoMeasurementLabels_ApartmentLabel() + { + Assert.Equal("Whg. 5", CoMeasurementLabels.ApartmentLabel(5)); + } + + [Fact] + public void CoMeasurementLabels_StatusText() + { + Assert.Equal("noch nicht abgesucht", CoMeasurementLabels.StatusText(DwellingStatus.NotSearched)); + Assert.Equal("abgesucht – keine Personen betroffen", CoMeasurementLabels.StatusText(DwellingStatus.Searched)); + Assert.Equal("Person(en) betroffen", CoMeasurementLabels.StatusText(DwellingStatus.Affected)); + } + + [Fact] + public void CoMeasurementLabels_DwellingLocation() + { + var building = Building.Create("Haus A", 8, 10, 0); + Assert.Equal("Haus A, 3. OG, Whg. 2", + CoMeasurementLabels.DwellingLocation(building, 3, 2)); + } + + [Fact] + public void Building_WithStructure_UpdatesCounts() + { + var building = Building.Create("Haus A", 8, 10, 0); + var updated = building.WithStructure(6, 8); + Assert.Equal(6, updated.FloorCount); + Assert.Equal(8, updated.ApartmentsPerFloor); + } + + [Fact] + public void Building_WithFloorDescription_SetsDescription() + { + var building = Building.Create("Haus A", 8, 10, 0); + var updated = building.WithFloorDescription(3, "links"); + Assert.Equal("links", updated.FloorDescriptions[3]); + } + + [Fact] + public void Building_WithFloorDescription_EmptyRemoves() + { + var building = Building.Create("Haus A", 8, 10, 0) + .WithFloorDescription(3, "links"); + var updated = building.WithFloorDescription(3, ""); + Assert.False(updated.FloorDescriptions.ContainsKey(3)); + } +} From 80c79f984ccae8daa6dced420d720f8fefd60153 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= <1005065+DeepDiver1975@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:58:06 +0200 Subject: [PATCH 03/18] feat(domain): add Building/Dwelling mutation methods to Incident MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com> --- src/LageBuch.Domain/CoMeasurement/Dwelling.cs | 10 ++ src/LageBuch.Domain/Incident.cs | 145 +++++++++++++++++- .../IncidentRepository.cs | 3 +- src/LageBuch.Sync/SnapshotMapper.cs | 4 +- .../CoMeasurementTests.cs | 130 ++++++++++++++++ .../IncidentTaskAggregateTests.cs | 3 +- .../IncidentTimerTests.cs | 4 +- .../LageBuch.Domain.Tests/RehydrationTests.cs | 12 +- 8 files changed, 303 insertions(+), 8 deletions(-) diff --git a/src/LageBuch.Domain/CoMeasurement/Dwelling.cs b/src/LageBuch.Domain/CoMeasurement/Dwelling.cs index ca2606f..5484054 100644 --- a/src/LageBuch.Domain/CoMeasurement/Dwelling.cs +++ b/src/LageBuch.Domain/CoMeasurement/Dwelling.cs @@ -35,4 +35,14 @@ public static Dwelling Rehydrate( KeyAvailable = keyAvailable, CoValue = coValue }; + + public Dwelling WithCoValue(int? coValue) => this with { CoValue = coValue }; + + public Dwelling WithStatus(DwellingStatus status) => this with { Status = status }; + + public Dwelling WithDetails(string? residentName, bool? keyAvailable) => this with + { + ResidentName = string.IsNullOrWhiteSpace(residentName) ? null : residentName.Trim(), + KeyAvailable = keyAvailable + }; } \ No newline at end of file diff --git a/src/LageBuch.Domain/Incident.cs b/src/LageBuch.Domain/Incident.cs index 083a8a7..3a5d200 100644 --- a/src/LageBuch.Domain/Incident.cs +++ b/src/LageBuch.Domain/Incident.cs @@ -1,4 +1,5 @@ using LageBuch.Domain.Atemschutz; +using LageBuch.Domain.CoMeasurement; using LageBuch.Domain.Etb; using LageBuch.Domain.Files; using LageBuch.Domain.Tasks; @@ -21,6 +22,8 @@ public sealed class Incident private readonly List _timers = new(); private readonly List _files = new(); private readonly List _tasks = new(); + private readonly List _buildings = new(); + private readonly List _dwellings = new(); private Incident() { } @@ -54,9 +57,27 @@ private Incident() { } /// view layer — the aggregate keeps insertion order, like every other list here. public IReadOnlyList Tasks => _tasks; + public IReadOnlyList Buildings => _buildings; + public IReadOnlyList Dwellings => _dwellings; + /// The persisted state of the timer with this key, or null if none has been recorded. public IncidentTimerState? FindTimer(string key) => _timers.Find(t => t.Key == key); + private static string FloorLabel(int ordinal) => + ordinal == 0 ? "EG" : $"{ordinal}. OG"; + + private Building FindBuilding(Guid buildingId) => + _buildings.FirstOrDefault(b => b.Id == buildingId) + ?? throw new KeyNotFoundException($"Haus {buildingId} nicht gefunden."); + + private Dwelling FindDwelling(Guid buildingId, int floorOrdinal, int apartmentNumber) => + _dwellings.FirstOrDefault(d => + d.BuildingId == buildingId && + d.FloorOrdinal == floorOrdinal && + d.ApartmentNumber == apartmentNumber) + ?? throw new KeyNotFoundException( + $"Wohnung nicht gefunden: {buildingId}, {FloorLabel(floorOrdinal)}, Whg. {apartmentNumber}"); + public static Incident Start( IClock clock, SessionOperator openedBy, @@ -101,7 +122,9 @@ public static Incident Rehydrate( IEnumerable audit, IEnumerable timers, IEnumerable files, - IEnumerable tasks) + IEnumerable tasks, + IEnumerable buildings, + IEnumerable dwellings) { var incident = new Incident { @@ -126,6 +149,8 @@ public static Incident Rehydrate( incident._timers.AddRange(timers); incident._files.AddRange(files); incident._tasks.AddRange(tasks); + incident._buildings.AddRange(buildings); + incident._dwellings.AddRange(dwellings); return incident; } @@ -698,4 +723,122 @@ public IncidentTask SetTaskCompleted(Guid taskId, bool isDone, IClock clock, Ses private AtemschutzTrupp FindScbaTrupp(Guid truppId) => _scbaTrupps.FirstOrDefault(t => t.Id == truppId) ?? throw new KeyNotFoundException($"Atemschutz-Trupp {truppId} not found."); + + public void AddCoBuilding(IClock clock, SessionOperator op, string name, int floorCount, int apartmentsPerFloor) + { + EnsureOpen(); + ArgumentNullException.ThrowIfNull(clock); + ArgumentNullException.ThrowIfNull(op); + + var ordinal = _buildings.Count; + var building = Building.Create(name, floorCount, apartmentsPerFloor, ordinal); + _buildings.Add(building); + + for (var floor = 0; floor <= floorCount; floor++) + for (var apt = 1; apt <= apartmentsPerFloor; apt++) + _dwellings.Add(Dwelling.Create(building.Id, floor, apt)); + + AppendSystemEntry(clock, op, + $"CO-Messprotokoll eröffnet: {building.Name} (EG–{FloorLabel(floorCount)}, {apartmentsPerFloor} Wohnungen je Geschoss)"); + } + + public void UpdateCoBuildingStructure(IClock clock, SessionOperator op, Guid buildingId, int floorCount, int apartmentsPerFloor) + { + EnsureOpen(); + ArgumentNullException.ThrowIfNull(clock); + ArgumentNullException.ThrowIfNull(op); + + var building = FindBuilding(buildingId); + var oldFloorCount = building.FloorCount; + var oldApts = building.ApartmentsPerFloor; + + var updated = building.WithStructure(floorCount, apartmentsPerFloor); + var index = _buildings.IndexOf(building); + _buildings[index] = updated; + + // Remove dwellings outside the new structure + var removed = _dwellings.RemoveAll(d => + d.BuildingId == buildingId && + (d.FloorOrdinal > floorCount || d.ApartmentNumber > apartmentsPerFloor)); + + var text = $"CO-Struktur geändert: {building.Name} jetzt EG–{FloorLabel(floorCount)}, {apartmentsPerFloor} Wohnungen je Geschoss"; + if (removed > 0) + text += $", {removed} Wohnungen entfernt"; + + AppendSystemEntry(clock, op, text); + } + + public void RemoveCoBuilding(IClock clock, SessionOperator op, Guid buildingId) + { + EnsureOpen(); + ArgumentNullException.ThrowIfNull(clock); + ArgumentNullException.ThrowIfNull(op); + + var building = FindBuilding(buildingId); + _buildings.Remove(building); + _dwellings.RemoveAll(d => d.BuildingId == buildingId); + + AppendSystemEntry(clock, op, $"CO-Messprotokoll entfernt: {building.Name}"); + } + + public void RecordCoValue(IClock clock, SessionOperator op, Guid buildingId, int floorOrdinal, int apartmentNumber, int? coValue) + { + EnsureOpen(); + ArgumentNullException.ThrowIfNull(clock); + ArgumentNullException.ThrowIfNull(op); + + if (coValue is < 0) + throw new ArgumentOutOfRangeException(nameof(coValue), "CO-Messwert darf nicht negativ sein."); + + var building = FindBuilding(buildingId); + var dwelling = FindDwelling(buildingId, floorOrdinal, apartmentNumber); + + if (dwelling.CoValue == coValue) + return; + + var index = _dwellings.IndexOf(dwelling); + _dwellings[index] = dwelling.WithCoValue(coValue); + + var location = CoMeasurementLabels.DwellingLocation(building, floorOrdinal, apartmentNumber); + var text = coValue is { } v + ? $"CO-Messung {location}: {v} ppm" + : $"CO-Messung {location}: Messwert gelöscht"; + AppendSystemEntry(clock, op, text); + } + + public void SetDwellingStatus(IClock clock, SessionOperator op, Guid buildingId, int floorOrdinal, int apartmentNumber, DwellingStatus status) + { + EnsureOpen(); + ArgumentNullException.ThrowIfNull(clock); + ArgumentNullException.ThrowIfNull(op); + + var building = FindBuilding(buildingId); + var dwelling = FindDwelling(buildingId, floorOrdinal, apartmentNumber); + + if (dwelling.Status == status) + return; + + var index = _dwellings.IndexOf(dwelling); + _dwellings[index] = dwelling.WithStatus(status); + + var location = CoMeasurementLabels.DwellingLocation(building, floorOrdinal, apartmentNumber); + AppendSystemEntry(clock, op, $"Whg.-Status {location}: {CoMeasurementLabels.StatusText(status)}"); + } + + public void SetDwellingDetails(Guid buildingId, int floorOrdinal, int apartmentNumber, string? residentName, bool? keyAvailable) + { + EnsureOpen(); + var dwelling = FindDwelling(buildingId, floorOrdinal, apartmentNumber); + var index = _dwellings.IndexOf(dwelling); + _dwellings[index] = dwelling.WithDetails(residentName, keyAvailable); + } + + public void SetFloorDescription(Guid buildingId, int ordinal, string? description) + { + EnsureOpen(); + var building = FindBuilding(buildingId); + var updated = building.WithFloorDescription(ordinal, description); + var index = _buildings.IndexOf(building); + _buildings[index] = updated; + } } diff --git a/src/LageBuch.Persistence/IncidentRepository.cs b/src/LageBuch.Persistence/IncidentRepository.cs index 624cb7c..e62d41e 100644 --- a/src/LageBuch.Persistence/IncidentRepository.cs +++ b/src/LageBuch.Persistence/IncidentRepository.cs @@ -393,7 +393,8 @@ public Incident Load(string path) meta[8] as string, meta[9] is string ca ? ParseDate(ca) : null, meta[10] as string, - checklistAufbau, checklistAbbau, journal, roles, forces, scbaTrupps, audit, timers, files, tasks); + checklistAufbau, checklistAbbau, journal, roles, forces, scbaTrupps, audit, timers, files, tasks, + Array.Empty(), Array.Empty()); } private static DateTimeOffset ParseDate(string s) => diff --git a/src/LageBuch.Sync/SnapshotMapper.cs b/src/LageBuch.Sync/SnapshotMapper.cs index 3585151..9d6ced5 100644 --- a/src/LageBuch.Sync/SnapshotMapper.cs +++ b/src/LageBuch.Sync/SnapshotMapper.cs @@ -73,7 +73,9 @@ public static Incident FromSnapshot(IncidentSnapshot snapshot) snapshot.Timers.Select(t => new IncidentTimerState(t.Key, t.CycleAnchor, t.IntervalMinutes, t.RecurringIntervalMinutes, t.IsRunning)), snapshot.Files.Select(f => IncidentFile.Rehydrate(f.Id, f.FileName, f.DisplayName, f.ContentType, f.SizeBytes, f.AddedAt, f.AddedBy)), snapshot.Tasks.Select(t => IncidentTask.Rehydrate(t.Id, t.CreatedAt, t.Text, t.Assignee, - t.Importance, t.Urgency, t.CreatedBy, t.DueAt, t.CompletedAt, t.CompletedBy))); + t.Importance, t.Urgency, t.CreatedBy, t.DueAt, t.CompletedAt, t.CompletedBy)), + Enumerable.Empty(), + Enumerable.Empty()); } private static ScbaTruppDto ToDto(AtemschutzTrupp t) => new( diff --git a/tests/LageBuch.Domain.Tests/CoMeasurementTests.cs b/tests/LageBuch.Domain.Tests/CoMeasurementTests.cs index 95c94ec..9e282de 100644 --- a/tests/LageBuch.Domain.Tests/CoMeasurementTests.cs +++ b/tests/LageBuch.Domain.Tests/CoMeasurementTests.cs @@ -120,4 +120,134 @@ public void Building_WithFloorDescription_EmptyRemoves() var updated = building.WithFloorDescription(3, ""); Assert.False(updated.FloorDescriptions.ContainsKey(3)); } + + [Fact] + public void Incident_AddCoBuilding_CreatesBuildingAndDwellings() + { + var clock = new FixedClock(new DateTimeOffset(2026, 8, 25, 10, 0, 0, TimeSpan.Zero)); + var op = new SessionOperator("Test", null); + var incident = Incident.Start(clock, op); + + incident.AddCoBuilding(clock, op, "Haus A", 2, 3); + + Assert.Single(incident.Buildings); + Assert.Equal("Haus A", incident.Buildings[0].Name); + Assert.Equal(9, incident.Dwellings.Count); + Assert.All(incident.Dwellings, d => Assert.Equal(DwellingStatus.NotSearched, d.Status)); + } + + [Fact] + public void Incident_AddCoBuilding_LogsToETB() + { + var clock = new FixedClock(new DateTimeOffset(2026, 8, 25, 10, 0, 0, TimeSpan.Zero)); + var op = new SessionOperator("Test", null); + var incident = Incident.Start(clock, op); + + incident.AddCoBuilding(clock, op, "Haus A", 8, 10); + + var entry = incident.Journal.Last(); + Assert.Contains("CO-Messprotokoll eröffnet", entry.Text); + Assert.Contains("Haus A", entry.Text); + } + + [Fact] + public void Incident_RecordCoValue_OnlyLogsOnRealChange() + { + var clock = new FixedClock(new DateTimeOffset(2026, 8, 25, 10, 0, 0, TimeSpan.Zero)); + var op = new SessionOperator("Test", null); + var incident = Incident.Start(clock, op); + incident.AddCoBuilding(clock, op, "Haus A", 2, 3); + var journalCountBefore = incident.Journal.Count; + + incident.RecordCoValue(clock, op, incident.Buildings[0].Id, 0, 1, 45); + Assert.Equal(journalCountBefore + 1, incident.Journal.Count); + + // Same value - no new entry + incident.RecordCoValue(clock, op, incident.Buildings[0].Id, 0, 1, 45); + Assert.Equal(journalCountBefore + 1, incident.Journal.Count); + } + + [Fact] + public void Incident_RecordCoValue_NegativeValue_Throws() + { + var clock = new FixedClock(new DateTimeOffset(2026, 8, 25, 10, 0, 0, TimeSpan.Zero)); + var op = new SessionOperator("Test", null); + var incident = Incident.Start(clock, op); + incident.AddCoBuilding(clock, op, "Haus A", 2, 3); + + Assert.Throws(() => + incident.RecordCoValue(clock, op, incident.Buildings[0].Id, 0, 1, -1)); + } + + [Fact] + public void Incident_SetDwellingStatus_OnlyLogsOnRealChange() + { + var clock = new FixedClock(new DateTimeOffset(2026, 8, 25, 10, 0, 0, TimeSpan.Zero)); + var op = new SessionOperator("Test", null); + var incident = Incident.Start(clock, op); + incident.AddCoBuilding(clock, op, "Haus A", 2, 3); + var journalCountBefore = incident.Journal.Count; + + incident.SetDwellingStatus(clock, op, incident.Buildings[0].Id, 0, 1, DwellingStatus.Searched); + Assert.Equal(journalCountBefore + 1, incident.Journal.Count); + + // Same status - no new entry + incident.SetDwellingStatus(clock, op, incident.Buildings[0].Id, 0, 1, DwellingStatus.Searched); + Assert.Equal(journalCountBefore + 1, incident.Journal.Count); + } + + [Fact] + public void Incident_UpdateCoBuildingStructure_RemovesDwellings() + { + var clock = new FixedClock(new DateTimeOffset(2026, 8, 25, 10, 0, 0, TimeSpan.Zero)); + var op = new SessionOperator("Test", null); + var incident = Incident.Start(clock, op); + incident.AddCoBuilding(clock, op, "Haus A", 4, 5); // 5 floors * 5 apts = 25 + + incident.UpdateCoBuildingStructure(clock, op, incident.Buildings[0].Id, 2, 3); + + Assert.Equal(2, incident.Buildings[0].FloorCount); + Assert.Equal(3, incident.Buildings[0].ApartmentsPerFloor); + Assert.Equal(9, incident.Dwellings.Count); // 3 floors * 3 apts + } + + [Fact] + public void Incident_RemoveCoBuilding_RemovesAllDwellings() + { + var clock = new FixedClock(new DateTimeOffset(2026, 8, 25, 10, 0, 0, TimeSpan.Zero)); + var op = new SessionOperator("Test", null); + var incident = Incident.Start(clock, op); + incident.AddCoBuilding(clock, op, "Haus A", 2, 3); + + incident.RemoveCoBuilding(clock, op, incident.Buildings[0].Id); + + Assert.Empty(incident.Buildings); + Assert.Empty(incident.Dwellings); + } + + [Fact] + public void Incident_EnsureOpen_ThrowsOnClosed() + { + var clock = new FixedClock(new DateTimeOffset(2026, 8, 25, 10, 0, 0, TimeSpan.Zero)); + var op = new SessionOperator("Test", null); + var incident = Incident.Start(clock, op); + incident.Close(clock, op); + + Assert.Throws(() => + incident.AddCoBuilding(clock, op, "Haus A", 2, 3)); + } + + [Fact] + public void Incident_SetDwellingDetails_DoesNotLogToETB() + { + var clock = new FixedClock(new DateTimeOffset(2026, 8, 25, 10, 0, 0, TimeSpan.Zero)); + var op = new SessionOperator("Test", null); + var incident = Incident.Start(clock, op); + incident.AddCoBuilding(clock, op, "Haus A", 2, 3); + var journalCountBefore = incident.Journal.Count; + + incident.SetDwellingDetails(incident.Buildings[0].Id, 0, 1, "Müller", true); + + Assert.Equal(journalCountBefore, incident.Journal.Count); + } } diff --git a/tests/LageBuch.Domain.Tests/IncidentTaskAggregateTests.cs b/tests/LageBuch.Domain.Tests/IncidentTaskAggregateTests.cs index c6c413d..ff75472 100644 --- a/tests/LageBuch.Domain.Tests/IncidentTaskAggregateTests.cs +++ b/tests/LageBuch.Domain.Tests/IncidentTaskAggregateTests.cs @@ -78,7 +78,8 @@ public void Rehydrate_round_trips_tasks_in_order() seed.Id, seed.StartedAt, seed.State, seed.IncidentNumber, seed.Keyword, seed.Street, seed.District, seed.Status, seed.ClosedAt, seed.ClosedBy, seed.ChecklistAufbau, seed.ChecklistAbbau, seed.Journal, seed.Roles, seed.Forces, - seed.ScbaTrupps, seed.Audit, seed.Timers, seed.Files, seed.Tasks); + seed.ScbaTrupps, seed.Audit, seed.Timers, seed.Files, seed.Tasks, + seed.Buildings, seed.Dwellings); Assert.Equal(2, restored.Tasks.Count); Assert.Equal("Offen", restored.Tasks[0].Text); diff --git a/tests/LageBuch.Domain.Tests/IncidentTimerTests.cs b/tests/LageBuch.Domain.Tests/IncidentTimerTests.cs index 6c05835..329e72b 100644 --- a/tests/LageBuch.Domain.Tests/IncidentTimerTests.cs +++ b/tests/LageBuch.Domain.Tests/IncidentTimerTests.cs @@ -57,7 +57,9 @@ public void Rehydrate_carries_timers() Array.Empty(), Array.Empty(), new[] { new IncidentTimerState("ils-reminder", T0, 15, 30, true) }, Array.Empty(), - Array.Empty()); + Array.Empty(), + Array.Empty(), + Array.Empty()); Assert.Equal("ils-reminder", Assert.Single(incident.Timers).Key); } diff --git a/tests/LageBuch.Domain.Tests/RehydrationTests.cs b/tests/LageBuch.Domain.Tests/RehydrationTests.cs index 78556d7..03828e9 100644 --- a/tests/LageBuch.Domain.Tests/RehydrationTests.cs +++ b/tests/LageBuch.Domain.Tests/RehydrationTests.cs @@ -71,7 +71,9 @@ public void Incident_rehydrate_restores_closed_incident_fully() new[] { new AuditEvent(T0, "opened", "Müller") }, Array.Empty(), Array.Empty(), - Array.Empty()); + Array.Empty(), + Array.Empty(), + Array.Empty()); Assert.Equal(id, incident.Id); Assert.Equal(IncidentState.Closed, incident.State); @@ -103,7 +105,9 @@ public void Incident_rehydrate_carries_files() Array.Empty(), Array.Empty(), Array.Empty(), new[] { Files.IncidentFile.Rehydrate(fileId, "bericht.pdf", "bericht.pdf", "application/pdf", 4096, T0, "Müller") }, - Array.Empty()); + Array.Empty(), + Array.Empty(), + Array.Empty()); var file = Assert.Single(incident.Files); Assert.Equal(fileId, file.Id); @@ -122,7 +126,9 @@ public void Rehydrated_closed_incident_rejects_mutation() Array.Empty(), Array.Empty(), Array.Empty(), - Array.Empty()); + Array.Empty(), + Array.Empty(), + Array.Empty()); Assert.Throws(() => incident.SetStatus("x")); } } From 52b084af237cd3daf8d3efb0688563a3c02ea921 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= <1005065+DeepDiver1975@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:18:25 +0200 Subject: [PATCH 04/18] feat(persistence): add V15 migration and Save/Load for CO measurement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com> --- .../IncidentRepository.cs | 59 ++++++++++++- src/LageBuch.Persistence/Sqlite/Migrations.cs | 32 +++++++- .../CoMeasurementPersistenceTests.cs | 82 +++++++++++++++++++ .../LageBuch.Persistence.Tests.csproj | 1 + 4 files changed, 171 insertions(+), 3 deletions(-) create mode 100644 tests/LageBuch.Persistence.Tests/CoMeasurementPersistenceTests.cs diff --git a/src/LageBuch.Persistence/IncidentRepository.cs b/src/LageBuch.Persistence/IncidentRepository.cs index e62d41e..d9c2739 100644 --- a/src/LageBuch.Persistence/IncidentRepository.cs +++ b/src/LageBuch.Persistence/IncidentRepository.cs @@ -19,7 +19,8 @@ public void Save(string path, Incident incident) { "incident_meta", "checklist_items", "etb_entries", "etb_entry_edits", "role_assignments", "force_units", "scba_trupps", "scba_trupp_members", "scba_pressure_readings", "audit_events", - "incident_timers", "incident_files", "incident_tasks" }) + "incident_timers", "incident_files", "incident_tasks", + "co_buildings", "co_dwellings" }) { Exec(cn, tx, $"DELETE FROM {table};"); } @@ -194,6 +195,36 @@ public void Save(string path, Incident incident) }); } + for (var i = 0; i < incident.Buildings.Count; i++) + { + var b = incident.Buildings[i]; + var descriptionsJson = System.Text.Json.JsonSerializer.Serialize(b.FloorDescriptions); + Run(cn, tx, + "INSERT INTO co_buildings (id, name, floor_count, apartments_per_floor, floor_descriptions, ordinal) VALUES ($id,$name,$fc,$apf,$fd,$o);", + p => + { + p("$id", b.Id.ToString()); p("$name", b.Name); + p("$fc", b.FloorCount); p("$apf", b.ApartmentsPerFloor); + p("$fd", descriptionsJson); p("$o", i); + }); + } + + for (var i = 0; i < incident.Dwellings.Count; i++) + { + var d = incident.Dwellings[i]; + Run(cn, tx, + "INSERT INTO co_dwellings (id, building_id, floor_ordinal, apartment_number, resident_name, status, key_available, co_value) VALUES ($id,$bid,$fo,$an,$rn,$st,$kv,$cv);", + p => + { + p("$id", d.Id.ToString()); p("$bid", d.BuildingId.ToString()); + p("$fo", d.FloorOrdinal); p("$an", d.ApartmentNumber); + p("$rn", (object?)d.ResidentName ?? DBNull.Value); + p("$st", (int)d.Status); + p("$kv", d.KeyAvailable is { } k ? (object)(k ? 1 : 0) : DBNull.Value); + p("$cv", (object?)d.CoValue ?? DBNull.Value); + }); + } + for (var i = 0; i < incident.Tasks.Count; i++) { var t = incident.Tasks[i]; @@ -367,6 +398,30 @@ public Incident Load(string path) r => Domain.Files.IncidentFile.Rehydrate(Guid.Parse(r.GetString(0)), r.GetString(1), Str(r, 6) ?? r.GetString(1), r.GetString(2), r.GetInt64(3), ParseDate(r.GetString(4)), r.GetString(5))); + var buildings = ReadAll(cn, + "SELECT id, name, floor_count, apartments_per_floor, floor_descriptions, ordinal FROM co_buildings ORDER BY ordinal;", + r => + { + var fdJson = r.GetString(4); + var fd = System.Text.Json.JsonSerializer.Deserialize>(fdJson) + ?? new Dictionary(); + var fdDict = fd.ToDictionary( + kv => int.Parse(kv.Key), + kv => kv.Value); + return Domain.CoMeasurement.Building.Rehydrate(Guid.Parse(r.GetString(0)), r.GetString(1), + r.GetInt32(2), r.GetInt32(3), fdDict, r.GetInt32(5)); + }); + + var dwellings = ReadAll(cn, + "SELECT id, building_id, floor_ordinal, apartment_number, resident_name, status, key_available, co_value FROM co_dwellings ORDER BY floor_ordinal, apartment_number;", + r => Domain.CoMeasurement.Dwelling.Rehydrate( + Guid.Parse(r.GetString(0)), Guid.Parse(r.GetString(1)), + r.GetInt32(2), r.GetInt32(3), + Str(r, 4), + (Domain.CoMeasurement.DwellingStatus)r.GetInt32(5), + r.IsDBNull(6) ? null : r.GetInt32(6) == 1, + NullableInt(r, 7))); + var tasks = ReadAll(cn, "SELECT id, text, assignee, importance, urgency, created_by, created_at, due_at, completed_at, completed_by FROM incident_tasks ORDER BY ordinal;", r => Domain.Tasks.IncidentTask.Rehydrate(Guid.Parse(r.GetString(0)), ParseDate(r.GetString(6)), @@ -394,7 +449,7 @@ public Incident Load(string path) meta[9] is string ca ? ParseDate(ca) : null, meta[10] as string, checklistAufbau, checklistAbbau, journal, roles, forces, scbaTrupps, audit, timers, files, tasks, - Array.Empty(), Array.Empty()); + buildings, dwellings); } private static DateTimeOffset ParseDate(string s) => diff --git a/src/LageBuch.Persistence/Sqlite/Migrations.cs b/src/LageBuch.Persistence/Sqlite/Migrations.cs index 838dbb6..f818533 100644 --- a/src/LageBuch.Persistence/Sqlite/Migrations.cs +++ b/src/LageBuch.Persistence/Sqlite/Migrations.cs @@ -5,7 +5,7 @@ namespace LageBuch.Persistence.Sqlite; public static class Migrations { - public const int CurrentVersion = 14; + public const int CurrentVersion = 15; public static int GetVersion(SqliteConnection cn) { @@ -88,6 +88,10 @@ public static void Migrate(SqliteConnection cn) { ApplyV14(cn, tx); } + if (version < 15) + { + ApplyV15(cn, tx); + } SetVersion(cn, tx, CurrentVersion); tx.Commit(); } @@ -444,6 +448,32 @@ completed_by TEXT """); } + private static void ApplyV15(SqliteConnection cn, SqliteTransaction tx) + { + Exec(cn, tx, """ + CREATE TABLE IF NOT EXISTS co_buildings ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + floor_count INTEGER NOT NULL, + apartments_per_floor INTEGER NOT NULL, + floor_descriptions TEXT NOT NULL DEFAULT '{}', + ordinal INTEGER NOT NULL + ); + """); + Exec(cn, tx, """ + CREATE TABLE IF NOT EXISTS co_dwellings ( + id TEXT PRIMARY KEY, + building_id TEXT NOT NULL, + floor_ordinal INTEGER NOT NULL, + apartment_number INTEGER NOT NULL, + resident_name TEXT, + status INTEGER NOT NULL, + key_available INTEGER, + co_value INTEGER + ); + """); + } + private static void SetVersion(SqliteConnection cn, SqliteTransaction tx, int version) { Exec(cn, tx, "DELETE FROM schema_version;"); diff --git a/tests/LageBuch.Persistence.Tests/CoMeasurementPersistenceTests.cs b/tests/LageBuch.Persistence.Tests/CoMeasurementPersistenceTests.cs new file mode 100644 index 0000000..60ec15d --- /dev/null +++ b/tests/LageBuch.Persistence.Tests/CoMeasurementPersistenceTests.cs @@ -0,0 +1,82 @@ +using LageBuch.Domain; +using LageBuch.Domain.CoMeasurement; +using LageBuch.Domain.Time; +using Microsoft.Data.Sqlite; + +namespace LageBuch.Persistence.Tests; + +public class CoMeasurementPersistenceTests : IDisposable +{ + private readonly string _path = Path.Combine(Path.GetTempPath(), $"co-{Guid.NewGuid():N}.fwincident"); + + private sealed class Clock : IClock + { + public DateTimeOffset Now { get; set; } = new(2026, 8, 25, 10, 0, 0, TimeSpan.Zero); + } + + public void Dispose() + { + SqliteConnection.ClearAllPools(); + if (File.Exists(_path)) File.Delete(_path); + } + + private static Incident CreateIncidentWithBuilding() + { + var clock = new Clock(); + var op = new SessionOperator("Test", null); + var incident = Incident.Start(clock, op); + incident.AddCoBuilding(clock, op, "Haus A", 2, 3); + incident.RecordCoValue(clock, op, incident.Buildings[0].Id, 0, 1, 45); + incident.SetDwellingStatus(clock, op, incident.Buildings[0].Id, 0, 2, DwellingStatus.Searched); + incident.SetDwellingDetails(incident.Buildings[0].Id, 0, 1, "Müller", true); + incident.SetFloorDescription(incident.Buildings[0].Id, 1, "rechts"); + return incident; + } + + [Fact] + public void SaveLoad_RoundTrip_BuildingsAndDwellings() + { + var original = CreateIncidentWithBuilding(); + var repo = new IncidentRepository(); + repo.Save(_path, original); + + var loaded = repo.Load(_path); + + Assert.Single(loaded.Buildings); + Assert.Equal("Haus A", loaded.Buildings[0].Name); + Assert.Equal(2, loaded.Buildings[0].FloorCount); + Assert.Equal(3, loaded.Buildings[0].ApartmentsPerFloor); + Assert.Equal(9, loaded.Dwellings.Count); + + var dwelling = loaded.Dwellings.First(d => + d.FloorOrdinal == 0 && d.ApartmentNumber == 1); + Assert.Equal(45, dwelling.CoValue); + Assert.Equal("Müller", dwelling.ResidentName); + Assert.True(dwelling.KeyAvailable); + + var searched = loaded.Dwellings.First(d => + d.FloorOrdinal == 0 && d.ApartmentNumber == 2); + Assert.Equal(DwellingStatus.Searched, searched.Status); + + Assert.Equal("rechts", loaded.Buildings[0].FloorDescriptions[1]); + } + + [Fact] + public void SaveLoad_RoundTrip_NullableFields() + { + var clock = new Clock(); + var op = new SessionOperator("Test", null); + var incident = Incident.Start(clock, op); + incident.AddCoBuilding(clock, op, "Haus A", 1, 1); + + var repo = new IncidentRepository(); + repo.Save(_path, incident); + + var loaded = repo.Load(_path); + + var dwelling = loaded.Dwellings[0]; + Assert.Null(dwelling.CoValue); + Assert.Null(dwelling.ResidentName); + Assert.Null(dwelling.KeyAvailable); + } +} diff --git a/tests/LageBuch.Persistence.Tests/LageBuch.Persistence.Tests.csproj b/tests/LageBuch.Persistence.Tests/LageBuch.Persistence.Tests.csproj index 5c05082..85dae2d 100644 --- a/tests/LageBuch.Persistence.Tests/LageBuch.Persistence.Tests.csproj +++ b/tests/LageBuch.Persistence.Tests/LageBuch.Persistence.Tests.csproj @@ -12,6 +12,7 @@ + From e5da68444edea7820f2b1d4224a55bf91128dddb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= <1005065+DeepDiver1975@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:32:37 +0200 Subject: [PATCH 05/18] feat(sync): add 7 CO measurement commands, DTOs, and mapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com> --- src/LageBuch.Sync/CommandApplier.cs | 21 ++++++++++++++++++ src/LageBuch.Sync/IncidentSnapshot.cs | 13 ++++++++++- src/LageBuch.Sync/SnapshotMapper.cs | 21 +++++++++++++++--- src/LageBuch.Sync/SyncCommand.cs | 31 +++++++++++++++++++++++++++ 4 files changed, 82 insertions(+), 4 deletions(-) diff --git a/src/LageBuch.Sync/CommandApplier.cs b/src/LageBuch.Sync/CommandApplier.cs index d0c1d48..82c690f 100644 --- a/src/LageBuch.Sync/CommandApplier.cs +++ b/src/LageBuch.Sync/CommandApplier.cs @@ -108,6 +108,27 @@ public static void Apply(SyncCommand command, Incident incident, IClock clock, A case SetTaskCompletedCommand c: incident.SetTaskCompleted(c.TaskId, c.IsDone, clock, Operator(c.Operator)); break; + case AddCoBuildingCommand c: + incident.AddCoBuilding(clock, Operator(c.Operator), c.Name, c.FloorCount, c.ApartmentsPerFloor); + break; + case UpdateCoBuildingStructureCommand c: + incident.UpdateCoBuildingStructure(clock, Operator(c.Operator), c.BuildingId, c.FloorCount, c.ApartmentsPerFloor); + break; + case RemoveCoBuildingCommand c: + incident.RemoveCoBuilding(clock, Operator(c.Operator), c.BuildingId); + break; + case RecordCoValueCommand c: + incident.RecordCoValue(clock, Operator(c.Operator), c.BuildingId, c.FloorOrdinal, c.ApartmentNumber, c.CoValue); + break; + case SetDwellingStatusCommand c: + incident.SetDwellingStatus(clock, Operator(c.Operator), c.BuildingId, c.FloorOrdinal, c.ApartmentNumber, c.Status); + break; + case UpdateDwellingDetailsCommand c: + incident.SetDwellingDetails(c.BuildingId, c.FloorOrdinal, c.ApartmentNumber, c.ResidentName, c.KeyAvailable); + break; + case SetFloorDescriptionCommand c: + incident.SetFloorDescription(c.BuildingId, c.FloorOrdinal, c.Description); + break; default: throw new ArgumentOutOfRangeException(nameof(command), $"Unbekannter Befehl: {command.GetType().Name}"); diff --git a/src/LageBuch.Sync/IncidentSnapshot.cs b/src/LageBuch.Sync/IncidentSnapshot.cs index fca089a..7bd2306 100644 --- a/src/LageBuch.Sync/IncidentSnapshot.cs +++ b/src/LageBuch.Sync/IncidentSnapshot.cs @@ -1,5 +1,6 @@ using LageBuch.Domain; using LageBuch.Domain.Atemschutz; +using LageBuch.Domain.CoMeasurement; using LageBuch.Domain.Etb; using LageBuch.Domain.Tasks; @@ -31,7 +32,9 @@ public sealed record IncidentSnapshot( IReadOnlyList Audit, IReadOnlyList Timers, IReadOnlyList Files, - IReadOnlyList Tasks); + IReadOnlyList Tasks, + IReadOnlyList Buildings, + IReadOnlyList Dwellings); public sealed record TimerDto( string Key, @@ -126,3 +129,11 @@ public sealed record TaskDto( DateTimeOffset DueAt, DateTimeOffset? CompletedAt, string? CompletedBy); + +public sealed record BuildingDto( + Guid Id, string Name, int FloorCount, int ApartmentsPerFloor, + Dictionary FloorDescriptions, int Ordinal); + +public sealed record DwellingDto( + Guid Id, Guid BuildingId, int FloorOrdinal, int ApartmentNumber, + string? ResidentName, DwellingStatus Status, bool? KeyAvailable, int? CoValue); diff --git a/src/LageBuch.Sync/SnapshotMapper.cs b/src/LageBuch.Sync/SnapshotMapper.cs index 9d6ced5..e31f3ca 100644 --- a/src/LageBuch.Sync/SnapshotMapper.cs +++ b/src/LageBuch.Sync/SnapshotMapper.cs @@ -1,5 +1,6 @@ using LageBuch.Domain; using LageBuch.Domain.Atemschutz; +using LageBuch.Domain.CoMeasurement; using LageBuch.Domain.Etb; using LageBuch.Domain.Files; using LageBuch.Domain.Tasks; @@ -43,7 +44,14 @@ public static IncidentSnapshot ToSnapshot(Incident incident) incident.Timers.Select(t => new TimerDto(t.Key, t.CycleAnchor, t.IntervalMinutes, t.RecurringIntervalMinutes, t.IsRunning)).ToList(), incident.Files.Select(f => new IncidentFileDto(f.Id, f.FileName, f.DisplayName, f.ContentType, f.SizeBytes, f.AddedAt, f.AddedBy)).ToList(), incident.Tasks.Select(t => new TaskDto(t.Id, t.Text, t.Assignee, t.Importance, t.Urgency, - t.CreatedBy, t.CreatedAt, t.DueAt, t.CompletedAt, t.CompletedBy)).ToList()); + t.CreatedBy, t.CreatedAt, t.DueAt, t.CompletedAt, t.CompletedBy)).ToList(), + incident.Buildings.Select(b => new BuildingDto( + b.Id, b.Name, b.FloorCount, b.ApartmentsPerFloor, + b.FloorDescriptions.ToDictionary(kv => kv.Key.ToString(), kv => kv.Value), + b.Ordinal)).ToList(), + incident.Dwellings.Select(d => new DwellingDto( + d.Id, d.BuildingId, d.FloorOrdinal, d.ApartmentNumber, + d.ResidentName, d.Status, d.KeyAvailable, d.CoValue)).ToList()); } public static Incident FromSnapshot(IncidentSnapshot snapshot) @@ -74,8 +82,15 @@ public static Incident FromSnapshot(IncidentSnapshot snapshot) snapshot.Files.Select(f => IncidentFile.Rehydrate(f.Id, f.FileName, f.DisplayName, f.ContentType, f.SizeBytes, f.AddedAt, f.AddedBy)), snapshot.Tasks.Select(t => IncidentTask.Rehydrate(t.Id, t.CreatedAt, t.Text, t.Assignee, t.Importance, t.Urgency, t.CreatedBy, t.DueAt, t.CompletedAt, t.CompletedBy)), - Enumerable.Empty(), - Enumerable.Empty()); + snapshot.Buildings.Select(b => Building.Rehydrate( + b.Id, b.Name, b.FloorCount, b.ApartmentsPerFloor, + b.FloorDescriptions.ToDictionary( + kv => int.Parse(kv.Key), + kv => kv.Value), + b.Ordinal)), + snapshot.Dwellings.Select(d => Dwelling.Rehydrate( + d.Id, d.BuildingId, d.FloorOrdinal, d.ApartmentNumber, + d.ResidentName, d.Status, d.KeyAvailable, d.CoValue))); } private static ScbaTruppDto ToDto(AtemschutzTrupp t) => new( diff --git a/src/LageBuch.Sync/SyncCommand.cs b/src/LageBuch.Sync/SyncCommand.cs index da4050e..b31afff 100644 --- a/src/LageBuch.Sync/SyncCommand.cs +++ b/src/LageBuch.Sync/SyncCommand.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using LageBuch.Domain.CoMeasurement; using LageBuch.Domain.Etb; using LageBuch.Domain.Tasks; @@ -35,6 +36,13 @@ namespace LageBuch.Sync; [JsonDerivedType(typeof(RenameFileCommand), "renameFile")] [JsonDerivedType(typeof(AddTaskCommand), "addTask")] [JsonDerivedType(typeof(SetTaskCompletedCommand), "setTaskCompleted")] +[JsonDerivedType(typeof(AddCoBuildingCommand), "addCoBuilding")] +[JsonDerivedType(typeof(UpdateCoBuildingStructureCommand), "updateCoBuildingStructure")] +[JsonDerivedType(typeof(RemoveCoBuildingCommand), "removeCoBuilding")] +[JsonDerivedType(typeof(RecordCoValueCommand), "recordCoValue")] +[JsonDerivedType(typeof(SetDwellingStatusCommand), "setDwellingStatus")] +[JsonDerivedType(typeof(UpdateDwellingDetailsCommand), "updateDwellingDetails")] +[JsonDerivedType(typeof(SetFloorDescriptionCommand), "setFloorDescription")] public abstract record SyncCommand; /// The operator at the sending device — carried on attributed mutations (see §6). @@ -108,3 +116,26 @@ public sealed record AddTaskCommand( TaskImportance Importance, TaskUrgency Urgency, int TimerMinutes) : SyncCommand; public sealed record SetTaskCompletedCommand(OperatorDto Operator, Guid TaskId, bool IsDone) : SyncCommand; + +public sealed record AddCoBuildingCommand( + OperatorDto Operator, string Name, int FloorCount, int ApartmentsPerFloor) : SyncCommand; + +public sealed record UpdateCoBuildingStructureCommand( + OperatorDto Operator, Guid BuildingId, int FloorCount, int ApartmentsPerFloor) : SyncCommand; + +public sealed record RemoveCoBuildingCommand( + OperatorDto Operator, Guid BuildingId) : SyncCommand; + +public sealed record RecordCoValueCommand( + OperatorDto Operator, Guid BuildingId, int FloorOrdinal, int ApartmentNumber, int? CoValue) : SyncCommand; + +public sealed record SetDwellingStatusCommand( + OperatorDto Operator, Guid BuildingId, int FloorOrdinal, int ApartmentNumber, DwellingStatus Status) : SyncCommand; + +// No operator (silent) +public sealed record UpdateDwellingDetailsCommand( + Guid BuildingId, int FloorOrdinal, int ApartmentNumber, string? ResidentName, bool? KeyAvailable) : SyncCommand; + +// No operator (silent) +public sealed record SetFloorDescriptionCommand( + Guid BuildingId, int FloorOrdinal, string? Description) : SyncCommand; From 0eec247a74fe5fd5aa8ac6d6b40ee2ff0d75a72e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= <1005065+DeepDiver1975@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:45:15 +0200 Subject: [PATCH 06/18] feat(session): add CO measurement methods to IIncidentSession MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com> --- src/LageBuch.AppLogic/LocalIncidentSession.cs | 22 +++++++++++++++++++ src/LageBuch.Sync/IIncidentSession.cs | 9 ++++++++ src/LageBuch.Sync/RemoteIncidentSession.cs | 22 +++++++++++++++++++ 3 files changed, 53 insertions(+) diff --git a/src/LageBuch.AppLogic/LocalIncidentSession.cs b/src/LageBuch.AppLogic/LocalIncidentSession.cs index 7438a2f..f3cb9f8 100644 --- a/src/LageBuch.AppLogic/LocalIncidentSession.cs +++ b/src/LageBuch.AppLogic/LocalIncidentSession.cs @@ -2,6 +2,7 @@ using LageBuch.Documents; using LageBuch.Domain; using LageBuch.Domain.Atemschutz; +using LageBuch.Domain.CoMeasurement; using LageBuch.Domain.Etb; using LageBuch.Domain.Files; using LageBuch.Domain.Time; @@ -204,6 +205,27 @@ public Task AddFileAsync(string fileName, string contentType, byte[] bytes) public void RenameFile(Guid fileId, string? displayName) => Mutate(() => Incident.RenameFile(fileId, displayName)); + public void AddCoBuilding(string name, int floorCount, int apartmentsPerFloor) => + Mutate(() => Incident.AddCoBuilding(_clock, RequireOperator(), name, floorCount, apartmentsPerFloor)); + + public void UpdateCoBuildingStructure(Guid buildingId, int floorCount, int apartmentsPerFloor) => + Mutate(() => Incident.UpdateCoBuildingStructure(_clock, RequireOperator(), buildingId, floorCount, apartmentsPerFloor)); + + public void RemoveCoBuilding(Guid buildingId) => + Mutate(() => Incident.RemoveCoBuilding(_clock, RequireOperator(), buildingId)); + + public void RecordCoValue(Guid buildingId, int floorOrdinal, int apartmentNumber, int? coValue) => + Mutate(() => Incident.RecordCoValue(_clock, RequireOperator(), buildingId, floorOrdinal, apartmentNumber, coValue)); + + public void SetDwellingStatus(Guid buildingId, int floorOrdinal, int apartmentNumber, DwellingStatus status) => + Mutate(() => Incident.SetDwellingStatus(_clock, RequireOperator(), buildingId, floorOrdinal, apartmentNumber, status)); + + public void SetDwellingDetails(Guid buildingId, int floorOrdinal, int apartmentNumber, string? residentName, bool? keyAvailable) => + Mutate(() => Incident.SetDwellingDetails(buildingId, floorOrdinal, apartmentNumber, residentName, keyAvailable)); + + public void SetFloorDescription(Guid buildingId, int floorOrdinal, string? description) => + Mutate(() => Incident.SetFloorDescription(buildingId, floorOrdinal, description)); + public void Close() { if (IsReadOnly) diff --git a/src/LageBuch.Sync/IIncidentSession.cs b/src/LageBuch.Sync/IIncidentSession.cs index feda28f..8e3a7a5 100644 --- a/src/LageBuch.Sync/IIncidentSession.cs +++ b/src/LageBuch.Sync/IIncidentSession.cs @@ -1,5 +1,6 @@ using LageBuch.Domain; using LageBuch.Domain.Atemschutz; +using LageBuch.Domain.CoMeasurement; using LageBuch.Domain.Etb; using LageBuch.Domain.Tasks; using LageBuch.Domain.ValueObjects; @@ -102,4 +103,12 @@ void AddScbaTrupp(string designation, IEnumerable members, string? /// Corrects a file's display label. Silent — no ETB entry, matching UpdateForceUnit's /// Bemerkung field. Null/blank resets the label back to the file's original name. void RenameFile(Guid fileId, string? displayName); + + void AddCoBuilding(string name, int floorCount, int apartmentsPerFloor); + void UpdateCoBuildingStructure(Guid buildingId, int floorCount, int apartmentsPerFloor); + void RemoveCoBuilding(Guid buildingId); + void RecordCoValue(Guid buildingId, int floorOrdinal, int apartmentNumber, int? coValue); + void SetDwellingStatus(Guid buildingId, int floorOrdinal, int apartmentNumber, DwellingStatus status); + void SetDwellingDetails(Guid buildingId, int floorOrdinal, int apartmentNumber, string? residentName, bool? keyAvailable); + void SetFloorDescription(Guid buildingId, int floorOrdinal, string? description); } diff --git a/src/LageBuch.Sync/RemoteIncidentSession.cs b/src/LageBuch.Sync/RemoteIncidentSession.cs index 8e12200..e8be7bc 100644 --- a/src/LageBuch.Sync/RemoteIncidentSession.cs +++ b/src/LageBuch.Sync/RemoteIncidentSession.cs @@ -3,6 +3,7 @@ using System.Text.Json.Serialization; using LageBuch.Domain; using LageBuch.Domain.Atemschutz; +using LageBuch.Domain.CoMeasurement; using LageBuch.Domain.Etb; using LageBuch.Domain.Files; using LageBuch.Domain.Tasks; @@ -258,6 +259,27 @@ public async Task AddFileAsync(string fileName, string contentType, byte[] bytes public void RenameFile(Guid fileId, string? displayName) => Send(new RenameFileCommand(fileId, displayName)); + public void AddCoBuilding(string name, int floorCount, int apartmentsPerFloor) => + Send(new AddCoBuildingCommand(Op(), name, floorCount, apartmentsPerFloor)); + + public void UpdateCoBuildingStructure(Guid buildingId, int floorCount, int apartmentsPerFloor) => + Send(new UpdateCoBuildingStructureCommand(Op(), buildingId, floorCount, apartmentsPerFloor)); + + public void RemoveCoBuilding(Guid buildingId) => + Send(new RemoveCoBuildingCommand(Op(), buildingId)); + + public void RecordCoValue(Guid buildingId, int floorOrdinal, int apartmentNumber, int? coValue) => + Send(new RecordCoValueCommand(Op(), buildingId, floorOrdinal, apartmentNumber, coValue)); + + public void SetDwellingStatus(Guid buildingId, int floorOrdinal, int apartmentNumber, DwellingStatus status) => + Send(new SetDwellingStatusCommand(Op(), buildingId, floorOrdinal, apartmentNumber, status)); + + public void SetDwellingDetails(Guid buildingId, int floorOrdinal, int apartmentNumber, string? residentName, bool? keyAvailable) => + Send(new UpdateDwellingDetailsCommand(buildingId, floorOrdinal, apartmentNumber, residentName, keyAvailable)); + + public void SetFloorDescription(Guid buildingId, int floorOrdinal, string? description) => + Send(new SetFloorDescriptionCommand(buildingId, floorOrdinal, description)); + private OperatorDto Op() => new(Operator!.Name, Operator.CallSign); // Fire-and-forget: the command is POSTed; the host's broadcast (or a rejection the host swallows) From 8673a7d19a779570823c20af1855c52fbd1b8312 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= <1005065+DeepDiver1975@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:57:05 +0200 Subject: [PATCH 07/18] feat(applogic): add CoMessprotokollViewModel with matrix and flyout editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com> --- .../ViewModels/CoMessprotokollViewModel.cs | 311 ++++++++++++++++++ .../CoMessprotokollViewModelTests.cs | 77 +++++ 2 files changed, 388 insertions(+) create mode 100644 src/LageBuch.AppLogic/ViewModels/CoMessprotokollViewModel.cs create mode 100644 tests/LageBuch.AppLogic.Tests/CoMessprotokollViewModelTests.cs diff --git a/src/LageBuch.AppLogic/ViewModels/CoMessprotokollViewModel.cs b/src/LageBuch.AppLogic/ViewModels/CoMessprotokollViewModel.cs new file mode 100644 index 0000000..f95a43c --- /dev/null +++ b/src/LageBuch.AppLogic/ViewModels/CoMessprotokollViewModel.cs @@ -0,0 +1,311 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LageBuch.Domain.CoMeasurement; +using LageBuch.Domain.Time; +using LageBuch.Sync; + +namespace LageBuch.AppLogic.ViewModels; + +public sealed partial class DwellingCellViewModel : ObservableObject +{ + private readonly Action _onStatusChanged; + private readonly Action _onCoValueChanged; + private readonly Action _onOpenEditor; + + public DwellingCellViewModel( + Dwelling dwelling, Building building, bool isReadOnly, + Action onStatusChanged, + Action onCoValueChanged, + Action onOpenEditor) + { + Id = dwelling.Id; + BuildingId = dwelling.BuildingId; + FloorOrdinal = dwelling.FloorOrdinal; + ApartmentNumber = dwelling.ApartmentNumber; + IsReadOnly = isReadOnly; + _onStatusChanged = onStatusChanged; + _onCoValueChanged = onCoValueChanged; + _onOpenEditor = onOpenEditor; + _status = dwelling.Status; + _coValue = dwelling.CoValue; + _residentName = dwelling.ResidentName; + _keyAvailable = dwelling.KeyAvailable; + StatusBrush = GetStatusBrush(dwelling.Status); + } + + public Guid Id { get; } + public Guid BuildingId { get; } + public int FloorOrdinal { get; } + public int ApartmentNumber { get; } + public bool IsReadOnly { get; } + + [ObservableProperty] + private DwellingStatus _status; + + [ObservableProperty] + private int? _coValue; + + [ObservableProperty] + private string? _residentName; + + [ObservableProperty] + private bool? _keyAvailable; + + [ObservableProperty] + private string _statusBrush; + + public string CoDisplay => CoValue is { } v ? $"{v} ppm" : "Kein Messwert"; + + public string Label => CoMeasurementLabels.ApartmentLabel(ApartmentNumber); + + private static string GetStatusBrush(DwellingStatus status) => status switch + { + DwellingStatus.NotSearched => "#FFC000", + DwellingStatus.Searched => "#92D050", + DwellingStatus.Affected => "#FF0000", + _ => "#FFC000" + }; + + partial void OnStatusChanged(DwellingStatus value) + { + StatusBrush = GetStatusBrush(value); + if (!IsReadOnly) + _onStatusChanged(BuildingId, FloorOrdinal, ApartmentNumber, value); + } + + partial void OnCoValueChanged(int? value) + { + OnPropertyChanged(nameof(CoDisplay)); + if (!IsReadOnly) + _onCoValueChanged(BuildingId, FloorOrdinal, ApartmentNumber, value); + } + + [RelayCommand] + private void OpenEditor() => _onOpenEditor(BuildingId, FloorOrdinal, ApartmentNumber); +} + +public sealed partial class FloorRowViewModel : ObservableObject +{ + public FloorRowViewModel(int ordinal, string label, IReadOnlyList cells, string? description) + { + Ordinal = ordinal; + Label = label; + Cells = cells; + Description = description; + } + + public int Ordinal { get; } + public string Label { get; } + public IReadOnlyList Cells { get; } + public string? Description { get; } +} + +public sealed partial class CoMessprotokollViewModel : ObservableObject +{ + private readonly IIncidentSession _session; + private readonly IClock _clock; + private readonly Action _onChanged; + + public CoMessprotokollViewModel(IIncidentSession session, IClock clock, Action onChanged) + { + _session = session; + _clock = clock; + _onChanged = onChanged; + IsReadOnly = session.IsReadOnly; + _session.Changed += Refresh; + Refresh(); + } + + public bool IsReadOnly { get; } + + public ObservableCollection BuildingOptions { get; } = new(); + + [ObservableProperty] + private Building? _selectedBuilding; + + [ObservableProperty] + private ObservableCollection _matrixRows = new(); + + [ObservableProperty] + private IReadOnlyList _apartmentLabels = Array.Empty(); + + [ObservableProperty] + private DwellingCellViewModel? _selectedCell; + + [ObservableProperty] + private bool _isEditorOpen; + + private void Refresh() + { + BuildingOptions.Clear(); + foreach (var b in _session.Incident.Buildings) + BuildingOptions.Add(b); + + if (SelectedBuilding is null || !_session.Incident.Buildings.Contains(SelectedBuilding)) + SelectedBuilding = BuildingOptions.FirstOrDefault(); + + BuildMatrix(); + OnPropertyChanged(nameof(IsReadOnly)); + } + + partial void OnSelectedBuildingChanged(Building? value) => BuildMatrix(); + + private void BuildMatrix() + { + MatrixRows.Clear(); + if (SelectedBuilding is null) + { + ApartmentLabels = Array.Empty(); + return; + } + + var building = SelectedBuilding; + ApartmentLabels = Enumerable.Range(1, building.ApartmentsPerFloor) + .Select(CoMeasurementLabels.ApartmentLabel) + .ToArray(); + + for (var floor = building.FloorCount; floor >= 0; floor--) + { + var cells = Enumerable.Range(1, building.ApartmentsPerFloor) + .Select(apt => + { + var dwelling = _session.Incident.Dwellings.FirstOrDefault(d => + d.BuildingId == building.Id && d.FloorOrdinal == floor && d.ApartmentNumber == apt); + return dwelling is not null + ? new DwellingCellViewModel(dwelling, building, IsReadOnly, OnStatusChanged, OnCoValueChanged, OnOpenEditor) + : null; + }) + .Where(c => c is not null) + .Cast() + .ToList(); + + var description = building.FloorDescriptions.TryGetValue(floor, out var d) ? d : null; + MatrixRows.Add(new FloorRowViewModel(floor, CoMeasurementLabels.FloorLabel(floor), cells, description)); + } + } + + private void OnStatusChanged(Guid buildingId, int floorOrdinal, int apartmentNumber, DwellingStatus status) + { + _session.SetDwellingStatus(buildingId, floorOrdinal, apartmentNumber, status); + _onChanged(); + } + + private void OnCoValueChanged(Guid buildingId, int floorOrdinal, int apartmentNumber, int? coValue) + { + _session.RecordCoValue(buildingId, floorOrdinal, apartmentNumber, coValue); + _onChanged(); + } + + private void OnOpenEditor(Guid buildingId, int floorOrdinal, int apartmentNumber) + { + SelectedCell = MatrixRows + .SelectMany(r => r.Cells) + .FirstOrDefault(c => c.BuildingId == buildingId && c.FloorOrdinal == floorOrdinal && c.ApartmentNumber == apartmentNumber); + IsEditorOpen = SelectedCell is not null; + } + + [RelayCommand(CanExecute = nameof(CanAddBuilding))] + private void AddBuilding() => IsAddBuildingDialogOpen = true; + + private bool CanAddBuilding => !IsReadOnly; + + [ObservableProperty] + private bool _isAddBuildingDialogOpen; + + [ObservableProperty] + private string _newBuildingName = string.Empty; + + [ObservableProperty] + private int _newBuildingFloors = 8; + + [ObservableProperty] + private int _newBuildingApartments = 10; + + [RelayCommand] + private void ConfirmAddBuilding() + { + _session.AddCoBuilding(NewBuildingName, NewBuildingFloors, NewBuildingApartments); + NewBuildingName = string.Empty; + NewBuildingFloors = 8; + NewBuildingApartments = 10; + IsAddBuildingDialogOpen = false; + _onChanged(); + } + + [RelayCommand] + private void CancelAddBuilding() => IsAddBuildingDialogOpen = false; + + [RelayCommand(CanExecute = nameof(CanModifyStructure))] + private void ModifyStructure() + { + if (SelectedBuilding is null) return; + NewStructureFloors = SelectedBuilding.FloorCount; + NewStructureApartments = SelectedBuilding.ApartmentsPerFloor; + IsModifyStructureDialogOpen = true; + } + + private bool CanModifyStructure => !IsReadOnly && SelectedBuilding is not null; + + [ObservableProperty] + private bool _isModifyStructureDialogOpen; + + [ObservableProperty] + private int _newStructureFloors; + + [ObservableProperty] + private int _newStructureApartments; + + [RelayCommand] + private void ConfirmModifyStructure() + { + if (SelectedBuilding is null) return; + _session.UpdateCoBuildingStructure(SelectedBuilding.Id, NewStructureFloors, NewStructureApartments); + IsModifyStructureDialogOpen = false; + _onChanged(); + } + + [RelayCommand] + private void CancelModifyStructure() => IsModifyStructureDialogOpen = false; + + [RelayCommand(CanExecute = nameof(CanRemoveBuilding))] + private void RemoveBuilding() + { + if (SelectedBuilding is null) return; + IsRemoveBuildingConfirmOpen = true; + } + + private bool CanRemoveBuilding => !IsReadOnly && SelectedBuilding is not null; + + [ObservableProperty] + private bool _isRemoveBuildingConfirmOpen; + + [RelayCommand] + private void ConfirmRemoveBuilding() + { + if (SelectedBuilding is null) return; + _session.RemoveCoBuilding(SelectedBuilding.Id); + IsRemoveBuildingConfirmOpen = false; + _onChanged(); + } + + [RelayCommand] + private void CancelRemoveBuilding() => IsRemoveBuildingConfirmOpen = false; + + [RelayCommand] + private void CloseEditor() => IsEditorOpen = false; + + [RelayCommand] + private void SetEditorStatus(DwellingStatus status) + { + if (SelectedCell is null) return; + SelectedCell.Status = status; + } + + [RelayCommand] + private void ConfirmEditor() + { + IsEditorOpen = false; + SelectedCell = null; + } +} diff --git a/tests/LageBuch.AppLogic.Tests/CoMessprotokollViewModelTests.cs b/tests/LageBuch.AppLogic.Tests/CoMessprotokollViewModelTests.cs new file mode 100644 index 0000000..f66cccb --- /dev/null +++ b/tests/LageBuch.AppLogic.Tests/CoMessprotokollViewModelTests.cs @@ -0,0 +1,77 @@ +using LageBuch.Domain; +using LageBuch.Domain.CoMeasurement; +using LageBuch.AppLogic.ViewModels; + +namespace LageBuch.AppLogic.Tests; + +public class CoMessprotokollViewModelTests +{ + private static readonly FixedClock Clock = new(new DateTimeOffset(2026, 8, 25, 10, 0, 0, TimeSpan.Zero)); + + private static (LocalIncidentSession session, CoMessprotokollViewModel vm) CreateVm() + { + var op = new SessionOperator("Test", null); + var store = new FakeStore(); + var session = LocalIncidentSession.StartNew(store, Clock, op, Path.GetTempFileName(), + Enumerable.Empty<(string, bool)>(), Enumerable.Empty<(string, bool)>()); + session.AddCoBuilding("Haus A", 2, 3); + var vm = new CoMessprotokollViewModel(session, Clock, () => { }); + return (session, vm); + } + + [Fact] + public void ViewModel_BuildsMatrix_FromIncident() + { + var (session, vm) = CreateVm(); + + Assert.Single(vm.BuildingOptions); + Assert.Equal("Haus A", vm.BuildingOptions[0].Name); + Assert.Equal(3, vm.MatrixRows.Count); // 2 OG + EG + Assert.Equal(3, vm.ApartmentLabels.Count); + } + + [Fact] + public void ViewModel_IsReadOnly_WhenSessionReadOnly() + { + var op = new SessionOperator("Test", null); + var store = new FakeStore(); + var path = Path.GetTempFileName(); + LocalIncidentSession.StartNew(store, Clock, op, path, + Enumerable.Empty<(string, bool)>(), Enumerable.Empty<(string, bool)>()); + var session = LocalIncidentSession.OpenReadOnly(store, Clock, path); + var vm = new CoMessprotokollViewModel(session, Clock, () => { }); + + Assert.True(vm.IsReadOnly); + } + + [Fact] + public void DwellingCellVM_StatusBrush_MatchesStatus() + { + var building = Building.Create("Haus A", 2, 3, 0); + var dwelling = Dwelling.Create(building.Id, 0, 1); + + var cell = new DwellingCellViewModel(dwelling, building, false, (_, _, _, _) => { }, (_, _, _, _) => { }, (_, _, _) => { }); + + Assert.Equal("#FFC000", cell.StatusBrush); // NotSearched = Gelb + + cell.Status = DwellingStatus.Searched; + Assert.Equal("#92D050", cell.StatusBrush); // Searched = Grün + + cell.Status = DwellingStatus.Affected; + Assert.Equal("#FF0000", cell.StatusBrush); // Affected = Rot + } + + [Fact] + public void DwellingCellVM_CoDisplay_ShowsPlaceholderWhenNull() + { + var building = Building.Create("Haus A", 2, 3, 0); + var dwelling = Dwelling.Create(building.Id, 0, 1); + + var cell = new DwellingCellViewModel(dwelling, building, false, (_, _, _, _) => { }, (_, _, _, _) => { }, (_, _, _) => { }); + + Assert.Equal("Kein Messwert", cell.CoDisplay); + + cell.CoValue = 45; + Assert.Equal("45 ppm", cell.CoDisplay); + } +} From ddf9c96e01a85de7a5752327bf5daaaa9082b281 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= <1005065+DeepDiver1975@users.noreply.github.com> Date: Wed, 26 Aug 2026 08:25:43 +0200 Subject: [PATCH 08/18] feat(ui): add CO-MESSUNG tab with matrix view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com> --- .../StatusToForegroundValueConverter.cs | 20 ++++ .../Views/CoMessprotokollView.axaml | 95 +++++++++++++++++++ .../Views/CoMessprotokollView.axaml.cs | 11 +++ .../Views/IncidentWorkspaceView.axaml | 5 + .../ViewModels/IncidentWorkspaceViewModel.cs | 4 + 5 files changed, 135 insertions(+) create mode 100644 src/LageBuch.App.Shared/Converters/StatusToForegroundValueConverter.cs create mode 100644 src/LageBuch.App.Shared/Views/CoMessprotokollView.axaml create mode 100644 src/LageBuch.App.Shared/Views/CoMessprotokollView.axaml.cs diff --git a/src/LageBuch.App.Shared/Converters/StatusToForegroundValueConverter.cs b/src/LageBuch.App.Shared/Converters/StatusToForegroundValueConverter.cs new file mode 100644 index 0000000..d8911fe --- /dev/null +++ b/src/LageBuch.App.Shared/Converters/StatusToForegroundValueConverter.cs @@ -0,0 +1,20 @@ +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace LageBuch.App.Shared.Converters; + +public sealed class StatusToForegroundValueConverter : IValueConverter +{ + public static readonly StatusToForegroundValueConverter Instance = new(); + + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is string hex && Color.TryParse(hex, out var color)) + return new SolidColorBrush(color); + return Brushes.White; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => + throw new NotSupportedException(); +} diff --git a/src/LageBuch.App.Shared/Views/CoMessprotokollView.axaml b/src/LageBuch.App.Shared/Views/CoMessprotokollView.axaml new file mode 100644 index 0000000..fecfa8d --- /dev/null +++ b/src/LageBuch.App.Shared/Views/CoMessprotokollView.axaml @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/LageBuch.App.Shared/Views/CoMessprotokollView.axaml.cs b/src/LageBuch.App.Shared/Views/CoMessprotokollView.axaml.cs new file mode 100644 index 0000000..74aede2 --- /dev/null +++ b/src/LageBuch.App.Shared/Views/CoMessprotokollView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace LageBuch.App.Shared.Views; + +public partial class CoMessprotokollView : UserControl +{ + public CoMessprotokollView() + { + InitializeComponent(); + } +} diff --git a/src/LageBuch.App.Shared/Views/IncidentWorkspaceView.axaml b/src/LageBuch.App.Shared/Views/IncidentWorkspaceView.axaml index 08ab03c..b887f46 100644 --- a/src/LageBuch.App.Shared/Views/IncidentWorkspaceView.axaml +++ b/src/LageBuch.App.Shared/Views/IncidentWorkspaceView.axaml @@ -271,6 +271,11 @@ + + + + + diff --git a/src/LageBuch.AppLogic/ViewModels/IncidentWorkspaceViewModel.cs b/src/LageBuch.AppLogic/ViewModels/IncidentWorkspaceViewModel.cs index bb08c66..daf4877 100644 --- a/src/LageBuch.AppLogic/ViewModels/IncidentWorkspaceViewModel.cs +++ b/src/LageBuch.AppLogic/ViewModels/IncidentWorkspaceViewModel.cs @@ -145,6 +145,7 @@ private void ConfirmIncidentNumber() [RelayCommand] private void CancelEditIncidentNumber() => IsEditingIncidentNumber = false; + public CoMessprotokollViewModel CoMessprotokoll { get; private set; } = null!; public ChecklistViewModel ChecklistAufbau { get; private set; } = null!; public ChecklistViewModel ChecklistAbbau { get; private set; } = null!; public EtbViewModel Etb { get; private set; } = null!; @@ -227,6 +228,8 @@ private void BuildChildren() Files = new FilesViewModel(_session, _dialogs, OnChanged); Links = new LinksViewModel(_masterData.Links, _dialogs); + CoMessprotokoll = new CoMessprotokollViewModel(_session, _clock, OnChanged); + Tasks?.Dispose(); Tasks = new TasksViewModel(_session, _clock, _ticker, _alarm, _masterData, OnChanged); @@ -245,6 +248,7 @@ private void BuildChildren() OnPropertyChanged(nameof(Roles)); OnPropertyChanged(nameof(Forces)); OnPropertyChanged(nameof(Scba)); + OnPropertyChanged(nameof(CoMessprotokoll)); OnPropertyChanged(nameof(Files)); OnPropertyChanged(nameof(Links)); OnPropertyChanged(nameof(Tasks)); From 6914d2a14282936f4aa7f40d94617bc98df548eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= <1005065+DeepDiver1975@users.noreply.github.com> Date: Wed, 26 Aug 2026 08:43:07 +0200 Subject: [PATCH 09/18] feat(documents): add CO-Messprotokoll PDF section with traffic-light cells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com> --- .../IncidentReportDocument.cs | 1 + .../Sections/CoMessprotokollSection.cs | 119 ++++++++++++++++++ .../CoMessprotokollSectionTests.cs | 46 +++++++ 3 files changed, 166 insertions(+) create mode 100644 src/LageBuch.Documents/Sections/CoMessprotokollSection.cs create mode 100644 tests/LageBuch.Documents.Tests/CoMessprotokollSectionTests.cs diff --git a/src/LageBuch.Documents/IncidentReportDocument.cs b/src/LageBuch.Documents/IncidentReportDocument.cs index 3173772..c497c3a 100644 --- a/src/LageBuch.Documents/IncidentReportDocument.cs +++ b/src/LageBuch.Documents/IncidentReportDocument.cs @@ -52,6 +52,7 @@ public void Compose(IDocumentContainer document) column.Item().Element(c => ForcesSection.Compose(c, _incident)); column.Item().Element(c => TasksSection.Compose(c, _incident)); column.Item().Element(c => AtemschutzSection.Compose(c, _incident)); + column.Item().Element(c => CoMessprotokollSection.Compose(c, _incident)); column.Item().Element(c => FilesSection.Compose(c, _incident.Files, _imageBytesById)); }); diff --git a/src/LageBuch.Documents/Sections/CoMessprotokollSection.cs b/src/LageBuch.Documents/Sections/CoMessprotokollSection.cs new file mode 100644 index 0000000..4426986 --- /dev/null +++ b/src/LageBuch.Documents/Sections/CoMessprotokollSection.cs @@ -0,0 +1,119 @@ +using LageBuch.Domain; +using LageBuch.Domain.CoMeasurement; +using QuestPDF.Fluent; +using QuestPDF.Helpers; +using QuestPDF.Infrastructure; + +namespace LageBuch.Documents.Sections; + +public static class CoMessprotokollSection +{ + public static void Compose(IContainer container, Incident incident) + { + container.Column(column => + { + column.Spacing(4); + column.Item().Text("CO-Messprotokoll").FontSize(14).SemiBold().FontColor(Colors.Blue.Darken1); + + if (incident.Buildings.Count == 0) + { + column.Item().Text("— kein CO-Messprotokoll erfasst —").Italic().FontColor(Colors.Grey.Medium); + return; + } + + foreach (var building in incident.Buildings) + { + column.Item().PaddingTop(8).Text(t => + { + t.Span($"{building.Name}: ").SemiBold(); + t.Span($"EG–{CoMeasurementLabels.FloorLabel(building.FloorCount)}, {building.ApartmentsPerFloor} Whg./Geschoss"); + }); + + column.Item().Table(table => + { + table.ColumnsDefinition(columns => + { + columns.ConstantColumn(60); + for (var apt = 1; apt <= building.ApartmentsPerFloor; apt++) + columns.ConstantColumn(65); + columns.RelativeColumn(2); + }); + + table.Header(header => + { + header.Cell().Element(Cells.Header).Text("Geschoss"); + for (var apt = 1; apt <= building.ApartmentsPerFloor; apt++) + header.Cell().Element(Cells.Header).Text(CoMeasurementLabels.ApartmentLabel(apt)); + header.Cell().Element(Cells.Header).Text("Lage"); + }); + + for (var floor = building.FloorCount; floor >= 0; floor--) + { + table.Cell().Element(Cells.Body).Text(CoMeasurementLabels.FloorLabel(floor)); + + for (var apt = 1; apt <= building.ApartmentsPerFloor; apt++) + { + var dwelling = incident.Dwellings.FirstOrDefault(d => + d.BuildingId == building.Id && + d.FloorOrdinal == floor && + d.ApartmentNumber == apt); + + if (dwelling?.CoValue is { } coVal) + { + table.Cell().Element(c => c + .Background(GetColor(dwelling.Status)) + .Padding(2)) + .Text($"{coVal} ppm") + .FontSize(8); + } + else + { + table.Cell().Element(Cells.Body).Text("—"); + } + } + + var description = building.FloorDescriptions.TryGetValue(floor, out var d) ? d : null; + table.Cell().Element(Cells.Body).Text(description ?? "—"); + } + }); + } + + var affected = incident.Dwellings.Where(d => d.Status == DwellingStatus.Affected).ToList(); + if (affected.Count > 0) + { + column.Item().PaddingTop(8).Text("Betroffene Wohnungen").SemiBold(); + foreach (var d in affected) + { + var building = incident.Buildings.FirstOrDefault(b => b.Id == d.BuildingId); + if (building is null) continue; + var location = CoMeasurementLabels.DwellingLocation(building, d.FloorOrdinal, d.ApartmentNumber); + var resident = d.ResidentName ?? "—"; + var key = d.KeyAvailable is true ? "ja" : d.KeyAvailable is false ? "nein" : "—"; + var co = d.CoValue is { } v ? $"{v} ppm" : "—"; + column.Item().Text($"• {location}, Bewohner: {resident}, Schlüssel: {key}, CO: {co}"); + } + } + + column.Item().PaddingTop(8).Text(t => + { + t.Span("Legende: ").SemiBold().FontSize(8); + t.Span("■ ").FontColor(HexColor("#FFC000")).FontSize(8); + t.Span("Nicht abgesucht ").FontSize(8); + t.Span("■ ").FontColor(HexColor("#92D050")).FontSize(8); + t.Span("Abgesucht ").FontSize(8); + t.Span("■ ").FontColor(HexColor("#FF0000")).FontSize(8); + t.Span("Betroffen").FontSize(8); + }); + }); + } + + private static string GetColor(DwellingStatus status) => status switch + { + DwellingStatus.NotSearched => HexColor("#FFC000"), + DwellingStatus.Searched => HexColor("#92D050"), + DwellingStatus.Affected => HexColor("#FF0000"), + _ => Colors.White + }; + + private static string HexColor(string hex) => hex; +} diff --git a/tests/LageBuch.Documents.Tests/CoMessprotokollSectionTests.cs b/tests/LageBuch.Documents.Tests/CoMessprotokollSectionTests.cs new file mode 100644 index 0000000..7c04151 --- /dev/null +++ b/tests/LageBuch.Documents.Tests/CoMessprotokollSectionTests.cs @@ -0,0 +1,46 @@ +using LageBuch.Domain; +using LageBuch.Domain.CoMeasurement; +using LageBuch.Domain.Time; + +namespace LageBuch.Documents.Tests; + +public class CoMessprotokollSectionTests +{ + private static readonly FixedClock Clock = new(new DateTimeOffset(2026, 8, 25, 10, 0, 0, TimeSpan.Zero)); + + private static Incident CreateIncidentWithBuilding() + { + var op = new SessionOperator("Test", null); + var incident = Incident.Start(Clock, op); + incident.AddCoBuilding(Clock, op, "Haus A", 2, 3); + incident.RecordCoValue(Clock, op, incident.Buildings[0].Id, 0, 1, 45); + incident.SetDwellingStatus(Clock, op, incident.Buildings[0].Id, 0, 2, DwellingStatus.Affected); + return incident; + } + + [Fact] + public void Pdf_Contains_CO_Section_With_Buildings() + { + var incident = CreateIncidentWithBuilding(); + var pdf = IncidentPdf.Generate(incident, new Dictionary()); + + Assert.True(pdf.Length > 1000); + Assert.Equal(0x25, pdf[0]); // '%' + } + + [Fact] + public void Pdf_Contains_CO_Section_EmptyState() + { + var op = new SessionOperator("Test", null); + var incident = Incident.Start(Clock, op); + var pdf = IncidentPdf.Generate(incident, new Dictionary()); + + Assert.True(pdf.Length > 1000); + Assert.Equal(0x25, pdf[0]); // '%' + } + + private sealed class FixedClock(DateTimeOffset now) : IClock + { + public DateTimeOffset Now { get; set; } = now; + } +} From cfec1c30bbd268541f3c8f8d8701b742153d739d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= <1005065+DeepDiver1975@users.noreply.github.com> Date: Wed, 26 Aug 2026 08:53:01 +0200 Subject: [PATCH 10/18] test(sync): add CommandApplier and SnapshotRoundTrip tests for CO measurement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com> --- .../CommandApplierTests.cs | 46 +++++++++++++++++++ .../SnapshotRoundTripTests.cs | 20 ++++++++ 2 files changed, 66 insertions(+) diff --git a/tests/LageBuch.Sync.Tests/CommandApplierTests.cs b/tests/LageBuch.Sync.Tests/CommandApplierTests.cs index 55749cc..157cb8e 100644 --- a/tests/LageBuch.Sync.Tests/CommandApplierTests.cs +++ b/tests/LageBuch.Sync.Tests/CommandApplierTests.cs @@ -1,5 +1,6 @@ using LageBuch.Domain; using LageBuch.Domain.Atemschutz; +using LageBuch.Domain.CoMeasurement; using LageBuch.Domain.Etb; using LageBuch.Domain.Tasks; @@ -193,4 +194,49 @@ public void SetTaskCompleted_toggles_completion_with_host_time() Assert.True(incident.Tasks[0].IsCompleted); Assert.Equal("Client", incident.Tasks[0].CompletedBy); } + + [Fact] + public void Apply_AddCoBuilding_CreatesBuildingAndDwellings() + { + var clock = new FixedClock(); + var incident = NewIncident(clock); + var cmd = new AddCoBuildingCommand(new OperatorDto("Test", null), "Haus A", 2, 3); + + ApplyOverWire(cmd, incident, clock); + + Assert.Single(incident.Buildings); + Assert.Equal(9, incident.Dwellings.Count); + } + + [Fact] + public void Apply_RecordCoValue_SetsValue() + { + var clock = new FixedClock(); + var incident = NewIncident(clock); + incident.AddCoBuilding(clock, new SessionOperator("Test", null), "Haus A", 2, 3); + var buildingId = incident.Buildings[0].Id; + + var cmd = new RecordCoValueCommand(new OperatorDto("Test", null), buildingId, 0, 1, 45); + ApplyOverWire(cmd, incident, clock); + + var dwelling = incident.Dwellings.First(d => + d.BuildingId == buildingId && d.FloorOrdinal == 0 && d.ApartmentNumber == 1); + Assert.Equal(45, dwelling.CoValue); + } + + [Fact] + public void Apply_SetDwellingStatus_SetsStatus() + { + var clock = new FixedClock(); + var incident = NewIncident(clock); + incident.AddCoBuilding(clock, new SessionOperator("Test", null), "Haus A", 2, 3); + var buildingId = incident.Buildings[0].Id; + + var cmd = new SetDwellingStatusCommand(new OperatorDto("Test", null), buildingId, 0, 1, DwellingStatus.Searched); + ApplyOverWire(cmd, incident, clock); + + var dwelling = incident.Dwellings.First(d => + d.BuildingId == buildingId && d.FloorOrdinal == 0 && d.ApartmentNumber == 1); + Assert.Equal(DwellingStatus.Searched, dwelling.Status); + } } diff --git a/tests/LageBuch.Sync.Tests/SnapshotRoundTripTests.cs b/tests/LageBuch.Sync.Tests/SnapshotRoundTripTests.cs index 5184044..6cb9d6e 100644 --- a/tests/LageBuch.Sync.Tests/SnapshotRoundTripTests.cs +++ b/tests/LageBuch.Sync.Tests/SnapshotRoundTripTests.cs @@ -176,4 +176,24 @@ public void Round_trip_preserves_incident_timers() Assert.Equal(30, timer.RecurringIntervalMinutes); Assert.True(timer.IsRunning); } + + [Fact] + public void SnapshotRoundTrip_BuildingsAndDwellings() + { + var clock = new FixedClock(); + var op = new SessionOperator("Test", null); + var original = Incident.Start(clock, op); + original.AddCoBuilding(clock, op, "Haus A", 2, 3); + original.RecordCoValue(clock, op, original.Buildings[0].Id, 0, 1, 45); + + var snapshot = SnapshotMapper.ToSnapshot(original); + var restored = SnapshotMapper.FromSnapshot(snapshot); + + Assert.Single(restored.Buildings); + Assert.Equal("Haus A", restored.Buildings[0].Name); + Assert.Equal(9, restored.Dwellings.Count); + var dwelling = restored.Dwellings.First(d => + d.FloorOrdinal == 0 && d.ApartmentNumber == 1); + Assert.Equal(45, dwelling.CoValue); + } } From 854ae8855325c8cfde4d0ccbcfd5c0374c7a36e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= <1005065+DeepDiver1975@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:10:11 +0200 Subject: [PATCH 11/18] test(acceptance): add CO-Messprotokoll render test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com> --- .../CoMessprotokollRenderTests.cs | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 tests/LageBuch.Acceptance.Tests/CoMessprotokollRenderTests.cs diff --git a/tests/LageBuch.Acceptance.Tests/CoMessprotokollRenderTests.cs b/tests/LageBuch.Acceptance.Tests/CoMessprotokollRenderTests.cs new file mode 100644 index 0000000..40850a5 --- /dev/null +++ b/tests/LageBuch.Acceptance.Tests/CoMessprotokollRenderTests.cs @@ -0,0 +1,70 @@ +using Avalonia.Controls; +using Avalonia.Headless; +using Avalonia.Headless.XUnit; +using Avalonia.Threading; +using LageBuch.App.Shared.Views; +using LageBuch.AppLogic; +using LageBuch.AppLogic.Services; +using LageBuch.AppLogic.ViewModels; +using LageBuch.Domain; + +namespace LageBuch.Acceptance.Tests; + +public class CoMessprotokollRenderTests +{ + private static (Window Window, IncidentWorkspaceViewModel Vm, LocalIncidentSession Session) ShowWorkspace() + { + var session = LocalIncidentSession.StartNew(new FakeStore(), new FixedClock(), + new SessionOperator("Müller", "FFB 12/1"), "/x.fwincident", + new[] { ("Blaulicht aus?", false) }, Array.Empty<(string, bool)>()); + var vm = new IncidentWorkspaceViewModel(session, new FixedClock(), new NoopTicker(), WorkspaceRenderHelper.MasterData(), + new FakeDialogs(), new NoopAlarmService(), new NoopIncidentHostController()); + var window = new Window { Content = new IncidentWorkspaceView { DataContext = vm }, Width = 1920, Height = 1032 }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + return (window, vm, session); + } + + private static void Capture(Window window, string name) + { + var dir = Environment.GetEnvironmentVariable("RENDER_OUT"); + if (string.IsNullOrWhiteSpace(dir)) + return; + Directory.CreateDirectory(dir); + using var frame = window.CaptureRenderedFrame()!; + frame.SavePng(Path.Combine(dir, name)); + } + + private static TabControl Tabs(Window window) => + ((IncidentWorkspaceView)window.Content!).GetControl("ModuleTabs"); + + [AvaloniaFact] + public void CoMessprotokoll_Tab_Renders() + { + var (window, vm, session) = ShowWorkspace(); + + session.AddCoBuilding("Mehrfamilienhaus A", 3, 4); + session.AddCoBuilding("Mehrfamilienhaus B", 2, 3); + Dispatcher.UIThread.RunJobs(); + + var buildingA = session.Incident.Buildings[0]; + session.RecordCoValue(buildingA.Id, 2, 1, 45); + session.SetDwellingStatus(buildingA.Id, 2, 1, Domain.CoMeasurement.DwellingStatus.Affected); + session.RecordCoValue(buildingA.Id, 2, 2, 120); + session.SetDwellingStatus(buildingA.Id, 2, 2, Domain.CoMeasurement.DwellingStatus.Searched); + session.RecordCoValue(buildingA.Id, 1, 1, 8); + session.SetDwellingStatus(buildingA.Id, 1, 1, Domain.CoMeasurement.DwellingStatus.Searched); + Dispatcher.UIThread.RunJobs(); + + var tabs = Tabs(window); + tabs.SelectedIndex = 6; // CO-MESSUNG + Dispatcher.UIThread.RunJobs(); + + Assert.Equal("CO-MESSUNG", ((TabItem)tabs.SelectedItem!).Header); + Assert.Equal(2, vm.CoMessprotokoll.BuildingOptions.Count); + Assert.NotNull(vm.CoMessprotokoll.SelectedBuilding); + Assert.NotEmpty(vm.CoMessprotokoll.MatrixRows); + + Capture(window, "co-messung.png"); + } +} From a6c575be849af21d01ec3b2030750e167888cd1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= <1005065+DeepDiver1975@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:13:47 +0200 Subject: [PATCH 12/18] fix: Co-Messung design --- .gitignore | 3 +- .../StatusToForegroundValueConverter.cs | 4 +- .../Views/CoMessprotokollView.axaml | 173 ++++++++++++++---- src/LageBuch.AppLogic/LocalIncidentSession.cs | 2 + .../ViewModels/CoMessprotokollViewModel.cs | 80 ++++---- .../CoMessprotokollViewModelTests.cs | 15 ++ 6 files changed, 204 insertions(+), 73 deletions(-) diff --git a/.gitignore b/.gitignore index bc2eac5..959085f 100644 --- a/.gitignore +++ b/.gitignore @@ -24,8 +24,9 @@ seed-source/ # Local SDD scratch / ledger .superpowers/ -# Local Claude Code state +# Local AI agent state .claude/ +.opencode/ # Generated voice/audio drafts (filenames can carry voice-artist names) *.mp3 diff --git a/src/LageBuch.App.Shared/Converters/StatusToForegroundValueConverter.cs b/src/LageBuch.App.Shared/Converters/StatusToForegroundValueConverter.cs index d8911fe..58b0eb7 100644 --- a/src/LageBuch.App.Shared/Converters/StatusToForegroundValueConverter.cs +++ b/src/LageBuch.App.Shared/Converters/StatusToForegroundValueConverter.cs @@ -10,9 +10,7 @@ public sealed class StatusToForegroundValueConverter : IValueConverter public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) { - if (value is string hex && Color.TryParse(hex, out var color)) - return new SolidColorBrush(color); - return Brushes.White; + return new SolidColorBrush(Colors.Black); } public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => diff --git a/src/LageBuch.App.Shared/Views/CoMessprotokollView.axaml b/src/LageBuch.App.Shared/Views/CoMessprotokollView.axaml index fecfa8d..4837c11 100644 --- a/src/LageBuch.App.Shared/Views/CoMessprotokollView.axaml +++ b/src/LageBuch.App.Shared/Views/CoMessprotokollView.axaml @@ -7,11 +7,11 @@ x:DataType="vm:CoMessprotokollViewModel"> - + + IsEnabled="{Binding CanModify}"> @@ -22,19 +22,138 @@ Command="{Binding AddBuildingCommand}" IsEnabled="{Binding !IsReadOnly}" Margin="8,0,0,0" /> - @@ -69,27 +192,9 @@ - + + - - - - - - - - - - - - - - - - - diff --git a/src/LageBuch.AppLogic/LocalIncidentSession.cs b/src/LageBuch.AppLogic/LocalIncidentSession.cs index f3cb9f8..c5e3282 100644 --- a/src/LageBuch.AppLogic/LocalIncidentSession.cs +++ b/src/LageBuch.AppLogic/LocalIncidentSession.cs @@ -237,6 +237,8 @@ public void Close() private void Mutate(Action apply) { + if (IsReadOnly) + throw new InvalidOperationException("Der Einsatz ist bereits abgeschlossen."); apply(); Save(); Changed?.Invoke(); diff --git a/src/LageBuch.AppLogic/ViewModels/CoMessprotokollViewModel.cs b/src/LageBuch.AppLogic/ViewModels/CoMessprotokollViewModel.cs index f95a43c..8bc55f4 100644 --- a/src/LageBuch.AppLogic/ViewModels/CoMessprotokollViewModel.cs +++ b/src/LageBuch.AppLogic/ViewModels/CoMessprotokollViewModel.cs @@ -59,6 +59,13 @@ public DwellingCellViewModel( public string Label => CoMeasurementLabels.ApartmentLabel(ApartmentNumber); + public string KeyDisplay => KeyAvailable switch + { + true => "\uD83D\uDD11", + false => "\u2716", + _ => "" + }; + private static string GetStatusBrush(DwellingStatus status) => status switch { DwellingStatus.NotSearched => "#FFC000", @@ -81,6 +88,8 @@ partial void OnCoValueChanged(int? value) _onCoValueChanged(BuildingId, FloorOrdinal, ApartmentNumber, value); } + partial void OnKeyAvailableChanged(bool? value) => OnPropertyChanged(nameof(KeyDisplay)); + [RelayCommand] private void OpenEditor() => _onOpenEditor(BuildingId, FloorOrdinal, ApartmentNumber); } @@ -113,12 +122,15 @@ public CoMessprotokollViewModel(IIncidentSession session, IClock clock, Action o _clock = clock; _onChanged = onChanged; IsReadOnly = session.IsReadOnly; - _session.Changed += Refresh; Refresh(); } public bool IsReadOnly { get; } + public bool HasBuildings => BuildingOptions.Count > 0; + + public bool CanModify => !IsReadOnly && HasBuildings; + public ObservableCollection BuildingOptions { get; } = new(); [ObservableProperty] @@ -147,9 +159,15 @@ private void Refresh() BuildMatrix(); OnPropertyChanged(nameof(IsReadOnly)); + OnPropertyChanged(nameof(HasBuildings)); + OnPropertyChanged(nameof(CanModify)); } - partial void OnSelectedBuildingChanged(Building? value) => BuildMatrix(); + partial void OnSelectedBuildingChanged(Building? value) + { + BuildMatrix(); + OnPropertyChanged(nameof(CanRemoveBuilding)); + } private void BuildMatrix() { @@ -231,43 +249,12 @@ private void ConfirmAddBuilding() NewBuildingApartments = 10; IsAddBuildingDialogOpen = false; _onChanged(); + Refresh(); } [RelayCommand] private void CancelAddBuilding() => IsAddBuildingDialogOpen = false; - [RelayCommand(CanExecute = nameof(CanModifyStructure))] - private void ModifyStructure() - { - if (SelectedBuilding is null) return; - NewStructureFloors = SelectedBuilding.FloorCount; - NewStructureApartments = SelectedBuilding.ApartmentsPerFloor; - IsModifyStructureDialogOpen = true; - } - - private bool CanModifyStructure => !IsReadOnly && SelectedBuilding is not null; - - [ObservableProperty] - private bool _isModifyStructureDialogOpen; - - [ObservableProperty] - private int _newStructureFloors; - - [ObservableProperty] - private int _newStructureApartments; - - [RelayCommand] - private void ConfirmModifyStructure() - { - if (SelectedBuilding is null) return; - _session.UpdateCoBuildingStructure(SelectedBuilding.Id, NewStructureFloors, NewStructureApartments); - IsModifyStructureDialogOpen = false; - _onChanged(); - } - - [RelayCommand] - private void CancelModifyStructure() => IsModifyStructureDialogOpen = false; - [RelayCommand(CanExecute = nameof(CanRemoveBuilding))] private void RemoveBuilding() { @@ -287,15 +274,29 @@ private void ConfirmRemoveBuilding() _session.RemoveCoBuilding(SelectedBuilding.Id); IsRemoveBuildingConfirmOpen = false; _onChanged(); + Refresh(); } [RelayCommand] private void CancelRemoveBuilding() => IsRemoveBuildingConfirmOpen = false; [RelayCommand] - private void CloseEditor() => IsEditorOpen = false; + private void CloseEditor() + { + PersistSelectedCellDetails(); + IsEditorOpen = false; + } + + + [RelayCommand] + private void SetEditorStatusNotSearched() => SetEditorStatus(DwellingStatus.NotSearched); [RelayCommand] + private void SetEditorStatusSearched() => SetEditorStatus(DwellingStatus.Searched); + + [RelayCommand] + private void SetEditorStatusAffected() => SetEditorStatus(DwellingStatus.Affected); + private void SetEditorStatus(DwellingStatus status) { if (SelectedCell is null) return; @@ -305,7 +306,16 @@ private void SetEditorStatus(DwellingStatus status) [RelayCommand] private void ConfirmEditor() { + PersistSelectedCellDetails(); IsEditorOpen = false; SelectedCell = null; } + + private void PersistSelectedCellDetails() + { + if (SelectedCell is null) return; + _session.SetDwellingDetails(SelectedCell.BuildingId, SelectedCell.FloorOrdinal, + SelectedCell.ApartmentNumber, SelectedCell.ResidentName, SelectedCell.KeyAvailable); + } + } diff --git a/tests/LageBuch.AppLogic.Tests/CoMessprotokollViewModelTests.cs b/tests/LageBuch.AppLogic.Tests/CoMessprotokollViewModelTests.cs index f66cccb..37e0eef 100644 --- a/tests/LageBuch.AppLogic.Tests/CoMessprotokollViewModelTests.cs +++ b/tests/LageBuch.AppLogic.Tests/CoMessprotokollViewModelTests.cs @@ -74,4 +74,19 @@ public void DwellingCellVM_CoDisplay_ShowsPlaceholderWhenNull() cell.CoValue = 45; Assert.Equal("45 ppm", cell.CoDisplay); } + + [Fact] + public void ViewModel_EmptyState_NoBuildings() + { + var op = new SessionOperator("Test", null); + var store = new FakeStore(); + var session = LocalIncidentSession.StartNew(store, Clock, op, Path.GetTempFileName(), + Enumerable.Empty<(string, bool)>(), Enumerable.Empty<(string, bool)>()); + var vm = new CoMessprotokollViewModel(session, Clock, () => { }); + + Assert.False(vm.HasBuildings); + Assert.False(vm.CanModify); + Assert.Empty(vm.BuildingOptions); + Assert.Empty(vm.MatrixRows); + } } From da544cc4d9bfe1232185598949bf6e094ba0a65d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= <1005065+DeepDiver1975@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:22:25 +0200 Subject: [PATCH 13/18] feat(co-messung): editable apartment headers, compact rows, and layout fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Give each matrix cell a real border/spacing instead of touching same-colored rectangles, and restore horizontal scrolling on the matrix ScrollViewer. - Move the editor sidebar ahead of the matrix in DockPanel order so its fixed 320px width is reserved before the matrix greedily claims space. - Override the global Button style's fixed Height on matrix cells, which was silently clamping cell content to 36px and rendering all cell text outside its clip region. - Let cells auto-size to content (Height="NaN") instead of a fixed 180px, so more floors fit without scrolling. - Add editable apartment column headers, persisted per building via a new SetApartmentLabel command/migration (V16), synced the same way as floor descriptions. - Default 3-per-floor buildings to "Links/Mitte/Rechts" instead of generic "Whg. N" labels; still user-editable per column. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01MuNeLrn1nJc7sGw78tEcEF Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com> --- .../Views/CoMessprotokollView.axaml | 146 ++++++++++-------- src/LageBuch.AppLogic/LocalIncidentSession.cs | 3 + .../ViewModels/CoMessprotokollViewModel.cs | 41 ++++- .../Sections/CoMessprotokollSection.cs | 2 +- src/LageBuch.Domain/CoMeasurement/Building.cs | 16 +- .../CoMeasurement/CoMeasurementLabels.cs | 20 ++- src/LageBuch.Domain/Incident.cs | 9 ++ .../IncidentRepository.cs | 16 +- src/LageBuch.Persistence/Sqlite/Migrations.cs | 13 +- src/LageBuch.Sync/CommandApplier.cs | 3 + src/LageBuch.Sync/IIncidentSession.cs | 1 + src/LageBuch.Sync/IncidentSnapshot.cs | 3 +- src/LageBuch.Sync/RemoteIncidentSession.cs | 3 + src/LageBuch.Sync/SnapshotMapper.cs | 8 +- src/LageBuch.Sync/SyncCommand.cs | 5 + .../CoMessprotokollViewModelTests.cs | 2 +- 16 files changed, 213 insertions(+), 78 deletions(-) diff --git a/src/LageBuch.App.Shared/Views/CoMessprotokollView.axaml b/src/LageBuch.App.Shared/Views/CoMessprotokollView.axaml index 4837c11..a467182 100644 --- a/src/LageBuch.App.Shared/Views/CoMessprotokollView.axaml +++ b/src/LageBuch.App.Shared/Views/CoMessprotokollView.axaml @@ -84,50 +84,64 @@ IsVisible="{Binding !HasBuildings}" Margin="0,20,0,0" HorizontalAlignment="Center" /> - - + - - - + BorderThickness="1,0,0,0" + Width="320" + Padding="16" + IsEnabled="{Binding !IsReadOnly}"> + + + + + - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/LageBuch.AppLogic/LocalIncidentSession.cs b/src/LageBuch.AppLogic/LocalIncidentSession.cs index c5e3282..fb7ea74 100644 --- a/src/LageBuch.AppLogic/LocalIncidentSession.cs +++ b/src/LageBuch.AppLogic/LocalIncidentSession.cs @@ -226,6 +226,9 @@ public void SetDwellingDetails(Guid buildingId, int floorOrdinal, int apartmentN public void SetFloorDescription(Guid buildingId, int floorOrdinal, string? description) => Mutate(() => Incident.SetFloorDescription(buildingId, floorOrdinal, description)); + public void SetApartmentLabel(Guid buildingId, int apartmentNumber, string? label) => + Mutate(() => Incident.SetApartmentLabel(buildingId, apartmentNumber, label)); + public void Close() { if (IsReadOnly) diff --git a/src/LageBuch.AppLogic/ViewModels/CoMessprotokollViewModel.cs b/src/LageBuch.AppLogic/ViewModels/CoMessprotokollViewModel.cs index 8bc55f4..229aa43 100644 --- a/src/LageBuch.AppLogic/ViewModels/CoMessprotokollViewModel.cs +++ b/src/LageBuch.AppLogic/ViewModels/CoMessprotokollViewModel.cs @@ -94,6 +94,31 @@ partial void OnCoValueChanged(int? value) private void OpenEditor() => _onOpenEditor(BuildingId, FloorOrdinal, ApartmentNumber); } +public sealed partial class ApartmentColumnViewModel : ObservableObject +{ + private readonly Action _onLabelChanged; + + public ApartmentColumnViewModel(int apartmentNumber, string label, bool isReadOnly, Action onLabelChanged) + { + ApartmentNumber = apartmentNumber; + IsReadOnly = isReadOnly; + _label = label; + _onLabelChanged = onLabelChanged; + } + + public int ApartmentNumber { get; } + public bool IsReadOnly { get; } + + [ObservableProperty] + private string _label; + + partial void OnLabelChanged(string value) + { + if (!IsReadOnly) + _onLabelChanged(ApartmentNumber, value); + } +} + public sealed partial class FloorRowViewModel : ObservableObject { public FloorRowViewModel(int ordinal, string label, IReadOnlyList cells, string? description) @@ -140,7 +165,7 @@ public CoMessprotokollViewModel(IIncidentSession session, IClock clock, Action o private ObservableCollection _matrixRows = new(); [ObservableProperty] - private IReadOnlyList _apartmentLabels = Array.Empty(); + private IReadOnlyList _apartmentColumns = Array.Empty(); [ObservableProperty] private DwellingCellViewModel? _selectedCell; @@ -174,13 +199,14 @@ private void BuildMatrix() MatrixRows.Clear(); if (SelectedBuilding is null) { - ApartmentLabels = Array.Empty(); + ApartmentColumns = Array.Empty(); return; } var building = SelectedBuilding; - ApartmentLabels = Enumerable.Range(1, building.ApartmentsPerFloor) - .Select(CoMeasurementLabels.ApartmentLabel) + ApartmentColumns = Enumerable.Range(1, building.ApartmentsPerFloor) + .Select(apt => new ApartmentColumnViewModel( + apt, CoMeasurementLabels.ApartmentLabel(building, apt), IsReadOnly, OnApartmentLabelChanged)) .ToArray(); for (var floor = building.FloorCount; floor >= 0; floor--) @@ -215,6 +241,13 @@ private void OnCoValueChanged(Guid buildingId, int floorOrdinal, int apartmentNu _onChanged(); } + private void OnApartmentLabelChanged(int apartmentNumber, string? label) + { + if (SelectedBuilding is null) return; + _session.SetApartmentLabel(SelectedBuilding.Id, apartmentNumber, label); + _onChanged(); + } + private void OnOpenEditor(Guid buildingId, int floorOrdinal, int apartmentNumber) { SelectedCell = MatrixRows diff --git a/src/LageBuch.Documents/Sections/CoMessprotokollSection.cs b/src/LageBuch.Documents/Sections/CoMessprotokollSection.cs index 4426986..2dd10e0 100644 --- a/src/LageBuch.Documents/Sections/CoMessprotokollSection.cs +++ b/src/LageBuch.Documents/Sections/CoMessprotokollSection.cs @@ -43,7 +43,7 @@ public static void Compose(IContainer container, Incident incident) { header.Cell().Element(Cells.Header).Text("Geschoss"); for (var apt = 1; apt <= building.ApartmentsPerFloor; apt++) - header.Cell().Element(Cells.Header).Text(CoMeasurementLabels.ApartmentLabel(apt)); + header.Cell().Element(Cells.Header).Text(CoMeasurementLabels.ApartmentLabel(building, apt)); header.Cell().Element(Cells.Header).Text("Lage"); }); diff --git a/src/LageBuch.Domain/CoMeasurement/Building.cs b/src/LageBuch.Domain/CoMeasurement/Building.cs index f0db1c1..db99b3c 100644 --- a/src/LageBuch.Domain/CoMeasurement/Building.cs +++ b/src/LageBuch.Domain/CoMeasurement/Building.cs @@ -8,6 +8,8 @@ public sealed record Building public int ApartmentsPerFloor { get; private init; } public IReadOnlyDictionary FloorDescriptions { get; private init; } = new Dictionary(); + public IReadOnlyDictionary ApartmentLabels { get; private init; } = + new Dictionary(); public int Ordinal { get; private init; } private Building() { } @@ -33,7 +35,8 @@ public static Building Create(string name, int floorCount, int apartmentsPerFloo public static Building Rehydrate( Guid id, string name, int floorCount, int apartmentsPerFloor, - IReadOnlyDictionary floorDescriptions, int ordinal) + IReadOnlyDictionary floorDescriptions, int ordinal, + IReadOnlyDictionary? apartmentLabels = null) => new() { Id = id, @@ -41,6 +44,7 @@ public static Building Rehydrate( FloorCount = floorCount, ApartmentsPerFloor = apartmentsPerFloor, FloorDescriptions = floorDescriptions, + ApartmentLabels = apartmentLabels ?? new Dictionary(), Ordinal = ordinal }; @@ -62,4 +66,14 @@ public Building WithFloorDescription(int ordinal, string? description) dict[ordinal] = description.Trim(); return this with { FloorDescriptions = dict }; } + + public Building WithApartmentLabel(int apartmentNumber, string? label) + { + var dict = new Dictionary(ApartmentLabels.ToDictionary(kv => kv.Key, kv => kv.Value)); + if (string.IsNullOrWhiteSpace(label)) + dict.Remove(apartmentNumber); + else + dict[apartmentNumber] = label.Trim(); + return this with { ApartmentLabels = dict }; + } } \ No newline at end of file diff --git a/src/LageBuch.Domain/CoMeasurement/CoMeasurementLabels.cs b/src/LageBuch.Domain/CoMeasurement/CoMeasurementLabels.cs index 619e792..548efd3 100644 --- a/src/LageBuch.Domain/CoMeasurement/CoMeasurementLabels.cs +++ b/src/LageBuch.Domain/CoMeasurement/CoMeasurementLabels.cs @@ -8,8 +8,26 @@ public static string FloorLabel(int ordinal) => public static string ApartmentLabel(int apartmentNumber) => $"Whg. {apartmentNumber}"; + // Three dwellings per floor is the common walk-up layout, so "links/Mitte/rechts" reads + // faster on scene than a generic "Whg. N" — still just the default, always user-editable. + public static string DefaultApartmentLabel(int apartmentNumber, int apartmentsPerFloor) => + apartmentsPerFloor == 3 + ? apartmentNumber switch + { + 1 => "Links", + 2 => "Mitte", + 3 => "Rechts", + _ => ApartmentLabel(apartmentNumber) + } + : ApartmentLabel(apartmentNumber); + + public static string ApartmentLabel(Building building, int apartmentNumber) => + building.ApartmentLabels.TryGetValue(apartmentNumber, out var custom) && !string.IsNullOrWhiteSpace(custom) + ? custom! + : DefaultApartmentLabel(apartmentNumber, building.ApartmentsPerFloor); + public static string DwellingLocation(Building building, int floorOrdinal, int apartmentNumber) => - $"{building.Name}, {FloorLabel(floorOrdinal)}, {ApartmentLabel(apartmentNumber)}"; + $"{building.Name}, {FloorLabel(floorOrdinal)}, {ApartmentLabel(building, apartmentNumber)}"; public static string StatusText(DwellingStatus status) => status switch { diff --git a/src/LageBuch.Domain/Incident.cs b/src/LageBuch.Domain/Incident.cs index 3a5d200..fb6aae6 100644 --- a/src/LageBuch.Domain/Incident.cs +++ b/src/LageBuch.Domain/Incident.cs @@ -841,4 +841,13 @@ public void SetFloorDescription(Guid buildingId, int ordinal, string? descriptio var index = _buildings.IndexOf(building); _buildings[index] = updated; } + + public void SetApartmentLabel(Guid buildingId, int apartmentNumber, string? label) + { + EnsureOpen(); + var building = FindBuilding(buildingId); + var updated = building.WithApartmentLabel(apartmentNumber, label); + var index = _buildings.IndexOf(building); + _buildings[index] = updated; + } } diff --git a/src/LageBuch.Persistence/IncidentRepository.cs b/src/LageBuch.Persistence/IncidentRepository.cs index d9c2739..7cfca9a 100644 --- a/src/LageBuch.Persistence/IncidentRepository.cs +++ b/src/LageBuch.Persistence/IncidentRepository.cs @@ -199,13 +199,14 @@ public void Save(string path, Incident incident) { var b = incident.Buildings[i]; var descriptionsJson = System.Text.Json.JsonSerializer.Serialize(b.FloorDescriptions); + var apartmentLabelsJson = System.Text.Json.JsonSerializer.Serialize(b.ApartmentLabels); Run(cn, tx, - "INSERT INTO co_buildings (id, name, floor_count, apartments_per_floor, floor_descriptions, ordinal) VALUES ($id,$name,$fc,$apf,$fd,$o);", + "INSERT INTO co_buildings (id, name, floor_count, apartments_per_floor, floor_descriptions, ordinal, apartment_labels) VALUES ($id,$name,$fc,$apf,$fd,$o,$al);", p => { p("$id", b.Id.ToString()); p("$name", b.Name); p("$fc", b.FloorCount); p("$apf", b.ApartmentsPerFloor); - p("$fd", descriptionsJson); p("$o", i); + p("$fd", descriptionsJson); p("$o", i); p("$al", apartmentLabelsJson); }); } @@ -399,7 +400,7 @@ public Incident Load(string path) r.GetString(2), r.GetInt64(3), ParseDate(r.GetString(4)), r.GetString(5))); var buildings = ReadAll(cn, - "SELECT id, name, floor_count, apartments_per_floor, floor_descriptions, ordinal FROM co_buildings ORDER BY ordinal;", + "SELECT id, name, floor_count, apartments_per_floor, floor_descriptions, ordinal, apartment_labels FROM co_buildings ORDER BY ordinal;", r => { var fdJson = r.GetString(4); @@ -408,8 +409,15 @@ public Incident Load(string path) var fdDict = fd.ToDictionary( kv => int.Parse(kv.Key), kv => kv.Value); + // apartment_labels is null on rows written before this column existed. + var alJson = Str(r, 6); + var alDict = alJson is null + ? new Dictionary() + : (System.Text.Json.JsonSerializer.Deserialize>(alJson) + ?? new Dictionary()) + .ToDictionary(kv => int.Parse(kv.Key), kv => kv.Value); return Domain.CoMeasurement.Building.Rehydrate(Guid.Parse(r.GetString(0)), r.GetString(1), - r.GetInt32(2), r.GetInt32(3), fdDict, r.GetInt32(5)); + r.GetInt32(2), r.GetInt32(3), fdDict, r.GetInt32(5), alDict); }); var dwellings = ReadAll(cn, diff --git a/src/LageBuch.Persistence/Sqlite/Migrations.cs b/src/LageBuch.Persistence/Sqlite/Migrations.cs index f818533..d9ae8ff 100644 --- a/src/LageBuch.Persistence/Sqlite/Migrations.cs +++ b/src/LageBuch.Persistence/Sqlite/Migrations.cs @@ -5,7 +5,7 @@ namespace LageBuch.Persistence.Sqlite; public static class Migrations { - public const int CurrentVersion = 15; + public const int CurrentVersion = 16; public static int GetVersion(SqliteConnection cn) { @@ -92,6 +92,10 @@ public static void Migrate(SqliteConnection cn) { ApplyV15(cn, tx); } + if (version < 16) + { + ApplyV16(cn, tx); + } SetVersion(cn, tx, CurrentVersion); tx.Commit(); } @@ -474,6 +478,13 @@ co_value INTEGER """); } + private static void ApplyV16(SqliteConnection cn, SqliteTransaction tx) + { + // Custom column headers (e.g. "Links/Mitte/Rechts") alongside the existing floor + // descriptions. Nullable-safe default so existing buildings just read back with no overrides. + SchemaHelpers.AddColumnIfMissing(cn, tx, "co_buildings", "apartment_labels", "TEXT NOT NULL DEFAULT '{}'"); + } + private static void SetVersion(SqliteConnection cn, SqliteTransaction tx, int version) { Exec(cn, tx, "DELETE FROM schema_version;"); diff --git a/src/LageBuch.Sync/CommandApplier.cs b/src/LageBuch.Sync/CommandApplier.cs index 82c690f..f77d9c5 100644 --- a/src/LageBuch.Sync/CommandApplier.cs +++ b/src/LageBuch.Sync/CommandApplier.cs @@ -129,6 +129,9 @@ public static void Apply(SyncCommand command, Incident incident, IClock clock, A case SetFloorDescriptionCommand c: incident.SetFloorDescription(c.BuildingId, c.FloorOrdinal, c.Description); break; + case SetApartmentLabelCommand c: + incident.SetApartmentLabel(c.BuildingId, c.ApartmentNumber, c.Label); + break; default: throw new ArgumentOutOfRangeException(nameof(command), $"Unbekannter Befehl: {command.GetType().Name}"); diff --git a/src/LageBuch.Sync/IIncidentSession.cs b/src/LageBuch.Sync/IIncidentSession.cs index 8e3a7a5..ca174d8 100644 --- a/src/LageBuch.Sync/IIncidentSession.cs +++ b/src/LageBuch.Sync/IIncidentSession.cs @@ -111,4 +111,5 @@ void AddScbaTrupp(string designation, IEnumerable members, string? void SetDwellingStatus(Guid buildingId, int floorOrdinal, int apartmentNumber, DwellingStatus status); void SetDwellingDetails(Guid buildingId, int floorOrdinal, int apartmentNumber, string? residentName, bool? keyAvailable); void SetFloorDescription(Guid buildingId, int floorOrdinal, string? description); + void SetApartmentLabel(Guid buildingId, int apartmentNumber, string? label); } diff --git a/src/LageBuch.Sync/IncidentSnapshot.cs b/src/LageBuch.Sync/IncidentSnapshot.cs index 7bd2306..fcdbf44 100644 --- a/src/LageBuch.Sync/IncidentSnapshot.cs +++ b/src/LageBuch.Sync/IncidentSnapshot.cs @@ -132,7 +132,8 @@ public sealed record TaskDto( public sealed record BuildingDto( Guid Id, string Name, int FloorCount, int ApartmentsPerFloor, - Dictionary FloorDescriptions, int Ordinal); + Dictionary FloorDescriptions, int Ordinal, + Dictionary? ApartmentLabels = null); public sealed record DwellingDto( Guid Id, Guid BuildingId, int FloorOrdinal, int ApartmentNumber, diff --git a/src/LageBuch.Sync/RemoteIncidentSession.cs b/src/LageBuch.Sync/RemoteIncidentSession.cs index e8be7bc..0f13267 100644 --- a/src/LageBuch.Sync/RemoteIncidentSession.cs +++ b/src/LageBuch.Sync/RemoteIncidentSession.cs @@ -280,6 +280,9 @@ public void SetDwellingDetails(Guid buildingId, int floorOrdinal, int apartmentN public void SetFloorDescription(Guid buildingId, int floorOrdinal, string? description) => Send(new SetFloorDescriptionCommand(buildingId, floorOrdinal, description)); + public void SetApartmentLabel(Guid buildingId, int apartmentNumber, string? label) => + Send(new SetApartmentLabelCommand(buildingId, apartmentNumber, label)); + private OperatorDto Op() => new(Operator!.Name, Operator.CallSign); // Fire-and-forget: the command is POSTed; the host's broadcast (or a rejection the host swallows) diff --git a/src/LageBuch.Sync/SnapshotMapper.cs b/src/LageBuch.Sync/SnapshotMapper.cs index e31f3ca..d9951bb 100644 --- a/src/LageBuch.Sync/SnapshotMapper.cs +++ b/src/LageBuch.Sync/SnapshotMapper.cs @@ -48,7 +48,8 @@ public static IncidentSnapshot ToSnapshot(Incident incident) incident.Buildings.Select(b => new BuildingDto( b.Id, b.Name, b.FloorCount, b.ApartmentsPerFloor, b.FloorDescriptions.ToDictionary(kv => kv.Key.ToString(), kv => kv.Value), - b.Ordinal)).ToList(), + b.Ordinal, + b.ApartmentLabels.ToDictionary(kv => kv.Key.ToString(), kv => kv.Value))).ToList(), incident.Dwellings.Select(d => new DwellingDto( d.Id, d.BuildingId, d.FloorOrdinal, d.ApartmentNumber, d.ResidentName, d.Status, d.KeyAvailable, d.CoValue)).ToList()); @@ -87,7 +88,10 @@ public static Incident FromSnapshot(IncidentSnapshot snapshot) b.FloorDescriptions.ToDictionary( kv => int.Parse(kv.Key), kv => kv.Value), - b.Ordinal)), + b.Ordinal, + b.ApartmentLabels?.ToDictionary( + kv => int.Parse(kv.Key), + kv => kv.Value))), snapshot.Dwellings.Select(d => Dwelling.Rehydrate( d.Id, d.BuildingId, d.FloorOrdinal, d.ApartmentNumber, d.ResidentName, d.Status, d.KeyAvailable, d.CoValue))); diff --git a/src/LageBuch.Sync/SyncCommand.cs b/src/LageBuch.Sync/SyncCommand.cs index b31afff..cc0ee70 100644 --- a/src/LageBuch.Sync/SyncCommand.cs +++ b/src/LageBuch.Sync/SyncCommand.cs @@ -43,6 +43,7 @@ namespace LageBuch.Sync; [JsonDerivedType(typeof(SetDwellingStatusCommand), "setDwellingStatus")] [JsonDerivedType(typeof(UpdateDwellingDetailsCommand), "updateDwellingDetails")] [JsonDerivedType(typeof(SetFloorDescriptionCommand), "setFloorDescription")] +[JsonDerivedType(typeof(SetApartmentLabelCommand), "setApartmentLabel")] public abstract record SyncCommand; /// The operator at the sending device — carried on attributed mutations (see §6). @@ -139,3 +140,7 @@ public sealed record UpdateDwellingDetailsCommand( // No operator (silent) public sealed record SetFloorDescriptionCommand( Guid BuildingId, int FloorOrdinal, string? Description) : SyncCommand; + +// No operator (silent) +public sealed record SetApartmentLabelCommand( + Guid BuildingId, int ApartmentNumber, string? Label) : SyncCommand; diff --git a/tests/LageBuch.AppLogic.Tests/CoMessprotokollViewModelTests.cs b/tests/LageBuch.AppLogic.Tests/CoMessprotokollViewModelTests.cs index 37e0eef..e59dfab 100644 --- a/tests/LageBuch.AppLogic.Tests/CoMessprotokollViewModelTests.cs +++ b/tests/LageBuch.AppLogic.Tests/CoMessprotokollViewModelTests.cs @@ -27,7 +27,7 @@ public void ViewModel_BuildsMatrix_FromIncident() Assert.Single(vm.BuildingOptions); Assert.Equal("Haus A", vm.BuildingOptions[0].Name); Assert.Equal(3, vm.MatrixRows.Count); // 2 OG + EG - Assert.Equal(3, vm.ApartmentLabels.Count); + Assert.Equal(3, vm.ApartmentColumns.Count); } [Fact] From 093d28ff4c9cfa89329b471d29907beed373dc87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= <1005065+DeepDiver1975@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:34:00 +0200 Subject: [PATCH 14/18] style(co-messung): replace solid-fill matrix cells with door-mark cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Traffic-light-style solid fills read as generic and hid the cell text behind a fixed-height clamp from the global Button style. Redesign each cell around the search-marking convention crews already use on doors: a dark card with a colored accent stripe/outline plus a small glyph (slash = in progress, X = cleared, circled X = victim found), CO reading in monospace, key icon as a corner badge. Same status colors, now doubled with a shape cue. Widened cell/header MinWidth (120 -> 136) and added a guaranteed margin before the key icon; the previous width let "Kein Messwert" and the key glyph crowd together with no gap. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01MuNeLrn1nJc7sGw78tEcEF Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com> --- .../Views/CoMessprotokollView.axaml | 42 ++++++++++++------- .../ViewModels/CoMessprotokollViewModel.cs | 12 ++++++ 2 files changed, 40 insertions(+), 14 deletions(-) diff --git a/src/LageBuch.App.Shared/Views/CoMessprotokollView.axaml b/src/LageBuch.App.Shared/Views/CoMessprotokollView.axaml index a467182..b69566d 100644 --- a/src/LageBuch.App.Shared/Views/CoMessprotokollView.axaml +++ b/src/LageBuch.App.Shared/Views/CoMessprotokollView.axaml @@ -159,7 +159,7 @@ - - - - - - - + + + + + + + + + + + + + diff --git a/src/LageBuch.AppLogic/ViewModels/CoMessprotokollViewModel.cs b/src/LageBuch.AppLogic/ViewModels/CoMessprotokollViewModel.cs index 229aa43..a513200 100644 --- a/src/LageBuch.AppLogic/ViewModels/CoMessprotokollViewModel.cs +++ b/src/LageBuch.AppLogic/ViewModels/CoMessprotokollViewModel.cs @@ -66,6 +66,17 @@ public DwellingCellViewModel( _ => "" }; + // Mirrors the spray-marked "X-code" convention search teams already use on doors: a single + // slash means the search is under way, a complete X means it's cleared, and a circled X flags + // a find. Shape carries the same meaning as StatusBrush's color, redundantly, on purpose. + public string StatusGlyph => Status switch + { + DwellingStatus.NotSearched => "\u2571", + DwellingStatus.Searched => "\u2715", + DwellingStatus.Affected => "\u2297", + _ => "\u2571" + }; + private static string GetStatusBrush(DwellingStatus status) => status switch { DwellingStatus.NotSearched => "#FFC000", @@ -77,6 +88,7 @@ public DwellingCellViewModel( partial void OnStatusChanged(DwellingStatus value) { StatusBrush = GetStatusBrush(value); + OnPropertyChanged(nameof(StatusGlyph)); if (!IsReadOnly) _onStatusChanged(BuildingId, FloorOrdinal, ApartmentNumber, value); } From 87cddb1992ad966343b264b5cce7a5f0a14febb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= <1005065+DeepDiver1975@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:37:31 +0200 Subject: [PATCH 15/18] fix(co-messung): stop long custom headers overflowing their column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header TextBox only had MinWidth, so a long custom apartment label (user-editable since the last change) grew past its 136px column and overlapped the next header, breaking alignment with the cells below. Fix to a hard Width so it always matches the cell column and clips overflow instead of stretching. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01MuNeLrn1nJc7sGw78tEcEF Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com> --- src/LageBuch.App.Shared/Views/CoMessprotokollView.axaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/LageBuch.App.Shared/Views/CoMessprotokollView.axaml b/src/LageBuch.App.Shared/Views/CoMessprotokollView.axaml index b69566d..8df013f 100644 --- a/src/LageBuch.App.Shared/Views/CoMessprotokollView.axaml +++ b/src/LageBuch.App.Shared/Views/CoMessprotokollView.axaml @@ -159,7 +159,7 @@ - Date: Thu, 27 Aug 2026 00:47:48 +0200 Subject: [PATCH 16/18] fix(co-messung): stop header inputs popping a full box on focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FluentTheme's TextBox control theme styles Border#PART_BorderElement directly on :focus/:pointerover (background, border brush, and a 4-sided BorderThemeThicknessFocused), completely bypassing the plain Background/ BorderThickness set on the control -- so every header looked like a quiet underline until you actually clicked into one, at which point it popped a full rounded box in the theme's default accent color. Confirmed by dumping the resolved PART_BorderElement properties at runtime. An earlier attempt to fix this by overriding the same-named resources via TextBox.Resources did not take (the resource resolved correctly via TryFindResource but wasn't the value the template style actually applied at render time) -- an instance-scoped TextBox.Styles targeting the same :focus//:pointerover selectors with direct Setters does. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01MuNeLrn1nJc7sGw78tEcEF Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com> --- .../Views/CoMessprotokollView.axaml | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/LageBuch.App.Shared/Views/CoMessprotokollView.axaml b/src/LageBuch.App.Shared/Views/CoMessprotokollView.axaml index 8df013f..433f1b1 100644 --- a/src/LageBuch.App.Shared/Views/CoMessprotokollView.axaml +++ b/src/LageBuch.App.Shared/Views/CoMessprotokollView.axaml @@ -159,11 +159,27 @@ + + IsEnabled="{Binding !IsReadOnly}"> + + + + + From 2daed7a02009a81739cbdc2f61cb40d97decd61e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= <1005065+DeepDiver1975@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:51:26 +0200 Subject: [PATCH 17/18] fix(co-messung): stop cells growing wider than their column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cell Border only had MinWidth, so a cell whose content didn't fit -- in practice, "Kein Messwert" plus the key icon -- grew past it (146px vs. the 136px baseline). UniformGrid then stretches every cell in that row to match the widest one, but rows without that combination stayed at 136px, so column boundaries drifted between floors instead of lining up. Same class of bug as the earlier header-overflow fix. Pin both the cell and header to a fixed 148px (confirmed via the actual rendered bounds to be enough for glyph + "Kein Messwert" + key icon with no truncation) so every row's columns land in the same place. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01MuNeLrn1nJc7sGw78tEcEF Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com> --- src/LageBuch.App.Shared/Views/CoMessprotokollView.axaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/LageBuch.App.Shared/Views/CoMessprotokollView.axaml b/src/LageBuch.App.Shared/Views/CoMessprotokollView.axaml index 433f1b1..498a342 100644 --- a/src/LageBuch.App.Shared/Views/CoMessprotokollView.axaml +++ b/src/LageBuch.App.Shared/Views/CoMessprotokollView.axaml @@ -163,7 +163,7 @@ Border#PART_BorderElement directly and ignoring the plain BorderBrush/ BorderThickness set below; override those exact states locally so the header stays a quiet underline throughout instead of popping a box while editing. --> - - From c416668df6471bfc6300e7f43e3b1d6d303c9a54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= <1005065+DeepDiver1975@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:57:14 +0200 Subject: [PATCH 18/18] fix(co-messung): refresh CO-Messprotokoll view model on session changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CoMessprotokollViewModel never subscribed to IIncidentSession.Changed like its sibling view models (Forces, Roles, ...), so it went stale whenever the domain mutated outside its own Add/Remove-building commands (e.g. remote sync). Also updates acceptance tests whose hardcoded tab count/index assumed 9 tabs before CO-MESSUNG was inserted at index 6, shifting Dateien/Links down by one. Fixes the CI Test-step failure on PR #138. Signed-off-by: Thomas Müller <1005065+DeepDiver1975@users.noreply.github.com> --- src/LageBuch.AppLogic/ViewModels/CoMessprotokollViewModel.cs | 1 + tests/LageBuch.Acceptance.Tests/FilesTabRenderTests.cs | 4 ++-- tests/LageBuch.Acceptance.Tests/LinksTabRenderTests.cs | 4 ++-- tests/LageBuch.Acceptance.Tests/TasksTabRenderTests.cs | 2 +- tests/LageBuch.Acceptance.Tests/WorkspaceAcceptanceTests.cs | 2 +- 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/LageBuch.AppLogic/ViewModels/CoMessprotokollViewModel.cs b/src/LageBuch.AppLogic/ViewModels/CoMessprotokollViewModel.cs index a513200..011fe1c 100644 --- a/src/LageBuch.AppLogic/ViewModels/CoMessprotokollViewModel.cs +++ b/src/LageBuch.AppLogic/ViewModels/CoMessprotokollViewModel.cs @@ -159,6 +159,7 @@ public CoMessprotokollViewModel(IIncidentSession session, IClock clock, Action o _clock = clock; _onChanged = onChanged; IsReadOnly = session.IsReadOnly; + _session.Changed += Refresh; Refresh(); } diff --git a/tests/LageBuch.Acceptance.Tests/FilesTabRenderTests.cs b/tests/LageBuch.Acceptance.Tests/FilesTabRenderTests.cs index ccd160e..5960cb5 100644 --- a/tests/LageBuch.Acceptance.Tests/FilesTabRenderTests.cs +++ b/tests/LageBuch.Acceptance.Tests/FilesTabRenderTests.cs @@ -46,7 +46,7 @@ public void Workspace_renders_eight_tabs_before_dateien_is_opened() var (window, _, _) = ShowWorkspace(); var tabs = Tabs(window); - Assert.Equal(9, tabs.Items.Count); + Assert.Equal(10, tabs.Items.Count); Capture(window, "files-before.png"); } @@ -62,7 +62,7 @@ public void Selecting_the_dateien_tab_shows_an_attached_file() Dispatcher.UIThread.RunJobs(); var tabs = Tabs(window); - tabs.SelectedIndex = 6; // DATEIEN + tabs.SelectedIndex = 7; // DATEIEN Dispatcher.UIThread.RunJobs(); Assert.Equal("DATEIEN", ((TabItem)tabs.SelectedItem!).Header); diff --git a/tests/LageBuch.Acceptance.Tests/LinksTabRenderTests.cs b/tests/LageBuch.Acceptance.Tests/LinksTabRenderTests.cs index a80004e..3b2fb70 100644 --- a/tests/LageBuch.Acceptance.Tests/LinksTabRenderTests.cs +++ b/tests/LageBuch.Acceptance.Tests/LinksTabRenderTests.cs @@ -46,7 +46,7 @@ public void Workspace_renders_eight_tabs_before_links_is_opened() var (window, _) = ShowWorkspace(); var tabs = Tabs(window); - Assert.Equal(9, tabs.Items.Count); + Assert.Equal(10, tabs.Items.Count); Capture(window, "links-before.png"); } @@ -56,7 +56,7 @@ public void Selecting_the_links_tab_shows_the_seeded_links() var (window, vm) = ShowWorkspace(); var tabs = Tabs(window); - tabs.SelectedIndex = 7; // LINKS + tabs.SelectedIndex = 8; // LINKS Dispatcher.UIThread.RunJobs(); Assert.Equal("LINKS", ((TabItem)tabs.SelectedItem!).Header); diff --git a/tests/LageBuch.Acceptance.Tests/TasksTabRenderTests.cs b/tests/LageBuch.Acceptance.Tests/TasksTabRenderTests.cs index a3780d8..4957250 100644 --- a/tests/LageBuch.Acceptance.Tests/TasksTabRenderTests.cs +++ b/tests/LageBuch.Acceptance.Tests/TasksTabRenderTests.cs @@ -47,7 +47,7 @@ private static TabControl Tabs(Window window) => public void Workspace_now_nine_tabs_with_aufgaben_third() { var (window, _, _, _, _) = ShowWorkspace(); - Assert.Equal(9, Tabs(window).Items.Count()); + Assert.Equal(10, Tabs(window).Items.Count()); var aufgabenTab = (TabItem)Tabs(window).Items.ElementAt(2)!; Assert.Equal("AUFGABEN", (string)aufgabenTab.Header!); } diff --git a/tests/LageBuch.Acceptance.Tests/WorkspaceAcceptanceTests.cs b/tests/LageBuch.Acceptance.Tests/WorkspaceAcceptanceTests.cs index e6fbcb2..af699f2 100644 --- a/tests/LageBuch.Acceptance.Tests/WorkspaceAcceptanceTests.cs +++ b/tests/LageBuch.Acceptance.Tests/WorkspaceAcceptanceTests.cs @@ -114,7 +114,7 @@ public void Workspace_renders_with_eight_tabs() window.Show(); var tabs = window.GetVisualDescendants().OfType().Single(); - Assert.Equal(9, tabs.Items.Count); + Assert.Equal(10, tabs.Items.Count); } [AvaloniaFact]