@thorprovider/blocks 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +15 -15
- package/.turbo/turbo-test.log +42 -5
- package/AGENTS.md +1 -1
- package/README.md +94 -1
- package/dist/.tsbuildinfo +1 -1
- package/dist/contracts.d.mts +1266 -97
- package/dist/contracts.d.ts +1266 -97
- package/dist/contracts.js +225 -9
- package/dist/contracts.js.map +1 -1
- package/dist/contracts.mjs +207 -10
- package/dist/contracts.mjs.map +1 -1
- package/dist/index.d.mts +756 -96
- package/dist/index.d.ts +756 -96
- package/dist/index.js +5114 -3702
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +4939 -3551
- package/dist/index.mjs.map +1 -1
- package/docs/standards/theming.md +32 -5
- package/package.json +2 -2
- package/src/blocks/e-commerce/category-filters/category-filter-2-filter-panel.tsx +136 -93
- package/src/blocks/e-commerce/category-filters/category-filter-2.test.tsx +513 -0
- package/src/blocks/e-commerce/category-filters/category-filter-2.tsx +728 -148
- package/src/blocks/e-commerce/checkout-forms/checkout-confirmation-fulfilment-progress.tsx +14 -8
- package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.test.tsx +136 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-confirmation.tsx +145 -99
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-order-summary.tsx +215 -102
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-payment-step.tsx +6 -2
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1-shipping-step.tsx +51 -2
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1.test.tsx +216 -0
- package/src/blocks/e-commerce/checkout-forms/checkout-form-1.tsx +114 -11
- package/src/blocks/e-commerce/navigation/back-to-top.test.tsx +27 -0
- package/src/blocks/e-commerce/navigation/back-to-top.tsx +71 -0
- package/src/blocks/e-commerce/navigation/breadcrumb.test.tsx +174 -0
- package/src/blocks/e-commerce/navigation/breadcrumb.tsx +130 -0
- package/src/blocks/e-commerce/navigation/mobile-bottom-nav.test.tsx +74 -0
- package/src/blocks/e-commerce/navigation/mobile-bottom-nav.tsx +78 -0
- package/src/blocks/e-commerce/navigation/mobile-category-drawer.test.tsx +42 -2
- package/src/blocks/e-commerce/navigation/mobile-category-drawer.tsx +10 -8
- package/src/blocks/e-commerce/navigation/skip-to-content.test.tsx +35 -0
- package/src/blocks/e-commerce/navigation/skip-to-content.tsx +37 -0
- package/src/blocks/e-commerce/product-categories/product-category-2.test.tsx +51 -0
- package/src/blocks/e-commerce/product-categories/product-category-2.tsx +56 -36
- package/src/blocks/e-commerce/product-list/product-card-1.test.tsx +88 -0
- package/src/blocks/e-commerce/product-list/product-card-1.tsx +141 -80
- package/src/blocks/e-commerce/product-list/product-showcase-1.test.tsx +78 -0
- package/src/blocks/e-commerce/product-list/product-showcase-1.tsx +277 -0
- package/src/blocks/e-commerce/product-overview/product-overview-1.test.tsx +182 -0
- package/src/blocks/e-commerce/product-overview/product-overview-1.tsx +298 -129
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.test.tsx +229 -0
- package/src/blocks/e-commerce/shopping-carts/shopping-cart-4.tsx +313 -110
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.test.tsx +72 -0
- package/src/blocks/e-commerce/storefront-hero/storefront-hero-1.tsx +20 -1
- package/src/blocks/marketing/footers/footer-section-1.test.tsx +117 -0
- package/src/blocks/marketing/footers/footer-section-1.tsx +46 -9
- package/src/blocks/marketing/navbars/navbar-4-account-menu.test.tsx +38 -0
- package/src/blocks/marketing/navbars/navbar-4-account-menu.tsx +30 -14
- package/src/blocks/marketing/navbars/navbar-4-cart-sheet.test.tsx +204 -0
- package/src/blocks/marketing/navbars/navbar-4-cart-sheet.tsx +144 -21
- package/src/blocks/marketing/navbars/navbar-4.test.tsx +233 -0
- package/src/blocks/marketing/navbars/navbar-4.tsx +235 -60
- package/src/index.ts +56 -0
- package/src/lib/cart.test.ts +15 -0
- package/src/lib/cart.ts +11 -0
- package/src/lib/contracts-curated.test.ts +207 -0
- package/src/lib/contracts-server-boundary.test.ts +52 -0
- package/src/lib/contracts.ts +234 -6
- package/src/lib/money.test.ts +64 -0
- package/src/lib/money.ts +65 -0
- package/tsup.config.ts +0 -1
- package/vitest.config.ts +9 -1
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
import type { ReactElement, ReactNode } from 'react'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import {
|
|
5
|
+
CategoryFilter2,
|
|
6
|
+
CategoryFilter2StatusView,
|
|
7
|
+
buildFilterChange,
|
|
8
|
+
reconcileSearchDraft,
|
|
9
|
+
toggleFilterValue,
|
|
10
|
+
type CategoryFilter2ActiveFilter,
|
|
11
|
+
type FilterProduct,
|
|
12
|
+
} from './category-filter-2'
|
|
13
|
+
|
|
14
|
+
const products: FilterProduct[] = [
|
|
15
|
+
{ id: 'pantalla-samsung-a54', name: 'Pantalla Samsung Galaxy A54 OLED', price: '$49.99', image: '/demo/pantalla.svg' },
|
|
16
|
+
{ id: 'bateria-iphone-13', name: 'Batería iPhone 13', price: '$29.99' },
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
const advertisedSorts = [
|
|
20
|
+
{ value: 'featured', label: 'Destacados' },
|
|
21
|
+
{ value: 'name', label: 'Nombre' },
|
|
22
|
+
{ value: 'newest', label: 'Más recientes' },
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
const activeChips: CategoryFilter2ActiveFilter[] = [
|
|
26
|
+
{ id: 'text', kind: 'text', label: 'pantalla', params: ['q'], value: 'pantalla' },
|
|
27
|
+
{ id: 'category:pantallas', kind: 'category', label: 'Pantallas', params: ['categories'], value: 'pantallas' },
|
|
28
|
+
{ id: 'price', kind: 'price', label: '10 – 40', params: ['priceMin', 'priceMax'] },
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
const countOccurrences = (html: string, needle: string) => html.split(needle).length - 1
|
|
32
|
+
|
|
33
|
+
/** Find every element whose prop `key` equals `value`, walking the returned
|
|
34
|
+
* element tree (no DOM needed — `CategoryFilter2StatusView` is hooks-free). */
|
|
35
|
+
function findAllByProp(
|
|
36
|
+
node: ReactNode,
|
|
37
|
+
key: string,
|
|
38
|
+
value: string,
|
|
39
|
+
): ReactElement<{ children?: ReactNode; onClick?: () => void; [prop: string]: unknown }>[] {
|
|
40
|
+
const found: ReactElement<{
|
|
41
|
+
children?: ReactNode
|
|
42
|
+
onClick?: () => void
|
|
43
|
+
[prop: string]: unknown
|
|
44
|
+
}>[] = []
|
|
45
|
+
const walk = (current: ReactNode): void => {
|
|
46
|
+
if (current === null || current === undefined) return
|
|
47
|
+
if (Array.isArray(current)) {
|
|
48
|
+
current.forEach(walk)
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
if (typeof current !== 'object') return
|
|
52
|
+
const element = current as ReactElement<{
|
|
53
|
+
children?: ReactNode
|
|
54
|
+
onClick?: () => void
|
|
55
|
+
[prop: string]: unknown
|
|
56
|
+
}>
|
|
57
|
+
if (element.props && element.props[key] === value) found.push(element)
|
|
58
|
+
walk(element.props?.children)
|
|
59
|
+
}
|
|
60
|
+
walk(node)
|
|
61
|
+
return found
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe('CategoryFilter2 — controlled canonical query', () => {
|
|
65
|
+
it('renders the query-selected search, category, sort and page values', () => {
|
|
66
|
+
const html = renderToStaticMarkup(
|
|
67
|
+
<CategoryFilter2
|
|
68
|
+
products={products}
|
|
69
|
+
categories={[
|
|
70
|
+
{ id: 'pantallas', label: 'Pantallas' },
|
|
71
|
+
{ id: 'baterias', label: 'Baterías' },
|
|
72
|
+
]}
|
|
73
|
+
sortOptions={advertisedSorts}
|
|
74
|
+
pagination={[1, 2, 3]}
|
|
75
|
+
showSearch
|
|
76
|
+
searchValue='pantalla'
|
|
77
|
+
selectedCategories={['pantallas']}
|
|
78
|
+
selectedSort='name'
|
|
79
|
+
selectedPage={2}
|
|
80
|
+
/>,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
expect(html).toContain('value="pantalla"')
|
|
84
|
+
// The sort control renders (its options are portaled client-side, so the
|
|
85
|
+
// advertised label set is asserted by the mapper test instead).
|
|
86
|
+
expect(html).toContain('aria-label="Ordenar por"')
|
|
87
|
+
expect(html).toContain('aria-checked="true"')
|
|
88
|
+
expect(html).toContain('aria-current="page"')
|
|
89
|
+
// The selected page is page 2; only one link is active.
|
|
90
|
+
expect(countOccurrences(html, 'aria-current="page"')).toBe(1)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('hides the search control by default and shows it when requested', () => {
|
|
94
|
+
const hidden = renderToStaticMarkup(<CategoryFilter2 products={products} sortOptions={advertisedSorts} />)
|
|
95
|
+
expect(hidden).not.toContain('type="search"')
|
|
96
|
+
|
|
97
|
+
const shown = renderToStaticMarkup(<CategoryFilter2 products={products} sortOptions={advertisedSorts} showSearch />)
|
|
98
|
+
expect(shown).toContain('type="search"')
|
|
99
|
+
expect(shown).toContain('aria-label="Buscar"')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('renders collection controls when collections are supplied', () => {
|
|
103
|
+
const html = renderToStaticMarkup(
|
|
104
|
+
<CategoryFilter2
|
|
105
|
+
products={products}
|
|
106
|
+
collections={[{ id: 'ofertas', label: 'Ofertas' }]}
|
|
107
|
+
selectedCollections={['ofertas']}
|
|
108
|
+
/>,
|
|
109
|
+
)
|
|
110
|
+
expect(html).toContain('Colección')
|
|
111
|
+
expect(html).toContain('Ofertas')
|
|
112
|
+
expect(html).toContain('aria-checked="true"')
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
describe('CategoryFilter2 — no fabricated price or sort choices', () => {
|
|
117
|
+
it('omits the price control without a real backend range', () => {
|
|
118
|
+
const html = renderToStaticMarkup(<CategoryFilter2 products={products} sortOptions={advertisedSorts} />)
|
|
119
|
+
expect(html).not.toContain('Rango de precio')
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('shows the price control only from a real range', () => {
|
|
123
|
+
const html = renderToStaticMarkup(
|
|
124
|
+
<CategoryFilter2 products={products} priceRange={{ min: 0, max: 100, step: 5 }} selectedPriceRange={[10, 40]} />,
|
|
125
|
+
)
|
|
126
|
+
expect(html).toContain('Rango de precio')
|
|
127
|
+
expect(html).toContain('value="10"')
|
|
128
|
+
expect(html).toContain('value="40"')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('renders the sort control only when advertised options are supplied', () => {
|
|
132
|
+
const withOptions = renderToStaticMarkup(<CategoryFilter2 products={products} sortOptions={advertisedSorts} />)
|
|
133
|
+
expect(withOptions).toContain('aria-label="Ordenar por"')
|
|
134
|
+
|
|
135
|
+
const withoutOptions = renderToStaticMarkup(<CategoryFilter2 products={products} />)
|
|
136
|
+
expect(withoutOptions).not.toContain('aria-label="Ordenar por"')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('renders no sort control when no options are advertised', () => {
|
|
140
|
+
const html = renderToStaticMarkup(<CategoryFilter2 products={products} />)
|
|
141
|
+
expect(html).not.toContain('Ordenar por')
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
describe('CategoryFilter2 — provider-paged products are not re-paginated', () => {
|
|
146
|
+
it('renders exactly the supplied page of products with backend page links', () => {
|
|
147
|
+
const html = renderToStaticMarkup(
|
|
148
|
+
<CategoryFilter2 products={products} pagination={[1, 2, 3]} selectedPage={2} sortOptions={advertisedSorts} />,
|
|
149
|
+
)
|
|
150
|
+
expect(html).toContain('Pantalla Samsung Galaxy A54 OLED')
|
|
151
|
+
expect(html).toContain('Batería iPhone 13')
|
|
152
|
+
expect(countOccurrences(html, 'data-slot="pagination-link"')).toBe(3)
|
|
153
|
+
// The second product has no image; the block omits src rather than emitting "".
|
|
154
|
+
expect(html).not.toContain('src=""')
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
describe('CategoryFilter2 — product cards link and placeholder (tasks 3.1/3.2)', () => {
|
|
159
|
+
it('renders each product card as a real anchor when href is present', () => {
|
|
160
|
+
const linked: FilterProduct[] = [
|
|
161
|
+
{ id: 'p1', name: 'Pantalla', price: '$49.99', href: '/productos/pantalla-samsung-a54' },
|
|
162
|
+
]
|
|
163
|
+
const html = renderToStaticMarkup(<CategoryFilter2 products={linked} />)
|
|
164
|
+
expect(html).toMatch(/<a[^>]*href="\/productos\/pantalla-samsung-a54"/)
|
|
165
|
+
expect(html).toContain('Pantalla')
|
|
166
|
+
// The card is not rendered as an article when it is a link.
|
|
167
|
+
expect(html).not.toContain('<article')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('keeps the onClick path (article, no anchor) when href is absent', () => {
|
|
171
|
+
const html = renderToStaticMarkup(
|
|
172
|
+
<CategoryFilter2 products={products} onProductClick={() => {}} />,
|
|
173
|
+
)
|
|
174
|
+
expect(html).not.toMatch(/<a[^>]*href="\/productos\//)
|
|
175
|
+
expect(html).toContain('<article')
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('links only the cards that carry an href', () => {
|
|
179
|
+
const mixed: FilterProduct[] = [
|
|
180
|
+
{ id: 'p1', name: 'Pantalla', price: '$49.99', href: '/productos/p1' },
|
|
181
|
+
{ id: 'p2', name: 'Batería', price: '$29.99' },
|
|
182
|
+
]
|
|
183
|
+
const html = renderToStaticMarkup(<CategoryFilter2 products={mixed} />)
|
|
184
|
+
expect(countOccurrences(html, '<a ')).toBe(1)
|
|
185
|
+
expect(html).toMatch(/<a[^>]*href="\/productos\/p1"/)
|
|
186
|
+
expect(html).toContain('Batería')
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('renders a neutral placeholder and no <img> when the image is absent', () => {
|
|
190
|
+
const html = renderToStaticMarkup(<CategoryFilter2 products={products} />)
|
|
191
|
+
expect(html).toContain('data-slot="product-image-placeholder"')
|
|
192
|
+
// The product without an image never emits an <img> without src.
|
|
193
|
+
expect(html).not.toContain('src=""')
|
|
194
|
+
// One product has an image (Pantalla), one does not (Batería): exactly one img.
|
|
195
|
+
expect(countOccurrences(html, '<img')).toBe(1)
|
|
196
|
+
expect(html).toMatch(/<img[^>]*src="\/demo\/pantalla.svg"/)
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
describe('CategoryFilter2 — emitted changes (pure reducer)', () => {
|
|
201
|
+
it('adds and removes a selection without duplicates', () => {
|
|
202
|
+
expect(toggleFilterValue([], 'a', true)).toEqual(['a'])
|
|
203
|
+
expect(toggleFilterValue(['a'], 'b', true)).toEqual(['a', 'b'])
|
|
204
|
+
expect(toggleFilterValue(['a', 'b'], 'a', false)).toEqual(['b'])
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('emits the canonical filter change including collections', () => {
|
|
208
|
+
expect(
|
|
209
|
+
buildFilterChange({
|
|
210
|
+
categories: ['pantallas'],
|
|
211
|
+
collections: ['ofertas'],
|
|
212
|
+
priceRange: [10, 40],
|
|
213
|
+
size: null,
|
|
214
|
+
color: null,
|
|
215
|
+
}),
|
|
216
|
+
).toEqual({
|
|
217
|
+
categories: ['pantallas'],
|
|
218
|
+
collections: ['ofertas'],
|
|
219
|
+
priceRange: [10, 40],
|
|
220
|
+
size: null,
|
|
221
|
+
color: null,
|
|
222
|
+
})
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
describe('CategoryFilter2 — controlled search draft (E2 task 8.2 corrective)', () => {
|
|
227
|
+
// Simulates the real client loop: the shopper types into the controlled input
|
|
228
|
+
// (mutating the draft immediately and emitting the full value), while the
|
|
229
|
+
// URL-backed `searchValue` prop arrives later from the server. The draft must
|
|
230
|
+
// keep every typed character and never be reverted by a stale response.
|
|
231
|
+
function type(seq: string) {
|
|
232
|
+
let draft = ''
|
|
233
|
+
let prevSearchValue: string | undefined = ''
|
|
234
|
+
const emitted: string[] = []
|
|
235
|
+
const deliver = (searchValue: string) => {
|
|
236
|
+
const next = reconcileSearchDraft({ searchValue, prevSearchValue, draft })
|
|
237
|
+
if (next) {
|
|
238
|
+
draft = next.draft
|
|
239
|
+
prevSearchValue = next.prevSearchValue
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
for (const ch of seq) {
|
|
243
|
+
const before = draft
|
|
244
|
+
draft += ch
|
|
245
|
+
emitted.push(draft)
|
|
246
|
+
// A fast server round-trip can deliver the response to the PREVIOUS
|
|
247
|
+
// keystroke after the shopper has already typed past it; each delivery
|
|
248
|
+
// must leave the draft intact.
|
|
249
|
+
deliver(before)
|
|
250
|
+
}
|
|
251
|
+
// The final server response reflects the whole committed query.
|
|
252
|
+
deliver(seq)
|
|
253
|
+
return { draft, emitted }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
it('keeps the full multi-character query under fast typing', () => {
|
|
257
|
+
const { draft, emitted } = type('alpha-001')
|
|
258
|
+
expect(emitted.at(-1)).toBe('alpha-001')
|
|
259
|
+
expect(draft).toBe('alpha-001')
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it('ignores a stale (unchanged) response that would revert the draft', () => {
|
|
263
|
+
expect(
|
|
264
|
+
reconcileSearchDraft({ searchValue: 'al', prevSearchValue: 'al', draft: 'alp' }),
|
|
265
|
+
).toBeNull()
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('records a stale older response without reverting the draft', () => {
|
|
269
|
+
expect(
|
|
270
|
+
reconcileSearchDraft({ searchValue: 'a', prevSearchValue: 'al', draft: 'alp' }),
|
|
271
|
+
).toEqual({ draft: 'alp', prevSearchValue: 'a' })
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('adopts an external navigation value when no local edit is pending', () => {
|
|
275
|
+
expect(
|
|
276
|
+
reconcileSearchDraft({ searchValue: 'beta', prevSearchValue: '', draft: '' }),
|
|
277
|
+
).toEqual({ draft: 'beta', prevSearchValue: 'beta' })
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('does not adopt while an uncommitted edit is pending', () => {
|
|
281
|
+
expect(
|
|
282
|
+
reconcileSearchDraft({ searchValue: 'other', prevSearchValue: 'alp', draft: 'alpha' }),
|
|
283
|
+
).toEqual({ draft: 'alpha', prevSearchValue: 'other' })
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('is a no-op for an unchanged prop', () => {
|
|
287
|
+
expect(reconcileSearchDraft({ searchValue: 'q', prevSearchValue: 'q', draft: 'q' })).toBeNull()
|
|
288
|
+
})
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
describe('CategoryFilter2 — observable listing states (task 4.5)', () => {
|
|
292
|
+
it('shows skeleton placeholders while loading instead of products', () => {
|
|
293
|
+
const html = renderToStaticMarkup(<CategoryFilter2 products={products} status={{ state: 'loading' }} />)
|
|
294
|
+
expect(html).toContain('data-slot="skeleton"')
|
|
295
|
+
expect(html).toContain('aria-busy="true"')
|
|
296
|
+
expect(html).not.toContain('Pantalla Samsung Galaxy A54 OLED')
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('renders a clearable empty state, distinct from an error', () => {
|
|
300
|
+
const html = renderToStaticMarkup(
|
|
301
|
+
<CategoryFilter2 products={[]} status={{ state: 'empty' }} onClearFilters={() => {}} />,
|
|
302
|
+
)
|
|
303
|
+
expect(html).toContain('Sin resultados')
|
|
304
|
+
expect(html).toContain('Limpiar filtros')
|
|
305
|
+
expect(html).not.toContain('Reintentar')
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
it('renders an invalid-query repair state naming the invalid fields', () => {
|
|
309
|
+
const html = renderToStaticMarkup(
|
|
310
|
+
<CategoryFilter2
|
|
311
|
+
products={[]}
|
|
312
|
+
status={{ state: 'invalid-query', error: { code: 'invalid-query', fields: ['page'] } }}
|
|
313
|
+
onRepair={() => {}}
|
|
314
|
+
/>,
|
|
315
|
+
)
|
|
316
|
+
expect(html).toContain('Consulta no válida')
|
|
317
|
+
expect(html).toContain('page')
|
|
318
|
+
expect(html).toContain('Corregir búsqueda')
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it('renders an unsupported-query repair state naming the unsupported fields', () => {
|
|
322
|
+
const html = renderToStaticMarkup(
|
|
323
|
+
<CategoryFilter2
|
|
324
|
+
products={[]}
|
|
325
|
+
status={{ state: 'unsupported-query', error: { code: 'unsupported-query', fields: ['priceMin'] } }}
|
|
326
|
+
onRepair={() => {}}
|
|
327
|
+
/>,
|
|
328
|
+
)
|
|
329
|
+
expect(html).toContain('Filtro no disponible')
|
|
330
|
+
expect(html).toContain('priceMin')
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it('renders an unmounted-capability repair state naming the missing feature', () => {
|
|
334
|
+
const html = renderToStaticMarkup(
|
|
335
|
+
<CategoryFilter2
|
|
336
|
+
products={[]}
|
|
337
|
+
status={{
|
|
338
|
+
state: 'unmounted-capability',
|
|
339
|
+
error: { code: 'unmounted-capability', fields: ['q'], feature: 'catalog-search' },
|
|
340
|
+
}}
|
|
341
|
+
onRepair={() => {}}
|
|
342
|
+
/>,
|
|
343
|
+
)
|
|
344
|
+
expect(html).toContain('Función no disponible')
|
|
345
|
+
expect(html).toContain('catalog-search')
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
it('renders an error state with a retry action and the failure message', () => {
|
|
349
|
+
const html = renderToStaticMarkup(
|
|
350
|
+
<CategoryFilter2 products={[]} status={{ state: 'error', message: 'Network down' }} onRetry={() => {}} />,
|
|
351
|
+
)
|
|
352
|
+
expect(html).toContain('Network down')
|
|
353
|
+
expect(html).toContain('Reintentar')
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
it('renders an accessible removable chip per active filter', () => {
|
|
357
|
+
const html = renderToStaticMarkup(<CategoryFilter2 products={products} activeFilters={activeChips} />)
|
|
358
|
+
expect(html).toContain('aria-label="Filtros activos"')
|
|
359
|
+
expect(html).toContain('pantalla')
|
|
360
|
+
expect(html).toContain('Pantallas')
|
|
361
|
+
expect(html).toContain('aria-label="Quitar filtro: Pantallas"')
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
it('keeps the legacy ready surface when no status is supplied', () => {
|
|
365
|
+
const html = renderToStaticMarkup(<CategoryFilter2 products={products} sortOptions={advertisedSorts} />)
|
|
366
|
+
expect(html).toContain('Pantalla Samsung Galaxy A54 OLED')
|
|
367
|
+
})
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
describe('CategoryFilter2StatusView — state and chip interactions (task 4.5)', () => {
|
|
371
|
+
it('removes exactly the clicked chip and preserves the others', () => {
|
|
372
|
+
const onRemoveFilter = vi.fn()
|
|
373
|
+
const tree = CategoryFilter2StatusView({
|
|
374
|
+
status: { state: 'ready' },
|
|
375
|
+
activeFilters: activeChips,
|
|
376
|
+
onRemoveFilter,
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
const removeButtons = findAllByProp(tree, 'data-action', 'remove-filter')
|
|
380
|
+
expect(removeButtons).toHaveLength(3)
|
|
381
|
+
|
|
382
|
+
const categoryButton = removeButtons.find(button => button.props['data-filter-id'] === 'category:pantallas')
|
|
383
|
+
categoryButton?.props.onClick?.()
|
|
384
|
+
|
|
385
|
+
expect(onRemoveFilter).toHaveBeenCalledTimes(1)
|
|
386
|
+
expect(onRemoveFilter).toHaveBeenCalledWith(activeChips[1])
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
it('repairs by emitting the exact bad fields of the typed error', () => {
|
|
390
|
+
const onRepair = vi.fn()
|
|
391
|
+
const tree = CategoryFilter2StatusView({
|
|
392
|
+
status: {
|
|
393
|
+
state: 'unsupported-query',
|
|
394
|
+
error: { code: 'unsupported-query', fields: ['priceMin', 'inStock'], message: 'Unsupported' },
|
|
395
|
+
},
|
|
396
|
+
onRepair,
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
const repair = findAllByProp(tree, 'data-action', 'repair')
|
|
400
|
+
expect(repair).toHaveLength(1)
|
|
401
|
+
repair[0].props.onClick?.()
|
|
402
|
+
|
|
403
|
+
expect(onRepair).toHaveBeenCalledWith(['priceMin', 'inStock'])
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
it('retries through the callback without emitting any query mutation', () => {
|
|
407
|
+
const onRetry = vi.fn()
|
|
408
|
+
const tree = CategoryFilter2StatusView({ status: { state: 'error' }, onRetry })
|
|
409
|
+
|
|
410
|
+
const retry = findAllByProp(tree, 'data-action', 'retry')
|
|
411
|
+
retry[0].props.onClick?.()
|
|
412
|
+
|
|
413
|
+
expect(onRetry).toHaveBeenCalledTimes(1)
|
|
414
|
+
expect(onRetry).toHaveBeenCalledWith()
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
it('clears active filters from the empty state', () => {
|
|
418
|
+
const onClearFilters = vi.fn()
|
|
419
|
+
const tree = CategoryFilter2StatusView({
|
|
420
|
+
status: { state: 'empty' },
|
|
421
|
+
activeFilters: activeChips,
|
|
422
|
+
onClearFilters,
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
const clear = findAllByProp(tree, 'data-action', 'clear-filters')
|
|
426
|
+
expect(clear).toHaveLength(1)
|
|
427
|
+
clear[0].props.onClick?.()
|
|
428
|
+
|
|
429
|
+
expect(onClearFilters).toHaveBeenCalledTimes(1)
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
it('offers no repair/retry action when the host binds no callback', () => {
|
|
433
|
+
const tree = CategoryFilter2StatusView({
|
|
434
|
+
status: { state: 'invalid-query', error: { code: 'invalid-query', fields: ['page'] } },
|
|
435
|
+
})
|
|
436
|
+
expect(findAllByProp(tree, 'data-action', 'repair')).toHaveLength(0)
|
|
437
|
+
})
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
describe('CategoryFilter2 — keyboard, labels and mobile sheet (task 4.6)', () => {
|
|
441
|
+
it('renders pagination as native buttons (keyboard-operable) with aria-current, not href-less anchors', () => {
|
|
442
|
+
const html = renderToStaticMarkup(
|
|
443
|
+
<CategoryFilter2 products={products} pagination={[1, 2, 3]} selectedPage={2} sortOptions={advertisedSorts} />,
|
|
444
|
+
)
|
|
445
|
+
// A bare `<a>` without href is not focusable and not a link role; the control
|
|
446
|
+
// must be a native button so it is reachable and operable by keyboard.
|
|
447
|
+
expect(html).toMatch(/<button[^>]*data-slot="pagination-link"/)
|
|
448
|
+
expect(html).not.toMatch(/<a[^>]*data-slot="pagination-link"/)
|
|
449
|
+
expect(countOccurrences(html, 'data-slot="pagination-link"')).toBe(3)
|
|
450
|
+
expect(countOccurrences(html, 'aria-current="page"')).toBe(1)
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
it('shows no pagination and no product count when the backend supplies no total (task 7.6)', () => {
|
|
454
|
+
// `pagination` is derived by the runtime seam from the backend `total`; an
|
|
455
|
+
// absent total yields `[]`, and the block renders no page links and no
|
|
456
|
+
// manufactured count.
|
|
457
|
+
const html = renderToStaticMarkup(
|
|
458
|
+
<CategoryFilter2 products={products} pagination={[]} sortOptions={advertisedSorts} />,
|
|
459
|
+
)
|
|
460
|
+
expect(countOccurrences(html, 'data-slot="pagination-link"')).toBe(0)
|
|
461
|
+
expect(html).not.toContain('aria-label="Paginación"')
|
|
462
|
+
expect(html).not.toMatch(/\d+\s*(producto|resultado)/i)
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
it('wires the mobile filter trigger as a dialog trigger so focus can return on close', () => {
|
|
466
|
+
const html = renderToStaticMarkup(<CategoryFilter2 products={products} />)
|
|
467
|
+
expect(html).toContain('aria-haspopup="dialog"')
|
|
468
|
+
expect(html).toContain('aria-expanded="false"')
|
|
469
|
+
expect(html).toMatch(/<button[^>]*lg:hidden[^>]*>[\s\S]*Filtros/)
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
it('labels the search, sort, view and chip controls', () => {
|
|
473
|
+
const html = renderToStaticMarkup(
|
|
474
|
+
<CategoryFilter2
|
|
475
|
+
products={products}
|
|
476
|
+
sortOptions={advertisedSorts}
|
|
477
|
+
viewOptions={[10, 25]}
|
|
478
|
+
showSearch
|
|
479
|
+
searchValue='pantalla'
|
|
480
|
+
activeFilters={activeChips}
|
|
481
|
+
/>,
|
|
482
|
+
)
|
|
483
|
+
expect(html).toContain('aria-label="Buscar"')
|
|
484
|
+
expect(html).toContain('aria-label="Ordenar por"')
|
|
485
|
+
expect(html).toContain('aria-label="Ver"')
|
|
486
|
+
expect(html).toContain('aria-label="Filtros activos"')
|
|
487
|
+
expect(html).toContain('aria-label="Quitar filtro: Pantallas"')
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
it('labels the filter-panel inputs and checkboxes it renders', () => {
|
|
491
|
+
const html = renderToStaticMarkup(
|
|
492
|
+
<CategoryFilter2
|
|
493
|
+
products={products}
|
|
494
|
+
categories={[{ id: 'pantallas', label: 'Pantallas' }]}
|
|
495
|
+
collections={[{ id: 'ofertas', label: 'Ofertas' }]}
|
|
496
|
+
priceRange={{ min: 0, max: 100, step: 5 }}
|
|
497
|
+
/>,
|
|
498
|
+
)
|
|
499
|
+
expect(html).toContain('>Mínimo</label>')
|
|
500
|
+
expect(html).toContain('>Máximo</label>')
|
|
501
|
+
expect(html).toMatch(/<label[^>]*for="[^"]*"[^>]*>\s*Pantallas\s*<\/label>/)
|
|
502
|
+
expect(html).toMatch(/<label[^>]*for="[^"]*"[^>]*>\s*Ofertas\s*<\/label>/)
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
it('adds no positive tabindex and keeps search and pagination in the natural tab order', () => {
|
|
506
|
+
const html = renderToStaticMarkup(
|
|
507
|
+
<CategoryFilter2 products={products} sortOptions={advertisedSorts} pagination={[1, 2]} showSearch />,
|
|
508
|
+
)
|
|
509
|
+
expect(html).not.toMatch(/tabindex="[1-9]/)
|
|
510
|
+
expect(html).not.toMatch(/<input[^>]*type="search"[^>]*tabindex="-1"/)
|
|
511
|
+
expect(html).not.toMatch(/<button[^>]*data-slot="pagination-link"[^>]*tabindex="-1"/)
|
|
512
|
+
})
|
|
513
|
+
})
|