mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 14:55:16 +00:00
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.
182 lines
6.6 KiB
TypeScript
182 lines
6.6 KiB
TypeScript
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()
|
||
})
|
||
})
|