teqo-backup/tests/unit/issuesPanel.unit.spec.ts

294 lines
10 KiB
TypeScript

// @vitest-environment node
import { describe, expect, it } from 'vitest'
import {
buildIssueRow,
buildPanelViewModel,
classifyPlanStatus,
filterByState,
filterCounts,
issueAction,
issueCode,
issueDepends,
issueState,
issueSummary,
parsePlanStatus,
planLabel,
siblingPlanPaths,
sortRows,
toJsonPayload,
} from '../../scripts/lib/issues-panel.mjs'
const issue = (over: Record<string, unknown> = {}) => ({
number: 1020,
title: 'OPS109 — Painel de issues e planos no terminal (via SSH)',
body: [
'---',
'id: OPS109',
'depends: [OPS110]',
'priority: P2',
'---',
'Plano: [`docs/plans/ops109-painel-issues-no-terminal.md`](docs/plans/ops109-painel-issues-no-terminal.md)',
'',
'Ferramenta local para visualizar o andamento.',
].join('\n'),
state: 'OPEN',
createdAt: '2026-09-15T10:00:00.000Z',
labels: [
{ name: 'in-progress', color: '' },
{ name: 'prio:P2', color: '' },
{ name: 'kind:feature', color: '' },
],
...over,
})
describe('parsePlanStatus (OPS109)', () => {
it('reads the raw and normalized Status line', () => {
expect(parsePlanStatus('# T\n\nStatus: aprovado\n')).toEqual({
raw: 'aprovado',
normalized: 'aprovado',
})
})
it('strips bold, backticks and parenthetical suffixes', () => {
expect(parsePlanStatus('Status: **entregue** (2026-07-29)')?.raw).toBe('entregue (2026-07-29)')
expect(parsePlanStatus('Status: `aprovado (gate humano 2026-09-10)`')?.raw).toBe(
'aprovado (gate humano 2026-09-10)',
)
})
it('returns null when there is no Status line', () => {
expect(parsePlanStatus('# só título')).toBeNull()
expect(parsePlanStatus('')).toBeNull()
})
})
describe('classifyPlanStatus (OPS109 — leitura de produto)', () => {
it.each([
['aprovado', 'aprovado'],
['aprovado (gate humano 2026-09-10)', 'aprovado'],
['rascunho', 'aguardando'],
['registrado', 'aguardando'],
['blocked', 'aguardando'],
['em execução', 'andou'],
['executado', 'andou'],
['entregue', 'andou'],
['entregue (2026-07-29)', 'andou'],
['plano — registrado (blocked até plano em main)', 'aguardando'],
['qualquer coisa nova', 'desconhecido'],
])('%s → %s', (raw, expected) => {
expect(classifyPlanStatus({ raw, normalized: raw.toLowerCase() })).toBe(expected)
})
it('fails safe to desconhecido when there is no status', () => {
expect(classifyPlanStatus(null)).toBe('desconhecido')
expect(classifyPlanStatus({ raw: '', normalized: '' })).toBe('desconhecido')
})
})
describe('siblingPlanPaths (OPS109)', () => {
it('derives impl and ui-draft from the intent path', () => {
expect(siblingPlanPaths('docs/plans/ops109-x.md')).toEqual({
impl: 'docs/plans/ops109-x-impl.md',
uiDraft: 'docs/plans/ops109-x-ui-draft.html',
})
})
it('does not double-suffix when given the impl path', () => {
expect(siblingPlanPaths('docs/plans/ops109-x-impl.md').impl).toBe('docs/plans/ops109-x-impl.md')
})
it('returns nulls for a non-markdown path', () => {
expect(siblingPlanPaths(null)).toEqual({ impl: null, uiDraft: null })
expect(siblingPlanPaths('docs/plans/x.html')).toEqual({ impl: null, uiDraft: null })
})
})
describe('issue field derivation (OPS109)', () => {
it('reads the frontmatter id and depends', () => {
expect(issueCode(issue())).toBe('OPS109')
expect(issueDepends(issue())).toEqual(['OPS110'])
})
it('derives state/priority/kind from labels', () => {
expect(issueState(issue())).toBe('in-progress')
expect(issueState(issue({ state: 'CLOSED' }))).toBe('done')
expect(issueState(issue({ labels: [{ name: 'ready', color: '' }] }))).toBe('ready')
})
it('summarizes the body after the frontmatter and plan link', () => {
expect(issueSummary(issue())).toBe('Ferramenta local para visualizar o andamento.')
})
it('returns null id and empty depends for an id-less body', () => {
const bare = issue({ body: 'sem frontmatter' })
expect(issueCode(bare)).toBeNull()
expect(issueDepends(bare)).toEqual([])
})
})
describe('buildIssueRow (OPS109)', () => {
const deps = {
doneIds: new Set<string>(),
knownIds: new Set(['OPS110']),
}
it('marks a plan present with the classification', () => {
const row = buildIssueRow(issue(), {
...deps,
intention: 'Status: aprovado\n',
impl: 'Status: em execução\n',
})
expect(row.plan.intention).toMatchObject({
path: 'docs/plans/ops109-painel-issues-no-terminal.md',
exists: true,
classification: 'aprovado',
})
expect(row.plan.impl).toMatchObject({
path: 'docs/plans/ops109-painel-issues-no-terminal-impl.md',
exists: true,
classification: 'andou',
})
expect(row.uiDraft).toEqual({
path: 'docs/plans/ops109-painel-issues-no-terminal-ui-draft.html',
exists: false,
})
})
it('explains missing intention and impl as first-class states', () => {
const row = buildIssueRow(issue(), { ...deps, intention: null, impl: null })
expect(row.plan.intention).toMatchObject({ exists: false, classification: 'não criado' })
expect(row.plan.impl).toMatchObject({ exists: false, classification: 'não criado' })
expect(planLabel({ plan: { intention: null, impl: null } })).toBe('sem plano')
})
it('reports sem plano when the body has no plan link', () => {
const row = buildIssueRow(issue({ body: '---\nid: OPS999\n---\nsem plano' }), deps)
expect(row.plan.intention.path).toBeNull()
expect(row.plan.intention.exists).toBe(false)
})
it('ties the session by issue number and falls back to none', () => {
const sessions = [
{ sessionID: 'ses_abc', issue: 1020, branch: 'OPS109-x', dir: '/tmp/x', status: 'working' },
{ sessionID: 'ses_def', issue: 9999, branch: 'OPS999-y', dir: '/tmp/y', status: 'idle' },
]
const withSession = buildIssueRow(issue(), { ...deps, sessions })
expect(withSession.session).toMatchObject({ status: 'working', sessionID: 'ses_abc' })
const withoutSession = buildIssueRow(issue({ number: 4242 }), { ...deps, sessions })
expect(withoutSession.session).toBeNull()
})
})
describe('issueAction (OPS109)', () => {
const base = {
state: 'ready',
plan: { intention: null, impl: null },
blockedBy: [],
}
it('prefers the blocked dependency reason', () => {
expect(issueAction({ ...base, blockedBy: ['OPS111'] })).toBe('travada (depends OPS111)')
})
it('reads in-progress, waiting, done and ready', () => {
expect(issueAction({ ...base, state: 'in-progress' })).toBe('em andamento')
expect(
issueAction({
...base,
plan: { intention: { classification: 'aguardando' }, impl: null },
}),
).toBe('aguardando aprovação')
expect(issueAction({ ...base, state: 'done' })).toBe('concluída')
expect(issueAction({ ...base, state: 'ready' })).toBe('pronta')
})
it('does not treat em execução as waiting', () => {
expect(
issueAction({
...base,
state: 'blocked',
plan: { intention: { classification: 'andou' }, impl: { classification: 'andou' } },
}),
).toBe('travada')
})
})
describe('filterByState / sortRows / filterCounts (OPS109)', () => {
const rows: { number: number; code: string; state: string; priority: string; action: string }[] =
[
{ number: 1, code: 'A', state: 'in-progress', priority: 'P1', action: 'em andamento' },
{ number: 2, code: 'B', state: 'ready', priority: 'P0', action: 'aguardando aprovação' },
{ number: 3, code: 'C', state: 'done', priority: 'P2', action: 'concluída' },
{ number: 4, code: 'D', state: 'blocked', priority: 'P2', action: 'travada' },
]
it('filters by the four keys and fails open on unknown', () => {
expect(filterByState(rows, 'all').length).toBe(4)
expect(filterByState(rows, 'in-progress').map((row) => row.number)).toEqual([1])
expect(filterByState(rows, 'waiting').map((row) => row.number)).toEqual([2])
expect(filterByState(rows, 'done').map((row) => row.number)).toEqual([3])
expect(filterByState(rows, 'nonsense').length).toBe(4)
})
it('counts each band', () => {
expect(filterCounts(rows)).toEqual({ all: 4, 'in-progress': 1, waiting: 1, done: 1 })
})
it('orders open work before blocked before done', () => {
expect(sortRows(rows).map((row) => row.number)).toEqual([1, 2, 4, 3])
})
})
describe('buildPanelViewModel / toJsonPayload (OPS109)', () => {
const issues = [
issue(),
issue({
number: 1019,
state: 'CLOSED',
labels: [
{ name: 'done', color: '' },
{ name: 'in-prod', color: '' },
{ name: 'prio:P2', color: '' },
],
body: '---\nid: OPS110\n---\nPlano: [`docs/plans/ops110.md`](docs/plans/ops110.md)',
}),
]
const plansByNumber = new Map([
[
1020,
{
intention: 'Status: aprovado\n',
impl: 'Status: em execução\n',
uiDraft: true,
},
],
])
it('assembles rows, counts and the truncation flag', () => {
const model = buildPanelViewModel({ issues, sessions: [], plansByNumber, limit: 200 })
expect(model.rows.map((row) => row.code)).toEqual(['OPS109', 'OPS110'])
expect(model.truncated).toBe(false)
expect(model.counts.all).toBe(2)
expect(model.counts['in-progress']).toBe(1)
})
it('flags truncation when the page fills the limit', () => {
const model = buildPanelViewModel({ issues, limit: 2 })
expect(model.truncated).toBe(true)
})
it('produces a JSON-safe payload with both plans', () => {
const model = buildPanelViewModel({ issues, plansByNumber, limit: 200 })
const payload = toJsonPayload(model)
expect(payload.version).toBe(1)
const row = payload.rows.find((entry: { code?: string }) => entry.code === 'OPS109')
expect(row.plan.intention).toMatchObject({ classification: 'aprovado', status: 'aprovado' })
expect(row.plan.impl).toMatchObject({ classification: 'andou', status: 'em execução' })
expect(row.uiDraft.exists).toBe(true)
expect(row.session).toBeNull()
expect(JSON.parse(JSON.stringify(payload))).toEqual(payload)
})
})