/** * Unit tests for the assignment policy resolution engine. * * These are pure-logic tests — no database or Prisma needed. * We construct MemberContext objects inline and verify the resolved policies. */ import { describe, it, expect } from 'vitest' import type { CapMode } from '@prisma/client' import type { MemberContext } from '@/server/services/competition-context' import { SYSTEM_DEFAULT_CAP, SYSTEM_DEFAULT_CAP_MODE, SYSTEM_DEFAULT_SOFT_BUFFER, resolveEffectiveCap, resolveEffectiveCapMode, resolveEffectiveSoftCapBuffer, resolveEffectiveCategoryBias, evaluateAssignmentPolicy, } from '@/server/services/assignment-policy' // ============================================================================ // Helpers — build minimal MemberContext stubs // ============================================================================ function baseMemberContext(overrides: Partial = {}): MemberContext { return { competition: {} as any, round: {} as any, roundConfig: {} as any, submissionWindows: [], juryGroup: null, member: { id: 'member-1', juryGroupId: 'jg-1', userId: 'user-1', role: 'MEMBER', maxAssignmentsOverride: null, capModeOverride: null, categoryQuotasOverride: null, preferredStartupRatio: null, availabilityNotes: null, selfServiceCap: null, selfServiceRatio: null, joinedAt: new Date(), } as any, user: { id: 'user-1', name: 'Test Juror', email: 'test@example.com', role: 'JURY_MEMBER' }, currentAssignmentCount: 0, assignmentsByCategory: {}, pendingIntents: [], ...overrides, } } function withJuryGroup( ctx: MemberContext, groupOverrides: Record = {}, ): MemberContext { const defaultGroup = { id: 'jg-1', competitionId: 'comp-1', name: 'Panel A', slug: 'panel-a', description: null, sortOrder: 0, defaultMaxAssignments: 20, defaultCapMode: 'SOFT' as CapMode, softCapBuffer: 3, categoryQuotasEnabled: false, defaultCategoryQuotas: null, allowJurorCapAdjustment: false, allowJurorRatioAdjustment: false, createdAt: new Date(), updatedAt: new Date(), ...groupOverrides, } return { ...ctx, juryGroup: defaultGroup as any } } // ============================================================================ // resolveEffectiveCap // ============================================================================ describe('resolveEffectiveCap', () => { it('returns system default (15) when no jury group', () => { const ctx = baseMemberContext() const result = resolveEffectiveCap(ctx) expect(result.value).toBe(SYSTEM_DEFAULT_CAP) expect(result.source).toBe('system') }) it('returns jury group default when group is set', () => { const ctx = withJuryGroup(baseMemberContext(), { defaultMaxAssignments: 25 }) const result = resolveEffectiveCap(ctx) expect(result.value).toBe(25) expect(result.source).toBe('jury_group') }) it('admin per-member override takes precedence over group default', () => { let ctx = withJuryGroup(baseMemberContext(), { defaultMaxAssignments: 25 }) ctx = { ...ctx, member: { ...ctx.member, maxAssignmentsOverride: 10 } } const result = resolveEffectiveCap(ctx) expect(result.value).toBe(10) expect(result.source).toBe('member') expect(result.explanation).toContain('Admin per-member override') }) it('self-service cap overrides admin when allowJurorCapAdjustment is true', () => { let ctx = withJuryGroup(baseMemberContext(), { defaultMaxAssignments: 25, allowJurorCapAdjustment: true, }) ctx = { ...ctx, member: { ...ctx.member, selfServiceCap: 12 } } const result = resolveEffectiveCap(ctx) expect(result.value).toBe(12) expect(result.source).toBe('member') expect(result.explanation).toContain('Self-service cap') }) it('self-service cap is bounded by admin max (override)', () => { let ctx = withJuryGroup(baseMemberContext(), { defaultMaxAssignments: 25, allowJurorCapAdjustment: true, }) ctx = { ...ctx, member: { ...ctx.member, selfServiceCap: 30, // Juror wants 30 but admin max is 10 maxAssignmentsOverride: 10, }, } const result = resolveEffectiveCap(ctx) expect(result.value).toBe(10) // Bounded to admin max expect(result.explanation).toContain('bounded') }) it('self-service cap is bounded by group default when no admin override', () => { let ctx = withJuryGroup(baseMemberContext(), { defaultMaxAssignments: 20, allowJurorCapAdjustment: true, }) ctx = { ...ctx, member: { ...ctx.member, selfServiceCap: 50 } } const result = resolveEffectiveCap(ctx) expect(result.value).toBe(20) // Bounded to group default }) it('self-service cap is ignored when allowJurorCapAdjustment is false', () => { let ctx = withJuryGroup(baseMemberContext(), { defaultMaxAssignments: 20, allowJurorCapAdjustment: false, }) ctx = { ...ctx, member: { ...ctx.member, selfServiceCap: 5 } } const result = resolveEffectiveCap(ctx) // Should fall through to group default since self-service is disabled expect(result.value).toBe(20) expect(result.source).toBe('jury_group') }) }) // ============================================================================ // resolveEffectiveCapMode // ============================================================================ describe('resolveEffectiveCapMode', () => { it('returns system default (SOFT) when no group', () => { const ctx = baseMemberContext() const result = resolveEffectiveCapMode(ctx) expect(result.value).toBe(SYSTEM_DEFAULT_CAP_MODE) expect(result.source).toBe('system') }) it('returns jury group default cap mode', () => { const ctx = withJuryGroup(baseMemberContext(), { defaultCapMode: 'HARD' }) const result = resolveEffectiveCapMode(ctx) expect(result.value).toBe('HARD') expect(result.source).toBe('jury_group') }) it('admin per-member cap mode override takes precedence', () => { let ctx = withJuryGroup(baseMemberContext(), { defaultCapMode: 'SOFT' }) ctx = { ...ctx, member: { ...ctx.member, capModeOverride: 'NONE' as CapMode } } const result = resolveEffectiveCapMode(ctx) expect(result.value).toBe('NONE') expect(result.source).toBe('member') }) }) // ============================================================================ // resolveEffectiveSoftCapBuffer // ============================================================================ describe('resolveEffectiveSoftCapBuffer', () => { it('returns system default (2) when no group', () => { const ctx = baseMemberContext() const result = resolveEffectiveSoftCapBuffer(ctx) expect(result.value).toBe(SYSTEM_DEFAULT_SOFT_BUFFER) expect(result.source).toBe('system') }) it('returns jury group buffer', () => { const ctx = withJuryGroup(baseMemberContext(), { softCapBuffer: 5 }) const result = resolveEffectiveSoftCapBuffer(ctx) expect(result.value).toBe(5) expect(result.source).toBe('jury_group') }) }) // ============================================================================ // resolveEffectiveCategoryBias // ============================================================================ describe('resolveEffectiveCategoryBias', () => { it('returns null when no bias configured', () => { const ctx = baseMemberContext() const result = resolveEffectiveCategoryBias(ctx) expect(result.value).toBeNull() expect(result.source).toBe('system') }) it('uses admin-set preferredStartupRatio', () => { let ctx = baseMemberContext() ctx = { ...ctx, member: { ...ctx.member, preferredStartupRatio: 0.7 } } const result = resolveEffectiveCategoryBias(ctx) expect(result.value!.STARTUP).toBeCloseTo(0.7) expect(result.value!.BUSINESS_CONCEPT).toBeCloseTo(0.3) expect(result.source).toBe('member') }) it('uses self-service ratio when allowJurorRatioAdjustment is true', () => { let ctx = withJuryGroup(baseMemberContext(), { allowJurorRatioAdjustment: true, }) ctx = { ...ctx, member: { ...ctx.member, selfServiceRatio: 0.6 } } const result = resolveEffectiveCategoryBias(ctx) expect(result.value).toEqual({ STARTUP: 0.6, BUSINESS_CONCEPT: 0.4 }) expect(result.source).toBe('member') }) it('self-service ratio takes precedence over admin ratio', () => { let ctx = withJuryGroup(baseMemberContext(), { allowJurorRatioAdjustment: true, }) ctx = { ...ctx, member: { ...ctx.member, selfServiceRatio: 0.8, preferredStartupRatio: 0.5 }, } const result = resolveEffectiveCategoryBias(ctx) expect(result.value!.STARTUP).toBe(0.8) // Self-service wins }) it('derives bias from group category quotas', () => { const ctx = withJuryGroup(baseMemberContext(), { categoryQuotasEnabled: true, defaultCategoryQuotas: { STARTUP: { min: 5, max: 15 }, BUSINESS_CONCEPT: { min: 3, max: 5 }, }, }) const result = resolveEffectiveCategoryBias(ctx) expect(result.source).toBe('jury_group') // 15/(15+5) = 0.75, 5/(15+5) = 0.25 expect(result.value!.STARTUP).toBe(0.75) expect(result.value!.BUSINESS_CONCEPT).toBe(0.25) }) it('ignores group quotas when categoryQuotasEnabled is false', () => { const ctx = withJuryGroup(baseMemberContext(), { categoryQuotasEnabled: false, defaultCategoryQuotas: { STARTUP: { min: 5, max: 15 }, BUSINESS_CONCEPT: { min: 3, max: 5 }, }, }) const result = resolveEffectiveCategoryBias(ctx) expect(result.value).toBeNull() }) }) // ============================================================================ // evaluateAssignmentPolicy — canAssignMore / remainingCapacity / isOverCap // ============================================================================ describe('evaluateAssignmentPolicy', () => { describe('HARD cap mode', () => { it('canAssignMore is true when below cap', () => { let ctx = withJuryGroup(baseMemberContext(), { defaultMaxAssignments: 10, defaultCapMode: 'HARD', }) ctx = { ...ctx, currentAssignmentCount: 7 } const result = evaluateAssignmentPolicy(ctx) expect(result.canAssignMore).toBe(true) expect(result.remainingCapacity).toBe(3) expect(result.isOverCap).toBe(false) }) it('canAssignMore is false when at cap', () => { let ctx = withJuryGroup(baseMemberContext(), { defaultMaxAssignments: 10, defaultCapMode: 'HARD', }) ctx = { ...ctx, currentAssignmentCount: 10 } const result = evaluateAssignmentPolicy(ctx) expect(result.canAssignMore).toBe(false) expect(result.remainingCapacity).toBe(0) }) it('detects over-cap with correct count', () => { let ctx = withJuryGroup(baseMemberContext(), { defaultMaxAssignments: 10, defaultCapMode: 'HARD', }) ctx = { ...ctx, currentAssignmentCount: 13 } const result = evaluateAssignmentPolicy(ctx) expect(result.isOverCap).toBe(true) expect(result.overCapBy).toBe(3) expect(result.canAssignMore).toBe(false) }) }) describe('SOFT cap mode', () => { it('canAssignMore is true within buffer zone', () => { let ctx = withJuryGroup(baseMemberContext(), { defaultMaxAssignments: 10, defaultCapMode: 'SOFT', softCapBuffer: 3, }) ctx = { ...ctx, currentAssignmentCount: 11 } // Over cap but within buffer const result = evaluateAssignmentPolicy(ctx) expect(result.canAssignMore).toBe(true) expect(result.isOverCap).toBe(true) // Over the nominal cap expect(result.remainingCapacity).toBe(2) // 10+3-11 }) it('canAssignMore is false beyond buffer', () => { let ctx = withJuryGroup(baseMemberContext(), { defaultMaxAssignments: 10, defaultCapMode: 'SOFT', softCapBuffer: 3, }) ctx = { ...ctx, currentAssignmentCount: 13 } // cap(10) + buffer(3) = 13 → full const result = evaluateAssignmentPolicy(ctx) expect(result.canAssignMore).toBe(false) expect(result.remainingCapacity).toBe(0) }) }) describe('NONE cap mode', () => { it('canAssignMore is always true', () => { let ctx = withJuryGroup(baseMemberContext(), { defaultMaxAssignments: 10, defaultCapMode: 'NONE', }) ctx = { ...ctx, currentAssignmentCount: 100 } const result = evaluateAssignmentPolicy(ctx) expect(result.canAssignMore).toBe(true) expect(result.remainingCapacity).toBe(Infinity) }) }) describe('aggregate provenance', () => { it('returns all four policy resolutions with correct sources', () => { let ctx = withJuryGroup(baseMemberContext(), { defaultMaxAssignments: 20, defaultCapMode: 'SOFT', softCapBuffer: 2, }) ctx = { ...ctx, currentAssignmentCount: 5 } const result = evaluateAssignmentPolicy(ctx) expect(result.effectiveCap.source).toBe('jury_group') expect(result.effectiveCap.value).toBe(20) expect(result.effectiveCapMode.source).toBe('jury_group') expect(result.effectiveCapMode.value).toBe('SOFT') expect(result.softCapBuffer.source).toBe('jury_group') expect(result.softCapBuffer.value).toBe(2) expect(result.categoryBias.source).toBe('system') expect(result.categoryBias.value).toBeNull() }) }) })