From 057ec75b262ee26fde31245ca015699c125a8f6c Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Thu, 24 Sep 2026 18:09:32 +0700 Subject: [PATCH] fix(terminal): handle locked scans consistently --- .../AttendanceTrackerTests.cs | 164 ++++++++++++++++++ apps/KronaStaffTerminal/App.xaml.cs | 3 + .../KronaStaffTerminal.csproj | 13 +- apps/KronaStaffTerminal/MainPage.xaml.cs | 88 ++++++++-- .../Services/AttendanceTracker.cs | 72 ++++++++ 5 files changed, 315 insertions(+), 25 deletions(-) diff --git a/apps/KronaStaffTerminal.Tests/AttendanceTrackerTests.cs b/apps/KronaStaffTerminal.Tests/AttendanceTrackerTests.cs index 1deb238..f0fd517 100644 --- a/apps/KronaStaffTerminal.Tests/AttendanceTrackerTests.cs +++ b/apps/KronaStaffTerminal.Tests/AttendanceTrackerTests.cs @@ -220,4 +220,168 @@ public class AttendanceTrackerTests Assert.Empty(checkedOutRecords); } + + [Fact] + public async Task ProcessScan_Locked_RegularEmployee_FirstScan_RecordsAttendanceAndKeepsLocked() + { + var (state, emp) = CreateTestState(); + var tracker = new AttendanceTracker(); + var now = DateTimeOffset.UtcNow; + + var result = await tracker.ProcessScanAsync( + state, emp, 0, now, isApplicationLocked: true, _ => Task.CompletedTask); + + Assert.True(result.Success); + Assert.False(result.ShouldUnlock); + Assert.NotNull(result.AttendanceResult.Record); + Assert.Equal("Приход", result.AttendanceResult.Record.EventType); + Assert.Equal("Отметка прихода или ухода зафиксирована. Интерфейс остаётся заблокированным.", result.LockScreenMessage); + Assert.Single(state.Attendance); + } + + [Fact] + public async Task ProcessScan_Locked_Administrator_FirstScan_RecordsAttendanceAndUnlocks() + { + var (state, emp) = CreateTestState(); + emp.IsAdministrator = true; + var tracker = new AttendanceTracker(); + var now = DateTimeOffset.UtcNow; + + var result = await tracker.ProcessScanAsync( + state, emp, 0, now, isApplicationLocked: true, _ => Task.CompletedTask); + + Assert.True(result.Success); + Assert.True(result.ShouldUnlock); + Assert.NotNull(result.AttendanceResult.Record); + Assert.Equal("Приход", result.AttendanceResult.Record.EventType); + Assert.Null(result.LockScreenMessage); + Assert.Contains(emp.DisplayName, result.StatusMessage); + Assert.Single(state.Attendance); + } + + [Fact] + public async Task ProcessScan_Locked_Administrator_SecondScanAfterGuard_RecordsCheckOutAndUnlocks() + { + var (state, emp) = CreateTestState(); + emp.IsAdministrator = true; + var tracker = new AttendanceTracker(); + var now = DateTimeOffset.UtcNow; + + // First scan: check-in + await tracker.ProcessScanAsync( + state, emp, 0, now, isApplicationLocked: true, _ => Task.CompletedTask); + + // Second scan after 35 seconds (guard is 30s) + var result = await tracker.ProcessScanAsync( + state, emp, 0, now.AddSeconds(35), isApplicationLocked: true, _ => Task.CompletedTask); + + Assert.True(result.Success); + Assert.True(result.ShouldUnlock); + Assert.NotNull(result.AttendanceResult.Record); + Assert.Equal("Уход", result.AttendanceResult.Record.EventType); + Assert.Null(result.LockScreenMessage); + Assert.Equal(2, state.Attendance.Count); + } + + [Fact] + public async Task ProcessScan_Locked_RegularEmployee_DuplicateScan_RejectsDuplicateAndKeepsLocked() + { + var (state, emp) = CreateTestState(); + var tracker = new AttendanceTracker(); + var now = DateTimeOffset.UtcNow; + + // First scan + await tracker.ProcessScanAsync( + state, emp, 0, now, isApplicationLocked: true, _ => Task.CompletedTask); + + // Immediate repeat scan 5s later + var result = await tracker.ProcessScanAsync( + state, emp, 0, now.AddSeconds(5), isApplicationLocked: true, _ => Task.CompletedTask); + + Assert.False(result.Success); + Assert.False(result.ShouldUnlock); + Assert.True(result.AttendanceResult.IsDuplicateSuppressed); + Assert.Equal(25, result.AttendanceResult.RemainingSeconds); + Assert.Equal("Повторный скан отклонён. Повторите через 25 сек.", result.LockScreenMessage); + Assert.Single(state.Attendance); + } + + [Fact] + public async Task ProcessScan_Locked_Administrator_DuplicateScan_RejectsDuplicateAndUnlocks() + { + var (state, emp) = CreateTestState(); + emp.IsAdministrator = true; + var tracker = new AttendanceTracker(); + var now = DateTimeOffset.UtcNow; + + // First scan + await tracker.ProcessScanAsync( + state, emp, 0, now, isApplicationLocked: true, _ => Task.CompletedTask); + + // Immediate repeat scan 5s later + var result = await tracker.ProcessScanAsync( + state, emp, 0, now.AddSeconds(5), isApplicationLocked: true, _ => Task.CompletedTask); + + Assert.False(result.Success); + Assert.True(result.ShouldUnlock); + Assert.True(result.AttendanceResult.IsDuplicateSuppressed); + Assert.Equal(25, result.AttendanceResult.RemainingSeconds); + Assert.Null(result.LockScreenMessage); + Assert.Single(state.Attendance); + } + + [Fact] + public async Task ProcessScan_Locked_BlockedEmployee_DoesNotRecordAttendance() + { + var (state, emp) = CreateTestState(); + emp.IsBlocked = true; + var tracker = new AttendanceTracker(); + var now = DateTimeOffset.UtcNow; + + var result = await tracker.ProcessScanAsync( + state, emp, 0, now, isApplicationLocked: true, _ => Task.CompletedTask); + + Assert.False(result.Success); + Assert.False(result.ShouldUnlock); + Assert.False(result.AttendanceResult.Success); + Assert.Equal("Этот сотрудник заблокирован.", result.LockScreenMessage); + Assert.Empty(state.Attendance); + } + + [Fact] + public async Task ProcessScan_Unlocked_RegularEmployee_RecordsAttendanceWithoutUnlock() + { + var (state, emp) = CreateTestState(); + var tracker = new AttendanceTracker(); + var now = DateTimeOffset.UtcNow; + + var result = await tracker.ProcessScanAsync( + state, emp, 0, now, isApplicationLocked: false, _ => Task.CompletedTask); + + Assert.True(result.Success); + Assert.False(result.ShouldUnlock); + Assert.NotNull(result.AttendanceResult.Record); + Assert.Equal("Приход", result.AttendanceResult.Record.EventType); + Assert.Null(result.LockScreenMessage); + Assert.Single(state.Attendance); + } + + [Fact] + public async Task ProcessScan_Unlocked_Administrator_RecordsAttendanceWithoutUnlock() + { + var (state, emp) = CreateTestState(); + emp.IsAdministrator = true; + var tracker = new AttendanceTracker(); + var now = DateTimeOffset.UtcNow; + + var result = await tracker.ProcessScanAsync( + state, emp, 0, now, isApplicationLocked: false, _ => Task.CompletedTask); + + Assert.True(result.Success); + Assert.False(result.ShouldUnlock); + Assert.NotNull(result.AttendanceResult.Record); + Assert.Equal("Приход", result.AttendanceResult.Record.EventType); + Assert.Null(result.LockScreenMessage); + Assert.Single(state.Attendance); + } } diff --git a/apps/KronaStaffTerminal/App.xaml.cs b/apps/KronaStaffTerminal/App.xaml.cs index 523bbcc..0f75f9d 100644 --- a/apps/KronaStaffTerminal/App.xaml.cs +++ b/apps/KronaStaffTerminal/App.xaml.cs @@ -123,6 +123,9 @@ public partial class App : Application public void NotifyMinimizedToTray() => _trayService?.ShowRunningBalloon(); + public void ShowNotificationBalloon(string title, string message, uint infoFlags = 1) => + _trayService?.ShowNotificationBalloon(title, message, infoFlags); + public void UpdateTray(TrayState state, int pendingCount, string? statusText = null) => _trayService?.UpdateState(state, pendingCount, statusText); diff --git a/apps/KronaStaffTerminal/KronaStaffTerminal.csproj b/apps/KronaStaffTerminal/KronaStaffTerminal.csproj index 20eddb7..00cea39 100644 --- a/apps/KronaStaffTerminal/KronaStaffTerminal.csproj +++ b/apps/KronaStaffTerminal/KronaStaffTerminal.csproj @@ -73,6 +73,7 @@ +