teqo/tests/unit/engagementLevel.unit.spec.ts
Francisco Ignácio Fontoura Solla c7001b75f0 E14: engagement levels N0-N4 and the first allocationDecision writer.
`municipality.engagementLevel` (staff-only, no default) records what the
campaign decided to invest in a município, next to the E8/E10 metrics that
describe what it knows. Moving it is a reallocation decision, so only
unrestricted staff may, every move carries a rationale plus the signals that
would reverse it, and the write files an `allocationDecision` in the same
transaction under an advisory lock taken before the current level is read —
otherwise the snapshot's `from` is a guess. The enum gains `movimento`: a level
move is neither `aceita` nor `descarta`.

No backfill. Null is "Sem nível", and filtering by it is the triage queue.
That is also why the list sorts `nivel` in memory rather than in the query:
Postgres puts NULLs first on DESC, so a native sort would open "N4 primeiro"
on a page of municípios nobody has decided about yet. The filter stays native.

The hysteresis rules from research §6.8 are pure and client-safe, so the
Popover explains a block before submitting and the server re-runs them anyway;
a blocked move answers 409 with typed violations and the override is recorded
with exactly the rules it contradicted.
2026-07-27 21:19:33 -03:00

100 lines
3.7 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
ENGAGEMENT_LEVEL_RULES,
engagementLevelRank,
engagementLevels,
getEngagementLevelViolations,
isEngagementLevel,
type EngagementLevel,
type EngagementLevelViolationId,
} from '@/lib/engagementLevel'
const NOW = new Date('2026-07-27T12:00:00.000Z')
const daysBefore = (days: number): string =>
new Date(NOW.getTime() - days * 24 * 60 * 60 * 1000).toISOString()
const violationIds = (input: {
from: EngagementLevel | null
to: EngagementLevel
levelChangedAt?: string | null
triangulatedShock?: boolean
}): EngagementLevelViolationId[] =>
getEngagementLevelViolations({
from: input.from,
to: input.to,
levelChangedAt: input.levelChangedAt ?? null,
now: NOW,
triangulatedShock: input.triangulatedShock ?? false,
}).map((violation) => violation.id)
describe('engagement ladder', () => {
it('ranks the levels in ladder order', () => {
expect(engagementLevels.map((level) => engagementLevelRank[level])).toEqual([0, 1, 2, 3, 4])
})
it('recognizes only the five levels', () => {
expect(engagementLevels.every(isEngagementLevel)).toBe(true)
expect(isEngagementLevel('n5')).toBe(false)
expect(isEngagementLevel(null)).toBe(false)
expect(isEngagementLevel(2)).toBe(false)
})
})
describe('engagement level movement rules', () => {
it('treats the first level as a starting point, not a movement', () => {
expect(violationIds({ from: null, to: 'n4' })).toEqual([])
})
it('allows a single-step movement outside the protection window', () => {
expect(violationIds({ from: 'n2', to: 'n3', levelChangedAt: daysBefore(60) })).toEqual([])
})
it('holds a two-level jump unless a triangulated shock is declared', () => {
const levelChangedAt = daysBefore(60)
expect(violationIds({ from: 'n1', to: 'n3', levelChangedAt })).toEqual(['salto-de-dois-niveis'])
expect(violationIds({ from: 'n1', to: 'n3', levelChangedAt, triangulatedShock: true })).toEqual(
[],
)
})
it('protects a fresh level from being undone, but only downwards', () => {
const levelChangedAt = daysBefore(ENGAGEMENT_LEVEL_RULES.protectionWindowDays - 1)
expect(violationIds({ from: 'n3', to: 'n2', levelChangedAt })).toContain('janela-de-protecao')
expect(violationIds({ from: 'n3', to: 'n4', levelChangedAt })).not.toContain(
'janela-de-protecao',
)
})
it('lets a rebaixamento through once the window closed and the month turned', () => {
expect(violationIds({ from: 'n3', to: 'n2', levelChangedAt: daysBefore(40) })).toEqual([])
})
it('counts by calendar month, so a movement early in the month still blocks a second one', () => {
// 26 days is past the 21-day protection window, and the month rule is what
// is left holding it — the two rules are not the same rule.
expect(
violationIds({ from: 'n2', to: 'n1', levelChangedAt: '2026-07-01T09:00:00.000Z' }),
).toEqual(['dois-movimentos-no-mes'])
expect(
violationIds({ from: 'n2', to: 'n1', levelChangedAt: '2026-06-30T09:00:00.000Z' }),
).toEqual([])
})
it('reports every reason at once so the coordinator overrides them knowingly', () => {
expect(violationIds({ from: 'n4', to: 'n2', levelChangedAt: daysBefore(2) })).toEqual([
'salto-de-dois-niveis',
'janela-de-protecao',
'dois-movimentos-no-mes',
])
})
it('ignores a movement to the level already recorded', () => {
expect(violationIds({ from: 'n2', to: 'n2', levelChangedAt: daysBefore(1) })).toEqual([])
})
it('survives an unparseable stored timestamp instead of inventing a violation', () => {
expect(violationIds({ from: 'n2', to: 'n1', levelChangedAt: 'nao-e-uma-data' })).toEqual([])
})
})