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); + } }