diff --git a/src/LageBuch.App.Android/Services/AndroidAlarmService.cs b/src/LageBuch.App.Android/Services/AndroidAlarmService.cs index e79f5ac..ceafce8 100644 --- a/src/LageBuch.App.Android/Services/AndroidAlarmService.cs +++ b/src/LageBuch.App.Android/Services/AndroidAlarmService.cs @@ -9,7 +9,5 @@ namespace LageBuch.App.Android.Services; /// public sealed class AndroidAlarmService : IAlarmService { - public void Start() { } - public void Stop() { } public void Play(AlarmSound sound) { } } diff --git a/src/LageBuch.App/Assets/voice-druckabfrage.wav b/src/LageBuch.App/Assets/voice-druckabfrage.wav new file mode 100644 index 0000000..404a065 Binary files /dev/null and b/src/LageBuch.App/Assets/voice-druckabfrage.wav differ diff --git a/src/LageBuch.App/Assets/voice-rueckzugsalarm.wav b/src/LageBuch.App/Assets/voice-rueckzugsalarm.wav new file mode 100644 index 0000000..2cb6f2e Binary files /dev/null and b/src/LageBuch.App/Assets/voice-rueckzugsalarm.wav differ diff --git a/src/LageBuch.App/Services/SerialAudioQueue.cs b/src/LageBuch.App/Services/SerialAudioQueue.cs new file mode 100644 index 0000000..a3fa57d --- /dev/null +++ b/src/LageBuch.App/Services/SerialAudioQueue.cs @@ -0,0 +1,43 @@ +using System.Collections.Concurrent; + +namespace LageBuch.App.Services; + +/// +/// Runs enqueued playback actions one at a time, in FIFO order, on a single dedicated +/// background thread — so cues that become due close together play sequentially instead of +/// overlapping. itself never blocks the caller. +/// +/// Each action gets a bounded time to finish (see constructor); if it hangs (e.g. a stuck OS +/// audio player process that never exits), the queue moves on to the next item anyway so one +/// stuck cue can't permanently silence later ones. +/// +public sealed class SerialAudioQueue +{ + private readonly BlockingCollection _queue = new(); + private readonly TimeSpan _perItemTimeout; + + public SerialAudioQueue(TimeSpan? perItemTimeout = null) + { + _perItemTimeout = perItemTimeout ?? TimeSpan.FromSeconds(10); + var worker = new Thread(Run) { IsBackground = true, Name = nameof(SerialAudioQueue) }; + worker.Start(); + } + + /// Queues to run after everything already queued. + public void Enqueue(Action play) => _queue.Add(play); + + private void Run() + { + foreach (var play in _queue.GetConsumingEnumerable()) + { + try + { + Task.Run(play).Wait(_perItemTimeout); + } + catch + { + // A misbehaving cue must not stop the queue from serving the next one. + } + } + } +} diff --git a/src/LageBuch.App/Services/SystemAlarmService.cs b/src/LageBuch.App/Services/SystemAlarmService.cs index a642b27..4f08074 100644 --- a/src/LageBuch.App/Services/SystemAlarmService.cs +++ b/src/LageBuch.App/Services/SystemAlarmService.cs @@ -7,21 +7,18 @@ namespace LageBuch.App.Services; /// -/// Audio for the desktop deployment. The looping life-safety alarm (/ -/// ) uses winmm's PlaySound on Windows and is a no-op elsewhere, unchanged. -/// The one-shot spoken cues () work on Windows, macOS and Linux: winmm in-memory -/// on Windows, and afplay/aplay on macOS/Linux fed a WAV extracted to a temp file. -/// Every path degrades to a silent no-op when the asset or the player is missing, so a build with -/// no voice clip yet — or a host without the CLI player — simply stays quiet rather than crashing. +/// Audio for the desktop deployment. One-shot spoken cues () work on Windows, +/// macOS and Linux: winmm in-memory on Windows, and afplay/aplay on macOS/Linux fed +/// a WAV extracted to a temp file. Every path degrades to a silent no-op when the asset or the +/// player is missing, so a build with no voice clip yet — or a host without the CLI player — +/// simply stays quiet rather than crashing. /// internal sealed class SystemAlarmService : IAlarmService { - private const uint SndAsync = 0x0001; // play asynchronously private const uint SndNodefault = 0x0002; // no default beep if it fails private const uint SndMemory = 0x0004; // pszSound points to in-memory WAV - private const uint SndLoop = 0x0008; // loop until the next PlaySound call - - private static readonly Uri AlarmAsset = new("avares://LageBuch.App/Assets/alarm.wav"); + // No SND_ASYNC: playback must block the queue's worker thread until the clip finishes, + // so cues play one after another instead of overlapping (see SerialAudioQueue). // One voice clip per AlarmSound. A missing entry or missing file just means that cue is silent. private static readonly IReadOnlyDictionary VoiceAssets = @@ -31,41 +28,22 @@ internal sealed class SystemAlarmService : IAlarmService // Generic tone (already bundled) — a task falling due is frequent enough that a spoken // sentence would be more noise than signal. [AlarmSound.TaskDue] = "alarm.wav", + [AlarmSound.PressureCheckDue] = "voice-druckabfrage.wav", + [AlarmSound.RetreatAlarm] = "voice-rueckzugsalarm.wav", }; - private readonly byte[]? _wav; private readonly Dictionary _voiceBytes = new(); private readonly Dictionary _voiceTempFiles = new(); - private bool _sounding; + private readonly SerialAudioQueue _queue = new(); public SystemAlarmService() { - // Load the looping alarm WAV once. Only needed on Windows; skip the work elsewhere. - if (OperatingSystem.IsWindows()) - _wav = TryLoad(AlarmAsset); - // Preload the voice clips (all platforms). Absent files are simply skipped. foreach (var (sound, file) in VoiceAssets) if (TryLoad(new Uri($"avares://LageBuch.App/Assets/{file}")) is { } bytes) _voiceBytes[sound] = bytes; } - public void Start() - { - if (_sounding || _wav is null || !OperatingSystem.IsWindows()) - return; - PlaySound(_wav, IntPtr.Zero, SndAsync | SndMemory | SndLoop | SndNodefault); - _sounding = true; - } - - public void Stop() - { - if (!_sounding || !OperatingSystem.IsWindows()) - return; - PlaySound(null, IntPtr.Zero, 0); // null sound stops any current playback - _sounding = false; - } - [SuppressMessage("Design", "CA1031", Justification = "A missing player binary must stay silent (see comment); a failed alarm never crashes the app.")] public void Play(AlarmSound sound) @@ -73,10 +51,15 @@ public void Play(AlarmSound sound) if (!_voiceBytes.TryGetValue(sound, out var bytes)) return; // no clip bundled for this cue yet + _queue.Enqueue(() => PlayBlocking(sound, bytes)); + } + + // Runs on the SerialAudioQueue's worker thread; blocks until the clip finishes playing. + private void PlayBlocking(AlarmSound sound, byte[] bytes) + { if (OperatingSystem.IsWindows()) { - // One-shot (no SndLoop). Deliberately distinct from Start's looping playback. - PlaySound(bytes, IntPtr.Zero, SndAsync | SndMemory | SndNodefault); + PlaySound(bytes, IntPtr.Zero, SndMemory | SndNodefault); return; } @@ -87,11 +70,12 @@ public void Play(AlarmSound sound) var player = OperatingSystem.IsMacOS() ? "afplay" : "aplay"; try { - Process.Start(new ProcessStartInfo(player, $"\"{path}\"") + using var process = Process.Start(new ProcessStartInfo(player, $"\"{path}\"") { UseShellExecute = false, CreateNoWindow = true, }); + process?.WaitForExit(); } catch { diff --git a/src/LageBuch.AppLogic/Services/IAlarmService.cs b/src/LageBuch.AppLogic/Services/IAlarmService.cs index 8589784..22a4a9b 100644 --- a/src/LageBuch.AppLogic/Services/IAlarmService.cs +++ b/src/LageBuch.AppLogic/Services/IAlarmService.cs @@ -11,21 +11,22 @@ public enum AlarmSound /// "Aufgabe fällig" — a task's timer expired while still open (#88). TaskDue, + + /// "Druckabfrage fällig" — a Trupp's pressure-control interval has elapsed (#78, #81). + PressureCheckDue, + + /// "Rückzugsalarm" — a Trupp has hit its time limit or return pressure (#81), + /// repeated until acknowledged. + RetreatAlarm, } /// -/// Sounds audible cues. / drive the looping life-safety tone -/// (the Atemschutz Rückzugsalarm) and are idempotent so callers can drive them straight from state -/// on every tick. is a fire-and-forget one-shot spoken announcement. +/// Sounds audible cues. is a fire-and-forget one-shot spoken announcement; +/// a caller that needs an insistent, repeating cue (e.g. the Atemschutz Rückzugsalarm) calls it +/// again on its own cadence rather than this service looping anything on its own (#81). /// public interface IAlarmService { - /// Begins (or continues) the looping alarm. Safe to call when already sounding. - void Start(); - - /// Silences the looping alarm. Safe to call when already silent. - void Stop(); - /// Plays a spoken cue once. Fire-and-forget; safe to call from the UI thread. void Play(AlarmSound sound); } diff --git a/src/LageBuch.AppLogic/ViewModels/ScbaViewModel.cs b/src/LageBuch.AppLogic/ViewModels/ScbaViewModel.cs index 1e2db9d..e536636 100644 --- a/src/LageBuch.AppLogic/ViewModels/ScbaViewModel.cs +++ b/src/LageBuch.AppLogic/ViewModels/ScbaViewModel.cs @@ -162,6 +162,20 @@ public sealed partial class ScbaViewModel : ObservableObject, IDisposable private readonly Action _onChanged; private readonly IDisposable? _subscription; private readonly HashSet _alarmLogged = new(); + + // Druckabfrage due-crossings that have already sounded. Unlike _alarmLogged (a one-way state + // that only ever grows until the Trupp returns), control-due toggles on and off every Abfrage- + // Intervall, so an id is removed once no longer due, letting the next crossing sound again. + private readonly HashSet _controlDueAnnounced = new(); + + // Rückzugsalarm speaks instead of sounding a looping siren (#81), repeating on this cadence + // while unacknowledged so it stays insistent -- mirrors ReminderViewModel's ILS cue exactly, + // just at 15s instead of 60s given the life-safety stakes. Reset to null by AcknowledgeAlarm + // (so the next cycle announces immediately) and by a newly-tripped alarm (so a second Trupp + // alarming after an ack is heard right away rather than waiting out the window). + private static readonly TimeSpan RetreatRepeatInterval = TimeSpan.FromSeconds(15); + private DateTimeOffset? _lastAlarmAnnouncedAt; + private readonly IncidentSettings _settings; // True once the user has hand-edited the Einsatzzeit; after that a Trupp-type switch must not @@ -383,8 +397,8 @@ public string AlarmDisplay [RelayCommand(CanExecute = nameof(CanAcknowledgeAlarm))] private void AcknowledgeAlarm() { - // Silence the sound; the visual banner stays until the trupp is back. - _alarm.Stop(); + // Silences the repeat cadence; the visual banner stays until the trupp is back. + _lastAlarmAnnouncedAt = null; IsAlarmAcknowledged = true; } @@ -392,18 +406,23 @@ private string AlarmReason(AtemschutzTrupp trupp) => trupp.IsTimeAlarm(_clock.No ? "Einsatzzeit erreicht" : $"Rückzugsdruck erreicht ({trupp.LatestPressure} bar)"; - /// Sounds or silences the audible alarm from current state, and keeps the banner - /// bindings fresh. A newly-alarming trupp re-arms the sound even after an earlier ack. + /// Speaks the Rückzugsalarm cue on its repeat cadence while unacknowledged (#81), and + /// keeps the banner bindings fresh. A newly-alarming trupp re-arms the cue even after an + /// earlier ack. private void UpdateAlarm(bool newAlarmTripped) { if (newAlarmTripped) IsAlarmAcknowledged = false; - if (IsAnyAlarm && !IsAlarmAcknowledged) - _alarm.Start(); + if (IsAnyAlarm && !IsAlarmAcknowledged && + (_lastAlarmAnnouncedAt is null || _clock.Now - _lastAlarmAnnouncedAt >= RetreatRepeatInterval)) + { + _alarm.Play(AlarmSound.RetreatAlarm); + _lastAlarmAnnouncedAt = _clock.Now; + } else if (!IsAnyAlarm) { - _alarm.Stop(); + _lastAlarmAnnouncedAt = null; IsAlarmAcknowledged = false; } @@ -559,10 +578,32 @@ private void OnTick() RefreshHeader(); var tripped = LogNewAlarms(); UpdateAlarm(tripped); + AnnounceControlDue(); if (tripped) _onChanged(); } + /// Plays a cue once per Druckabfrage due-crossing per Trupp. Unlike + /// this is local feedback, not a journal write, so it runs on joined clients too (not gated on + /// IsRemote) — only a closed/read-only workspace stays silent. + private void AnnounceControlDue() + { + if (IsReadOnly) + return; + foreach (var trupp in _session.Incident.ScbaTrupps) + { + if ((trupp.IsActive || trupp.IsWithdrawing) && trupp.IsControlDue(_clock.Now)) + { + if (_controlDueAnnounced.Add(trupp.Id)) + _alarm.Play(AlarmSound.PressureCheckDue); + } + else + { + _controlDueAnnounced.Remove(trupp.Id); + } + } + } + /// Appends one ETB entry per Trupp that has newly entered the alarm state. /// Returns whether anything was logged (so callers can persist). No-op when read-only. private bool LogNewAlarms() @@ -587,7 +628,6 @@ private bool LogNewAlarms() public void Dispose() { _session.Changed -= RefreshTrupps; - _alarm.Stop(); _subscription?.Dispose(); } } diff --git a/tests/LageBuch.App.Tests/SerialAudioQueueTests.cs b/tests/LageBuch.App.Tests/SerialAudioQueueTests.cs new file mode 100644 index 0000000..00ede32 --- /dev/null +++ b/tests/LageBuch.App.Tests/SerialAudioQueueTests.cs @@ -0,0 +1,93 @@ +using System.Diagnostics; +using LageBuch.App.Services; + +namespace LageBuch.App.Tests; + +public class SerialAudioQueueTests +{ + [Fact] + public void Enqueued_actions_do_not_overlap() + { + var sw = Stopwatch.StartNew(); + var events = new List<(string Id, long StartMs, long EndMs)>(); + var done = new CountdownEvent(2); + var queue = new SerialAudioQueue(); + + queue.Enqueue(() => RecordTimedRun("a", sw, events, done, TimeSpan.FromMilliseconds(150))); + queue.Enqueue(() => RecordTimedRun("b", sw, events, done, TimeSpan.FromMilliseconds(50))); + + Assert.True(done.Wait(TimeSpan.FromSeconds(5)), "queued actions never completed"); + Assert.Equal(2, events.Count); + Assert.True(events[1].StartMs >= events[0].EndMs, + "second action started before the first one finished"); + } + + [Fact] + public void Enqueued_actions_run_in_fifo_order() + { + var order = new List(); + var done = new CountdownEvent(5); + var queue = new SerialAudioQueue(); + + for (var i = 0; i < 5; i++) + { + var id = i; + queue.Enqueue(() => + { + lock (order) order.Add(id); + done.Signal(); + }); + } + + Assert.True(done.Wait(TimeSpan.FromSeconds(5)), "queued actions never completed"); + Assert.Equal(new[] { 0, 1, 2, 3, 4 }, order); + } + + [Fact] + public void Enqueue_returns_without_waiting_for_playback_to_finish() + { + var queue = new SerialAudioQueue(); + var started = new ManualResetEventSlim(); + + queue.Enqueue(() => + { + started.Set(); + Thread.Sleep(TimeSpan.FromSeconds(2)); + }); + + Assert.True(started.Wait(TimeSpan.FromSeconds(1)), "action never started"); + + var sw = Stopwatch.StartNew(); + queue.Enqueue(() => { }); + sw.Stop(); + + Assert.True(sw.Elapsed < TimeSpan.FromMilliseconds(500), "Enqueue blocked the caller"); + } + + [Fact] + public void A_stuck_action_does_not_permanently_block_later_ones() + { + var queue = new SerialAudioQueue(perItemTimeout: TimeSpan.FromMilliseconds(200)); + var laterRan = new ManualResetEventSlim(); + + queue.Enqueue(() => Thread.Sleep(TimeSpan.FromSeconds(30))); // simulates a hung player + queue.Enqueue(() => laterRan.Set()); + + Assert.True(laterRan.Wait(TimeSpan.FromSeconds(2)), + "later action never ran; the stuck item wedged the queue"); + } + + private static void RecordTimedRun( + string id, + Stopwatch sw, + List<(string Id, long StartMs, long EndMs)> events, + CountdownEvent done, + TimeSpan sleep) + { + var start = sw.ElapsedMilliseconds; + Thread.Sleep(sleep); + var end = sw.ElapsedMilliseconds; + lock (events) events.Add((id, start, end)); + done.Signal(); + } +} diff --git a/tests/LageBuch.AppLogic.Tests/ReminderViewModelTests.cs b/tests/LageBuch.AppLogic.Tests/ReminderViewModelTests.cs index a5a7107..bf37d5a 100644 --- a/tests/LageBuch.AppLogic.Tests/ReminderViewModelTests.cs +++ b/tests/LageBuch.AppLogic.Tests/ReminderViewModelTests.cs @@ -9,12 +9,7 @@ namespace LageBuch.AppLogic.Tests; // audible output fired without real audio. internal sealed class FakeAlarmService : IAlarmService { - public int StartCount { get; private set; } - public int StopCount { get; private set; } - public bool IsSounding { get; private set; } public List Played { get; } = new(); - public void Start() { StartCount++; IsSounding = true; } - public void Stop() { StopCount++; IsSounding = false; } public void Play(AlarmSound sound) => Played.Add(sound); } diff --git a/tests/LageBuch.AppLogic.Tests/ScbaViewModelTests.cs b/tests/LageBuch.AppLogic.Tests/ScbaViewModelTests.cs index efc39d1..af0ea0f 100644 --- a/tests/LageBuch.AppLogic.Tests/ScbaViewModelTests.cs +++ b/tests/LageBuch.AppLogic.Tests/ScbaViewModelTests.cs @@ -1,3 +1,4 @@ +using LageBuch.AppLogic.Services; using LageBuch.AppLogic.ViewModels; using LageBuch.Domain.Atemschutz; using LageBuch.Domain; @@ -243,43 +244,70 @@ public void Tick_past_max_duration_sounds_the_audible_alarm_and_sets_banner() var alarm = new FakeAlarmService(); var vm = Vm(clock, NewSession(clock), ticker: ticker, alarm: alarm); vm.NewMaxDurationMinutes = 30; + vm.NewControlIntervalMinutes = 999; // keep Druckabfrage out of this Rückzugsalarm-only test var row = Register(vm); row.StartCommand.Execute(null); Assert.False(vm.IsAnyAlarm); - Assert.False(alarm.IsSounding); + Assert.Empty(alarm.Played); clock.Now = T0.AddMinutes(31); ticker.Fire(); Assert.True(vm.IsAnyAlarm); - Assert.True(alarm.IsSounding); + Assert.Contains(AlarmSound.RetreatAlarm, alarm.Played); Assert.Contains("RÜCKZUGSALARM", vm.AlarmDisplay); Assert.Contains("Trupp 1 (Angriffstrupp)", vm.AlarmDisplay); Assert.True(vm.AcknowledgeAlarmCommand.CanExecute(null)); } [Fact] - public void Acknowledging_alarm_silences_sound_but_keeps_banner() + public void Acknowledging_alarm_silences_the_repeat_but_keeps_banner() { var clock = new FixedClock(T0); var ticker = new FakeTicker(); var alarm = new FakeAlarmService(); var vm = Vm(clock, NewSession(clock), ticker: ticker, alarm: alarm); vm.NewMaxDurationMinutes = 30; + vm.NewControlIntervalMinutes = 999; // keep Druckabfrage out of this Rückzugsalarm-only test var row = Register(vm); row.StartCommand.Execute(null); clock.Now = T0.AddMinutes(31); ticker.Fire(); + Assert.Single(alarm.Played); vm.AcknowledgeAlarmCommand.Execute(null); - Assert.False(alarm.IsSounding); // sound stopped - Assert.True(vm.IsAnyAlarm); // but banner remains while the alarm condition persists + Assert.True(vm.IsAnyAlarm); // banner remains while the alarm condition persists - // A further tick with the alarm acknowledged must not re-arm the sound. + // A further tick with the alarm acknowledged must not re-announce. ticker.Fire(); - Assert.False(alarm.IsSounding); + Assert.Single(alarm.Played); + } + + [Fact] + public void Repeat_cadence_is_fifteen_seconds_while_unacknowledged() + { + var clock = new FixedClock(T0); + var ticker = new FakeTicker(); + var alarm = new FakeAlarmService(); + var vm = Vm(clock, NewSession(clock), ticker: ticker, alarm: alarm); + vm.NewMaxDurationMinutes = 30; + vm.NewControlIntervalMinutes = 999; // keep Druckabfrage out of this Rückzugsalarm-only test + var row = Register(vm); + row.StartCommand.Execute(null); + + clock.Now = T0.AddMinutes(30); + ticker.Fire(); + Assert.Single(alarm.Played); + + clock.Now = T0.AddMinutes(30).AddSeconds(10); // still inside the 15s window + ticker.Fire(); + Assert.Single(alarm.Played); + + clock.Now = T0.AddMinutes(30).AddSeconds(16); // past the 15s window + ticker.Fire(); + Assert.Equal(2, alarm.Played.Count); } [Fact] @@ -290,24 +318,29 @@ public void Alarm_and_control_reminder_persist_through_Rueckzug_and_clear_on_Abg var alarm = new FakeAlarmService(); var vm = Vm(clock, NewSession(clock), ticker: ticker, alarm: alarm); vm.NewMaxDurationMinutes = 30; + vm.NewControlIntervalMinutes = 999; // keep Druckabfrage out of this Rückzugsalarm-only test var row = Register(vm); row.StartCommand.Execute(null); clock.Now = T0.AddMinutes(31); ticker.Fire(); - Assert.True(alarm.IsSounding); + Assert.Single(alarm.Played); // Rückzug alone does not silence the alarm -- the crew is still consuming air. row.WithdrawCommand.Execute(null); Assert.True(vm.IsAnyAlarm); + var countBeforeRemoval = alarm.Played.Count; row.MarkRemovedCommand.Execute(null); Assert.False(vm.IsAnyAlarm); - Assert.False(alarm.IsSounding); + Assert.Equal(countBeforeRemoval, alarm.Played.Count); // returning does not itself announce + + ticker.Fire(); + Assert.Equal(countBeforeRemoval, alarm.Played.Count); // and no further ticks announce once cleared } [Fact] - public void A_second_trupp_newly_alarming_re_arms_the_sound_after_ack() + public void A_second_trupp_newly_alarming_re_announces_after_ack() { var clock = new FixedClock(T0); var ticker = new FakeTicker(); @@ -315,36 +348,156 @@ public void A_second_trupp_newly_alarming_re_arms_the_sound_after_ack() var vm = Vm(clock, NewSession(clock), ticker: ticker, alarm: alarm); vm.NewMaxDurationMinutes = 30; + vm.NewControlIntervalMinutes = 999; // keep Druckabfrage out of this Rückzugsalarm-only test var first = Register(vm); first.StartCommand.Execute(null); // Second trupp goes under air 10 minutes later, so its limit falls after the first's. clock.Now = T0.AddMinutes(10); vm.NewMaxDurationMinutes = 30; + vm.NewControlIntervalMinutes = 999; var second = Register(vm, truppfuehrer: "Huber", truppmann: "Mayer"); second.StartCommand.Execute(null); clock.Now = T0.AddMinutes(31); ticker.Fire(); // first trupp alarms (limit at T0+30) + Assert.Single(alarm.Played); vm.AcknowledgeAlarmCommand.Execute(null); - Assert.False(alarm.IsSounding); clock.Now = T0.AddMinutes(41); - ticker.Fire(); // second trupp now past its limit (T0+40) → re-arm + ticker.Fire(); // second trupp now past its limit (T0+40) → re-announce - Assert.True(alarm.IsSounding); + Assert.Equal(2, alarm.Played.Count); + Assert.All(alarm.Played, s => Assert.Equal(AlarmSound.RetreatAlarm, s)); } [Fact] - public void Dispose_stops_the_alarm() + public void A_new_trupp_alarming_after_ack_reannounces_immediately_not_after_the_full_window() { var clock = new FixedClock(T0); + var ticker = new FakeTicker(); var alarm = new FakeAlarmService(); - var vm = Vm(clock, NewSession(clock), alarm: alarm); + var vm = Vm(clock, NewSession(clock), ticker: ticker, alarm: alarm); - vm.Dispose(); + vm.NewMaxDurationMinutes = 30; + vm.NewControlIntervalMinutes = 999; // keep Druckabfrage out of this Rückzugsalarm-only test + var first = Register(vm); + first.StartCommand.Execute(null); // starts at T0, alarms at T0+30:00 + + // Second trupp starts 5s later than the first, same 30-minute duration, so it crosses its + // own threshold at T0+30:05 -- 5s after the first's ack, well inside the 15s repeat window. + clock.Now = T0.AddSeconds(5); + vm.NewMaxDurationMinutes = 30; + vm.NewControlIntervalMinutes = 999; + var second = Register(vm, truppfuehrer: "Huber", truppmann: "Mayer"); + second.StartCommand.Execute(null); + + clock.Now = T0.AddMinutes(30); + ticker.Fire(); // first trupp alarms + Assert.Single(alarm.Played); + vm.AcknowledgeAlarmCommand.Execute(null); + + clock.Now = T0.AddMinutes(30).AddSeconds(5); // second trupp's own crossing, 5s after ack + ticker.Fire(); + + Assert.Equal(2, alarm.Played.Count); // announced immediately, not after waiting out 15s + } + + // --- Druckabfrage audio cue (issue #78 follow-up) --- + + [Fact] + public void Control_due_plays_the_cue_once_and_not_again_while_still_due() + { + var clock = new FixedClock(T0); + var ticker = new FakeTicker(); + var alarm = new FakeAlarmService(); + var vm = Vm(clock, NewSession(clock), ticker: ticker, alarm: alarm); + vm.NewControlIntervalMinutes = 5; + var row = Register(vm); + row.StartCommand.Execute(null); - Assert.True(alarm.StopCount >= 1); + clock.Now = T0.AddMinutes(6); // past the 5-minute control interval + ticker.Fire(); + ticker.Fire(); // must not sound a second time for the same due-crossing + + Assert.Single(alarm.Played); + Assert.Equal(AlarmSound.PressureCheckDue, alarm.Played[0]); + } + + [Fact] + public void Recording_pressure_silences_the_cue_until_the_next_interval() + { + var clock = new FixedClock(T0); + var ticker = new FakeTicker(); + var alarm = new FakeAlarmService(); + var vm = Vm(clock, NewSession(clock), ticker: ticker, alarm: alarm); + vm.NewControlIntervalMinutes = 5; + var row = Register(vm); + row.StartCommand.Execute(null); + + clock.Now = T0.AddMinutes(6); + ticker.Fire(); + Assert.Single(alarm.Played); + + // A recorded reading re-anchors the next control interval, silencing the due state. + row.PressureInput = 250; + row.RecordPressureCommand.Execute(null); + ticker.Fire(); + Assert.Single(alarm.Played); // still just the one from before + + clock.Now = T0.AddMinutes(12); // past the next 5-minute interval from the reading + ticker.Fire(); + + Assert.Equal(2, alarm.Played.Count); + Assert.All(alarm.Played, s => Assert.Equal(AlarmSound.PressureCheckDue, s)); + } + + [Fact] + public void Readonly_session_never_plays_the_control_due_cue() + { + var clock = new FixedClock(T0); + var store = new FakeStore(); + var seed = LocalIncidentSession.StartNew(store, clock, new SessionOperator("Müller"), "/x.fwincident", Array.Empty<(string, bool)>(), Array.Empty<(string, bool)>()); + var seedVm = Vm(clock, seed); + seedVm.NewControlIntervalMinutes = 5; + var seedRow = Register(seedVm); + seedRow.StartCommand.Execute(null); + clock.Now = T0.AddMinutes(6); + seed.Close(); + + var ticker = new FakeTicker(); + var alarm = new FakeAlarmService(); + var ro = LocalIncidentSession.OpenReadOnly(store, clock, "/x.fwincident"); + _ = Vm(clock, ro, ticker: ticker, alarm: alarm); + ticker.Fire(); + + Assert.Empty(alarm.Played); + } + + [Fact] + public void Two_trupps_due_at_different_times_each_sound_their_own_cue() + { + var clock = new FixedClock(T0); + var ticker = new FakeTicker(); + var alarm = new FakeAlarmService(); + var vm = Vm(clock, NewSession(clock), ticker: ticker, alarm: alarm); + + vm.NewControlIntervalMinutes = 5; + var first = Register(vm); + first.StartCommand.Execute(null); + + clock.Now = T0.AddMinutes(2); + vm.NewControlIntervalMinutes = 5; + var second = Register(vm, truppfuehrer: "Huber", truppmann: "Mayer"); + second.StartCommand.Execute(null); + + clock.Now = T0.AddMinutes(6); // first trupp's interval elapsed, second's has not + ticker.Fire(); + Assert.Single(alarm.Played); + + clock.Now = T0.AddMinutes(8); // second trupp's interval (registered/started 2 min later) now elapsed + ticker.Fire(); + Assert.Equal(2, alarm.Played.Count); } // --- Crew entry (issue #15) ---