using KronaStaffTerminal.Models; using KronaStaffTerminal.Services; using Xunit; namespace KronaStaffTerminal.Tests; public class AttendanceTrackerTests { private static (TerminalState, EmployeeRecord) CreateTestState() { var emp = new EmployeeRecord { Id = "emp-1", DisplayName = "Тестовый Сотрудник", FingerprintId = 1 }; var state = new TerminalState { Employees = { emp }, Settings = new TerminalSettings { DuplicateScanGuardSeconds = 30, MaximumShiftHours = 14, AutomaticCheckoutEnabled = true, AutomaticCheckoutHour = 23, AutomaticCheckoutMinute = 0 } }; return (state, emp); } [Fact] public async Task TryRecordAttendance_FirstScan_RecordsCheckIn() { var (state, emp) = CreateTestState(); var tracker = new AttendanceTracker(); var now = DateTimeOffset.UtcNow; var result = await tracker.TryRecordAttendanceAsync( state, emp, 0, now, _ => Task.CompletedTask); Assert.True(result.Success); Assert.False(result.IsDuplicateSuppressed); Assert.NotNull(result.Record); Assert.Equal("Приход", result.Record.EventType); Assert.Equal(emp.Id, result.Record.EmployeeId); Assert.False(result.Record.IsAutomatic); Assert.Single(state.Attendance); } [Fact] public async Task TryRecordAttendance_ImmediateSecondScan_RejectedAsDuplicate() { var (state, emp) = CreateTestState(); var tracker = new AttendanceTracker(); var now = DateTimeOffset.UtcNow; // First scan var first = await tracker.TryRecordAttendanceAsync( state, emp, 0, now, _ => Task.CompletedTask); Assert.True(first.Success); // Immediate second scan 5 seconds later (guard is 30s) var second = await tracker.TryRecordAttendanceAsync( state, emp, 0, now.AddSeconds(5), _ => Task.CompletedTask); Assert.False(second.Success); Assert.True(second.IsDuplicateSuppressed); Assert.Null(second.Record); Assert.Equal(25, second.RemainingSeconds); Assert.Contains("Повторный скан отклонён", second.Message); Assert.Single(state.Attendance); // No extra record inserted } [Fact] public async Task TryRecordAttendance_SecondScanAfterGuard_RecordsCheckOut() { var (state, emp) = CreateTestState(); var tracker = new AttendanceTracker(); var now = DateTimeOffset.UtcNow; // Check in await tracker.TryRecordAttendanceAsync( state, emp, 0, now, _ => Task.CompletedTask); // Check out after 35 seconds (guard is 30s) var second = await tracker.TryRecordAttendanceAsync( state, emp, 0, now.AddSeconds(35), _ => Task.CompletedTask); Assert.True(second.Success); Assert.False(second.IsDuplicateSuppressed); Assert.NotNull(second.Record); Assert.Equal("Уход", second.Record.EventType); Assert.Equal(2, state.Attendance.Count); } [Fact] public async Task TryRecordAttendance_DifferentEmployee_NotSuppressed() { var (state, emp1) = CreateTestState(); var emp2 = new EmployeeRecord { Id = "emp-2", DisplayName = "Второй Сотрудник", FingerprintId = 2 }; state.Employees.Add(emp2); var tracker = new AttendanceTracker(); var now = DateTimeOffset.UtcNow; // Scan emp 1 var r1 = await tracker.TryRecordAttendanceAsync( state, emp1, 0, now, _ => Task.CompletedTask); Assert.True(r1.Success); // Scan emp 2 1 second later var r2 = await tracker.TryRecordAttendanceAsync( state, emp2, 0, now.AddSeconds(1), _ => Task.CompletedTask); Assert.True(r2.Success); Assert.False(r2.IsDuplicateSuppressed); Assert.Equal(2, state.Attendance.Count); } [Fact] public async Task Concurrency_FiftyParallelScans_OnlyOneSucceeds() { var (state, emp) = CreateTestState(); var tracker = new AttendanceTracker(); var now = DateTimeOffset.UtcNow; var saveCount = 0; Task SaveCallback(TerminalState s) { Interlocked.Increment(ref saveCount); return Task.CompletedTask; } // Launch 50 concurrent tasks at the exact same moment var tasks = Enumerable.Range(0, 50).Select(_ => tracker.TryRecordAttendanceAsync(state, emp, 0, now, SaveCallback) ).ToArray(); var results = await Task.WhenAll(tasks); var successCount = results.Count(r => r.Success); var suppressedCount = results.Count(r => r.IsDuplicateSuppressed); Assert.Equal(1, successCount); Assert.Equal(49, suppressedCount); Assert.Equal(1, saveCount); Assert.Single(state.Attendance); } [Fact] public async Task RunScheduledCheckout_AutoChecksOutOpenShifts() { var (state, emp) = CreateTestState(); var tracker = new AttendanceTracker(); // Employee checked in at 09:00 UTC var checkInTime = new DateTimeOffset(2026, 9, 24, 9, 0, 0, TimeSpan.Zero); state.Attendance.Add(new AttendanceRecord { Id = UlidGenerator.New(), EmployeeId = emp.Id, EmployeeName = emp.DisplayName, OccurredAtUtc = checkInTime, EventType = "Приход", IsAutomatic = false }); // Current time is 23:30 (checkout scheduled for 23:00) var nowUtc = new DateTimeOffset(2026, 9, 24, 23, 30, 0, TimeSpan.Zero); var checkedOutRecords = await tracker.RunScheduledCheckoutAsync( state, nowUtc, _ => Task.CompletedTask); Assert.Single(checkedOutRecords); var autoRecord = checkedOutRecords[0]; Assert.Equal("Уход автоматически", autoRecord.EventType); Assert.True(autoRecord.IsAutomatic); Assert.Equal(emp.Id, autoRecord.EmployeeId); Assert.Equal(2, state.Attendance.Count); } [Fact] public async Task RunScheduledCheckout_AlreadyCheckedOut_DoesNotDoubleCheckout() { var (state, emp) = CreateTestState(); var tracker = new AttendanceTracker(); var checkInTime = new DateTimeOffset(2026, 9, 24, 9, 0, 0, TimeSpan.Zero); var checkOutTime = new DateTimeOffset(2026, 9, 24, 18, 0, 0, TimeSpan.Zero); state.Attendance.Add(new AttendanceRecord { Id = UlidGenerator.New(), EmployeeId = emp.Id, EmployeeName = emp.DisplayName, OccurredAtUtc = checkOutTime, EventType = "Уход", IsAutomatic = false }); state.Attendance.Add(new AttendanceRecord { Id = UlidGenerator.New(), EmployeeId = emp.Id, EmployeeName = emp.DisplayName, OccurredAtUtc = checkInTime, EventType = "Приход", IsAutomatic = false }); var nowUtc = new DateTimeOffset(2026, 9, 24, 23, 30, 0, TimeSpan.Zero); var checkedOutRecords = await tracker.RunScheduledCheckoutAsync( state, nowUtc, _ => Task.CompletedTask); 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); } }