mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:53:05 +00:00
feat(B166): largura padrão do chat Sollinha com teto de 360 px e resize livre persistido
This commit is contained in:
parent
ec0b109b5a
commit
2ca1afa6a7
5 changed files with 360 additions and 5 deletions
73
docs/plans/largura-padrao-chat-sollinha-impl.md
Normal file
73
docs/plans/largura-padrao-chat-sollinha-impl.md
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# Impl: B166 — Largura padrão do chat Sollinha com teto no desktop
|
||||
|
||||
Status: aprovado
|
||||
Atualizado em: 2026-08-08
|
||||
Issue: #414
|
||||
Intenção: docs/plans/largura-padrao-chat-sollinha.md
|
||||
Appetite restante: ~0,25–0,5 dia eng (mantido; sem migration)
|
||||
|
||||
## Leitura da intenção
|
||||
|
||||
- **Outcome:** no desktop de `/campanha`, o chat Sollinha abre — sozinho (carregamento) ou pelo botão do header — com `min(25% da janela, 360 px)` quando não há tamanho salvo; depois de aberto o resize é livre (sem teto superior, piso ~280 px mantido); o último tamanho escolhido pelo usuário é lembrado nas próximas aberturas; mobile (drawer full-screen) e demais superfícies intactos.
|
||||
- **O que NÃO negociar:** teto de 360 px vale **só** para a abertura padrão (sem tamanho salvo); resize do usuário **sem máximo**; persistir **só o tamanho** (não as conversas); piso ~280 px; mobile drawer intacto.
|
||||
- **O que reavaliar:** a “Direção no codebase” apontava `CampaignAISidebarShell.tsx` (Panel: `defaultSize="25"`, `minSize="280px"`, `maxSize="50"`, `collapsible`) — **confirmado como dono**. O mecanismo de persistência era aberto (“o executor escolhe”) — escolho persistência **própria em px** no `localStorage`, não `useDefaultLayout` do `react-resizable-panels` (razões abaixo).
|
||||
|
||||
## Abordagem recomendada
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[CampaignAISidebarShell] -->|Group elementRef + onLayoutChanged isUserInteraction| B[save px → localStorage]
|
||||
A -->|useIsMobile + resize(): PanelImperativeHandle| C[open size = saved ?? min 25%,360]
|
||||
C --> D[remove maxSize=50 → resize livre]
|
||||
B --> E[próxima abertura restaura px]
|
||||
```
|
||||
|
||||
**Opções consideradas:** A | B | C | D
|
||||
**Recomendação:** **A** — persistência própria em **px** + aplicação pontual no `useLayoutEffect`, porque casa com o aceite em unidades reais (px), persiste **apenas o tamanho** (hoje a abertura sozinha em `/campanha` é parte do comportamento — não se muda), e expressa o default `min(25%, 360 px)` sem depender de proporções do layout.
|
||||
**Rejeitadas:**
|
||||
|
||||
- **B — `useDefaultLayout` do `react-resizable-panels`** (persistência de layout por %, `onlySaveAfterUserInteractions`): persiste o layout **inteiro, incluindo collapse/expand** → um usuário que fechou o chat o encontraria fechado no próximo load, o que muda o “abre sozinho ao carregar `/campanha`” que a intenção descreve como comportamento atual a preservar; além disso, o default `min(25%, 360)` exigiria medição do grupo de qualquer forma, e salvar % re-escala com a janela em vez de lembrar o tamanho em px escolhido.
|
||||
- **C — teto via CSS (`max-width`/cap no Panel)**: travaria **também o resize do usuário** (produto diz “sem teto depois de aberto”) e briga com a matemática flex do library.
|
||||
- **D — persistir % em vez de px**: drift com largura da janela; o aceite é explícito em px (“480–640 px hoje”, “teto de 360 px”, “o tamanho escolhido é lembrado”).
|
||||
|
||||
### Componentes / mudanças
|
||||
|
||||
- **`src/lib/sollinhaChatPanelWidth.ts`** (novo — puro, client-safe; referencia `campaignLastActedMunicipality.ts`): storage key `teoq:campaign:sollinha-chat-width-px`, leitura/escrita seguras (guard `typeof window`, try/catch private-mode), e a função pura `resolveChatPanelWidthPx(groupWidthPx, savedPx)` → `savedPx` válido (clampeado a `[CHAT_MIN_PX, groupWidthPx]`) **ou** `min(25% × groupWidth, 360 px)` (também clampeado). Move `CHAT_MIN_PX` (280) da shell para cá (fonte única; `minSize` do Panel e clamp do resolver passam a compartilhar).
|
||||
- **`src/components/campaign/shell/ai/CampaignAISidebarShell.tsx`** (editar — dono do Group/Panel):
|
||||
- Chat `Panel`: **remove `maxSize="50"`** (resize livre até o limite natural), mantém `minSize={CHAT_MIN_PX}`, `collapsible`, `collapsedSize={0}`, `defaultSize={CHAT_DEFAULT_PCT}` como valor inicial do library.
|
||||
- `Group`: adiciona `elementRef` (medir largura do grupo) e `onLayoutChanged` — quando `meta.isUserInteraction` e o painel **não** estiver `isCollapsed()` e `inPixels >= CHAT_MIN_PX`, grava `Math.round(inPixels)` no storage.
|
||||
- `useLayoutEffect` (client), gate por `useIsMobile()` + guardas: aplica `resolveChatPanelWidthPx(...)` via `panelRef.current.resize(px)` **uma vez** quando o painel está visível no desktop e **não** está colapsado e o usuário ainda **não** mexeu na sessão (ref `hasUserSized`). Re-executa só quando o eixo mobile→desktop cruza (load mobile-first cresce para desktop), para o default capado valer também aí.
|
||||
- Separator/resize handle: intocado (já `md:flex`, existe).
|
||||
- **Migration:** sem migration (zero schema).
|
||||
- **Access / Consent:** nenhum (client-only; sem chave nova).
|
||||
- **UI:** Impeccable **B** — encaixe no comportamento de abertura (sem redesign visual; o handle de resize já existe); shape→craft→critique→polish resumem-se a verificar o comportamento real em dev.
|
||||
|
||||
### Dados → forma (se aplicável)
|
||||
|
||||
- N/A — nenhum número novo apresentado; é ajuste de largura de painel.
|
||||
|
||||
## Fases verificáveis
|
||||
|
||||
1. **Tracer / server+schema:** nada de schema. Implementar `src/lib/sollinhaChatPanelWidth.ts` + unit spec (`tests/unit/sollinhaChatPanelWidth.unit.spec.ts`, espelhando `campaignLastActedMunicipality.unit.spec.ts`: leitura inválida/not-json, gravação, clear, e casos do `resolveChatPanelWidthPx` — grupo 1920 → default 360; grupo 1280 → 320; salvo 520 → 520 mesmo acima de 360; salvo longo clampeado ao grupo; salvo inválido → default).
|
||||
2. **UI/wire:** editar `CampaignAISidebarShell.tsx` (remover `maxSize`, medir + aplicar, salvar em `onLayoutChanged`). Verificar em `pnpm dev` (porta 3266): abertura fresca em 1920 ≤360 px; arrastar para 520 px → reload restaura 520; fechar/reabrir mantém o da sessão; piso 280; mobile drawer intacto; painel colapsado (fechado pelo X) não reabre sozinho.
|
||||
3. **Gates:** `pnpm gate:fast` na iteração; `pnpm push` na entrega. E2e `campaignMunicipalityResponsiveColumns` (condicional do `Fechar` no chat) deve continuar verde — comportamento de “abrir/colapsar muda colunas” inalterado (a largura default em 1600 px segue < 360 px). Avaliar se vale um e2e dedicado de persistência (barato em Playwright) — decidir na execução conforme appetite.
|
||||
|
||||
## Rabbit holes / Não escopo (engenharia)
|
||||
|
||||
- Persistir **conversas** do chat — corte explícito da intenção (sessão nova a cada abertura).
|
||||
- Mexer no piso (~280 px) ou na semântica mobile drawer — fora de escopo.
|
||||
- Usar `useDefaultLayout`/persistência de % do library — rejeitado (mudaria aberto/fechado e re-escalaria).
|
||||
- Não criar hook intermediário pass-through; a lógica cabe no shell + lib puro (depth check: reusa `panelRef`/`useIsMobile` existentes).
|
||||
|
||||
## Riscos e mitigação
|
||||
|
||||
- **Semântica do `resize()`/`onLayoutChanged` do library (4.12.2):** `meta.isUserInteraction` discrimina drag de usuário vs imperativo (confirmado no bundle); `resize` valida/clampeia contra min/max. Mitigação: salvar só quando `!isCollapsed()` (arrastar até colapsar não grava 0); `isCollapsed()+hasUserSized` nas guardas para **nunca** forçar abrir nem sobrescrever escolha da sessão.
|
||||
- **Cruzamento mobile↔desktop no meio da sessão:** efeito chaveado em `useIsMobile()`; `resize()` sobre painel `hidden` (mobile) é inerte, e as guardas evitam reabrir painel colapsado.
|
||||
- **Garbage no storage:** parse/validação na leitura (espelha padrão `campaignLastActedMunicipality`/`recentVisits`), retorna default.
|
||||
- **e2e existente:** largura default em viewport 1600 continua < 360 px (grupo ≈ 1320 px → 25% ≈ 330 px) → sem mudança de estágio de coluna.
|
||||
|
||||
## Aceite de engenharia
|
||||
|
||||
- [ ] Aceite de produto da intenção ainda coberto (default capado + resize livre + persistência de tamanho; mobile intacto)
|
||||
- [ ] Invariantes AGENTS/engineering-standards (sem Local API/overrideAccess; client-only; identificadores em inglês)
|
||||
- [ ] Testes de domínio previstos (unit do resolver/storage; e2e existente segue verde)
|
||||
|
|
@ -1,23 +1,68 @@
|
|||
'use client'
|
||||
|
||||
import { type ReactNode } from 'react'
|
||||
import { type ReactNode, useLayoutEffect, useRef } from 'react'
|
||||
import { Group, Panel, Separator, usePanelRef } from 'react-resizable-panels'
|
||||
|
||||
import { CampaignAIFab } from '@/components/campaign/shell/ai/CampaignAIFab'
|
||||
import { CampaignAISidebar } from '@/components/campaign/shell/ai/CampaignAISidebar'
|
||||
import { CampaignAISidebarProvider } from '@/components/campaign/shell/ai/CampaignAISidebarContext'
|
||||
import { useIsMobile } from '@/hooks/use-mobile'
|
||||
import {
|
||||
CHAT_MIN_PX,
|
||||
getSavedChatWidthPx,
|
||||
resolveChatPanelWidthPx,
|
||||
saveChatWidthPx,
|
||||
} from '@/lib/sollinhaChatPanelWidth'
|
||||
|
||||
const CHAT_MIN_PX = '280px'
|
||||
const CHAT_DEFAULT_PCT = '25'
|
||||
const MAIN_MIN_PCT = '35'
|
||||
|
||||
export const CampaignAISidebarShell = ({ children }: { children: ReactNode }) => {
|
||||
const panelRef = usePanelRef()
|
||||
const groupRef = useRef<HTMLDivElement | null>(null)
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
// Once the user resizes the panel, a re-render across the mobile/desktop
|
||||
// boundary (or a re-run of the sizing effect) must never override the
|
||||
// size chosen live in this session.
|
||||
const hasUserSizedRef = useRef(false)
|
||||
|
||||
// Apply the remembered — or the capped-default — width once the desktop
|
||||
// panel's layout has settled; re-applies if the device later crosses back
|
||||
// into desktop (a mobile-first load that grows to desktop still gets it).
|
||||
// The imperative `resize(px)` converts px→% from the group's measured
|
||||
// offsets, so it must not run during the first commit (transient values).
|
||||
useLayoutEffect(() => {
|
||||
if (isMobile) return
|
||||
const frame = requestAnimationFrame(() => {
|
||||
const group = groupRef.current
|
||||
const panel = panelRef.current
|
||||
if (!group || !panel || hasUserSizedRef.current || panel.isCollapsed()) return
|
||||
const groupWidth = group.getBoundingClientRect().width
|
||||
if (groupWidth <= 0) return
|
||||
panel.resize(resolveChatPanelWidthPx(groupWidth, getSavedChatWidthPx()))
|
||||
})
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [isMobile, panelRef])
|
||||
|
||||
return (
|
||||
<CampaignAISidebarProvider panelRef={panelRef}>
|
||||
<CampaignAIFab />
|
||||
<Group orientation="horizontal" className="min-h-0 flex-1">
|
||||
<Group
|
||||
id="campaign-ai-shell"
|
||||
orientation="horizontal"
|
||||
className="min-h-0 flex-1"
|
||||
elementRef={groupRef}
|
||||
onLayoutChanged={(_layout, meta) => {
|
||||
if (!meta.isUserInteraction) return
|
||||
const panel = panelRef.current
|
||||
if (!panel || panel.isCollapsed()) return
|
||||
const { inPixels } = panel.getSize()
|
||||
if (inPixels < CHAT_MIN_PX) return
|
||||
hasUserSizedRef.current = true
|
||||
saveChatWidthPx(inPixels)
|
||||
}}
|
||||
>
|
||||
{/* Main content */}
|
||||
<Panel defaultSize="75" minSize={MAIN_MIN_PCT}>
|
||||
<div className="[contain:layout_paint] h-full">
|
||||
|
|
@ -26,16 +71,24 @@ export const CampaignAISidebarShell = ({ children }: { children: ReactNode }) =>
|
|||
</Panel>
|
||||
|
||||
{/* Resize handle */}
|
||||
<Separator className="hidden w-3 shrink-0 cursor-col-resize transition-colors focus:outline-none md:flex items-center justify-center group/separator data-[resize-handle-active]:bg-primary/20">
|
||||
{/*
|
||||
Double-click resets the panel to its raw `defaultSize` (uncapped 25%);
|
||||
the product default is the capped min(25%, 360px), so the stale
|
||||
reset affordance would reopen wider than allowed — disable it.
|
||||
*/}
|
||||
<Separator
|
||||
disableDoubleClick
|
||||
className="hidden w-3 shrink-0 cursor-col-resize transition-colors focus:outline-none md:flex items-center justify-center group/separator data-[resize-handle-active]:bg-primary/20"
|
||||
>
|
||||
<div className="h-full w-px bg-border transition-colors group-hover/separator:bg-primary/50 group-data-[resize-handle-active]/separator:bg-primary/50" />
|
||||
</Separator>
|
||||
|
||||
{/* AI chat sidebar — collapsible, controlled by context */}
|
||||
<Panel
|
||||
id="ai-chat-panel"
|
||||
panelRef={panelRef}
|
||||
defaultSize={CHAT_DEFAULT_PCT}
|
||||
minSize={CHAT_MIN_PX}
|
||||
maxSize="50"
|
||||
collapsible
|
||||
collapsedSize={0}
|
||||
className="hidden md:block"
|
||||
|
|
|
|||
58
src/lib/sollinhaChatPanelWidth.ts
Normal file
58
src/lib/sollinhaChatPanelWidth.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/** Device-local Sollinha chat panel width (px), remembered across opens. */
|
||||
|
||||
export const SOLLINHA_CHAT_WIDTH_STORAGE_KEY = 'teqo:campaign:sollinha-chat-width-px'
|
||||
|
||||
/** Floor of the chat panel — reuse it wherever the panel width is constrained. */
|
||||
export const CHAT_MIN_PX = 280
|
||||
|
||||
/** Cap of the default open width (`min(25% of the group, this)`). */
|
||||
export const CHAT_DEFAULT_MAX_PX = 360
|
||||
|
||||
const isStoredWidth = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value) && value > 0
|
||||
|
||||
export const getSavedChatWidthPx = (): number | null => {
|
||||
if (typeof window === 'undefined') return null
|
||||
try {
|
||||
const raw = localStorage.getItem(SOLLINHA_CHAT_WIDTH_STORAGE_KEY)
|
||||
if (!raw) return null
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
return isStoredWidth(parsed) ? parsed : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const saveChatWidthPx = (widthPx: number): void => {
|
||||
if (typeof window === 'undefined') return
|
||||
const rounded = Math.round(widthPx)
|
||||
if (rounded <= 0) return
|
||||
try {
|
||||
localStorage.setItem(SOLLINHA_CHAT_WIDTH_STORAGE_KEY, JSON.stringify(rounded))
|
||||
} catch {
|
||||
// Ignore quota / private-mode failures.
|
||||
}
|
||||
}
|
||||
|
||||
export const clearSavedChatWidthPx = (): void => {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
localStorage.removeItem(SOLLINHA_CHAT_WIDTH_STORAGE_KEY)
|
||||
} catch {
|
||||
// Ignore private-mode failures.
|
||||
}
|
||||
}
|
||||
|
||||
const clamp = (value: number, min: number, max: number): number =>
|
||||
Math.min(Math.max(value, min), Math.max(min, max))
|
||||
|
||||
/** Default open width: 25% of the group, capped at `CHAT_DEFAULT_MAX_PX`. */
|
||||
export const defaultChatWidthPx = (groupWidthPx: number): number =>
|
||||
clamp(Math.round(Math.min(0.25 * groupWidthPx, CHAT_DEFAULT_MAX_PX)), CHAT_MIN_PX, groupWidthPx)
|
||||
|
||||
/**
|
||||
* Width for the next open: the remembered choice wins (even above the cap),
|
||||
* otherwise the capped 25% default. Always bounded to `[CHAT_MIN_PX, group]`.
|
||||
*/
|
||||
export const resolveChatPanelWidthPx = (groupWidthPx: number, savedPx: number | null): number =>
|
||||
savedPx === null ? defaultChatWidthPx(groupWidthPx) : clamp(savedPx, CHAT_MIN_PX, groupWidthPx)
|
||||
90
tests/e2e/campaignSollinhaWidth.e2e.spec.ts
Normal file
90
tests/e2e/campaignSollinhaWidth.e2e.spec.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { expect, test } from './fixtures/campaignE2EFixtures.js'
|
||||
|
||||
type ResponsiveCampaignFixture = {
|
||||
fixtures: {
|
||||
createCampaignUser: (
|
||||
role: 'advisor' | 'coordinator',
|
||||
input: { name: string },
|
||||
) => Promise<{ email?: string | null; password: string }>
|
||||
value: (prefix: string) => string
|
||||
}
|
||||
login: (page: Page, identifier: string, password: string) => Promise<void>
|
||||
}
|
||||
|
||||
const loginAs = async (page: Page, campaign: ResponsiveCampaignFixture) => {
|
||||
const user = await campaign.fixtures.createCampaignUser('coordinator', {
|
||||
name: campaign.fixtures.value('Sollinha Largura'),
|
||||
})
|
||||
await campaign.login(page, user.email!, user.password)
|
||||
await page.goto('/campanha/municipios')
|
||||
await page.waitForLoadState('networkidle')
|
||||
}
|
||||
|
||||
const chatPanel = (page: Page) => page.locator('#ai-chat-panel')
|
||||
const chatSeparator = (page: Page) => page.locator('#campaign-ai-shell [role="separator"]')
|
||||
|
||||
const panelWidth = (panel: Locator) => panel.evaluate((element) => element.clientWidth)
|
||||
|
||||
const dragSeparatorBy = async (page: Page, deltaX: number) => {
|
||||
const separator = chatSeparator(page)
|
||||
const box = (await separator.boundingBox())!
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(box.x + box.width / 2 + deltaX, box.y + box.height / 2, { steps: 6 })
|
||||
await page.mouse.up()
|
||||
}
|
||||
|
||||
test.describe('B166 — largura padrão do chat Sollinha com teto no desktop', () => {
|
||||
test('opens capped at 360px, grows freely and remembers the resized width', async ({
|
||||
campaign,
|
||||
page,
|
||||
}) => {
|
||||
test.slow()
|
||||
|
||||
// Wide desktop: 25% of the group exceeds 360px, so the cap must bite. The
|
||||
// sidebar is open by default on a fresh context.
|
||||
await page.setViewportSize({ width: 1920, height: 1080 })
|
||||
await loginAs(page, campaign)
|
||||
|
||||
const panel = chatPanel(page)
|
||||
await expect(panel).toBeVisible()
|
||||
// The panel first paints at the raw 25% default and is capped one frame
|
||||
// later by the sizing effect — poll until the cap lands (also proves the
|
||||
// default open never exceeds 360px, acceptance 1).
|
||||
await expect.poll(() => panelWidth(panel)).toBeLessThanOrEqual(360)
|
||||
|
||||
const defaultWidth = await panelWidth(panel)
|
||||
expect(defaultWidth).toBeGreaterThanOrEqual(280)
|
||||
|
||||
// Drag the separator to the left: chat grows beyond the 360px cap — the
|
||||
// cap applies only to the default open, never to the user resize.
|
||||
await dragSeparatorBy(page, -220)
|
||||
await expect.poll(() => panelWidth(panel)).toBeGreaterThan(360)
|
||||
const resizedWidth = await panelWidth(panel)
|
||||
|
||||
// The chosen size was saved locally: a page reload reopens at it, not at
|
||||
// the capped default.
|
||||
await page.reload()
|
||||
await expect(panel).toBeVisible()
|
||||
await expect.poll(() => panelWidth(panel)).toBeGreaterThan(360)
|
||||
const restoredWidth = await panelWidth(panel)
|
||||
expect(Math.abs(restoredWidth - resizedWidth)).toBeLessThanOrEqual(4)
|
||||
|
||||
// Reopening via the header button (acceptance 1 — "pelo botão") restores
|
||||
// the same remembered width instead of a fresh cap.
|
||||
await page
|
||||
.getByRole('button', { name: 'Fechar', exact: true })
|
||||
.filter({ visible: true })
|
||||
.click()
|
||||
await expect.poll(() => panelWidth(panel)).toBeLessThan(200)
|
||||
await page
|
||||
.getByRole('button', { name: 'Sollinha — Assistente virtual' })
|
||||
.filter({ visible: true })
|
||||
.click()
|
||||
await expect.poll(() => panelWidth(panel)).toBeGreaterThan(360)
|
||||
const buttonOpenWidth = await panelWidth(panel)
|
||||
expect(Math.abs(buttonOpenWidth - restoredWidth)).toBeLessThanOrEqual(4)
|
||||
})
|
||||
})
|
||||
81
tests/unit/sollinhaChatPanelWidth.unit.spec.ts
Normal file
81
tests/unit/sollinhaChatPanelWidth.unit.spec.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
CHAT_DEFAULT_MAX_PX,
|
||||
CHAT_MIN_PX,
|
||||
clearSavedChatWidthPx,
|
||||
defaultChatWidthPx,
|
||||
getSavedChatWidthPx,
|
||||
resolveChatPanelWidthPx,
|
||||
saveChatWidthPx,
|
||||
SOLLINHA_CHAT_WIDTH_STORAGE_KEY,
|
||||
} from '@/lib/sollinhaChatPanelWidth'
|
||||
|
||||
describe('sollinhaChatPanelWidth storage', () => {
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('returns null when storage is missing or invalid', () => {
|
||||
expect(getSavedChatWidthPx()).toBeNull()
|
||||
localStorage.setItem(SOLLINHA_CHAT_WIDTH_STORAGE_KEY, 'not-json')
|
||||
expect(getSavedChatWidthPx()).toBeNull()
|
||||
localStorage.setItem(SOLLINHA_CHAT_WIDTH_STORAGE_KEY, JSON.stringify({ px: 520 }))
|
||||
expect(getSavedChatWidthPx()).toBeNull()
|
||||
localStorage.setItem(SOLLINHA_CHAT_WIDTH_STORAGE_KEY, JSON.stringify(-50))
|
||||
expect(getSavedChatWidthPx()).toBeNull()
|
||||
localStorage.setItem(SOLLINHA_CHAT_WIDTH_STORAGE_KEY, JSON.stringify(NaN))
|
||||
expect(getSavedChatWidthPx()).toBeNull()
|
||||
})
|
||||
|
||||
it('records and reads the saved width (rounded)', () => {
|
||||
saveChatWidthPx(520.4)
|
||||
expect(getSavedChatWidthPx()).toBe(520)
|
||||
saveChatWidthPx(300)
|
||||
expect(getSavedChatWidthPx()).toBe(300)
|
||||
})
|
||||
|
||||
it('clears the saved width', () => {
|
||||
saveChatWidthPx(360)
|
||||
clearSavedChatWidthPx()
|
||||
expect(getSavedChatWidthPx()).toBeNull()
|
||||
expect(localStorage.getItem(SOLLINHA_CHAT_WIDTH_STORAGE_KEY)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('defaultChatWidthPx', () => {
|
||||
it('caps the 25% default at CHAT_DEFAULT_MAX_PX', () => {
|
||||
expect(defaultChatWidthPx(1920)).toBe(CHAT_DEFAULT_MAX_PX)
|
||||
expect(defaultChatWidthPx(1920)).toBe(360)
|
||||
expect(defaultChatWidthPx(1440)).toBe(360)
|
||||
})
|
||||
|
||||
it('stays at 25% when that is below the cap', () => {
|
||||
expect(defaultChatWidthPx(1280)).toBe(320)
|
||||
expect(defaultChatWidthPx(1200)).toBe(300)
|
||||
})
|
||||
|
||||
it('never goes below CHAT_MIN_PX', () => {
|
||||
expect(defaultChatWidthPx(1000)).toBe(CHAT_MIN_PX)
|
||||
expect(defaultChatWidthPx(700)).toBe(CHAT_MIN_PX)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveChatPanelWidthPx', () => {
|
||||
it('uses the capped 25% default when nothing is saved', () => {
|
||||
expect(resolveChatPanelWidthPx(1920, null)).toBe(360)
|
||||
expect(resolveChatPanelWidthPx(1280, null)).toBe(320)
|
||||
})
|
||||
|
||||
it('lets a saved choice win even above the cap', () => {
|
||||
expect(resolveChatPanelWidthPx(1920, 520)).toBe(520)
|
||||
})
|
||||
|
||||
it('clamps a saved choice to the group width', () => {
|
||||
expect(resolveChatPanelWidthPx(500, 900)).toBe(500)
|
||||
})
|
||||
|
||||
it('clamps a saved choice below the floor up to CHAT_MIN_PX', () => {
|
||||
expect(resolveChatPanelWidthPx(1920, 150)).toBe(CHAT_MIN_PX)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue