diff --git a/src/components/ThemeInitializer.test.tsx b/src/components/ThemeInitializer.test.tsx
index 48bc22f2b..9a0faa12d 100644
--- a/src/components/ThemeInitializer.test.tsx
+++ b/src/components/ThemeInitializer.test.tsx
@@ -22,6 +22,7 @@ describe('ThemeInitializer', () => {
it('waits for ThemeContext to be available', () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
render(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -32,9 +33,11 @@ describe('ThemeInitializer', () => {
it('applies theme configuration when context is available', async () => {
const mockConfig = { presetId: 'corporate', radius: 14, mode: 'dark' };
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(themePresets.loadThemeConfig).mockReturnValue(mockConfig as any);
render(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
diff --git a/src/components/products/ProductCard.tsx b/src/components/products/ProductCard.tsx
index cc0893c65..b050e30ec 100644
--- a/src/components/products/ProductCard.tsx
+++ b/src/components/products/ProductCard.tsx
@@ -190,6 +190,7 @@ export const ProductCard = memo(
}
}
}
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- granular deps (product.id, product.colors) intentionally preferred over `product` to avoid spurious re-runs
}, [product.id, product.colors, selectedColorFromStore, activeColorFilter, allMatchingVariants, activeVariantIdx]);
const actionBusyRef = useRef(false);
diff --git a/src/hooks/products/useCatalogPrefetch.test.ts b/src/hooks/products/useCatalogPrefetch.test.ts
index 4a4ba3cc3..2eff569c5 100644
--- a/src/hooks/products/useCatalogPrefetch.test.ts
+++ b/src/hooks/products/useCatalogPrefetch.test.ts
@@ -26,16 +26,19 @@ describe('useCatalogPrefetch', () => {
beforeEach(() => {
vi.clearAllMocks();
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(useQueryClient).mockReturnValue(mockQueryClient as any);
});
it('does not prefetch if not authenticated', () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(useAuth).mockReturnValue({ isAuthenticated: false, isLoading: false } as any);
renderHook(() => useCatalogPrefetch());
expect(mockQueryClient.prefetchInfiniteQuery).not.toHaveBeenCalled();
});
it('prefetches catalog after delay when authenticated', async () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(useAuth).mockReturnValue({ isAuthenticated: true, isLoading: false } as any);
renderHook(() => useCatalogPrefetch());
diff --git a/src/hooks/products/useProductImages.ts b/src/hooks/products/useProductImages.ts
index e1f44733a..f406d8b31 100644
--- a/src/hooks/products/useProductImages.ts
+++ b/src/hooks/products/useProductImages.ts
@@ -77,37 +77,55 @@ export async function fetchProductImages(productId: string): Promise> {
if (productIds.length === 0) return new Map();
+ const uniqueIds = [...new Set(productIds)];
+ const chunks: string[][] = [];
+ for (let i = 0; i < uniqueIds.length; i += IMAGE_BATCH_CHUNK_SIZE) {
+ chunks.push(uniqueIds.slice(i, i + IMAGE_BATCH_CHUNK_SIZE));
+ }
+
try {
- // Buscar todas as imagens ativas
- // Nota: O bridge não suporta IN() diretamente, então buscamos todas e filtramos
- const result = await dbInvoke({
- table: 'product_images',
- operation: 'select',
- select:
- 'id, product_id, variant_id, color_id, supplier_code, url_cdn, url_original, image_type, is_primary, is_og_image, display_order, is_active, alt_text, title_text',
- filters: { is_active: true },
- orderBy: { column: 'display_order', ascending: true },
- limit: 5000,
- });
+ const results = await Promise.all(
+ chunks.map((chunk) =>
+ dbInvoke({
+ table: 'product_images',
+ operation: 'select',
+ select:
+ 'id, product_id, variant_id, color_id, supplier_code, url_cdn, url_original, image_type, is_primary, is_og_image, display_order, is_active, alt_text, title_text',
+ filters: { is_active: true, product_id: chunk },
+ orderBy: { column: 'display_order', ascending: true },
+ limit: 1000,
+ }),
+ ),
+ );
// Agrupar por product_id
const imagesByProduct = new Map();
- const productIdSet = new Set(productIds);
-
- result.records.forEach((image) => {
- if (!productIdSet.has(image.product_id)) return;
-
- const productImages = imagesByProduct.get(image.product_id) ?? [];
- imagesByProduct.set(image.product_id, productImages);
- productImages.push(image);
- });
+ for (const result of results) {
+ result.records.forEach((image) => {
+ const productImages = imagesByProduct.get(image.product_id) ?? [];
+ imagesByProduct.set(image.product_id, productImages);
+ productImages.push(image);
+ });
+ }
return imagesByProduct;
} catch (err) {
diff --git a/src/hooks/ui/useMobileSidebarFix.ts b/src/hooks/ui/useMobileSidebarFix.ts
index e2876ab94..769aa33cc 100644
--- a/src/hooks/ui/useMobileSidebarFix.ts
+++ b/src/hooks/ui/useMobileSidebarFix.ts
@@ -17,5 +17,6 @@ export function useMobileSidebarFix(onToggle: () => void, isOpen: boolean) {
if (isOpen && window.innerWidth < 1024) {
onToggle();
}
- }, [pathname]); // Só depende do pathname
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [pathname]); // intentionally omit isOpen/onToggle: effect must only fire on route change
}
diff --git a/src/pages/filters/useFiltersPageState.ts b/src/pages/filters/useFiltersPageState.ts
index 71a30920e..1eaeae432 100644
--- a/src/pages/filters/useFiltersPageState.ts
+++ b/src/pages/filters/useFiltersPageState.ts
@@ -12,9 +12,21 @@ import { useSupplierSalesRanking } from '@/hooks/products/useSupplierSalesRankin
import { useDebounce } from '@/hooks/common/useDebounce';
import { usePromoSalesRanking } from '@/hooks/intelligence/usePromoSalesRanking';
import { sortProducts } from '@/utils/product-sorting';
+import { SORT_OPTIONS } from '@/constants/filters';
import { toast } from 'sonner';
import type { ProductVariation } from '@/types/product-catalog';
+// Valores de sortBy aceitos: os expostos na UI (SORT_OPTIONS) + os internos
+// suportados pelo pipeline sortProducts (color-match/popularity são definidos
+// upstream; name-asc/name-desc são aliases tratados no sorter).
+const VALID_SORT_VALUES = new Set([
+ ...SORT_OPTIONS.map((o) => o.value),
+ 'name-asc',
+ 'name-desc',
+ 'popularity',
+ 'color-match',
+]);
+
export function useFiltersPageState() {
const [searchParams, setSearchParams] = useSearchParams();
const isInitialMount = useRef(true);
@@ -71,9 +83,22 @@ export function useFiltersPageState() {
const pMin = get('priceMin');
const pMax = get('priceMax');
// FIX-04: usar parseFloat para preservar centavos (ex: "15.99" → 15.99, não 15)
- if (pMin || pMax) f.priceRange = [pMin ? parseFloat(pMin) : 0, pMax ? parseFloat(pMax) : 9999];
+ // FIX-28: validar NaN e fazer clamp (min<=max). Valores inválidos na URL
+ // (?priceMin=abc, min>max) caíam como NaN e zeravam a lista sem feedback.
+ if (pMin || pMax) {
+ const PRICE_MAX = 9999;
+ const parsedMin = pMin ? parseFloat(pMin) : 0;
+ const parsedMax = pMax ? parseFloat(pMax) : PRICE_MAX;
+ let min = Number.isFinite(parsedMin) && parsedMin >= 0 ? parsedMin : 0;
+ let max = Number.isFinite(parsedMax) && parsedMax >= 0 ? parsedMax : PRICE_MAX;
+ if (min > max) [min, max] = [max, min];
+ f.priceRange = [min, max];
+ }
const ms = get('minStock');
- if (ms) f.minStock = parseInt(ms); // minStock é sempre inteiro — parseInt ok
+ if (ms) {
+ const parsedMs = parseInt(ms, 10);
+ if (Number.isFinite(parsedMs) && parsedMs >= 0) f.minStock = parsedMs;
+ }
if (get('inStock') === '1') f.inStock = true;
if (get('isKit') === '1') f.isKit = true;
if (get('featured') === '1') f.featured = true;
@@ -81,8 +106,11 @@ export function useFiltersPageState() {
if (get('hasPersonalization') === '1') f.hasPersonalization = true;
if (get('onSale') === '1') f.onSale = true;
if (get('hasCommercialPackaging') === '1') f.hasCommercialPackaging = true;
+ // FIX-28/B5: só aceitar sortBy da URL se for um valor conhecido — evita
+ // que o Select fique sem opção correspondente (placeholder vazio) e que o
+ // pipeline de sort receba um valor que cai no no-op silencioso.
const sortByParam = get('sortBy');
- if (sortByParam) f.sortBy = sortByParam;
+ if (sortByParam && VALID_SORT_VALUES.has(sortByParam)) f.sortBy = sortByParam;
return f;
});
@@ -117,6 +145,20 @@ export function useFiltersPageState() {
() => (catalogData?.pages ? catalogData.pages.flatMap((page) => page.products) : []),
[catalogData],
);
+
+ // FIX-20: o filtro de Técnicas só funciona se os produtos carregados trouxerem
+ // `metadata.techniques`. Quando nenhum produto tem esse dado (caso do catálogo
+ // lightweight atual), selecionar uma técnica não filtra nada — então não
+ // devemos contá-la como filtro ativo nem exibir o chip (evita falso positivo).
+ // Até existir um hook server-side (useProductsByTechnique), este sinal mantém
+ // a UI honesta.
+ const techniquesDataAvailable = useMemo(
+ () =>
+ realProducts.some(
+ (p) => ((p.metadata?.techniques as string[] | undefined)?.length || 0) > 0,
+ ),
+ [realProducts],
+ );
const totalEstimate = catalogData?.pages?.[0]?.totalEstimate ?? null;
const isFullyLoaded = !hasNextPage && !isFetchingNextPage;
const loadedCount = realProducts.length;
@@ -276,13 +318,13 @@ export function useFiltersPageState() {
if (filters.hasPersonalization) count++;
if (filters.onSale) count++;
if (filters.hasCommercialPackaging) count++;
- if ((filters.techniques?.length || 0) > 0) count++;
+ if (techniquesDataAvailable && (filters.techniques?.length || 0) > 0) count++;
if ((filters.tags?.length || 0) > 0) count++;
if ((filters.gender?.length || 0) > 0) count++;
if ((filters.sizes?.length || 0) > 0) count++;
if (filters.search) count++;
return count;
- }, [filters]);
+ }, [filters, techniquesDataAvailable]);
const handleReset = () => {
const hadFilters = activeFiltersCount > 0;
@@ -457,16 +499,17 @@ export function useFiltersPageState() {
// O campo techniques não existe diretamente no Product lightweight — filtro
// client-side faz match pelo ID/nome da técnica no metadata do produto.
// Para filtro server-side completo, implementar useProductsByTechnique hook.
- if (filters.techniques?.length) {
+ // FIX-20: só aplica o filtro quando há dados de técnica nos produtos
+ // (techniquesDataAvailable). Sem dados, o filtro era um no-op que ainda
+ // contava como ativo/chip — agora a seleção é inerte de forma consistente
+ // (não conta, não chipa, não filtra) até existir suporte server-side.
+ if (techniquesDataAvailable && filters.techniques?.length) {
const techSet = new Set(filters.techniques.map((t) => t.toLowerCase()));
result = result.filter((product) => {
- // Tenta match via metadata.techniques (se disponível no produto enriquecido)
const metaTechs: string[] = (product.metadata?.techniques as string[]) || [];
if (metaTechs.length > 0) {
return metaTechs.some((t: string) => techSet.has(t.toLowerCase()));
}
- // Fallback: sem dados de técnica no produto — não filtra (inclui o produto)
- // para não esconder produtos válidos enquanto o hook server-side não existe.
return true;
});
}
@@ -481,6 +524,7 @@ export function useFiltersPageState() {
hasFuzzySearch,
fuzzySearchResults,
realProducts,
+ techniquesDataAvailable,
hasMaterialFilter,
materialFilteredProductIds,
isLoadingMaterialFilter,
@@ -634,7 +678,7 @@ export function useFiltersPageState() {
});
// Tipos ausentes no original — FIX-05:
const techArr = filters.techniques || [];
- if (techArr.length > 0)
+ if (techArr.length > 0 && techniquesDataAvailable)
summary.push({
label: 'Técnicas',
value: `${techArr.length} selecionada${techArr.length > 1 ? 's' : ''}`,
@@ -664,12 +708,13 @@ export function useFiltersPageState() {
if (filters.isNew) summary.push({ label: 'Lançamento', value: 'Sim', key: 'isNew' });
if (filters.hasPersonalization)
summary.push({ label: 'Personalizável', value: 'Sim', key: 'hasPersonalization' });
+ if (filters.onSale) summary.push({ label: 'Em Oferta', value: 'Sim', key: 'onSale' });
if (filters.hasCommercialPackaging)
summary.push({ label: 'Embalagem', value: 'Comercial', key: 'hasCommercialPackaging' });
if (filters.search)
summary.push({ label: 'Busca', value: `"${filters.search}"`, key: 'search' });
return summary;
- }, [filters]);
+ }, [filters, techniquesDataAvailable]);
const clearSingleFilter = (key: keyof FilterState) => {
if (key === 'colors')
diff --git a/src/services/productService.ts b/src/services/productService.ts
index f1fd69e7a..a85ece179 100644
--- a/src/services/productService.ts
+++ b/src/services/productService.ts
@@ -34,7 +34,11 @@ export const productService = {
case 'best-seller-promo':
orderBy = { column: 'is_featured', ascending: false };
break;
+ case 'name-desc':
+ orderBy = { column: 'name', ascending: false };
+ break;
case 'name':
+ case 'name-asc':
default:
orderBy = { column: 'name', ascending: true };
break;
diff --git a/src/utils/product-sorting.ts b/src/utils/product-sorting.ts
index 3ccb04058..502b1e3f4 100644
--- a/src/utils/product-sorting.ts
+++ b/src/utils/product-sorting.ts
@@ -1,5 +1,47 @@
import { type Product, type SupplierSalesEntry } from '@/hooks/products';
+/**
+ * Collator pt-BR único e reutilizável para ordenação alfabética de nomes.
+ *
+ * - `numeric: true` → "Caneta 2" antes de "Caneta 10" (ordenação natural).
+ * - `sensitivity: 'base'` → ignora caixa e acento na comparação principal
+ * ("Água"/"agua" tratados de forma consistente), evitando ordem fora de
+ * lugar para acentuação típica do português.
+ *
+ * Sem isso, `String.localeCompare` sem locale usa o locale default do
+ * ambiente (Node/SSR/test/browser) → ordem não-determinística.
+ */
+const PT_BR_COLLATOR = new Intl.Collator('pt-BR', {
+ numeric: true,
+ sensitivity: 'base',
+});
+
+/** Compara dois nomes usando o collator pt-BR (null/undefined viram ''). */
+export function compareNamePtBR(a?: string | null, b?: string | null): number {
+ return PT_BR_COLLATOR.compare(a || '', b || '');
+}
+
+/**
+ * Comparador estável: ordena por nome (pt-BR) e desempata por `id`.
+ * Garante ordem determinística entre páginas no infinite scroll.
+ */
+function byNameThenId(a: Product, b: Product): number {
+ const byName = compareNamePtBR(a.name, b.name);
+ if (byName !== 0) return byName;
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
+}
+
+/** Desempate final por id, preservando o comparador primário fornecido. */
+function withIdTiebreak(
+ primary: (a: Product, b: Product) => number,
+): (a: Product, b: Product) => number {
+ return (a, b) => {
+ const result = primary(a, b);
+ if (result !== 0) return result;
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
+ };
+}
+
/**
* Centralized product sorting logic.
* Used by both the Catalog (Index) and Super Filter (FiltersPage).
@@ -16,18 +58,25 @@ export function sortProducts(
if (options?.skipSort) return products;
switch (sortBy) {
+ // BUG-SORT FIX: 'name-asc'/'name-desc' caíam no default (no-op) apesar de
+ // serem valores válidos de ProductFilters.sortBy. Tratados aqui explicitamente.
+ // ('name' e 'name-asc' compartilham o mesmo corpo via fall-through de case vazio.)
case 'name':
- products.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
+ case 'name-asc':
+ products.sort(byNameThenId);
+ break;
+ case 'name-desc':
+ products.sort((a, b) => byNameThenId(b, a));
break;
case 'price-asc':
- products.sort((a, b) => a.price - b.price);
+ products.sort(withIdTiebreak((a, b) => a.price - b.price));
break;
case 'price-desc':
- products.sort((a, b) => b.price - a.price);
+ products.sort(withIdTiebreak((a, b) => b.price - a.price));
break;
case 'stock':
- products.sort((a, b) => (b.stock || 0) - (a.stock || 0));
+ products.sort(withIdTiebreak((a, b) => (b.stock || 0) - (a.stock || 0)));
break;
case 'newest':
products.sort((a, b) => {
@@ -36,8 +85,7 @@ export function sortProducts(
if (bTime !== aTime) return bTime - aTime;
// Se datas iguais, prioriza os que têm flag newArrival
if (b.newArrival !== a.newArrival) return b.newArrival ? 1 : -1;
- return (a.name || '').localeCompare(b.name || '');
-
+ return byNameThenId(a, b);
});
break;
case 'best-seller-supplier': {
@@ -54,8 +102,7 @@ export function sortProducts(
const aVel = aEntry?.velocity7d ?? 0;
const bVel = bEntry?.velocity7d ?? 0;
if (bVel !== aVel) return bVel - aVel;
- return (a.name || '').localeCompare(b.name || '');
-
+ return byNameThenId(a, b);
});
} else {
// Fallback: flags do produto (quando MV nao populada)
@@ -67,8 +114,7 @@ export function sortProducts(
const aStock = a.stock || 0;
const bStock = b.stock || 0;
if (bStock !== aStock) return bStock - aStock;
- return (a.name || '').localeCompare(b.name || '');
-
+ return byNameThenId(a, b);
});
}
break;
@@ -89,7 +135,7 @@ export function sortProducts(
const aCount = map?.get(a.id) || 0;
const bCount = map?.get(b.id) || 0;
if (bCount !== aCount) return bCount - aCount;
- return (a.name || '').localeCompare(b.name || '');
+ return byNameThenId(a, b);
});
break;
default:
diff --git a/supabase/migrations/20260604120000_add_catalog_sort_indexes.sql b/supabase/migrations/20260604120000_add_catalog_sort_indexes.sql
new file mode 100644
index 000000000..a1197cb22
--- /dev/null
+++ b/supabase/migrations/20260604120000_add_catalog_sort_indexes.sql
@@ -0,0 +1,25 @@
+-- Índices para as colunas de ORDENAÇÃO do catálogo de produtos.
+--
+-- Contexto (auditoria 2026-06-04): o catálogo (view v_products_public sobre
+-- public.products) ordena por sale_price (Preço ↑/↓), created_at (Mais Recentes)
+-- e stock_quantity (Maior Estoque). Já existem índices para busca
+-- (name/sku/description trigram, search_vector) e para category/supplier/brand,
+-- mas NÃO para essas colunas de sort — o que força full sort e está alinhado
+-- com os statement timeouts observados na paginação defensiva do front
+-- (src/lib/external-db/products.ts).
+--
+-- Índices PARCIAIS (WHERE active = true) para casar com o filtro padrão do
+-- catálogo e manter o índice pequeno, no mesmo estilo dos índices já existentes
+-- (idx_products_active_name_sort). Em ~6k linhas a criação é instantânea.
+
+CREATE INDEX IF NOT EXISTS idx_products_active_sale_price
+ ON public.products (sale_price)
+ WHERE active = true;
+
+CREATE INDEX IF NOT EXISTS idx_products_active_created_at
+ ON public.products (created_at DESC)
+ WHERE active = true;
+
+CREATE INDEX IF NOT EXISTS idx_products_active_stock_quantity
+ ON public.products (stock_quantity DESC)
+ WHERE active = true;
diff --git a/tests/utils/product-sorting.test.ts b/tests/utils/product-sorting.test.ts
index bfd8f604b..0aec38b9a 100644
--- a/tests/utils/product-sorting.test.ts
+++ b/tests/utils/product-sorting.test.ts
@@ -3,7 +3,7 @@
* Validates all 7 sort modes, edge cases, and parity between Catalog & Super Filter.
*/
import { describe, it, expect } from "vitest";
-import { sortProducts } from "@/utils/product-sorting";
+import { sortProducts, compareNamePtBR } from "@/utils/product-sorting";
// Minimal product factory
function makeProduct(overrides: Record = {}) {
@@ -263,6 +263,79 @@ describe("sortProducts", () => {
});
});
+ // ===== PT-BR COLLATOR + NAME ALIASES + DETERMINISM =====
+ describe("pt-BR collation and name aliases", () => {
+ it("orders numbers naturally (Caneta 2 before Caneta 10)", () => {
+ const products = [
+ makeProduct({ id: "a", name: "Caneta 10" }),
+ makeProduct({ id: "b", name: "Caneta 2" }),
+ makeProduct({ id: "c", name: "Caneta 1" }),
+ ];
+ sortProducts(products, "name");
+ expect(products.map((p) => p.name)).toEqual(["Caneta 1", "Caneta 2", "Caneta 10"]);
+ });
+
+ it("orders accented pt-BR names base-insensitively", () => {
+ const products = [
+ makeProduct({ id: "a", name: "Água" }),
+ makeProduct({ id: "b", name: "Abacaxi" }),
+ makeProduct({ id: "c", name: "Açaí" }),
+ ];
+ sortProducts(products, "name");
+ // base sensitivity: Abacaxi < Açaí < Água
+ expect(products.map((p) => p.name)).toEqual(["Abacaxi", "Açaí", "Água"]);
+ });
+
+ it("treats 'name-asc' as ascending name sort", () => {
+ const products = [makeProduct({ name: "Zebra" }), makeProduct({ name: "Alpha" })];
+ sortProducts(products, "name-asc");
+ expect(products.map((p) => p.name)).toEqual(["Alpha", "Zebra"]);
+ });
+
+ it("treats 'name-desc' as descending name sort", () => {
+ const products = [makeProduct({ name: "Alpha" }), makeProduct({ name: "Zebra" })];
+ sortProducts(products, "name-desc");
+ expect(products.map((p) => p.name)).toEqual(["Zebra", "Alpha"]);
+ });
+
+ it("compareNamePtBR is null/undefined safe", () => {
+ expect(compareNamePtBR(null, "a")).toBeLessThan(0);
+ expect(compareNamePtBR("a", undefined)).toBeGreaterThan(0);
+ expect(compareNamePtBR(null, null)).toBe(0);
+ });
+ });
+
+ describe("deterministic id tiebreak", () => {
+ it("breaks equal names by id (stable, page-safe order)", () => {
+ const products = [
+ makeProduct({ id: "p3", name: "Same" }),
+ makeProduct({ id: "p1", name: "Same" }),
+ makeProduct({ id: "p2", name: "Same" }),
+ ];
+ sortProducts(products, "name");
+ expect(products.map((p) => p.id)).toEqual(["p1", "p2", "p3"]);
+ });
+
+ it("breaks equal prices by id deterministically", () => {
+ const products = [
+ makeProduct({ id: "p3", price: 5 }),
+ makeProduct({ id: "p1", price: 5 }),
+ makeProduct({ id: "p2", price: 5 }),
+ ];
+ sortProducts(products, "price-asc");
+ expect(products.map((p) => p.id)).toEqual(["p1", "p2", "p3"]);
+ });
+
+ it("breaks equal stock by id deterministically", () => {
+ const products = [
+ makeProduct({ id: "b", stock: 7 }),
+ makeProduct({ id: "a", stock: 7 }),
+ ];
+ sortProducts(products, "stock");
+ expect(products.map((p) => p.id)).toEqual(["a", "b"]);
+ });
+ });
+
// ===== PARITY CHECK =====
describe("parity between Catalog and Super Filter", () => {
it("produces identical results for all sort modes", () => {