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
19 changes: 19 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,13 +135,23 @@ export default [
//
// Documentação completa: docs/redeploy/T-FIX-5-LINT-GUARDRAIL.md
// ──────────────────────────────────────────────────────────────
//
// T-FIX-5b: Anti-padrão B — forEach() com expect() dentro de it()
// Array vazio → nenhuma asserção roda → teste verde falso.
// Correção: adicione expect(array).not.toHaveLength(0) antes do forEach.
// ──────────────────────────────────────────────────────────────
'no-restricted-syntax': [
'error',
{
selector: "CallExpression[callee.property.name='forEach'] CallExpression[callee.name=/^(it|test|describe)$/]",
message:
'Anti-padrão T-FIX-4: forEach() declarando it()/test()/describe() — use it.each(), test.each() ou describe.each() para registrar cada caso como teste isolado e evitar que falhas mascarem umas às outras. Veja docs/redeploy/T-FIX-5-LINT-GUARDRAIL.md',
},
{
selector: "CallExpression[callee.property.name='forEach']:has(CallExpression[callee.name='expect'])",
message:
'Anti-padrão T-FIX-5b: forEach() com expect() — array vazio faz o teste passar silenciosamente. Adicione expect(array).not.toHaveLength(0) antes do forEach, ou use it.each() para expor cada caso como teste isolado. Veja docs/redeploy/T-FIX-5-LINT-GUARDRAIL.md',
},
],
},
},
Expand Down Expand Up @@ -235,13 +245,22 @@ export default [

// T-FIX-5: mesmo guard de src/ — aplicado também em tests/** para
// cobertura completa. Veja docs/redeploy/T-FIX-5-LINT-GUARDRAIL.md
//
// T-FIX-5b: Anti-padrão B — forEach() com expect() dentro de it()
// Array vazio → nenhuma asserção roda → teste verde falso.
// Correção: adicione expect(array).not.toHaveLength(0) antes do forEach.
'no-restricted-syntax': [
'error',
{
selector: "CallExpression[callee.property.name='forEach'] CallExpression[callee.name=/^(it|test|describe)$/]",
message:
'Anti-padrão T-FIX-4: forEach() declarando it()/test()/describe() — use it.each(), test.each() ou describe.each() para registrar cada caso como teste isolado e evitar que falhas mascarem umas às outras. Veja docs/redeploy/T-FIX-5-LINT-GUARDRAIL.md',
},
{
selector: "CallExpression[callee.property.name='forEach']:has(CallExpression[callee.name='expect'])",
message:
'Anti-padrão T-FIX-5b: forEach() com expect() — array vazio faz o teste passar silenciosamente. Adicione expect(array).not.toHaveLength(0) antes do forEach, ou use it.each() para expor cada caso como teste isolado. Veja docs/redeploy/T-FIX-5-LINT-GUARDRAIL.md',
},
],
},
settings: {
Expand Down
91 changes: 56 additions & 35 deletions src/components/catalog/CatalogContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { memo, type RefObject } from 'react';
import type { ActiveColorFilter } from '@/utils/color-image-resolver';
import { Skeleton } from '@/components/ui/skeleton';


import { ProductGrid } from '@/components/products/ProductGrid';
import { ProductList } from '@/components/products/ProductList';
import { ProductTableView } from '@/components/products/ProductTableView';
Expand All @@ -13,7 +12,7 @@ import { EmptyState } from '@/components/common/EmptyState';
import { CatalogBulkModals } from './CatalogBulkModals';
import { useCatalogSelection } from './useCatalogSelection';
import { cn } from '@/lib/utils';
import { type Product, type ViewMode } from "@/hooks/products";
import { type Product, type ViewMode } from '@/hooks/products';
import type { ColumnCount } from '@/components/products/ColumnSelector';
import { SparklineSalesProvider } from '@/hooks/intelligence';
import { ScrollToTopButton } from '@/components/common/ScrollToTopButton';
Expand Down Expand Up @@ -79,38 +78,52 @@ export const CatalogContent = memo(function CatalogContent({
activeProductId: _activeProductId,
setActiveProductId: _setActiveProductId,
}: CatalogContentProps) {
const selection = useCatalogSelection(
paginatedProducts,
selectionMode,
onSelectedCountChange
);
const selection = useCatalogSelection(paginatedProducts, selectionMode, onSelectedCountChange);
const { selectedIds, toggleSelect: onToggleSelect } = selection;

if (shouldShowCatalogSkeleton) {
if (viewMode === "list") {
if (viewMode === 'list') {
return (
<div className="space-y-4">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="animate-in fade-in slide-in-from-left-2 duration-300" style={{ animationDelay: `${i * 30}ms` }}>
<div
key={i}
className="duration-300 animate-in fade-in slide-in-from-left-2"
style={{ animationDelay: `${i * 30}ms` }}
>
<ProductListItemSkeleton />
</div>
))}
</div>
);
}
if (viewMode === "table") {
if (viewMode === 'table') {
return <ProductTableSkeleton rows={10} />;
}
return (
<div className={cn("grid", {
"grid-cols-2 sm:grid-cols-3": gridColumns === 3,
"grid-cols-2 sm:grid-cols-3 lg:grid-cols-4": gridColumns === 4,
"grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5": gridColumns === 5,
"grid-cols-3 sm:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6": gridColumns === 6,
"grid-cols-3 sm:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8": gridColumns === 8,
}, gridColumns >= 8 ? 'gap-x-4 gap-y-8' : gridColumns >= 6 ? 'gap-x-6 gap-y-8' : 'gap-x-4 sm:gap-x-6 lg:gap-x-8 gap-y-8')}>
<div
className={cn(
'grid',
{
'grid-cols-2 sm:grid-cols-3': gridColumns === 3,
'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4': gridColumns === 4,
'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5': gridColumns === 5,
'grid-cols-3 sm:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6': gridColumns === 6,
'grid-cols-3 sm:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8': gridColumns === 8,
},
gridColumns >= 8
? 'gap-x-4 gap-y-8'
: gridColumns >= 6
? 'gap-x-6 gap-y-8'
: 'gap-x-4 gap-y-8 sm:gap-x-6 lg:gap-x-8',
)}
>
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="animate-in fade-in duration-300" style={{ animationDelay: `${i * 40}ms` }}>
<div
key={i}
className="duration-300 animate-in fade-in"
style={{ animationDelay: `${i * 40}ms` }}
>
<ProductCardSkeleton />
</div>
))}
Expand All @@ -125,21 +138,21 @@ export const CatalogContent = memo(function CatalogContent({
title="Nenhum produto encontrado"
description={
hasActiveCatalogConstraints
? "Tente ajustar seus filtros para ver mais resultados."
: "Explore nosso catálogo completo para encontrar o que procura."
? 'Tente ajustar seus filtros para ver mais resultados.'
: 'Explore nosso catálogo completo para encontrar o que procura.'
}
action={{
label: "Limpar todos os filtros",
label: 'Limpar todos os filtros',
onClick: onResetFilters,
}}
/>
);
}

return (
<div className="space-y-8 pb-12 relative animate-in fade-in duration-500">
<div className="relative space-y-8 pb-12 duration-500 animate-in fade-in">
<SparklineSalesProvider>
{viewMode === "grid" && (
{viewMode === 'grid' && (
<ProductGrid
products={paginatedProducts}
isLoading={isLoadingMore}
Expand All @@ -160,7 +173,7 @@ export const CatalogContent = memo(function CatalogContent({
/>
)}

{viewMode === "list" && (
{viewMode === 'list' && (
<ProductList
products={paginatedProducts}
isLoading={isLoadingMore}
Expand All @@ -180,7 +193,7 @@ export const CatalogContent = memo(function CatalogContent({
/>
)}

{viewMode === "table" && (
{viewMode === 'table' && (
<ProductTableView
products={paginatedProducts}
isLoading={isLoadingMore}
Expand All @@ -200,24 +213,32 @@ export const CatalogContent = memo(function CatalogContent({
</SparklineSalesProvider>

{hasMoreProducts && (
<div
ref={loadMoreRef}
className="flex justify-center py-8"
>
<div ref={loadMoreRef} className="flex justify-center py-8">
{isLoadingMore ? (
<div className="flex flex-col items-center gap-3">
<div className="flex gap-1.5">
<Skeleton className="h-2 w-2 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
<Skeleton className="h-2 w-2 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<Skeleton className="h-2 w-2 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
<Skeleton
className="h-2 w-2 animate-bounce rounded-full"
style={{ animationDelay: '0ms' }}
/>
<Skeleton
className="h-2 w-2 animate-bounce rounded-full"
style={{ animationDelay: '150ms' }}
/>
<Skeleton
className="h-2 w-2 animate-bounce rounded-full"
style={{ animationDelay: '300ms' }}
/>
</div>
<p className="text-xs text-muted-foreground font-medium animate-pulse">
<p className="animate-pulse text-xs font-medium text-muted-foreground">
Carregando mais produtos...
</p>
</div>
) : (
<div className="text-center text-sm text-muted-foreground">
Exibindo {Math.min(paginatedProducts.length, totalEstimate || filteredProducts.length)} de {totalEstimate || filteredProducts.length} produtos
Exibindo{' '}
{Math.min(paginatedProducts.length, totalEstimate || filteredProducts.length)} de{' '}
{totalEstimate || filteredProducts.length} produtos
</div>
)}
</div>
Expand All @@ -228,7 +249,7 @@ export const CatalogContent = memo(function CatalogContent({
selectionMode={selectionMode}
totalCount={totalEstimate || filteredProducts.length}
/>

<ScrollToTopButton className="fixed bottom-6 right-6 z-50" />
</div>
);
Expand Down
37 changes: 34 additions & 3 deletions tests/components/intelligence/commercial-intelligence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,8 @@ describe('Period options', () => {
});

it('all days values should be positive', () => {
expect(PERIOD_OPTIONS).not.toHaveLength(0);
// eslint-disable-next-line no-restricted-syntax
PERIOD_OPTIONS.forEach(p => expect(p.days).toBeGreaterThan(0));
});

Expand Down Expand Up @@ -737,24 +739,32 @@ describe('Mock data integrity', () => {
});

it('all conversionRate values should be between 0 and 100', () => {
expect(MOCK_TRENDING).not.toHaveLength(0);
// eslint-disable-next-line no-restricted-syntax
MOCK_TRENDING.forEach(p => {
expect(p.conversionRate).toBeGreaterThanOrEqual(0);
expect(p.conversionRate).toBeLessThanOrEqual(100);
});
});

it('all revenue values should be positive', () => {
expect(MOCK_TRENDING).not.toHaveLength(0);
// eslint-disable-next-line no-restricted-syntax
MOCK_TRENDING.forEach(p => expect(p.totalRevenue).toBeGreaterThan(0));
});

it('orderCount should be <= quoteCount for realistic data', () => {
expect(MOCK_TRENDING).not.toHaveLength(0);
// eslint-disable-next-line no-restricted-syntax
MOCK_TRENDING.forEach(p => {
expect(p.orderCount).toBeLessThanOrEqual(p.quoteCount);
});
});

it('trend values should be valid enum values', () => {
const validTrends = ['up', 'down', 'stable'];
expect(MOCK_TRENDING).not.toHaveLength(0);
// eslint-disable-next-line no-restricted-syntax
MOCK_TRENDING.forEach(p => expect(validTrends).toContain(p.trend));
});
});
Expand All @@ -771,10 +781,14 @@ describe('Mock opportunity data integrity', () => {
];

it('all opportunities should have conversionRate < 60%', () => {
expect(MOCK_OPPORTUNITIES).not.toHaveLength(0);
// eslint-disable-next-line no-restricted-syntax
MOCK_OPPORTUNITIES.forEach(o => expect(o.conversionRate).toBeLessThan(60));
});

it('all opportunities should have quoteCount >= 2', () => {
expect(MOCK_OPPORTUNITIES).not.toHaveLength(0);
// eslint-disable-next-line no-restricted-syntax
MOCK_OPPORTUNITIES.forEach(o => expect(o.quoteCount).toBeGreaterThanOrEqual(2));
});

Expand All @@ -785,6 +799,8 @@ describe('Mock opportunity data integrity', () => {
});

it('reasons should match conversion rate rules', () => {
expect(MOCK_OPPORTUNITIES).not.toHaveLength(0);
// eslint-disable-next-line no-restricted-syntax
MOCK_OPPORTUNITIES.forEach(o => {
if (o.conversionRate === 0) {
expect(o.reason).toBe('Cotado mas nunca vendido');
Expand Down Expand Up @@ -888,13 +904,19 @@ describe('Revenue trend date generation', () => {

it('all dates should be in YYYY-MM-DD format', () => {
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
generateDateMap(30).forEach((_, key) => {
const dateMap = generateDateMap(30);
expect(dateMap.size).toBeGreaterThan(0);
// eslint-disable-next-line no-restricted-syntax
dateMap.forEach((_, key) => {
expect(key).toMatch(datePattern);
});
});

it('all entries should start with zero values', () => {
generateDateMap(30).forEach(entry => {
const dateMap = generateDateMap(30);
expect(dateMap.size).toBeGreaterThan(0);
// eslint-disable-next-line no-restricted-syntax
dateMap.forEach(entry => {
expect(entry.revenue).toBe(0);
expect(entry.orders).toBe(0);
expect(entry.quotes).toBe(0);
Expand Down Expand Up @@ -941,16 +963,22 @@ describe('Market intelligence mock data', () => {

it('stock should never go below 100', () => {
const { daily } = generateMockMarketData(360);
expect(daily).not.toHaveLength(0);
// eslint-disable-next-line no-restricted-syntax
daily.forEach(d => expect(d.stockClose).toBeGreaterThanOrEqual(100));
});

it('depleted should never be negative', () => {
const { daily } = generateMockMarketData(360);
expect(daily).not.toHaveLength(0);
// eslint-disable-next-line no-restricted-syntax
daily.forEach(d => expect(d.depleted).toBeGreaterThanOrEqual(0));
});

it('restocked should be 0 or positive', () => {
const { daily } = generateMockMarketData(360);
expect(daily).not.toHaveLength(0);
// eslint-disable-next-line no-restricted-syntax
daily.forEach(d => expect(d.restocked).toBeGreaterThanOrEqual(0));
});

Expand Down Expand Up @@ -1024,7 +1052,10 @@ describe('Supabase query limit concerns', () => {
// This documents a potential data integrity issue:
// Categories with >200 products will only analyze the first 200
const categorySizes = { 'Canetas': 50, 'Garrafas': 30, 'Mochilas': 15, 'Todos': 5000 };
Object.entries(categorySizes).forEach(([cat, size]) => {
const entries = Object.entries(categorySizes);
expect(entries).not.toHaveLength(0);
// eslint-disable-next-line no-restricted-syntax
entries.forEach(([_cat, size]) => {
if (size > 200) {
// This category will have truncated results
expect(size).toBeGreaterThan(200);
Expand Down
Loading
Loading