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) ---