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
new file mode 100644
index 0000000..58b0eb7
--- /dev/null
+++ b/src/LageBuch.App.Shared/Converters/StatusToForegroundValueConverter.cs
@@ -0,0 +1,18 @@
+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)
+ {
+ return new SolidColorBrush(Colors.Black);
+ }
+
+ 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..498a342
--- /dev/null
+++ b/src/LageBuch.App.Shared/Views/CoMessprotokollView.axaml
@@ -0,0 +1,252 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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/LocalIncidentSession.cs b/src/LageBuch.AppLogic/LocalIncidentSession.cs
index 7438a2f..fb7ea74 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,30 @@ 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 SetApartmentLabel(Guid buildingId, int apartmentNumber, string? label) =>
+ Mutate(() => Incident.SetApartmentLabel(buildingId, apartmentNumber, label));
+
public void Close()
{
if (IsReadOnly)
@@ -215,6 +240,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
new file mode 100644
index 0000000..011fe1c
--- /dev/null
+++ b/src/LageBuch.AppLogic/ViewModels/CoMessprotokollViewModel.cs
@@ -0,0 +1,367 @@
+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);
+
+ public string KeyDisplay => KeyAvailable switch
+ {
+ true => "\uD83D\uDD11",
+ false => "\u2716",
+ _ => ""
+ };
+
+ // 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",
+ DwellingStatus.Searched => "#92D050",
+ DwellingStatus.Affected => "#FF0000",
+ _ => "#FFC000"
+ };
+
+ partial void OnStatusChanged(DwellingStatus value)
+ {
+ StatusBrush = GetStatusBrush(value);
+ OnPropertyChanged(nameof(StatusGlyph));
+ if (!IsReadOnly)
+ _onStatusChanged(BuildingId, FloorOrdinal, ApartmentNumber, value);
+ }
+
+ partial void OnCoValueChanged(int? value)
+ {
+ OnPropertyChanged(nameof(CoDisplay));
+ if (!IsReadOnly)
+ _onCoValueChanged(BuildingId, FloorOrdinal, ApartmentNumber, value);
+ }
+
+ partial void OnKeyAvailableChanged(bool? value) => OnPropertyChanged(nameof(KeyDisplay));
+
+ [RelayCommand]
+ 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)
+ {
+ 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 bool HasBuildings => BuildingOptions.Count > 0;
+
+ public bool CanModify => !IsReadOnly && HasBuildings;
+
+ public ObservableCollection BuildingOptions { get; } = new();
+
+ [ObservableProperty]
+ private Building? _selectedBuilding;
+
+ [ObservableProperty]
+ private ObservableCollection _matrixRows = new();
+
+ [ObservableProperty]
+ private IReadOnlyList _apartmentColumns = 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));
+ OnPropertyChanged(nameof(HasBuildings));
+ OnPropertyChanged(nameof(CanModify));
+ }
+
+ partial void OnSelectedBuildingChanged(Building? value)
+ {
+ BuildMatrix();
+ OnPropertyChanged(nameof(CanRemoveBuilding));
+ }
+
+ private void BuildMatrix()
+ {
+ MatrixRows.Clear();
+ if (SelectedBuilding is null)
+ {
+ ApartmentColumns = Array.Empty();
+ return;
+ }
+
+ var building = SelectedBuilding;
+ 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--)
+ {
+ 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 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
+ .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();
+ Refresh();
+ }
+
+ [RelayCommand]
+ private void CancelAddBuilding() => IsAddBuildingDialogOpen = 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();
+ Refresh();
+ }
+
+ [RelayCommand]
+ private void CancelRemoveBuilding() => IsRemoveBuildingConfirmOpen = false;
+
+ [RelayCommand]
+ 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;
+ SelectedCell.Status = 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/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));
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..2dd10e0
--- /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(building, 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/src/LageBuch.Domain/CoMeasurement/Building.cs b/src/LageBuch.Domain/CoMeasurement/Building.cs
new file mode 100644
index 0000000..db99b3c
--- /dev/null
+++ b/src/LageBuch.Domain/CoMeasurement/Building.cs
@@ -0,0 +1,79 @@
+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 IReadOnlyDictionary ApartmentLabels { 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,
+ IReadOnlyDictionary? apartmentLabels = null)
+ => new()
+ {
+ Id = id,
+ Name = name,
+ FloorCount = floorCount,
+ ApartmentsPerFloor = apartmentsPerFloor,
+ FloorDescriptions = floorDescriptions,
+ ApartmentLabels = apartmentLabels ?? new Dictionary(),
+ 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 };
+ }
+
+ 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
new file mode 100644
index 0000000..548efd3
--- /dev/null
+++ b/src/LageBuch.Domain/CoMeasurement/CoMeasurementLabels.cs
@@ -0,0 +1,47 @@
+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}";
+
+ // 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(building, 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..5484054
--- /dev/null
+++ b/src/LageBuch.Domain/CoMeasurement/Dwelling.cs
@@ -0,0 +1,48 @@
+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
+ };
+
+ 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/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
diff --git a/src/LageBuch.Domain/Incident.cs b/src/LageBuch.Domain/Incident.cs
index 083a8a7..fb6aae6 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,131 @@ 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;
+ }
+
+ 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 624cb7c..7cfca9a 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,37 @@ 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);
+ 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, 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("$al", apartmentLabelsJson);
+ });
+ }
+
+ 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 +399,37 @@ 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, apartment_labels 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);
+ // 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), alDict);
+ });
+
+ 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)),
@@ -393,7 +456,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,
+ 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..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 = 14;
+ public const int CurrentVersion = 16;
public static int GetVersion(SqliteConnection cn)
{
@@ -88,6 +88,14 @@ public static void Migrate(SqliteConnection cn)
{
ApplyV14(cn, tx);
}
+ if (version < 15)
+ {
+ ApplyV15(cn, tx);
+ }
+ if (version < 16)
+ {
+ ApplyV16(cn, tx);
+ }
SetVersion(cn, tx, CurrentVersion);
tx.Commit();
}
@@ -444,6 +452,39 @@ 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 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 d0c1d48..f77d9c5 100644
--- a/src/LageBuch.Sync/CommandApplier.cs
+++ b/src/LageBuch.Sync/CommandApplier.cs
@@ -108,6 +108,30 @@ 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;
+ 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 feda28f..ca174d8 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,13 @@ 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);
+ void SetApartmentLabel(Guid buildingId, int apartmentNumber, string? label);
}
diff --git a/src/LageBuch.Sync/IncidentSnapshot.cs b/src/LageBuch.Sync/IncidentSnapshot.cs
index fca089a..fcdbf44 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,12 @@ 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,
+ Dictionary? ApartmentLabels = null);
+
+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/RemoteIncidentSession.cs b/src/LageBuch.Sync/RemoteIncidentSession.cs
index 8e12200..0f13267 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,30 @@ 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));
+
+ 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 3585151..d9951bb 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,15 @@ 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,
+ 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());
}
public static Incident FromSnapshot(IncidentSnapshot snapshot)
@@ -73,7 +82,19 @@ 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)),
+ 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,
+ 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)));
}
private static ScbaTruppDto ToDto(AtemschutzTrupp t) => new(
diff --git a/src/LageBuch.Sync/SyncCommand.cs b/src/LageBuch.Sync/SyncCommand.cs
index da4050e..cc0ee70 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,14 @@ 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")]
+[JsonDerivedType(typeof(SetApartmentLabelCommand), "setApartmentLabel")]
public abstract record SyncCommand;
/// The operator at the sending device — carried on attributed mutations (see §6).
@@ -108,3 +117,30 @@ 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;
+
+// No operator (silent)
+public sealed record SetApartmentLabelCommand(
+ Guid BuildingId, int ApartmentNumber, string? Label) : SyncCommand;
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");
+ }
+}
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]
diff --git a/tests/LageBuch.AppLogic.Tests/CoMessprotokollViewModelTests.cs b/tests/LageBuch.AppLogic.Tests/CoMessprotokollViewModelTests.cs
new file mode 100644
index 0000000..e59dfab
--- /dev/null
+++ b/tests/LageBuch.AppLogic.Tests/CoMessprotokollViewModelTests.cs
@@ -0,0 +1,92 @@
+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.ApartmentColumns.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);
+ }
+
+ [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);
+ }
+}
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;
+ }
+}
diff --git a/tests/LageBuch.Domain.Tests/CoMeasurementTests.cs b/tests/LageBuch.Domain.Tests/CoMeasurementTests.cs
new file mode 100644
index 0000000..9e282de
--- /dev/null
+++ b/tests/LageBuch.Domain.Tests/CoMeasurementTests.cs
@@ -0,0 +1,253 @@
+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));
+ }
+
+ [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"));
}
}
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 @@
+
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);
+ }
}