From 4a90fb19ecdf91e07376daac57238417aea33dcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Tue, 4 Aug 2026 00:58:20 -0300 Subject: [PATCH 1/2] =?UTF-8?q?C86+=20=E2=80=94=20pathname=20de=20lista=20?= =?UTF-8?q?das=20a=C3=A7=C3=B5es=20r=C3=A1pidas=20em=20fonte=20=C3=BAnica?= =?UTF-8?q?=20(isListPath)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extrai isListPath + normalizePathname exportados em campaignQuickActionPaths.ts e migra os 9 wrappers (7 públicos + 2 privados) + a 3a cópia de normalize em campaignPageChrome.ts. Refactor estrutural sem mudança de comportamento; derivação de regex de detalhe numérico adiada (gatilho: 3o vertical [id]). Inclui docs/plans/escala-dry-pos-acoes-rapidas-impl.md e o fix de format pré-existente de docs/plans/liderancas-coluna-municipios.md (bloqueava format:check). --- .../escala-dry-pos-acoes-rapidas-impl.md | 109 ++++++++++++++++++ src/lib/campaignAdvisorQuickActions.ts | 3 +- src/lib/campaignMunicipalityQuickActions.ts | 3 +- src/lib/campaignPageChrome.ts | 8 +- src/lib/campaignQuickActionDemands.ts | 3 +- src/lib/campaignQuickActionDobradinhas.ts | 3 +- src/lib/campaignQuickActionLeadership.ts | 3 +- src/lib/campaignQuickActionMount.ts | 5 +- src/lib/campaignQuickActionPaths.ts | 10 +- src/lib/campaignReferenceQuickActions.ts | 5 +- src/lib/supporterQuickActions.ts | 3 +- tests/unit/campaignQuickAction.unit.spec.ts | 22 ++++ 12 files changed, 158 insertions(+), 19 deletions(-) create mode 100644 docs/plans/escala-dry-pos-acoes-rapidas-impl.md diff --git a/docs/plans/escala-dry-pos-acoes-rapidas-impl.md b/docs/plans/escala-dry-pos-acoes-rapidas-impl.md new file mode 100644 index 00000000..1f21dfe3 --- /dev/null +++ b/docs/plans/escala-dry-pos-acoes-rapidas-impl.md @@ -0,0 +1,109 @@ +# Impl: Escala/DRY pós-B86 — normalização de pathname de lista nas ações rápidas + +Status: aprovado +Atualizado em: 2026-08-04 +Issue: #353 +Intenção: docs/plans/escala-dry-pos-acoes-rapidas.md +Appetite restante: herdado (~0,25–0,5 dia eng; sem migration) + +## Leitura da intenção + +- **Outcome:** o conhecimento "normalizar pathname de lista (match exato + trailing slash)" deixa de estar duplicado nos módulos da família de ações rápidas; um helper compartilhado em `campaignQuickActionPaths.ts` é a fonte única; os exports públicos `isXListPath` continuam existindo como wrappers delegando. +- **O que NÃO negociar:** comportamento idêntico (refactor puramente estrutural); exports públicos preservados (specs + registry importam `isDemandsListPath` etc.); client-safe (zero server-only); sem migration; wrappers de superfície activity/organization intactos além de reusar o `normalizePathname` exportado. +- **O que reavaliar:** + - A intenção lista "6+ call sites" e nomeia 7 módulos. O inventário real tem **9 sites com o mesmo shape** — `isMunicipalitiesListPath` (`campaignMunicipalityQuickActions.ts`) e `isCampaignHomePath` (`campaignQuickActionMount.ts`) ficaram de fora da lista. + - A intenção fala de um `normalizePathname` privado "só usado pelos parsers de superfície activity/organization". Há uma **3ª cópia idêntica** em `campaignPageChrome.ts` (usada por `resolveCampaignPageChrome`), não citada na intenção. + +## Abordagem recomendada + +```mermaid +flowchart LR + subgraph helper["campaignQuickActionPaths.ts (home)"] + norm["export normalizePathname"] --> list["export isListPath(pathname, home)"] + end + list --> w1["isAdvisorsListPath"] + list --> w2["isDemandsListPath"] + list --> w3["isStateDeputyListPath"] + list --> w4["isConceptsPath + isProfilePath"] + list --> w5["isLeadershipListPath"] + list --> w6["isSupportersListPath"] + list --> w7["isMunicipalitiesListPath"] + list --> w8["isCampaignHomePath"] + norm --> chrome["campaignPageChrome (3ª cópia removida)"] +``` + +**Opções consideradas:** ver Decisões D1–D4. + +**Recomendação:** exportar `normalizePathname` + novo `isListPath(pathname, home)` em `campaignQuickActionPaths.ts`; os 9 wrappers de lista (7 públicos + 2 privados) delegam; `campaignPageChrome` importa o `normalizePathname` exportado (mata a 3ª cópia); testes novos para o helper compartilhado. + +**Rejeitadas:** só `normalizePathname` exportado (o spelling de comparação continua duplicado em cada site); só `isListPath` sem exportar `normalizePathname` (`campaignPageChrome` mantém a cópia); migrar apenas os 7 nomeados (deixa gêmeos do mesmo conhecimento); derivar as regex de detalhe numérico agora (gatilho não atingido). + +### Decisões de engenharia + +**D1 — Shape do helper.** +Opções: A) exportar `normalizePathname` **e** `isListPath(pathname, home)` | B) só `normalizePathname`; cada site faz `normalizePathname(pathname) === HOME` | C) só `isListPath`; `normalizePathname` segue privado. +Recomendação: **A** — `isListPath` nomeia o conceito "lista casa exatamente, com trailing slash opcional" (a semântica que os 9 sites repetem), e `normalizePathname` é a primitiva que os parsers activity/organization já usam no mesmo arquivo e que `campaignPageChrome` duplica. Ambos são puros, client-safe, 1–2 linhas. +Rejeitadas: B — a comparação `=== HOME` fica repetida em cada site, que é exatamente a duplicação que o lote remove; C — `campaignPageChrome` não tem uso de list-match, só de normalize; continuaria com cópia própria. + +**D2 — Escopo da migração.** +Opções: A) só os 7 módulos nomeados na intenção | B) os 9 sites com o mesmo shape (`+ isMunicipalitiesListPath`, `+ isCampaignHomePath`) e também a 3ª cópia de `normalizePathname` em `campaignPageChrome`. +Recomendação: **B** — `isMunicipalitiesListPath` e `isCampaignHomePath` são byte-idênticos em shape, semântica e intenção de comentário ("matches exactly"); consolidar 7 e deixar 2 gêmeos seria o anti-padrão que o lote existe para corrigir. `campaignPageChrome.normalizePathname` é a função idêntica — vira import de 2 linhas e a 3ª cópia morre. Nenhum comportamento muda; cada superfície tem spec própria pinando o resultado. +Rejeitadas: A — duplicação residual de 2 sites + 1 cópia de normalize; o "6+" da intenção já sinaliza inventário não-exaustivo. + +**D3 — Derivar regex de detalhe numérico da home.** +Opções: A) `escapeRegExp(home)` novo + regex derivada para advisor/supporter (`(\d+)(?:\/|$)`) | B) defer com gatilho (3º vertical com `[id]` numérico). +Recomendação: **B** — `escapeRegExp` não existe em `src/` (helper novo para 2 sites); `parseLeadershipDetailId` usa âncora diferente (`(\d+)$`, sem tolerância a trailing slash) — derivação compartilhada mudaria semântica ou precisaria de opção; o diff deixa de ser limpo. O gatilho da própria intenção ("3º vertical com [id] numérico — hoje são 2: advisor + supporter") não está atingido. +Rejeitadas: A — helper novo + diff sujo para consolidar conhecimento que só existe em 2 sites (abaixo do limiar de DRY do repo, <3 call sites). + +**D4 — Testes do helper compartilhado.** +Opções: A) specs existentes dos 9 wrappers cobrem a delegação; helper novo sem testes diretos | B) + describe novo em `campaignQuickAction.unit.spec.ts` para `isListPath`/`normalizePathname` (bordas: `/`, `/campanha/`, multi-slash, home `'/campanha'`). +Recomendação: **B** — o helper é API pública nova; os specs dos wrappers pinam a delegação, não as bordas do helper (ex. `normalizePathname` com `length > 1` preservando `/`, `isListPath` recusando `/campanha/foo`). O arquivo já hospeda os testes da família (`campaignQuickActionPaths` activities/organizations). +Rejeitadas: A — regressão das bordas ficaria descoberta quando um 3º/4º vertical passar a usar o helper. + +### Componentes / mudanças + +- **`normalizePathname` + `isListPath`** (`src/lib/campaignQuickActionPaths.ts`): `normalizePathname` deixa de ser privado (mesma implementação); `isListPath(pathname, home)` = `normalizePathname(pathname) === home`. Home nunca é `/` no repo, então o guard `length > 1` é inócuo para os 9 sites — nota no JSDoc. +- **Wrappers (delegam a `isListPath`, exports/contrato intactos):** + - `isAdvisorsListPath` (`campaignAdvisorQuickActions.ts`, público) + - `isDemandsListPath` (`campaignQuickActionDemands.ts`, público — importado pelo registry) + - `isStateDeputyListPath` (`campaignQuickActionDobradinhas.ts`, privado) + - `isConceptsPath` / `isProfilePath` (`campaignReferenceQuickActions.ts`, públicos) + - `isLeadershipListPath` (`campaignQuickActionLeadership.ts`, privado) + - `isSupportersListPath` (`supporterQuickActions.ts`, público) + - `isMunicipalitiesListPath` (`campaignMunicipalityQuickActions.ts`, público) + - `isCampaignHomePath` (`campaignQuickActionMount.ts`, público) +- **`campaignPageChrome.ts`**: remove o `normalizePathname` privado; importa o exportado. Zero mudança em `resolveCampaignPageChrome`. +- **Migration:** nenhuma (refactor client-safe, sem schema). +- **Access / Consent:** nenhum (path helpers puros; nada de escrita, PII ou chave nova). +- **UI:** Impeccable A — sem superfície de UI. + +### Dados → forma + +N/A — refactor estrutural; nenhuma métrica, série, ranking ou mapa novo. + +## Fases verificáveis + +1. **Helper + migração** (todo o appetite): exportar `normalizePathname` + `isListPath` → migrar os 9 wrappers → `campaignPageChrome` → testes novos do helper. `pnpm gate:fast` (lint + typecheck + unit) — os specs existentes de cada módulo pinam a delegação. +2. **Gates de entrega:** `pnpm check:cycles` (madge — `campaignQuickActionPaths` não importa nada, sem ciclo), `pnpm format:check`, `pnpm exec knip` (nenhum export morre: todos os `isXListPath` seguem públicos/usados), `pnpm push`. + +## Rabbit holes / Não escopo (engenharia) + +- Renomear `campaignQuickActionPaths.ts` ou mover para outro diretório — o arquivo já é o home designado pela intenção; renomear é noise de diff. +- Derivação de regex de detalhe numérico (D3) — defer com gatilho. +- Tocar `isDemandDetailPath`, `parseStateDeputyDetailSlug`, `parseMunicipalityDetailSlug`, `parseLeadershipDetailId` — não são list-match; ficam como estão. +- `isLeaderContactsPath` / `isCampaignActionsPath` (`campaignQuickActionMount.ts`) — prefix-match de descendentes, semântica diferente; fora. +- Normalizar em vez de comparar nos wrappers (mudar `isListPath` para aceitar descendants) — mudaria comportamento. +- Cache `Map` (S7 da intenção) — defer com gatilho (4º site com shape compatível); não é deste lote. + +## Riscos e mitigação + +- **Diff ruidoso (9 sites + chrome):** wrappers de 1 linha delegando mantêm cada diff mínimo; specs de cada módulo já existem e pinam o comportamento (`campaignAdvisorQuickActions`, `campaignQuickAction`, `campaignReferenceQuickActions`, `supporterQuickActions`, `campaignMunicipalityQuickActions`, `campaignPageChrome` unit specs). +- **Ciclo de import:** `campaignQuickActionPaths.ts` não importa nada; todos os importadores apontam para ele (folha do DAG) — `check:cycles` confirma. +- **Regressão de trailing slash em chrome:** `resolveCampaignPageChrome('/campanha/')` já é coberto por spec (`campaignPageChrome.unit.spec.ts` L10-13) e o normalize exportado é idêntico ao removido. + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção ainda coberto: conhecimento de list-match em fonte única; exports preservados; comportamento idêntico +- [ ] Invariantes AGENTS/engineering-standards: client-safe, sem migration, sem access/Consent, identificadores em inglês +- [ ] Testes de domínio: specs existentes dos 9 wrappers + describe novo de `isListPath`/`normalizePathname` +- [ ] Self-score decision-quality: 5/5 (decisões caras com rejeitadas; appetite respeitado; rabbit holes nomeados; depth check reusa o home existente; outcome preservado) diff --git a/src/lib/campaignAdvisorQuickActions.ts b/src/lib/campaignAdvisorQuickActions.ts index 1043a05c..17e9206a 100644 --- a/src/lib/campaignAdvisorQuickActions.ts +++ b/src/lib/campaignAdvisorQuickActions.ts @@ -2,6 +2,7 @@ import { UserPlus } from 'lucide-react' import { CAMPAIGN_ADVISORS_HOME } from '@/lib/campaignPaths' import type { CampaignQuickActionContext } from '@/lib/campaignQuickActionContext' +import { isListPath } from '@/lib/campaignQuickActionPaths' import type { CampaignQuickAction } from '@/lib/campaignQuickActionTypes' import { isUnrestrictedCampaignRole, type CampaignRole } from '@/lib/campaignRoles' @@ -13,7 +14,7 @@ export const advisorQuickCreateHref = const advisorDetailPathPattern = /^\/campanha\/assessores\/(\d+)(?:\/|$)/ export const isAdvisorsListPath = (pathname: string): boolean => - pathname === CAMPAIGN_ADVISORS_HOME || pathname === `${CAMPAIGN_ADVISORS_HOME}/` + isListPath(pathname, CAMPAIGN_ADVISORS_HOME) export const isAdvisorsPath = (pathname: string): boolean => isAdvisorsListPath(pathname) || parseAdvisorDetailId(pathname) !== undefined diff --git a/src/lib/campaignMunicipalityQuickActions.ts b/src/lib/campaignMunicipalityQuickActions.ts index 0fd76537..32c9d51a 100644 --- a/src/lib/campaignMunicipalityQuickActions.ts +++ b/src/lib/campaignMunicipalityQuickActions.ts @@ -5,6 +5,7 @@ import { type ResolvedCampaignHomeAction, } from '@/lib/campaignHomeActions' import type { CampaignQuickActionContext } from '@/lib/campaignQuickActionContext' +import { isListPath } from '@/lib/campaignQuickActionPaths' import type { CampaignRole } from '@/lib/campaignRoles' import { isStaffCampaignRole } from '@/lib/campaignRoles' @@ -13,7 +14,7 @@ const MUNICIPALITIES_LIST_PATH = '/campanha/municipios' as const const municipalityDetailPathPattern = /^\/campanha\/municipios\/([^/]+)(?:\/|$)/ export const isMunicipalitiesListPath = (pathname: string): boolean => - pathname === MUNICIPALITIES_LIST_PATH || pathname === `${MUNICIPALITIES_LIST_PATH}/` + isListPath(pathname, MUNICIPALITIES_LIST_PATH) export const parseMunicipalityDetailSlug = (pathname: string): string | undefined => { const match = pathname.match(municipalityDetailPathPattern) diff --git a/src/lib/campaignPageChrome.ts b/src/lib/campaignPageChrome.ts index b31ad82f..03095b7b 100644 --- a/src/lib/campaignPageChrome.ts +++ b/src/lib/campaignPageChrome.ts @@ -1,6 +1,7 @@ import type { Metadata } from 'next' import { CAMPAIGN_HOME, LEADER_CONTACTS_HOME } from '@/lib/campaignPaths' +import { normalizePathname } from '@/lib/campaignQuickActionPaths' import type { CampaignUser } from '@/payload-types' export type CampaignPageChrome = { @@ -10,13 +11,6 @@ export type CampaignPageChrome = { type CampaignRole = CampaignUser['role'] -const normalizePathname = (pathname: string): string => { - if (pathname.length > 1 && pathname.endsWith('/')) { - return pathname.slice(0, -1) - } - return pathname -} - /** Client-safe vocabulary for shell chrome and tab titles (B123). */ export const campaignPageChromeCatalog = { home: null, diff --git a/src/lib/campaignQuickActionDemands.ts b/src/lib/campaignQuickActionDemands.ts index a8005f31..b301091d 100644 --- a/src/lib/campaignQuickActionDemands.ts +++ b/src/lib/campaignQuickActionDemands.ts @@ -6,6 +6,7 @@ import { import { homeActionsForRole } from '@/lib/campaignHomeActions' import { CAMPAIGN_DEMANDS_HOME } from '@/lib/campaignPaths' import type { CampaignQuickActionContext } from '@/lib/campaignQuickActionContext' +import { isListPath } from '@/lib/campaignQuickActionPaths' import type { CampaignQuickAction } from '@/lib/campaignQuickActionTypes' import { isStaffCampaignRole, type CampaignRole } from '@/lib/campaignRoles' @@ -27,7 +28,7 @@ export const demandCreateHref = (municipalityId?: number): string => { } export const isDemandsListPath = (pathname: string): boolean => - pathname === CAMPAIGN_DEMANDS_HOME || pathname === `${CAMPAIGN_DEMANDS_HOME}/` + isListPath(pathname, CAMPAIGN_DEMANDS_HOME) export const isDemandDetailPath = (pathname: string): boolean => { if (!pathname.startsWith(`${CAMPAIGN_DEMANDS_HOME}/`)) { diff --git a/src/lib/campaignQuickActionDobradinhas.ts b/src/lib/campaignQuickActionDobradinhas.ts index dc6ed3be..bcc72013 100644 --- a/src/lib/campaignQuickActionDobradinhas.ts +++ b/src/lib/campaignQuickActionDobradinhas.ts @@ -1,6 +1,7 @@ import { Plus } from 'lucide-react' import { resolveStaffHomeQuickActions } from '@/lib/campaignHomeActions' +import { isListPath } from '@/lib/campaignQuickActionPaths' import type { CampaignQuickAction } from '@/lib/campaignQuickActionTypes' import { isStaffCampaignRole, type CampaignRole } from '@/lib/campaignRoles' @@ -9,7 +10,7 @@ const STATE_DEPUTY_CREATE_PATH = '/campanha/dobradinhas/nova' as const const STATE_DEPUTY_PATH_PREFIX = `${STATE_DEPUTY_LIST_PATH}/` as const const isStateDeputyListPath = (pathname: string): boolean => - pathname === STATE_DEPUTY_LIST_PATH || pathname === `${STATE_DEPUTY_LIST_PATH}/` + isListPath(pathname, STATE_DEPUTY_LIST_PATH) const isStateDeputyCreatePath = (pathname: string): boolean => pathname === STATE_DEPUTY_CREATE_PATH diff --git a/src/lib/campaignQuickActionLeadership.ts b/src/lib/campaignQuickActionLeadership.ts index dc721b00..3476313b 100644 --- a/src/lib/campaignQuickActionLeadership.ts +++ b/src/lib/campaignQuickActionLeadership.ts @@ -11,6 +11,7 @@ import { type ResolvedCampaignHomeAction, } from '@/lib/campaignHomeActions' import type { CampaignQuickActionContext } from '@/lib/campaignQuickActionContext' +import { isListPath } from '@/lib/campaignQuickActionPaths' import type { CampaignRole } from '@/lib/campaignRoles' import { isStaffCampaignRole } from '@/lib/campaignRoles' @@ -25,7 +26,7 @@ const LEADERSHIP_DETAIL_WIZARD_IDS: readonly CampaignWizardActionId[] = [ ] const isLeadershipListPath = (pathname: string): boolean => - pathname === LEADERSHIP_LIST_PATH || pathname === `${LEADERSHIP_LIST_PATH}/` + isListPath(pathname, LEADERSHIP_LIST_PATH) const parseLeadershipDetailId = (pathname: string): number | undefined => { const match = pathname.match(/^\/campanha\/liderancas\/(\d+)$/) diff --git a/src/lib/campaignQuickActionMount.ts b/src/lib/campaignQuickActionMount.ts index c8ccee56..756ec7df 100644 --- a/src/lib/campaignQuickActionMount.ts +++ b/src/lib/campaignQuickActionMount.ts @@ -1,7 +1,7 @@ import { CAMPAIGN_ACTIONS_HOME } from '@/lib/campaignActionRoutes' import { isAdvisorsPath } from '@/lib/campaignAdvisorQuickActions' import { CAMPAIGN_HOME, LEADER_CONTACTS_HOME } from '@/lib/campaignPaths' -import { isActivityTourComposerPath } from '@/lib/campaignQuickActionPaths' +import { isActivityTourComposerPath, isListPath } from '@/lib/campaignQuickActionPaths' import { isStaffCampaignRole, isUnrestrictedCampaignRole, @@ -9,8 +9,7 @@ import { } from '@/lib/campaignRoles' /** Início matches only exactly — same rule as sidebar nav. */ -export const isCampaignHomePath = (pathname: string): boolean => - pathname === CAMPAIGN_HOME || pathname === `${CAMPAIGN_HOME}/` +export const isCampaignHomePath = (pathname: string): boolean => isListPath(pathname, CAMPAIGN_HOME) export const isCampaignActionsPath = (pathname: string): boolean => pathname === CAMPAIGN_ACTIONS_HOME || pathname.startsWith(`${CAMPAIGN_ACTIONS_HOME}/`) diff --git a/src/lib/campaignQuickActionPaths.ts b/src/lib/campaignQuickActionPaths.ts index d08c83f9..8dd2cb6a 100644 --- a/src/lib/campaignQuickActionPaths.ts +++ b/src/lib/campaignQuickActionPaths.ts @@ -16,9 +16,17 @@ export type OrganizationQuickActionSurface = | { kind: 'list' } | { kind: 'detail'; organizationSlug: string } -const normalizePathname = (pathname: string): string => +/** + * Strips a single trailing slash for client-side path comparisons, preserving `/`. + * Homes in this codebase are never `/`, so `/campanha/` → `/campanha` while `/` stays `/`. + */ +export const normalizePathname = (pathname: string): string => pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname +/** Whether `pathname` is exactly `home`, with an optional trailing slash (list pages). */ +export const isListPath = (pathname: string, home: string): boolean => + normalizePathname(pathname) === home + const listDetailSlug = ( pathname: string, listPath: string, diff --git a/src/lib/campaignReferenceQuickActions.ts b/src/lib/campaignReferenceQuickActions.ts index f14f0d5e..c53c6eb4 100644 --- a/src/lib/campaignReferenceQuickActions.ts +++ b/src/lib/campaignReferenceQuickActions.ts @@ -1,14 +1,15 @@ import { CAMPAIGN_CONCEPTS_PATH } from '@/lib/campaignIntelligenceConcepts' import { CAMPAIGN_PROFILE_HOME } from '@/lib/campaignPaths' import type { CampaignQuickActionContext } from '@/lib/campaignQuickActionContext' +import { isListPath } from '@/lib/campaignQuickActionPaths' import type { CampaignQuickAction } from '@/lib/campaignQuickActionTypes' import { isStaffCampaignRole, type CampaignRole } from '@/lib/campaignRoles' export const isConceptsPath = (pathname: string): boolean => - pathname === CAMPAIGN_CONCEPTS_PATH || pathname === `${CAMPAIGN_CONCEPTS_PATH}/` + isListPath(pathname, CAMPAIGN_CONCEPTS_PATH) export const isProfilePath = (pathname: string): boolean => - pathname === CAMPAIGN_PROFILE_HOME || pathname === `${CAMPAIGN_PROFILE_HOME}/` + isListPath(pathname, CAMPAIGN_PROFILE_HOME) /** B90 — reference/account surfaces with search-only drawer (no action catalog). */ export const isReferenceQuickActionPath = (pathname: string): boolean => diff --git a/src/lib/supporterQuickActions.ts b/src/lib/supporterQuickActions.ts index 371b7caf..538862bc 100644 --- a/src/lib/supporterQuickActions.ts +++ b/src/lib/supporterQuickActions.ts @@ -2,6 +2,7 @@ import { FileUp, UserPlus } from 'lucide-react' import { CAMPAIGN_SUPPORTERS_HOME } from '@/lib/campaignPaths' import type { CampaignQuickActionContext } from '@/lib/campaignQuickActionContext' +import { isListPath } from '@/lib/campaignQuickActionPaths' import type { CampaignQuickAction } from '@/lib/campaignQuickActionTypes' import { isStaffCampaignRole, type CampaignRole } from '@/lib/campaignRoles' @@ -12,7 +13,7 @@ export const SUPPORTER_IMPORT_HREF = `${CAMPAIGN_SUPPORTERS_HOME}/importar` as c const supporterDetailPathPattern = /^\/campanha\/apoiadores\/(\d+)(?:\/|$)/ export const isSupportersListPath = (pathname: string): boolean => - pathname === CAMPAIGN_SUPPORTERS_HOME || pathname === `${CAMPAIGN_SUPPORTERS_HOME}/` + isListPath(pathname, CAMPAIGN_SUPPORTERS_HOME) export const parseSupporterDetailId = (pathname: string): number | undefined => { const match = pathname.match(supporterDetailPathPattern) diff --git a/tests/unit/campaignQuickAction.unit.spec.ts b/tests/unit/campaignQuickAction.unit.spec.ts index 30447756..6e57e4cc 100644 --- a/tests/unit/campaignQuickAction.unit.spec.ts +++ b/tests/unit/campaignQuickAction.unit.spec.ts @@ -32,6 +32,8 @@ import { ORGANIZATIONS_LIST_PATH, ORGANIZATION_NEW_PATH, isActivityTourComposerPath, + isListPath, + normalizePathname, parseActivityQuickActionSurface, parseOrganizationQuickActionSurface, } from '@/lib/campaignQuickActionPaths' @@ -114,6 +116,26 @@ describe('campaignQuickActionPaths (activities)', () => { }) }) +describe('campaignQuickActionPaths (shared list helpers, C86+)', () => { + it('matches exactly with an optional trailing slash', () => { + expect(isListPath('/campanha/demandas', '/campanha/demandas')).toBe(true) + expect(isListPath('/campanha/demandas/', '/campanha/demandas')).toBe(true) + }) + + it('rejects siblings and descendants', () => { + expect(isListPath('/campanha/demandas/nova', '/campanha/demandas')).toBe(false) + expect(isListPath('/campanha/demandas/pedido-cairu', '/campanha/demandas')).toBe(false) + expect(isListPath('/campanha/municipios', '/campanha/demandas')).toBe(false) + }) + + it('normalizes a single trailing slash while preserving /', () => { + expect(normalizePathname('/campanha/')).toBe('/campanha') + expect(normalizePathname('/campanha')).toBe('/campanha') + expect(normalizePathname('/')).toBe('/') + expect(normalizePathname('/campanha/apoiadores/')).toBe('/campanha/apoiadores') + }) +}) + describe('organizationQuickActions (B88)', () => { it('parses list and detail surfaces', () => { expect(parseOrganizationQuickActionSurface(ORGANIZATIONS_LIST_PATH)).toEqual({ kind: 'list' }) From b74268714c2d543f16856c91a1d7938daf7ace49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Tue, 4 Aug 2026 01:13:44 -0300 Subject: [PATCH 2/2] =?UTF-8?q?C86+=20=E2=80=94=20simplify:=20JSDoc=20do?= =?UTF-8?q?=20contrato=20home,=20testes=20de=20borda=20e=20nota=20no=20imp?= =?UTF-8?q?l=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes do /simplify (3 revisores read-only): JSDoc de normalizePathname/isListPath documentando o invariante (homes nunca são '/' nem terminam em slash), teste de multi-slash e inputs degenerados, comentário de isCampaignHomePath encurtado. Sem mudança de comportamento. --- .../plans/escala-dry-pos-acoes-rapidas-impl.md | 18 ++++++++++++++++++ src/lib/campaignQuickActionMount.ts | 2 +- src/lib/campaignQuickActionPaths.ts | 7 ++++--- tests/unit/campaignQuickAction.unit.spec.ts | 7 +++++++ 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/docs/plans/escala-dry-pos-acoes-rapidas-impl.md b/docs/plans/escala-dry-pos-acoes-rapidas-impl.md index 1f21dfe3..dc58d8b2 100644 --- a/docs/plans/escala-dry-pos-acoes-rapidas-impl.md +++ b/docs/plans/escala-dry-pos-acoes-rapidas-impl.md @@ -107,3 +107,21 @@ N/A — refactor estrutural; nenhuma métrica, série, ranking ou mapa novo. - [ ] Invariantes AGENTS/engineering-standards: client-safe, sem migration, sem access/Consent, identificadores em inglês - [ ] Testes de domínio: specs existentes dos 9 wrappers + describe novo de `isListPath`/`normalizePathname` - [ ] Self-score decision-quality: 5/5 (decisões caras com rejeitadas; appetite respeitado; rabbit holes nomeados; depth check reusa o home existente; outcome preservado) + +## Simplify (2026-08-04) — aplicado e deferido + +3 revisores paralelos (read-only) no diff da sessão; veredito do revisor de corretude: **bordas fechadas, comportamento preservado** (tabela de equivalência borda a borda dos matchers antigo × `isListPath`). Revisor de reuse: **sem duplicação real remanescente** (zero matchers exatos fora do diff; prefix-matches de descendentes deixados de fora por semântica diferente). + +**Aplicados (fixes pontuais que preservam comportamento):** + +- JSDoc de `normalizePathname` documenta o invariante "homes nunca são `/` nem terminam com slash" (S1 quality). +- Wording do JSDoc de `isListPath` corrigido ("equals `home`, optionally with a single trailing slash") (S3 quality). +- Testes novos pinando multi-slash (`/campanha//`) e inputs degenerados (`''`, `'/'` como home, double trailing slash) (S2 quality + S1 correctness). +- Comentário de `isCampaignHomePath` encurtado para o why ("same exact-match rule as the sidebar nav") (S4 quality). + +**Descartados:** + +- `isLeadershipListPath` como wrapper privado com 1 call site — manter (consistência de padrão com os 7 irmãos; inline seria micro-otimização de estilo) (S1 reuse, score 1). +- Contrato `home` sem tipo restrito — JSDoc documenta; gatilho: chamador futuro com home terminando em `/` (S2 correctness, score 1). + +**Defer com gatilho (já registrados na intenção, não reabrir):** derivação de regex numérica de detalhe (3º vertical com `[id]` numérico — hoje 2: advisor + supporter); cache `Map` (4º site com shape compatível); id `register-supporter` sobrecarregado (agregação de catálogos). diff --git a/src/lib/campaignQuickActionMount.ts b/src/lib/campaignQuickActionMount.ts index 756ec7df..cf56febc 100644 --- a/src/lib/campaignQuickActionMount.ts +++ b/src/lib/campaignQuickActionMount.ts @@ -8,7 +8,7 @@ import { type CampaignRole, } from '@/lib/campaignRoles' -/** Início matches only exactly — same rule as sidebar nav. */ +/** Same exact-match rule as the sidebar nav. */ export const isCampaignHomePath = (pathname: string): boolean => isListPath(pathname, CAMPAIGN_HOME) export const isCampaignActionsPath = (pathname: string): boolean => diff --git a/src/lib/campaignQuickActionPaths.ts b/src/lib/campaignQuickActionPaths.ts index 8dd2cb6a..31aff93a 100644 --- a/src/lib/campaignQuickActionPaths.ts +++ b/src/lib/campaignQuickActionPaths.ts @@ -17,13 +17,14 @@ export type OrganizationQuickActionSurface = | { kind: 'detail'; organizationSlug: string } /** - * Strips a single trailing slash for client-side path comparisons, preserving `/`. - * Homes in this codebase are never `/`, so `/campanha/` → `/campanha` while `/` stays `/`. + * Strips a single trailing slash for client-side path comparisons, preserving `/` + * (`/campanha/` → `/campanha`, while `/` stays `/`). Homes in this codebase never + * are `/` nor end with a slash — `isListPath` relies on that contract. */ export const normalizePathname = (pathname: string): string => pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname -/** Whether `pathname` is exactly `home`, with an optional trailing slash (list pages). */ +/** Whether `pathname` equals `home`, optionally with a single trailing slash. */ export const isListPath = (pathname: string, home: string): boolean => normalizePathname(pathname) === home diff --git a/tests/unit/campaignQuickAction.unit.spec.ts b/tests/unit/campaignQuickAction.unit.spec.ts index 6e57e4cc..eee4cf04 100644 --- a/tests/unit/campaignQuickAction.unit.spec.ts +++ b/tests/unit/campaignQuickAction.unit.spec.ts @@ -133,6 +133,13 @@ describe('campaignQuickActionPaths (shared list helpers, C86+)', () => { expect(normalizePathname('/campanha')).toBe('/campanha') expect(normalizePathname('/')).toBe('/') expect(normalizePathname('/campanha/apoiadores/')).toBe('/campanha/apoiadores') + expect(normalizePathname('/campanha//')).toBe('/campanha/') + }) + + it('pins the degenerate inputs the inline matchers used to handle', () => { + expect(isListPath('/campanha/demandas//', '/campanha/demandas')).toBe(false) + expect(isListPath('', '/campanha/demandas')).toBe(false) + expect(isListPath('/', '/')).toBe(true) }) })