From fe81358fe3415165555f30132229269f549fa59a Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Sun, 9 Aug 2026 01:24:03 -0300 Subject: [PATCH] =?UTF-8?q?B170=20=E2=80=94=20spec=20do=20contrato=20do=20?= =?UTF-8?q?colapso=20de=20chips=20+=20shift=20=C3=BAnico=20do=20load=20SSR?= =?UTF-8?q?=20deferido=20documentado?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F1: RelationChipCell ganha spec unitário (relationChipCellCollapse) que pina o colapso medido pelo layout real (16 chips → slice de 3 linhas = 12), expande pelo 'Ver mais…' e recolhe pelo 'Ver menos', com getBoundingClientRect stubado por índice — a lacuna que o B169 deixou (specs com measureOverflow:false ou 1 chip) não tinha regressão do comportamento de colapso após a mudança de timing. F2: medição PerformanceObserver no worktree confirma o resíduo pré-hidratação do B169 sem regressão material: load SSR→hidratação com shifts únicos [0.139, 0.043, 0.257] (fontes do shell, nenhuma célula de chips) e ordenação client-side com zero shifts novos; eliminá-lo exigiria estimativa de largura (rejeitada) ou mudança de layout/regra (anti-goal) → defer documentado. Sem migration, dados, access ou mudança visual. --- docs/CHANGELOG-AGENTS.md | 2 + ...bilizar-lista-dobradinhas-followup-impl.md | 75 ++++++++ .../estabilizar-lista-dobradinhas-followup.md | 6 +- .../relationChipCellCollapse.unit.spec.tsx | 182 ++++++++++++++++++ 4 files changed, 262 insertions(+), 3 deletions(-) create mode 100644 docs/plans/estabilizar-lista-dobradinhas-followup-impl.md create mode 100644 tests/unit/relationChipCellCollapse.unit.spec.tsx diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index c3d639a0..240f92f3 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -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-` (sufixo `-2`/`-3` se o nome já estiver vivo); sem `bag`, o próximo sequencial `plans/plan-issue-` 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`/`teqo_wt_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). diff --git a/docs/plans/estabilizar-lista-dobradinhas-followup-impl.md b/docs/plans/estabilizar-lista-dobradinhas-followup-impl.md new file mode 100644 index 00000000..bf571b1c --- /dev/null +++ b/docs/plans/estabilizar-lista-dobradinhas-followup-impl.md @@ -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
RelationChipCell + rects stubados] --> B[Colapso 3 linhas / Ver mais / Ver menos
pinned em jsdom] + C[F2: medição PerformanceObserver
/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 diff --git a/docs/plans/estabilizar-lista-dobradinhas-followup.md b/docs/plans/estabilizar-lista-dobradinhas-followup.md index 87cbf167..768f554e 100644 --- a/docs/plans/estabilizar-lista-dobradinhas-followup.md +++ b/docs/plans/estabilizar-lista-dobradinhas-followup.md @@ -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). diff --git a/tests/unit/relationChipCellCollapse.unit.spec.tsx b/tests/unit/relationChipCellCollapse.unit.spec.tsx new file mode 100644 index 00000000..e8fe49c2 --- /dev/null +++ b/tests/unit/relationChipCellCollapse.unit.spec.tsx @@ -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 => ({ 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() + }) +})