223 lines
7.4 KiB
C#
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);
|
|
}
|
|
}
|