Competition/Round architecture: full platform rewrite (Phases 1-9)
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m45s
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m45s
Replace Pipeline/Stage system with Competition/Round architecture. New schema: Competition, Round (7 types), JuryGroup, AssignmentPolicy, ProjectRoundState, DeliberationSession, ResultLock, SubmissionWindow. New services: round-engine, round-assignment, deliberation, result-lock, submission-manager, competition-context, ai-prompt-guard. Full admin/jury/applicant/mentor UI rewrite. AI prompt hardening with structured prompts, retry logic, and injection detection. All legacy pipeline/stage code removed. 4 new migrations + seed aligned. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
382
tests/unit/assignment-policy.test.ts
Normal file
382
tests/unit/assignment-policy.test.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
/**
|
||||
* 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> = {}): 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<string, unknown> = {},
|
||||
): 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user