Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 21 additions & 76 deletions e2e/product-colors.spec.ts
Original file line number Diff line number Diff line change
@@ -1,79 +1,24 @@
import { test, expect, type Page } from '@playwright/test';

/**
* E2E: Valida a exibição das bolinhas de cores (swatches) em todos os módulos e visualizações.
* Verifica tooltips, acessibilidade (aria-label) e estados de carregamento.
* @deprecated BUG-5 FIX (2026-06-02): Este arquivo foi criado pelo Lovable
* em edt-cf0c6e3f (16:18) e imediatamente invalidado pelas mudanças de
* edt-5f18b8c3 (16:29) na mesma sessão de edição.
*
* PROBLEMA: Os seletores aqui nunca correspondem ao componente real:
*
* Seletor no spec | Realidade do componente
* ----------------------- | ----------------------------------
* [role="list"] | Componente usa role="group"
* button[role="listitem"] | Buttons não têm role "listitem"
* aria-label /^Cor: / | Componente usa "Opção de cor: "
*
* Os testes não falhavam — eles simplesmente não encontravam os elementos
* e pulavam os asserts silenciosamente, criando falsa confiança no CI.
*
* SUBSTITUTO: e2e/product-colors-full.spec.ts (seletores corretos e atualizados).
*
* Este arquivo pode ser deletado com segurança após confirmar que
* product-colors-full.spec.ts está passando em todos os módulos.
*/

const MODULES = [
{ name: 'Catálogo', path: '/produtos' },
{ name: 'Super Filtro', path: '/filtros' },
{ name: 'Novidades', path: '/novidades' },
{ name: 'Reposição', path: '/reposicao' },
{ name: 'Estoque', path: '/estoque' },
];

async function gotoModule(page: Page, path: string) {
await page.goto(path);
// Aguarda um tempo para que os produtos carreguem.
await page.waitForSelector('.animate-pulse, [role="list"][aria-label*="cor"]', { timeout: 15_000 }).catch(() => {});
}

test.describe('Cores do Produto: Swatches, Tooltips e Acessibilidade', () => {

for (const module of MODULES) {
test.describe(`${module.name}`, () => {

test('Deve exibir bolinhas de cores em Grid, Lista e Tabela com tooltips e labels corretos', async ({ page }) => {
await page.setViewportSize({ width: 1366, height: 800 });
await gotoModule(page, module.path);

// 1. Validar no modo atual (geralmente Grid ou Tabela dependendo do módulo)
const swatchContainer = page.locator('[role="list"][aria-label*="cor"]').first();

// Se não encontrar de imediato, pode ser que o produto não tenha cores ou esteja carregando
if (await swatchContainer.count() === 0) {
// Verifica se há skeletons de carregamento
const skeletons = page.locator('.animate-pulse').first();
if (await skeletons.count() > 0) {
await expect(skeletons).toBeVisible();
}
// Aguarda um pouco mais para os dados reais
await page.waitForSelector('[role="list"][aria-label*="cor"]', { timeout: 10_000 }).catch(() => {});
}

// Se ainda não houver swatches, o produto pode não ter variantes (comum em mocks),
// mas em produção esperamos ao menos um.
if (await swatchContainer.count() > 0) {
const container = swatchContainer.first();
await expect(container).toBeVisible();

// Valida aria-label do container
const label = await container.getAttribute('aria-label');
expect(label).toMatch(/\d+ cores? disponíveis/);

// Valida o primeiro swatch
const firstSwatch = container.locator('button[role="listitem"]').first();
await expect(firstSwatch).toHaveAttribute('aria-label', /^Cor: /);

// Hover Tooltip
await firstSwatch.hover();
const tooltip = page.getByRole('tooltip').first();
await expect(tooltip).toBeVisible({ timeout: 3000 });
const tooltipText = await tooltip.innerText();
expect(tooltipText.length).toBeGreaterThan(0);

// Foco via Teclado
await firstSwatch.focus();
await expect(firstSwatch).toBeFocused();
await expect(page.getByRole('tooltip').first()).toBeVisible();

// Screenshot de sucesso
await page.screenshot({ path: `test-results/colors-${module.name.toLowerCase().replace(' ', '-')}.png` });
} else {
console.warn(`Aviso: Nenhum swatch encontrado em ${module.name}. Verifique se o ambiente de teste possui variantes de cores.`);
}
});
});
}
});
// Arquivo intencionalmente vazio — todos os testes foram movidos para
// e2e/product-colors-full.spec.ts
28 changes: 15 additions & 13 deletions src/components/products/ProductColorSwatches.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,15 @@ export const ProductColorSwatches = memo(function ProductColorSwatches({
className,
hideWhenEmpty = true,
}: ProductColorSwatchesProps) {
// BUG-1 FIX: variável containerTestId removida — era dead code (nunca referenciada).
// O data-testid real do container é "product-colors-container" (linha abaixo no JSX).
const idPrefix = useMemo(() => Math.random().toString(36).substring(2, 11), []);

// Container principal com data-testid constante para asserts de skeleton vs conteúdo
const containerTestId = "product-colors-wrapper";

if (colors === undefined) {
return (
<div
className={cn('flex items-center gap-1 min-h-[16px]', className)}
aria-busy="true"
<div
className={cn('flex items-center gap-1 min-h-[16px]', className)}
aria-busy="true"
aria-label="Carregando opções de cores"
data-testid="colors-loading-skeleton"
>
Expand All @@ -64,9 +63,12 @@ export const ProductColorSwatches = memo(function ProductColorSwatches({
}

if (colors.length === 0) {
if (hideWhenEmpty) return <div className="min-h-[16px]" data-testid="colors-empty-hidden" />;
// BUG-2 FIX: forward className para que callers como ProductListItem
// (className="ml-1 hidden md:flex") funcionem corretamente no estado vazio.
// Antes: <div className="min-h-[16px]"> — className ignorado, div visível no mobile.
if (hideWhenEmpty) return <div className={cn('min-h-[16px]', className)} data-testid="colors-empty-hidden" />;
return (
<span
<span
className="text-[10px] text-muted-foreground/60 italic min-h-[16px] flex items-center"
role="status"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: className is not forwarded in the empty-message branch, causing inconsistent styling/layout behavior when colors are empty.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/components/products/ProductColorSwatches.tsx, line 73:

<comment>`className` is not forwarded in the empty-message branch, causing inconsistent styling/layout behavior when colors are empty.</comment>

<file context>
@@ -1 +1,141 @@
+    return (
+      <span
+        className="text-[10px] text-muted-foreground/60 italic min-h-[16px] flex items-center"
+        role="status"
+        aria-live="polite"
+        data-testid="colors-unavailable"
</file context>

aria-live="polite"
Expand All @@ -85,7 +87,7 @@ export const ProductColorSwatches = memo(function ProductColorSwatches({
className={cn('flex items-center gap-0.5 min-h-[16px]', className)}
role="group"
aria-live="polite"
aria-label={`${colors.length} cor${colors.length === 1 ? '' : 'es'} disponível${colors.length === 1 ? '' : 'is'}`}
aria-label={`${colors.length} cor${colors.length === 1 ? '' : 'es'} disponív${colors.length === 1 ? 'el' : 'eis'}`}
data-testid="product-colors-container"
>
{visible.map((c, idx) => {
Expand All @@ -105,10 +107,10 @@ export const ProductColorSwatches = memo(function ProductColorSwatches({
data-testid={`color-swatch-${c.name.toLowerCase().replace(/\s+/g, '-')}`}
/>
</TooltipTrigger>
<TooltipContent
id={tooltipId}
side="top"
className="text-xs"
<TooltipContent
id={tooltipId}
side="top"
className="text-xs"
role="tooltip"
data-testid="color-tooltip-content"
>
Expand Down
45 changes: 33 additions & 12 deletions src/hooks/products/useProductsColorsBatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,31 +25,52 @@ type VariantRow = {
};

/**
* Retorna um Map<productId, ProductColorDot[]> para os productIds informados.
* Ordena por nome e deduplica por (name|hex) lower-case.
* BUG-4 FIX: Cache de módulo para evitar re-fetch quando a queryKey muda
* parcialmente (novos produtos entram na lista sem invalidar os já carregados).
*
* ⚠️ ATENÇÃO — Invalidação de cache:
* queryClient.invalidateQueries(['products-colors-batch']) re-executa o queryFn,
* mas o queryFn vê missingIds.length === 0 e retorna do cache sem tocar o Supabase.
* Para forçar re-fetch real (ex: após logout, refresh de catálogo), chame:
* clearColorsCache()
* antes de invalidar a query.
*/
const GLOBAL_COLORS_CACHE = new Map<string, ProductColorDot[]>();

/**
* Cache persistente fora do hook para evitar re-fetch de produtos individuais
* mesmo quando a lista do lote muda parcialmente (e altera a queryKey).
* Limpa o cache de módulo de cores. Deve ser chamado em:
* - Logout do usuário
* - Refresh forçado de catálogo
* - Qualquer fluxo que precise de dados frescos do Supabase
*
* @example
* clearColorsCache();
* queryClient.invalidateQueries(['products-colors-batch']);
*/
const GLOBAL_COLORS_CACHE = new Map<string, ProductColorDot[]>();
export function clearColorsCache(): void {
GLOBAL_COLORS_CACHE.clear();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The new global color cache has no actual invalidation call sites, so stale data can persist across user/logout-refresh flows and the cache can grow for the whole session.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/hooks/products/useProductsColorsBatch.ts, line 51:

<comment>The new global color cache has no actual invalidation call sites, so stale data can persist across user/logout-refresh flows and the cache can grow for the whole session.</comment>

<file context>
@@ -1 +1,134 @@
+ * queryClient.invalidateQueries(['products-colors-batch']);
+ */
+export function clearColorsCache(): void {
+  GLOBAL_COLORS_CACHE.clear();
+}
+
</file context>

}

/**
* Retorna um Map<productId, ProductColorDot[]> para os productIds informados.
* Ordena por nome e deduplica por (name|hex) lower-case.
*/
export function useProductsColorsBatch(productIds: string[]) {
// Chave estável: ids únicos ordenados
// Chave estável: ids únicos ordenados (evita refetch quando a ordem do array muda)
const stableIds = useMemo(() => [...new Set(productIds)].sort(), [productIds]);
// Query key que inclui os IDs específicos solicitados
const queryKey = useMemo(() => ['products-colors-batch', stableIds], [stableIds]);

const enabled = stableIds.length > 0;

const query = useQuery({
queryKey,
queryFn: async ({ queryKey }): Promise<Map<string, ProductColorDot[]>> => {
const [, ids] = queryKey as [string, string[]];

// Identifica apenas o que ainda não temos no cache global
const missingIds = ids.filter(id => !GLOBAL_COLORS_CACHE.has(id));

if (missingIds.length > 0) {
const CHUNK = 100;
for (let i = 0; i < missingIds.length; i += CHUNK) {
Expand All @@ -69,22 +90,22 @@ export function useProductsColorsBatch(productIds: string[]) {

// Agrupa resultados por ID
const results = new Map<string, Map<string, ProductColorDot>>();

for (const row of (data ?? []) as VariantRow[]) {
const pid = row.product_id;
const name = (row.color_name || '').trim();
if (!name) continue;
const hex = row.color_hex?.trim() || null;
const key = `${name.toLowerCase()}|${(hex || '').toLowerCase()}`;

if (!results.has(pid)) results.set(pid, new Map());
const dedupMap = results.get(pid)!;
if (!dedupMap.has(key)) {
dedupMap.set(key, { name, hex });
}
}

// Salva no cache global garantindo que IDs sem variantes também fiquem marcados (como array vazio)
// Salva no cache global; IDs sem variantes ficam marcados como array vazio
chunk.forEach(id => {
const productColors = results.get(id);
const arr = productColors ? Array.from(productColors.values()) : [];
Expand Down
Loading