Merge pull request #467 from fsolla/B170-follow-up-b169-contrato-do-colapso-de-chips-spec-shift-

B170 — Follow-up B169: contrato do colapso de chips (spec) + shift único no load SSR
This commit is contained in:
Francisco Solla 2026-08-09 01:54:41 -03:00 committed by GitHub
commit 44f2d35fae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 262 additions and 3 deletions

View file

@ -6,6 +6,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente
**Recently resolved (2026-08-08):** **B174** (Issue #449) — a Sollinha (`/campanha/api/ai-chat`) trocou o provider de `@ai-sdk/deepseek` (API oficial `api.deepseek.com`) pelo **`@ai-sdk/deepinfra`** no caminho OpenAI-compatível da Deep Infra — **mesmo modelo** (`deepseek-ai/DeepSeek-V4-Flash`), **mesmas tools** e **mesma chave** que os agentes do time usam (um provedor, uma chave, uma fatura). Swap de 2 linhas no `route.ts`; dep `@ai-sdk/deepinfra@3.0.20` pinada na mesma ferroviária do `ai@7.0.47` (provider 4.0.4 / provider-utils 5.0.18, `pnpm why` confirma cópia única — nada de 4.0.7 duplicado), `@ai-sdk/deepseek` removido (dead dep, knip); `.env.example` `DEEPSEEK_API_KEY` → `DEEPINFRA_API_KEY` (B173 STT/Whisper reusa a mesma chave). Sem migration, access, UI ou teste novo — e2e já mocka o SSE (sem chave real no CI); rollback documentado = `git revert` (restaura provider/baseURL antiga); smoke de deploy = 2–3 perguntas forçando tool calling real antes de confiar o flip. Intenção [`sollinha-no-deepinfra.md`](plans/sollinha-no-deepinfra.md); impl [`sollinha-no-deepinfra-impl.md`](plans/sollinha-no-deepinfra-impl.md).
**Recently resolved (2026-08-08):** **B170** (Issue #441, follow-up B169) — o **contrato do colapso de chips virou spec unitário** (`tests/unit/relationChipCellCollapse.unit.spec.tsx`): `RelationChipCell` renderiza 16 chips com `getBoundingClientRect` stubado (4/linha, 20px, gap 6 — a matemática real de `COLLAPSED_CHIP_ROWS`/`CHIP_GAP_PX`) e o spec pina que o slice fica em 12 (3 linhas), "Ver mais…" expande para 16 e "Ver menos" recolhe para 12; casos tudo-cabe (8) e exatamente-cheio (12) não mostram toggle. Antes, os specs das células usavam `measureOverflow: false` ou colapsavam para 1 chip — a mudança de timing do B169 (medição em `useLayoutEffect`) não tinha regressão do comportamento de colapso. **F2 medido e deferido em vez de eliminado**: `PerformanceObserver('layout-shift')` no worktree confirma o resíduo pré-hidratação exatamente como registrado no B169 — load SSR→hidratação com shifts únicos `0.139/0.043/0.257` (fontes: shell — sidebar/Drawer/main/col-resize — nenhuma célula de chips) e a linha da célula indo `97px/42chips → 57px/0chips` numa única transição; **ordenação client-side (RSC) → zero shifts novos**. Eliminar a paint pré-hidratação exigiria estimativa de largura por label (rejeitada pelo dono, que mede layout real de propósito) ou mudança de layout/regra de colapso (anti-goal) — o shift único do primeiro load é aceitável num painel interno staff. Sem migration, dados, access ou mudança visual. Intenção [`estabilizar-lista-dobradinhas-followup.md`](docs/plans/estabilizar-lista-dobradinhas-followup.md); impl [`estabilizar-lista-dobradinhas-followup-impl.md`](docs/plans/estabilizar-lista-dobradinhas-followup-impl.md).
**Recently resolved (2026-08-08):** **`pnpm worktree plan [bag] [--go] [--no-migrate]`** — worktree de **planejamento** para rodar a skill `/plan-issue` sem ocupar o main: **cada invocação cria UM DIFERENTE**, para sessões de planejamento paralelas — com `bag`, branch `plans/plan-issue-<bag>` (sufixo `-2`/`-3` se o nome já estiver vivo); sem `bag`, o próximo sequencial `plans/plan-issue-<n>` livre (`PLAN_BRANCH_PREFIX`/`planBranchName`, `scripts/lib/worktree.mjs`). Prefixo minúsculo `plans/…` nunca colide com um `next` posterior em branch nem em slot (slot do plano = hash do branch, registrado via marcador e bumpado pelos vivos). Mesmo provisionamento isolado do `next` (porta `3100+slot`, bancos `teqo_wt<slot>`/`teqo_wt<slot>_test`, migrations; sem Docker degrada ao compartilhado). `kill` cobre; comment das envs distingue `gerado por pnpm worktree plan`. Sem migration. Naming unit-testada.
**Recently resolved (2026-08-08):** **B168** (Issue #422) — desfeito o **encadeamento automático** das ações rápidas do wizard (B98 era o precedente que este item inverte; evidência de campo: o coordenador rejeitou a tela avançando sozinha). Ao salvar **qualquer** ação de ajuste (votos, tendência, atualização, liderança), o usuário volta à origem (`from` allowlist B110 mantido; Início sem `from`) com toast — nunca avança para outro wizard. Removidos a fila/pipeline `wizardActionChain.ts` (fundido `wizardReturnHref` + `wizardPreviousHref` só com passos internos em `campaignActionRoutes.ts`), o param `entry`, os skip resolvers/`Pular` (`WizardSkipTrailing`/`WizardTrendSkipTrailing` deletados, chrome mobile sempre “X”) e o prefill de nota sem produtor (`notePrefill`/`voteFrom`/`voteTo`). Tendência: pós-save o RSC refresha com status já aplicado e o **client** é o único dono da navegação (toast + retorno) — o redirect servidor engolia o toast (B97 corrigido no caminho). Liderança: botão flutuante pós-save passou de “Continuar” (avançava) para **“Concluir”** (retorna à origem). Sem migration. Intenção [`desfazer-encadeamento-acoes-wizard.md`](plans/desfazer-encadeamento-acoes-wizard.md); impl [`desfazer-encadeamento-acoes-wizard-impl.md`](plans/desfazer-encadeamento-acoes-wizard-impl.md).

View file

@ -0,0 +1,75 @@
# Impl: B170 — Follow-up B169: contrato do colapso de chips (spec) + shift único no load SSR
Status: aprovado
Atualizado em: 2026-08-09
Issue: #441
Intenção: docs/plans/estabilizar-lista-dobradinhas-followup.md
Appetite restante: ~0,5–1 dia eng (herdado)
## Leitura da intenção
- **Outcome:** (F1) um spec unitário pinando o contrato do colapso de chips (recolher para o slice de 3 linhas medido, "Ver mais…" expande, "Ver menos" recolhe) com `getBoundingClientRect` stubado para múltiplos chips — cobrindo exatamente a lacuna que o B169 deixou (`measureOverflow: false` ou colapso para 1 chip nos specs atuais); (F2) avaliar o shift único do load SSR, com medição no browser, e deferi-lo documentado se aceitável — nunca reintroduzindo a estimativa de largura por label (anti-goal).
- **O que NÃO negociar:** sem mudança de layout, regra de colapso (3 linhas), dados, access ou schema; sem estimativa de largura por label; F1 não deve quebrar os specs existentes das células (`relation*`, `municipalityPortfolioCell`).
- **O que reavaliar:** a intenção deixa F2 condicionada à evidência ("sem evidência de impacto, F2 é defer"). A medição do B169 já registra os números (0,13–0,26, único, pré-hidratação); este impl **reproduz** a medição no worktree e confirma ou refuta antes de deferir.
## Abordagem recomendada
```mermaid
flowchart LR
A[F1: spec contrato do colapso<br/>RelationChipCell + rects stubados] --> B[Colapso 3 linhas / Ver mais / Ver menos<br/>pinned em jsdom]
C[F2: medição PerformanceObserver<br/>/campanha/dobradinhas no worktree] --> D{shift único pré-hidratação?}
D -->|sim ~0,13-0,26| E[Defer + CHANGELOG com medição]
D -->|regressão material nova| F[Investigar: precision, não estimar]
```
**Opções consideradas:**
- **F1-A — renderizar `RelationChipCell` diretamente com fixture mínima** (ownerId/ownerName/ids/buildChips/buildFormData/commitAction/copy) e stub global de `Element.prototype.getBoundingClientRect` (mesmo padrão de `scrollIntoView`/`ResizeObserver`). Pina o contrato no **dono** do colapso, sem a indireção do wrapper.
- **F1-B — renderizar um wrapper (`LeadershipStateDeputyRelationCell` / `StateDeputyAdvisorRelationCell`) com `measureOverflow` default true.** Props mais curtas, mas o contrato vive no `RelationChipCell` embutido — o spec passaria por um nível a mais e os fixtures dos wrappers já existem.
- **F2-A — medição no browser + defer documentado.** Reproduz a medição `layout-shift` no worktree (porta 3270, DB `teqo_wt170`), confirma o shift único pré-hidratação (~0,13–0,26) e registra em `docs/CHANGELOG-AGENTS.md` com os números; F2 vira "documentado".
- **F2-B — eliminar via estimativa de largura no servidor** — reintroduz exatamente o que o dono evita; rejeitada pela intenção e pelo B169.
- **F2-C — reserva de altura estável no estado medindo** — já testada empiricamente no B169 (opção C: `max-h` é teto, não piso; o slice colapsado é naturalmente mais baixo); não funciona. Rejeitada.
**Recomendação: F1-A + F2-A.**
- F1 no dono compartilhado, porque a lacuna é "nenhum spec cobra o colapso com layout real" no nível onde o colapso acontece.
- F2 mede e defer com documentação — a eliminação honesta não existe sem violar anti-goals (estimativa de largura ou mudança de layout/regra), e o segredo do useLayoutEffect já é "a primeira pintura chega colapsada" nas navegações; o resíduo é só a paint pré-hidratação do primeiro load.
### Componentes / mudanças
- **`tests/unit/relationChipCellCollapse.unit.spec.tsx`** (novo): renderiza `RelationChipCell` direto com `measureOverflow: true` (default), stub de `ResizeObserver` e de `Element.prototype.getBoundingClientRect` devolvendo geometria determinística (4 chips/linha, 20px, gap 6 → `COLLAPSED_CHIP_ROWS=3` ⇒ 12 chips no slice; 16 no total ⇒ 4 ocultos). Asserções: 12 `[data-relation-chip]` no DOM colapsado; "Ver mais…" presente; clique → 16 chips + "Ver menos"; clique → volta a 12 + "Ver mais…"; caso tudo-cabe (8 chips) → sem toggle, todos visíveis. Reset do stub via `Reflect.deleteProperty` no `afterAll` (precedente `municipalityStateDeputyRelationCell.unit.spec.tsx`). Sem migration, sem production code.
- **F2 (medição + documentação):** sem migração; registra entrada em `docs/CHANGELOG-AGENTS.md` (B170) com os números medidos e a decisão de defer.
- **Access / Consent:** toca nenhum.
- **UI:** Impeccable A — engenharia, sem mudança de superfície (intenção).
### Dados → forma
- N/A — nenhum dado novo. Aceite F1 = spec verde; aceite F2 = medição registrada + decisão documentada no CHANGELOG.
## Fases verificáveis
1. **F1 — spec** — `tests/unit/relationChipCellCollapse.unit.spec.tsx`; rodar `pnpm test:unit` (target do arquivo) até verde, e a suíte de células inteira (`relation*`, `municipalityPortfolioCell`) para provar que não regride. **FEITO** (4/4 verde; suíte completa 1451 verde).
2. **F2 — medição** — dev server do worktree (porta 3270, `teqo_wt170`); login coordenador seed; `PerformanceObserver('layout-shift')` em `/campanha/dobradinhas` (load/reload, `hadRecentInput=false`); comparar com a linha de base do B169 (0,13–0,26 único). **FEITO** — mesmo worktree provisionado + dobradinhas sintética com 40 lideranças (célula colapsa). Resultados: load SSR→hidratação com shifts únicos `0.139/0.043/0.257` (fontes `hadRecentInput:false` do shell — sidebar/Drawer/main/col-resize — nenhuma célula de chips); linha da célula `97px/42chips → 57px/0chips` numa única transição pré-hidratação; **ordenação client-side (RSC) → zero shifts novos** (a única fila era replay buffer do load). **Decisão: defer, conforme a intenção** — sem evidência de regressão material; eliminar a paint pré-hidratação exigiria estimativa de largura por label (rejeitada) ou mudança de layout/regra (anti-goal); registrado no CHANGELOG.
3. **Gates** — `pnpm gate:fast`; `pnpm exec tsc --noEmit`; `pnpm lint`; `pnpm format:check`; `pnpm exec knip`; `pnpm check:cycles`; `pnpm test`; `pnpm build` local. Push via `pnpm push`.
## Rabbit holes / Não escopo (engenharia)
- Tentativa de eliminar o shift SSR com estimativa de largura (F2-B) ou reserva de altura (F2-C).
- Redesign das células/da tabela; outras fontes de CLS (sidebar, Sollinha, agenda).
- Migration, schema, access, dados.
- Mudança de `COLLAPSED_CHIP_ROWS`, `max-h-18` ou qualquer regra de colapso.
## Riscos e mitigação
- **Stub global de `getBoundingClientRect` vaza para outros renders do spec:** reset via `beforeAll`/`afterAll` com `Reflect.deleteProperty`; fixtures isoladas por `it`.
- **O stub é frágil à geometria interna da medição** (`lastVisibleTop`, trailing `toggleWidth`+`inputWidth`): manter `rowRect.right` folgado (ex. 500) para o loop trailing não encolher abaixo do slice esperado, e comentar a matemática no spec.
- **`useLayoutEffect` em jsdom:** roda sincronamente dentro do `act` do RTL (`render`), então o estado colapsado já está no DOM após `render()`; asserções diretas, sem `waitFor` (se precisar, `waitFor` como rede de segurança).
- **F2 sem dados de dobradinhas no worktree:** se o seed local não tiver células com overflow, semear um dobradinha sintética com várias lideranças antes de medir; se a medição não reproduzir (regressão material), **parar e investigar** antes de deferir.
- **Divergência da intenção:** a intenção assume F2 defer com medição; se a medição mostrar shift materialmente maior que o registrado (nova fonte), tratar como correção, não como defer.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto: F1 pina o contrato; F2 (se deferido) documentado com medição no CHANGELOG
- [ ] Invariantes AGENTS/engineering-standards: nada de schema/access/URLs; identificadores em inglês; pt-BR só em copy de teste
- [ ] Specs das células existentes continuam verdes (`relation*`, `municipalityPortfolioCell`)
- [ ] Testes de domínio previstos: novo spec verde + medição registrada no diff/entrega

View file

@ -1,7 +1,7 @@
# B170 — Follow-up B169: contrato do colapso de chips + shift único no load SSR
Status: rascunho
Atualizado em: 2026-08-08
Status: aprovado
Atualizado em: 2026-08-09
Issue: #441
Priority: P2
Model: composer-2.5
@ -40,4 +40,4 @@ Follow-up do B169 (#425, entregue). Duas lacunas registradas na triage de débit
## Questões em aberto
- O shift único do load (0,13–0,26) é aceitável em produção? Se sim, F2 vira só o teste de contrato e o shift documentado no CHANGELOG (com medição). Validar com medição no browser; sem evidência de impacto, F2 é defer.
- ~~O shift único do load (0,13–0,26) é aceitável em produção? Se sim, F2 vira só o teste de contrato e o shift documentado no CHANGELOG (com medição). Validar com medição no browser; sem evidência de impacto, F2 é defer.~~ **RESOLVIDO (2026-08-09):** medido no worktree (`[0.139, 0.043, 0.257]`, únicos, pré-hidratação, fontes do shell — nenhuma célula de chips; ordenação client-side = zero shifts). Sem evidência de regressão material → **F2 deferido e documentado** no CHANGELOG. Eliminar exigiria estimativa de largura (rejeitada) ou mudança de layout (anti-goal).

View file

@ -0,0 +1,182 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { createElement } from 'react'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import {
RelationChipCell,
type RelationChip,
type RelationChipCellCopy,
type RelationSearchHit,
} from '@/components/campaign/shared/RelationChipCell'
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
/**
* B170 F1: pins the collapse contract that B169 hardened and left untested —
* how many chips fit below the 3-row cap is decided by the REAL layout
* (`getBoundingClientRect`), not by an estimate. Every spec that predates this
* one either disabled measurement (`measureOverflow: false`) or collapsed to a
* single chip, so the "recolher para N chips cujas linhas cabem" branch had no
* regression unit. Here the geometry is stubbed deterministically: 4 chips per
* line, 20px tall, 6px flex gap — the shared cell's own `COLLAPSED_CHIP_ROWS`
* (=3) then leaves exactly 12 chips visible out of 16, with
* "Ver mais…"/"Ver menos" toggling between the slice and the full set.
*
* The stub keys off attributes, mirroring how the measurement walks the DOM:
* `[data-relation-chip]` spans get index-derived rects (line = floor(idx/4)),
* `[data-relation-toggle]` reserves a small width on the last line, INPUT is
* width 0 (so the `minWidth` branch is skipped), and every other element is the
* row, whose generously wide `right` keeps the trailing-width loop from
* shrinking the slice.
*/
vi.mock('sonner', () => ({
toast: { success: vi.fn(), error: vi.fn() },
}))
const CHIP_GAP_PX = 6
const CHIP_HEIGHT = 20
const CHIP_WIDTH = 100
const CHIPS_PER_LINE = 4
const ROW_RIGHT = 500
const TOGGLE_WIDTH = 40
const chipRect = (index: number) => {
const line = Math.floor(index / CHIPS_PER_LINE)
const col = index % CHIPS_PER_LINE
const top = line * (CHIP_HEIGHT + CHIP_GAP_PX)
const left = col * (CHIP_WIDTH + CHIP_GAP_PX)
return {
top,
left,
right: left + CHIP_WIDTH,
bottom: top + CHIP_HEIGHT,
width: CHIP_WIDTH,
height: CHIP_HEIGHT,
x: left,
y: top,
toJSON: () => ({}) as DOMRect,
} as DOMRect
}
const indexAmongChipSiblings = (element: Element): number => {
let index = 0
let sibling = element.previousElementSibling
while (sibling) {
if (sibling.hasAttribute('data-relation-chip')) index += 1
sibling = sibling.previousElementSibling
}
return index
}
const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect
const chip = (id: number): RelationChip => ({
key: `chip-${id}`,
label: `Estado ${id}`,
href: `/campanha/dobradinhas/${id}`,
ids: [id],
})
const copy: RelationChipCellCopy = {
searchPlaceholder: 'Buscar deputado estadual…',
searchLabel: 'Buscar deputado estadual',
suggestionsLabel: 'Sugestões de deputados estaduais',
emptyDrawerMessage: 'Nenhuma dobradinha vinculada.',
savingMessage: 'Salvando dobradinhas.',
savedMessage: 'Dobradinhas salvas.',
removedMessage: (count) => (count === 1 ? 'Dobradinha removida.' : `${count} removidas.`),
}
const chipCell = (ids: number[]) =>
createElement(RelationChipCell, {
ownerId: 99,
ownerName: 'Maria Souza',
ids,
buildChips: (assignedIds: number[]) => assignedIds.map(chip),
searchHits: (): RelationSearchHit[] => [],
buildFormData: () => new FormData(),
commitAction: async (): Promise<CampaignFormActionState> => ({ status: 'success' as const }),
drawerTitle: 'Dobradinhas da liderança',
triggerLabel: 'Editar dobradinhas de Maria Souza',
updateErrorMessage: 'Não foi possível atualizar as dobradinhas. Tente novamente.',
copy,
})
const chipCount = (container: HTMLElement): number =>
container.querySelectorAll('[data-relation-chip]').length
beforeAll(() => {
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
},
)
Element.prototype.getBoundingClientRect = function (this: Element) {
if (this.hasAttribute('data-relation-chip')) {
return chipRect(indexAmongChipSiblings(this))
}
if (this.hasAttribute('data-relation-toggle')) {
return { width: TOGGLE_WIDTH } as DOMRect
}
if (this.tagName === 'INPUT') {
return { width: 0, height: CHIP_HEIGHT } as DOMRect
}
return { right: ROW_RIGHT, width: ROW_RIGHT } as DOMRect
}
})
afterEach(cleanup)
afterAll(() => {
vi.unstubAllGlobals()
Element.prototype.getBoundingClientRect = originalGetBoundingClientRect
})
describe('RelationChipCell collapse contract (B170 F1)', () => {
it('collapses 16 chips to the 12 that fit in three measured rows', () => {
const { container } = render(chipCell([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]))
// 4 per line × 3 lines = 12; the 4 chips whose rect lands on line 3 are out.
expect(chipCount(container)).toBe(12)
expect(screen.getByText('Estado 12')).toBeTruthy()
expect(screen.queryByText('Estado 13')).toBeNull()
expect(screen.getByRole('button', { name: 'Ver mais…' })).toBeTruthy()
})
it('expands to every chip on "Ver mais…" and collapses back on "Ver menos"', () => {
const { container } = render(chipCell([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]))
fireEvent.click(screen.getByRole('button', { name: 'Ver mais…' }))
expect(chipCount(container)).toBe(16)
expect(screen.getByText('Estado 16')).toBeTruthy()
expect(screen.queryByRole('button', { name: 'Ver mais…' })).toBeNull()
expect(screen.getByRole('button', { name: 'Ver menos' })).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Ver menos' }))
expect(chipCount(container)).toBe(12)
expect(screen.queryByText('Estado 13')).toBeNull()
expect(screen.getByRole('button', { name: 'Ver mais…' })).toBeTruthy()
})
it('renders every chip without a toggle when all fit in three rows', () => {
// 8 chips occupy exactly lines 0–1, well inside the 3-row cap.
const { container } = render(chipCell([1, 2, 3, 4, 5, 6, 7, 8]))
expect(chipCount(container)).toBe(8)
expect(screen.getByText('Estado 8')).toBeTruthy()
expect(screen.queryByRole('button', { name: /Ver mais/ })).toBeNull()
expect(screen.queryByRole('button', { name: /Ver menos/ })).toBeNull()
})
it('does not show a toggle when the count exactly fills the three rows', () => {
// 12 chips = the full 3×4 grid: nothing hidden, no affordance.
const { container } = render(chipCell([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]))
expect(chipCount(container)).toBe(12)
expect(screen.getByText('Estado 12')).toBeTruthy()
expect(screen.queryByRole('button', { name: /Ver mais/ })).toBeNull()
})
})