Em dashes (—) and en dashes (–) had spread across comments, docs, tests, and a few UI strings, reading as AI-generated boilerplate rather than house style. Replaced each with punctuation matching its context: colon for explanatory clauses, comma for asides, plain hyphen for numeric/legal ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for paired-dash asides. messages/en.json and messages/sv.json were fixed by hand together to keep sv/en in sync. Left untouched where the dash is the functional subject rather than decorative punctuation: date-range-parser.ts's separator regex, charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the agent system-prompt files that already instruct against em dashes, and a golden iXBRL test fixture compared byte-for-byte. Also fixes two bugs surfaced along the way: an off-by-one in ApiKeysPanel's scope-label split (a leftover from an earlier partial pass), and a charset-repair test that had lost the literal en-dash it exists to verify. Regenerated the agent atom seed migration (skills:generate) since 27 SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes, with an explicit carve-out for the functional-dash cases above. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
418 lines
14 KiB
TypeScript
418 lines
14 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
||
import { computePremiumLines } from '../shift-premium-engine'
|
||
import type { ShiftPremiumRule } from '@/types'
|
||
|
||
const baseRule = (overrides: Partial<ShiftPremiumRule> = {}): ShiftPremiumRule => ({
|
||
id: 'rule-1',
|
||
company_id: 'co-1',
|
||
name: '',
|
||
applies_to_all_employees: true,
|
||
applies_to_employee_ids: [],
|
||
day_of_week: [1, 2, 3, 4, 5, 6, 7],
|
||
start_time: '00:00',
|
||
end_time: '00:00',
|
||
premium_percent: 25,
|
||
item_type: 'ob_weekday_evening',
|
||
priority: 0,
|
||
is_active: true,
|
||
created_at: '2026-01-01T00:00:00Z',
|
||
updated_at: '2026-01-01T00:00:00Z',
|
||
created_by: null,
|
||
...overrides,
|
||
})
|
||
|
||
const r2 = (n: number) => Math.round(n * 100) / 100
|
||
|
||
describe('computePremiumLines: basic semantics', () => {
|
||
it('returns no lines when there are no rules', () => {
|
||
const result = computePremiumLines({
|
||
employeeId: 'emp-1',
|
||
baseHourlyRate: 200,
|
||
workedDays: [{ work_date: '2026-05-25', hours: 8, start_time: '09:00', end_time: '17:00' }],
|
||
rules: [],
|
||
})
|
||
expect(result).toEqual([])
|
||
})
|
||
|
||
it('returns no lines when there are no worked days', () => {
|
||
const result = computePremiumLines({
|
||
employeeId: 'emp-1',
|
||
baseHourlyRate: 200,
|
||
workedDays: [],
|
||
rules: [baseRule()],
|
||
})
|
||
expect(result).toEqual([])
|
||
})
|
||
|
||
it('standard weekday 09:00-17:00 with no matching rule yields no premium', () => {
|
||
const eveningRule = baseRule({
|
||
id: 'r-evening',
|
||
day_of_week: [1, 2, 3, 4, 5],
|
||
start_time: '18:00',
|
||
end_time: '22:00',
|
||
premium_percent: 25,
|
||
item_type: 'ob_weekday_evening',
|
||
})
|
||
const result = computePremiumLines({
|
||
employeeId: 'emp-1',
|
||
baseHourlyRate: 200,
|
||
// 2026-05-25 is a Monday
|
||
workedDays: [{ work_date: '2026-05-25', hours: 8, start_time: '09:00', end_time: '17:00' }],
|
||
rules: [eveningRule],
|
||
})
|
||
expect(result).toEqual([])
|
||
})
|
||
})
|
||
|
||
describe('computePremiumLines: weekend rule', () => {
|
||
it('Saturday 06:00-22:00 with 33% rule generates 8h × 33% premium', () => {
|
||
const weekendRule = baseRule({
|
||
id: 'r-weekend',
|
||
name: 'Lördag',
|
||
day_of_week: [6],
|
||
start_time: '06:00',
|
||
end_time: '22:00',
|
||
premium_percent: 33,
|
||
item_type: 'ob_weekend',
|
||
})
|
||
const result = computePremiumLines({
|
||
employeeId: 'emp-1',
|
||
baseHourlyRate: 250,
|
||
// 2026-05-23 = Saturday (Sat 23 May 2026)
|
||
workedDays: [{ work_date: '2026-05-23', hours: 8, start_time: '08:00', end_time: '16:00' }],
|
||
rules: [weekendRule],
|
||
})
|
||
expect(result).toHaveLength(1)
|
||
expect(result[0].itemType).toBe('ob_weekend')
|
||
expect(result[0].hours).toBe(8)
|
||
// 250 × 8 × 0.33 = 660
|
||
expect(result[0].amount).toBe(r2(250 * 8 * 0.33))
|
||
expect(result[0].sourceRuleId).toBe('r-weekend')
|
||
})
|
||
|
||
it('Sunday full-day with 100% rule pays full premium', () => {
|
||
const sundayRule = baseRule({
|
||
id: 'r-sunday',
|
||
day_of_week: [7],
|
||
start_time: '00:00',
|
||
end_time: '00:00', // full-day special-case (00:00 to 00:00 = 24h)
|
||
premium_percent: 100,
|
||
item_type: 'ob_holiday',
|
||
})
|
||
// 2026-05-24 = Sunday
|
||
const result = computePremiumLines({
|
||
employeeId: 'emp-1',
|
||
baseHourlyRate: 200,
|
||
workedDays: [{ work_date: '2026-05-24', hours: 8, start_time: '09:00', end_time: '17:00' }],
|
||
rules: [sundayRule],
|
||
})
|
||
expect(result).toHaveLength(1)
|
||
expect(result[0].itemType).toBe('ob_holiday')
|
||
expect(result[0].hours).toBe(8)
|
||
expect(result[0].amount).toBe(r2(200 * 8 * 1.0))
|
||
})
|
||
})
|
||
|
||
describe('computePremiumLines: holiday gating for ob_holiday', () => {
|
||
it('ob_holiday fires on Midsommarafton 2025 (Friday, June 20) but not on a regular Sunday', () => {
|
||
const holidayRule = baseRule({
|
||
id: 'r-holiday',
|
||
name: 'OB helgdag',
|
||
day_of_week: [1, 2, 3, 4, 5, 6, 7], // all days: gating is by holiday calendar, not weekday
|
||
start_time: '00:00',
|
||
end_time: '00:00', // full 24h window
|
||
premium_percent: 100,
|
||
item_type: 'ob_holiday',
|
||
})
|
||
|
||
// Midsommarafton 2025 is Friday 20 June: a Swedish public holiday despite being a weekday.
|
||
const midsommarafton = computePremiumLines({
|
||
employeeId: 'emp-1',
|
||
baseHourlyRate: 250,
|
||
workedDays: [{ work_date: '2025-06-20', hours: 8, start_time: '08:00', end_time: '16:00' }],
|
||
rules: [holidayRule],
|
||
})
|
||
expect(midsommarafton).toHaveLength(1)
|
||
expect(midsommarafton[0].itemType).toBe('ob_holiday')
|
||
expect(midsommarafton[0].hours).toBe(8)
|
||
expect(midsommarafton[0].amount).toBe(r2(250 * 8 * 1.0))
|
||
|
||
// A regular Sunday: 2026-05-31 is a plain Sunday (after Pingstdagen on the 24th), not a holiday.
|
||
// The same rule must NOT fire because day_of_week matching alone is not enough for ob_holiday.
|
||
const regularSunday = computePremiumLines({
|
||
employeeId: 'emp-1',
|
||
baseHourlyRate: 250,
|
||
workedDays: [{ work_date: '2026-05-31', hours: 8, start_time: '08:00', end_time: '16:00' }],
|
||
rules: [holidayRule],
|
||
})
|
||
expect(regularSunday).toEqual([])
|
||
})
|
||
})
|
||
|
||
describe('computePremiumLines: night shift crossing midnight', () => {
|
||
it('22:00-06:00 night rule covers both halves correctly', () => {
|
||
const nightRule = baseRule({
|
||
id: 'r-night',
|
||
day_of_week: [1, 2, 3, 4, 5, 6, 7],
|
||
start_time: '22:00',
|
||
end_time: '06:00',
|
||
premium_percent: 50,
|
||
item_type: 'ob_night',
|
||
})
|
||
// Shift 22:00-06:00 on Monday → wraps into Tuesday
|
||
const result = computePremiumLines({
|
||
employeeId: 'emp-1',
|
||
baseHourlyRate: 300,
|
||
workedDays: [{ work_date: '2026-05-25', hours: 8, start_time: '22:00', end_time: '06:00' }],
|
||
rules: [nightRule],
|
||
})
|
||
expect(result).toHaveLength(1)
|
||
expect(result[0].hours).toBe(8)
|
||
// 300 × 8 × 0.5 = 1200
|
||
expect(result[0].amount).toBe(r2(300 * 8 * 0.5))
|
||
})
|
||
|
||
it('shift 18:00-04:00 with night rule 22:00-06:00 only awards 6h (22-04)', () => {
|
||
const nightRule = baseRule({
|
||
id: 'r-night',
|
||
day_of_week: [1, 2, 3, 4, 5, 6, 7],
|
||
start_time: '22:00',
|
||
end_time: '06:00',
|
||
premium_percent: 50,
|
||
item_type: 'ob_night',
|
||
})
|
||
const result = computePremiumLines({
|
||
employeeId: 'emp-1',
|
||
baseHourlyRate: 200,
|
||
workedDays: [{ work_date: '2026-05-25', hours: 10, start_time: '18:00', end_time: '04:00' }],
|
||
rules: [nightRule],
|
||
})
|
||
expect(result).toHaveLength(1)
|
||
// 22:00→24:00 = 2h, 00:00→04:00 = 4h, total 6h
|
||
expect(result[0].hours).toBe(6)
|
||
expect(result[0].amount).toBe(r2(200 * 6 * 0.5))
|
||
})
|
||
})
|
||
|
||
describe('computePremiumLines: partial overlap', () => {
|
||
it('weekday rule 18:00-22:00 with shift 16:00-22:00 generates 4h premium', () => {
|
||
const eveningRule = baseRule({
|
||
id: 'r-eve',
|
||
day_of_week: [1, 2, 3, 4, 5],
|
||
start_time: '18:00',
|
||
end_time: '22:00',
|
||
premium_percent: 25,
|
||
item_type: 'ob_weekday_evening',
|
||
})
|
||
// 2026-05-25 = Monday
|
||
const result = computePremiumLines({
|
||
employeeId: 'emp-1',
|
||
baseHourlyRate: 250,
|
||
workedDays: [{ work_date: '2026-05-25', hours: 6, start_time: '16:00', end_time: '22:00' }],
|
||
rules: [eveningRule],
|
||
})
|
||
expect(result).toHaveLength(1)
|
||
expect(result[0].hours).toBe(4)
|
||
expect(result[0].amount).toBe(r2(250 * 4 * 0.25))
|
||
})
|
||
})
|
||
|
||
describe('computePremiumLines: overlapping rules', () => {
|
||
it('priority wins when two rules cover the same interval', () => {
|
||
const lowPriority = baseRule({
|
||
id: 'r-low',
|
||
day_of_week: [6],
|
||
start_time: '08:00',
|
||
end_time: '18:00',
|
||
premium_percent: 33,
|
||
item_type: 'ob_weekend',
|
||
priority: 0,
|
||
})
|
||
const highPriority = baseRule({
|
||
id: 'r-high',
|
||
day_of_week: [6],
|
||
start_time: '10:00',
|
||
end_time: '14:00',
|
||
premium_percent: 100,
|
||
item_type: 'ob_holiday',
|
||
priority: 10,
|
||
})
|
||
// 2026-10-31 = Saturday AND Alla helgons dag → both ob_weekend and ob_holiday rules are eligible.
|
||
const result = computePremiumLines({
|
||
employeeId: 'emp-1',
|
||
baseHourlyRate: 200,
|
||
workedDays: [{ work_date: '2026-10-31', hours: 10, start_time: '08:00', end_time: '18:00' }],
|
||
rules: [lowPriority, highPriority],
|
||
})
|
||
// Expect: 6h low (08-10 + 14-18) + 4h high (10-14).
|
||
expect(result).toHaveLength(2)
|
||
const low = result.find((r) => r.sourceRuleId === 'r-low')
|
||
const high = result.find((r) => r.sourceRuleId === 'r-high')
|
||
expect(low?.hours).toBe(6)
|
||
expect(high?.hours).toBe(4)
|
||
// Total premium hours = 6 + 4 = 10 (no double count)
|
||
expect((low?.hours ?? 0) + (high?.hours ?? 0)).toBe(10)
|
||
})
|
||
|
||
it('ties broken by higher premium_percent when priority equal', () => {
|
||
const a = baseRule({
|
||
id: 'r-a',
|
||
day_of_week: [6],
|
||
start_time: '08:00',
|
||
end_time: '18:00',
|
||
premium_percent: 33,
|
||
item_type: 'ob_weekend',
|
||
priority: 0,
|
||
})
|
||
const b = baseRule({
|
||
id: 'r-b',
|
||
day_of_week: [6],
|
||
start_time: '08:00',
|
||
end_time: '18:00',
|
||
premium_percent: 50,
|
||
item_type: 'ob_weekend',
|
||
priority: 0,
|
||
})
|
||
const result = computePremiumLines({
|
||
employeeId: 'emp-1',
|
||
baseHourlyRate: 200,
|
||
workedDays: [{ work_date: '2026-05-23', hours: 10, start_time: '08:00', end_time: '18:00' }],
|
||
rules: [a, b],
|
||
})
|
||
expect(result).toHaveLength(1)
|
||
expect(result[0].sourceRuleId).toBe('r-b')
|
||
expect(result[0].hours).toBe(10)
|
||
})
|
||
})
|
||
|
||
describe('computePremiumLines: hours-only fallback', () => {
|
||
it('hours-only worked day uses default 08:00-17:00: only weekday-daytime rules match', () => {
|
||
const eveningRule = baseRule({
|
||
id: 'r-eve',
|
||
day_of_week: [1, 2, 3, 4, 5],
|
||
start_time: '18:00',
|
||
end_time: '22:00',
|
||
premium_percent: 25,
|
||
item_type: 'ob_weekday_evening',
|
||
})
|
||
const middayRule = baseRule({
|
||
id: 'r-day',
|
||
day_of_week: [1, 2, 3, 4, 5],
|
||
start_time: '10:00',
|
||
end_time: '14:00',
|
||
premium_percent: 10,
|
||
item_type: 'ob_weekday_evening',
|
||
})
|
||
const result = computePremiumLines({
|
||
employeeId: 'emp-1',
|
||
baseHourlyRate: 200,
|
||
workedDays: [{ work_date: '2026-05-25', hours: 8 }], // no times → default 08-17
|
||
rules: [eveningRule, middayRule],
|
||
})
|
||
// Evening rule must NOT match (18-22 outside 08-17). Mid-day rule matches 10-14.
|
||
expect(result).toHaveLength(1)
|
||
expect(result[0].sourceRuleId).toBe('r-day')
|
||
expect(result[0].hours).toBe(4)
|
||
})
|
||
})
|
||
|
||
describe('computePremiumLines: employee filtering', () => {
|
||
it('employee-specific rule does not apply to other employees', () => {
|
||
const rule = baseRule({
|
||
id: 'r-specific',
|
||
applies_to_all_employees: false,
|
||
applies_to_employee_ids: ['emp-2'],
|
||
day_of_week: [6],
|
||
start_time: '06:00',
|
||
end_time: '22:00',
|
||
premium_percent: 33,
|
||
item_type: 'ob_weekend',
|
||
})
|
||
const wrongEmp = computePremiumLines({
|
||
employeeId: 'emp-1',
|
||
baseHourlyRate: 200,
|
||
workedDays: [{ work_date: '2026-05-23', hours: 8, start_time: '09:00', end_time: '17:00' }],
|
||
rules: [rule],
|
||
})
|
||
expect(wrongEmp).toEqual([])
|
||
|
||
const rightEmp = computePremiumLines({
|
||
employeeId: 'emp-2',
|
||
baseHourlyRate: 200,
|
||
workedDays: [{ work_date: '2026-05-23', hours: 8, start_time: '09:00', end_time: '17:00' }],
|
||
rules: [rule],
|
||
})
|
||
expect(rightEmp).toHaveLength(1)
|
||
expect(rightEmp[0].hours).toBe(8)
|
||
})
|
||
|
||
it('inactive rules are ignored', () => {
|
||
const rule = baseRule({
|
||
id: 'r-inactive',
|
||
day_of_week: [6],
|
||
start_time: '06:00',
|
||
end_time: '22:00',
|
||
premium_percent: 33,
|
||
item_type: 'ob_weekend',
|
||
is_active: false,
|
||
})
|
||
const result = computePremiumLines({
|
||
employeeId: 'emp-1',
|
||
baseHourlyRate: 200,
|
||
workedDays: [{ work_date: '2026-05-23', hours: 8, start_time: '09:00', end_time: '17:00' }],
|
||
rules: [rule],
|
||
})
|
||
expect(result).toEqual([])
|
||
})
|
||
})
|
||
|
||
describe('computePremiumLines: multiple worked days', () => {
|
||
it('emits one line per (workDate × winning rule)', () => {
|
||
const weekendRule = baseRule({
|
||
id: 'r-weekend',
|
||
day_of_week: [6, 7],
|
||
start_time: '00:00',
|
||
end_time: '00:00',
|
||
premium_percent: 50,
|
||
item_type: 'ob_weekend',
|
||
})
|
||
const result = computePremiumLines({
|
||
employeeId: 'emp-1',
|
||
baseHourlyRate: 200,
|
||
workedDays: [
|
||
{ work_date: '2026-05-23', hours: 8, start_time: '08:00', end_time: '16:00' },
|
||
{ work_date: '2026-05-24', hours: 8, start_time: '08:00', end_time: '16:00' },
|
||
],
|
||
rules: [weekendRule],
|
||
})
|
||
expect(result).toHaveLength(2)
|
||
expect(result[0].workDate).toBe('2026-05-23')
|
||
expect(result[1].workDate).toBe('2026-05-24')
|
||
expect(result.every((r) => r.hours === 8)).toBe(true)
|
||
})
|
||
})
|
||
|
||
describe('computePremiumLines: invalid inputs', () => {
|
||
it('zero hourly rate yields no premium', () => {
|
||
const rule = baseRule({ day_of_week: [6], start_time: '06:00', end_time: '22:00', item_type: 'ob_weekend' })
|
||
const result = computePremiumLines({
|
||
employeeId: 'emp-1',
|
||
baseHourlyRate: 0,
|
||
workedDays: [{ work_date: '2026-05-23', hours: 8, start_time: '08:00', end_time: '16:00' }],
|
||
rules: [rule],
|
||
})
|
||
expect(result).toEqual([])
|
||
})
|
||
|
||
it('worked day with zero hours is ignored', () => {
|
||
const rule = baseRule({ day_of_week: [6], start_time: '06:00', end_time: '22:00', item_type: 'ob_weekend' })
|
||
const result = computePremiumLines({
|
||
employeeId: 'emp-1',
|
||
baseHourlyRate: 200,
|
||
workedDays: [{ work_date: '2026-05-23', hours: 0, start_time: '08:00', end_time: '16:00' }],
|
||
rules: [rule],
|
||
})
|
||
expect(result).toEqual([])
|
||
})
|
||
})
|