krona-staff/apps/KronaStaffTerminal.Tests/AttendanceTrackerTests.cs

223 lines
7.4 KiB
C#

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