mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:05:19 +00:00
`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.
100 lines
3.7 KiB
TypeScript
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([])
|
|
})
|
|
})
|