@akinon/pz-theme 2.0.29-beta.0 → 2.0.29

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/CHANGELOG.md CHANGED
@@ -1,11 +1,11 @@
1
1
  # @akinon/pz-theme
2
2
 
3
- ## 2.0.29-beta.0
3
+ ## 2.0.29
4
4
 
5
5
  ### Patch Changes
6
6
 
7
- - Updated dependencies [cbbbfd75]
8
- - @akinon/next@2.0.29-beta.0
7
+ - fe2f23e: ZERO-4584: Add jest (ts-jest + jsdom) test infrastructure to pz-theme and core unit/parity tests for the theme render engine (responsive/color/opacity resolution, visibility rules, publish window, iterator binding/price escaping, block/section registry parity).
8
+ - @akinon/next@2.0.29
9
9
 
10
10
  ## 2.0.28
11
11
 
@@ -0,0 +1,2 @@
1
+ // Stub for side-effect CSS imports during tests.
2
+ module.exports = {};
package/jest.config.js ADDED
@@ -0,0 +1,40 @@
1
+ /** @type {import('ts-jest').JestConfigWithTsJest} */
2
+ module.exports = {
3
+ preset: 'ts-jest',
4
+ // jsdom so the same config serves both pure-function tests and future
5
+ // React component (RTL) tests without reconfiguration.
6
+ testEnvironment: 'jsdom',
7
+ roots: ['<rootDir>/src'],
8
+ testMatch: ['**/*.test.ts', '**/*.test.tsx'],
9
+ // Stub side-effect CSS imports (e.g. react-multi-carousel styles) so that
10
+ // importing a component never fails on a missing CSS transform.
11
+ moduleNameMapper: {
12
+ '\\.(css|less|scss|sass)$': '<rootDir>/jest/style-mock.js'
13
+ },
14
+ transform: {
15
+ '^.+\\.(ts|tsx)$': [
16
+ 'ts-jest',
17
+ {
18
+ tsconfig: {
19
+ // Transpile-only: avoids whole-program type-checking (pz-theme has
20
+ // no tsconfig and relies on consumer transpilation) while still
21
+ // eliding type-only imports such as `import { Block }`.
22
+ isolatedModules: true,
23
+ jsx: 'react-jsx',
24
+ esModuleInterop: true,
25
+ allowSyntheticDefaultImports: true,
26
+ module: 'commonjs',
27
+ target: 'es2020',
28
+ lib: ['es2020', 'dom', 'dom.iterable'],
29
+ moduleResolution: 'node',
30
+ resolveJsonModule: true,
31
+ skipLibCheck: true
32
+ }
33
+ }
34
+ ]
35
+ },
36
+ // ESM-only deps that must be transformed if a test pulls them in.
37
+ transformIgnorePatterns: [
38
+ 'node_modules/(?!(lottie-web|react-multi-carousel|swiper|ssr-window|dom7|@akinon)/)'
39
+ ]
40
+ };
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "@akinon/pz-theme",
3
3
  "description": "Theme package for Project Zero Next — ThemePlaceholder system and theme editor infrastructure",
4
- "version": "2.0.29-beta.0",
4
+ "version": "2.0.29",
5
5
  "license": "MIT",
6
6
  "main": "src/index.ts",
7
+ "scripts": {
8
+ "test": "jest"
9
+ },
7
10
  "peerDependencies": {
8
- "@akinon/next": "2.0.29-beta.0",
11
+ "@akinon/next": "2.0.29",
9
12
  "react": "^18.0.0 || ^19.0.0",
10
13
  "react-dom": "^18.0.0 || ^19.0.0"
11
14
  },
@@ -15,13 +18,17 @@
15
18
  "tailwind-merge": "^2.5.4"
16
19
  },
17
20
  "devDependencies": {
18
- "@akinon/next": "2.0.29-beta.0",
21
+ "@akinon/next": "2.0.29",
22
+ "@types/jest": "29.5.14",
19
23
  "@types/node": "^18.7.8",
20
24
  "@types/react": "^18.0.17",
21
25
  "@types/react-dom": "^18.0.6",
26
+ "jest": "29.7.0",
27
+ "jest-environment-jsdom": "29.7.0",
22
28
  "prettier": "^3.0.3",
23
29
  "react": "19.2.5",
24
30
  "react-dom": "19.2.5",
31
+ "ts-jest": "29.2.6",
25
32
  "typescript": "^5.2.2"
26
33
  }
27
34
  }
@@ -0,0 +1,95 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ /**
5
+ * Tier 2 — in-package registry lock test.
6
+ *
7
+ * Catches the most common silent render bug: a renderer that is never
8
+ * `register()`-ed, a typo'd type key (e.g. `stat-counter` vs `stats-counter`),
9
+ * an accidental removal, or a duplicate. The registries are NOT imported at
10
+ * runtime here on purpose — importing them transitively pulls the whole
11
+ * component tree (@akinon/next data hooks, react-multi-carousel, `settings`,
12
+ * yup, …). Instead we parse the `register('type', …)` calls from source.
13
+ *
14
+ * NOTE: this does NOT catch editor<->render drift (a type that exists in the
15
+ * pz-dynamic-widget BlockType/SectionType enum but has no renderer here). That
16
+ * needs a committed cross-repo manifest and is a separate follow-up.
17
+ */
18
+
19
+ const readSource = (relPath: string): string =>
20
+ fs.readFileSync(path.resolve(__dirname, relPath), 'utf8');
21
+
22
+ const extractRegisteredTypes = (source: string): string[] => {
23
+ const matches = source.matchAll(/\.register\(\s*['"]([^'"]+)['"]/g);
24
+ return Array.from(matches, (m) => m[1]);
25
+ };
26
+
27
+ const EXPECTED_BLOCK_TYPES = [
28
+ 'accordion',
29
+ 'button',
30
+ 'counter',
31
+ 'divider',
32
+ 'embed',
33
+ 'group',
34
+ 'hotspot',
35
+ 'icon',
36
+ 'image',
37
+ 'image-gallery',
38
+ 'input',
39
+ 'link',
40
+ 'lottie',
41
+ 'map',
42
+ 'slider',
43
+ 'tab',
44
+ 'text',
45
+ 'video'
46
+ ];
47
+
48
+ const EXPECTED_SECTION_TYPES = [
49
+ 'before-after',
50
+ 'contact-form',
51
+ 'countdown-banner',
52
+ 'coupon-banner',
53
+ 'divider',
54
+ 'featured-product-spotlight',
55
+ 'find-in-store',
56
+ 'flash-deals-slider',
57
+ 'hover-showcase',
58
+ 'image-hotspot',
59
+ 'installment-options',
60
+ 'notification-banner',
61
+ 'order-tracking-lookup',
62
+ 'posts-slider',
63
+ 'pre-order-launch-banner',
64
+ 'shipping-threshold-progress',
65
+ 'stats-counter',
66
+ 'tabs'
67
+ ];
68
+
69
+ describe('block renderer registry parity', () => {
70
+ const registered = extractRegisteredTypes(
71
+ readSource('../blocks/block-renderer-registry.tsx')
72
+ );
73
+
74
+ it('registers exactly the approved set of block types', () => {
75
+ expect([...registered].sort()).toEqual([...EXPECTED_BLOCK_TYPES].sort());
76
+ });
77
+
78
+ it('has no duplicate registrations', () => {
79
+ expect(new Set(registered).size).toBe(registered.length);
80
+ });
81
+ });
82
+
83
+ describe('section renderer registry parity', () => {
84
+ const registered = extractRegisteredTypes(
85
+ readSource('../sections/section-renderer-registry.tsx')
86
+ );
87
+
88
+ it('registers exactly the approved set of section types', () => {
89
+ expect([...registered].sort()).toEqual([...EXPECTED_SECTION_TYPES].sort());
90
+ });
91
+
92
+ it('has no duplicate registrations', () => {
93
+ expect(new Set(registered).size).toBe(registered.length);
94
+ });
95
+ });
@@ -0,0 +1,171 @@
1
+ import { buildIteratorBlock, resolveBlockBindings } from './iterator-utils';
2
+
3
+ // Minimal block factory — pz-theme tests run transpile-only (isolatedModules),
4
+ // so partial shapes are fine and keep the cases readable.
5
+ const textBlock = (props: Record<string, unknown>, id = 'b') =>
6
+ ({
7
+ id,
8
+ type: 'text',
9
+ label: id,
10
+ value: '',
11
+ properties: {},
12
+ styles: {},
13
+ order: 0,
14
+ hidden: false,
15
+ ...props
16
+ } as any);
17
+
18
+ describe('resolveBlockBindings (page-context, non-iterator)', () => {
19
+ const dataSource = { type: 'page-context' } as any;
20
+
21
+ it('resolves an array-index path (products[1].name)', () => {
22
+ const result = resolveBlockBindings({
23
+ block: textBlock({ properties: { dataBinding: 'products[1].name' } }),
24
+ sectionDataSource: dataSource,
25
+ pageContext: { products: [{ name: 'A' }, { name: 'B' }] }
26
+ });
27
+ expect(result.value).toBe('B');
28
+ });
29
+
30
+ it('leaves the value untouched when the path does not resolve', () => {
31
+ const result = resolveBlockBindings({
32
+ block: textBlock({
33
+ value: 'fallback',
34
+ properties: { dataBinding: 'products[5].name' }
35
+ }),
36
+ sectionDataSource: dataSource,
37
+ pageContext: { products: [{ name: 'A' }] }
38
+ });
39
+ expect(result.value).toBe('fallback');
40
+ });
41
+
42
+ it('writes a link binding to properties.href (default target for links)', () => {
43
+ const result = resolveBlockBindings({
44
+ block: {
45
+ id: 'l',
46
+ type: 'link',
47
+ label: 'l',
48
+ properties: { dataBinding: 'products[0].url' },
49
+ styles: {},
50
+ order: 0,
51
+ hidden: false
52
+ } as any,
53
+ sectionDataSource: dataSource,
54
+ pageContext: { products: [{ url: '/p/1' }] }
55
+ });
56
+ expect(result.properties?.href).toBe('/p/1');
57
+ });
58
+
59
+ it('does NOT resolve item.* paths (those belong to the iterator path)', () => {
60
+ const result = resolveBlockBindings({
61
+ block: textBlock({
62
+ value: 'keep',
63
+ properties: { dataBinding: 'item.title' }
64
+ }),
65
+ sectionDataSource: dataSource,
66
+ pageContext: { title: 'ignored' }
67
+ });
68
+ expect(result.value).toBe('keep');
69
+ });
70
+ });
71
+
72
+ describe('buildIteratorBlock — price formatting', () => {
73
+ const iterator = (template: any) =>
74
+ ({
75
+ id: 'iter',
76
+ type: 'group',
77
+ label: 'iter',
78
+ isIterator: true,
79
+ properties: {},
80
+ styles: {},
81
+ order: 0,
82
+ hidden: false,
83
+ blocks: [template]
84
+ } as any);
85
+
86
+ const staticSource = (items: unknown[]) =>
87
+ ({ type: 'static', details: { static: { data: items } } } as any);
88
+
89
+ it('renders a discounted price (retail struck-through + bold current)', () => {
90
+ const result = buildIteratorBlock({
91
+ iteratorBlock: iterator(
92
+ textBlock({ properties: { dataBinding: 'item.active_price.price' } }, 'price')
93
+ ),
94
+ sectionDataSource: staticSource([
95
+ {
96
+ active_price: {
97
+ price: '899.99',
98
+ retail_price: '1299.99',
99
+ currency_type: 'TRY'
100
+ }
101
+ }
102
+ ]),
103
+ isDesigner: false
104
+ });
105
+
106
+ const html = result.blocks?.[0].value as string;
107
+ expect(html).toContain('text-decoration:line-through');
108
+ expect(html).toContain('font-weight:600');
109
+ // tr-TR formatting + TRY normalized to TL.
110
+ expect(html).toContain('1.299,99 TL');
111
+ expect(html).toContain('899,99 TL');
112
+ // retail must appear before current.
113
+ expect(html.indexOf('1.299,99')).toBeLessThan(html.indexOf('899,99'));
114
+ });
115
+
116
+ it('renders a plain price when there is no higher retail price', () => {
117
+ const result = buildIteratorBlock({
118
+ iteratorBlock: iterator(
119
+ textBlock({ properties: { dataBinding: 'item.price' } }, 'price')
120
+ ),
121
+ sectionDataSource: staticSource([{ price: '49.9', currency_type: 'TL' }]),
122
+ isDesigner: false
123
+ });
124
+
125
+ const html = result.blocks?.[0].value as string;
126
+ expect(html).toContain('49,90 TL');
127
+ expect(html).not.toContain('line-through');
128
+ });
129
+
130
+ it('escapes HTML in price/currency (no raw tags leak into the markup)', () => {
131
+ const result = buildIteratorBlock({
132
+ iteratorBlock: iterator(
133
+ textBlock({ properties: { dataBinding: 'item.active_price.price' } }, 'price')
134
+ ),
135
+ sectionDataSource: staticSource([
136
+ {
137
+ active_price: {
138
+ price: '10',
139
+ currency_type: '<img src=x onerror=alert(1)>'
140
+ }
141
+ }
142
+ ]),
143
+ isDesigner: false
144
+ });
145
+
146
+ const html = result.blocks?.[0].value as string;
147
+ expect(html).not.toMatch(/<img/i);
148
+ expect(html).toContain('&lt;');
149
+ });
150
+
151
+ it('produces one clone per item', () => {
152
+ const result = buildIteratorBlock({
153
+ iteratorBlock: iterator(
154
+ textBlock({ properties: { dataBinding: 'item.title' } }, 'title')
155
+ ),
156
+ sectionDataSource: staticSource([
157
+ { title: 'One' },
158
+ { title: 'Two' },
159
+ { title: 'Three' }
160
+ ]),
161
+ isDesigner: false
162
+ });
163
+
164
+ expect(result.blocks).toHaveLength(3);
165
+ expect(result.blocks?.map((b: any) => b.value)).toEqual([
166
+ 'One',
167
+ 'Two',
168
+ 'Three'
169
+ ]);
170
+ });
171
+ });
@@ -0,0 +1,96 @@
1
+ import {
2
+ getPublishWindowStatus,
3
+ isPublishWindowVisible,
4
+ normalizePublishWindowValue
5
+ } from './publish-window';
6
+
7
+ const NOW = new Date('2026-06-25T12:00:00.000Z');
8
+
9
+ describe('getPublishWindowStatus', () => {
10
+ it('is inactive when disabled or missing', () => {
11
+ expect(getPublishWindowStatus(undefined, NOW)).toBe('inactive');
12
+ expect(getPublishWindowStatus({ enabled: false }, NOW)).toBe('inactive');
13
+ });
14
+
15
+ it('is active when enabled with no bounds', () => {
16
+ expect(getPublishWindowStatus({ enabled: true }, NOW)).toBe('active');
17
+ });
18
+
19
+ it('is scheduled before the start and expired after the end', () => {
20
+ expect(
21
+ getPublishWindowStatus(
22
+ { enabled: true, startAt: '2026-06-26T00:00:00.000Z' },
23
+ NOW
24
+ )
25
+ ).toBe('scheduled');
26
+ expect(
27
+ getPublishWindowStatus(
28
+ { enabled: true, endAt: '2026-06-24T00:00:00.000Z' },
29
+ NOW
30
+ )
31
+ ).toBe('expired');
32
+ });
33
+
34
+ it('is active inside the window', () => {
35
+ expect(
36
+ getPublishWindowStatus(
37
+ {
38
+ enabled: true,
39
+ startAt: '2026-06-24T00:00:00.000Z',
40
+ endAt: '2026-06-26T00:00:00.000Z'
41
+ },
42
+ NOW
43
+ )
44
+ ).toBe('active');
45
+ });
46
+
47
+ it('fails open (active) on an unparseable date string', () => {
48
+ expect(
49
+ getPublishWindowStatus({ enabled: true, startAt: 'not-a-date' }, NOW)
50
+ ).toBe('active');
51
+ });
52
+
53
+ it('reads a responsive-nested publish window value', () => {
54
+ expect(
55
+ getPublishWindowStatus(
56
+ { desktop: { enabled: true, startAt: '2026-06-26T00:00:00.000Z' } },
57
+ NOW
58
+ )
59
+ ).toBe('scheduled');
60
+ });
61
+ });
62
+
63
+ describe('isPublishWindowVisible', () => {
64
+ it('is visible only for inactive and active statuses', () => {
65
+ expect(isPublishWindowVisible({ enabled: false }, NOW)).toBe(true);
66
+ expect(isPublishWindowVisible({ enabled: true }, NOW)).toBe(true);
67
+ expect(
68
+ isPublishWindowVisible(
69
+ { enabled: true, startAt: '2026-06-26T00:00:00.000Z' },
70
+ NOW
71
+ )
72
+ ).toBe(false);
73
+ expect(
74
+ isPublishWindowVisible(
75
+ { enabled: true, endAt: '2026-06-24T00:00:00.000Z' },
76
+ NOW
77
+ )
78
+ ).toBe(false);
79
+ });
80
+ });
81
+
82
+ describe('normalizePublishWindowValue', () => {
83
+ it('normalizes a flat value', () => {
84
+ expect(
85
+ normalizePublishWindowValue({ enabled: true, startAt: '2026-01-01' })
86
+ ).toEqual({ enabled: true, startAt: '2026-01-01', endAt: '' });
87
+ });
88
+
89
+ it('returns a disabled default for non-window shapes', () => {
90
+ expect(normalizePublishWindowValue(null)).toEqual({
91
+ enabled: false,
92
+ startAt: '',
93
+ endAt: ''
94
+ });
95
+ });
96
+ });
@@ -0,0 +1,138 @@
1
+ import {
2
+ getResponsiveValue,
3
+ colorToRgba,
4
+ applyAlphaToColor,
5
+ clampAlpha,
6
+ resolveThemeCssVariables
7
+ } from './index';
8
+
9
+ describe('getResponsiveValue', () => {
10
+ it('returns the value for the active breakpoint', () => {
11
+ expect(
12
+ getResponsiveValue({ desktop: '24px', mobile: '16px' }, 'mobile')
13
+ ).toBe('16px');
14
+ expect(
15
+ getResponsiveValue({ desktop: '24px', mobile: '16px' }, 'desktop')
16
+ ).toBe('24px');
17
+ });
18
+
19
+ it('falls back to desktop when the requested breakpoint is missing (desktop-first inheritance)', () => {
20
+ expect(getResponsiveValue({ desktop: '24px' }, 'mobile')).toBe('24px');
21
+ });
22
+
23
+ it('a mobile-only value is NOT rendered on desktop (known edge case)', () => {
24
+ // No desktop base => nothing to inherit, returns the fallback.
25
+ expect(getResponsiveValue({ mobile: '16px' }, 'desktop')).toBe('');
26
+ expect(getResponsiveValue({ mobile: '16px' }, 'mobile')).toBe('16px');
27
+ });
28
+
29
+ it('passes through plain (non-responsive) values for every breakpoint', () => {
30
+ expect(getResponsiveValue('24px', 'mobile')).toBe('24px');
31
+ expect(getResponsiveValue(0, 'mobile')).toBe(0);
32
+ expect(getResponsiveValue(false, 'desktop')).toBe(false);
33
+ });
34
+
35
+ it('returns the fallback for empty / null / undefined', () => {
36
+ expect(getResponsiveValue(undefined, 'desktop', 'normal')).toBe('normal');
37
+ expect(getResponsiveValue(null, 'desktop', 'normal')).toBe('normal');
38
+ expect(getResponsiveValue('', 'desktop', 'normal')).toBe('normal');
39
+ });
40
+
41
+ it('treats an array as an (empty) responsive object and returns the fallback', () => {
42
+ // Documents that arrays are mis-handled by the typeof-object branch.
43
+ expect(getResponsiveValue([1, 2, 3], 'desktop', 'fb')).toBe('fb');
44
+ });
45
+ });
46
+
47
+ describe('colorToRgba', () => {
48
+ it('parses 6-digit and 3-digit hex', () => {
49
+ expect(colorToRgba('#e63946', 0.9)).toBe('rgba(230, 57, 70, 0.9)');
50
+ expect(colorToRgba('#abc', 1)).toBe('rgba(170, 187, 204, 1)');
51
+ });
52
+
53
+ it('accepts hex without a leading #', () => {
54
+ expect(colorToRgba('e63946', 0.5)).toBe('rgba(230, 57, 70, 0.5)');
55
+ });
56
+
57
+ it('parses comma and space rgb()/rgba()', () => {
58
+ expect(colorToRgba('rgb(12, 34, 56)', 0.5)).toBe('rgba(12, 34, 56, 0.5)');
59
+ expect(colorToRgba('rgb(12 34 56)', 0.5)).toBe('rgba(12, 34, 56, 0.5)');
60
+ });
61
+
62
+ it('returns null for formats it cannot parse (named, var, 8-digit hex, hsl)', () => {
63
+ // These return null -> the caller silently drops the alpha. Locking the
64
+ // current behavior so a future change here is a conscious decision.
65
+ expect(colorToRgba('red', 0.5)).toBeNull();
66
+ expect(colorToRgba('var(--theme-primary)', 0.5)).toBeNull();
67
+ expect(colorToRgba('#11223344', 0.5)).toBeNull();
68
+ expect(colorToRgba('hsl(0, 100%, 50%)', 0.5)).toBeNull();
69
+ });
70
+ });
71
+
72
+ describe('applyAlphaToColor', () => {
73
+ it('returns transparent at alpha 0 and the raw color at alpha >= 1', () => {
74
+ expect(applyAlphaToColor('#e63946', 0)).toBe('transparent');
75
+ expect(applyAlphaToColor('#e63946', 1)).toBe('#e63946');
76
+ });
77
+
78
+ it('produces rgba for parseable colors', () => {
79
+ expect(applyAlphaToColor('#e63946', 0.9)).toBe('rgba(230, 57, 70, 0.9)');
80
+ });
81
+
82
+ it('falls back to color-mix for named / var() colors (unlike the inline color path)', () => {
83
+ expect(applyAlphaToColor('var(--theme-secondary)', 0.5)).toBe(
84
+ 'color-mix(in srgb, var(--theme-secondary) 50%, transparent)'
85
+ );
86
+ expect(applyAlphaToColor('red', 0.5)).toBe(
87
+ 'color-mix(in srgb, red 50%, transparent)'
88
+ );
89
+ });
90
+ });
91
+
92
+ describe('clampAlpha', () => {
93
+ it('converts percentage to a 0..1 alpha', () => {
94
+ expect(clampAlpha(50)).toBe(0.5);
95
+ expect(clampAlpha('50')).toBe(0.5);
96
+ });
97
+
98
+ it('uses the fallback for empty / null / undefined (default 100 => 1)', () => {
99
+ expect(clampAlpha('')).toBe(1);
100
+ expect(clampAlpha(undefined)).toBe(1);
101
+ expect(clampAlpha(null)).toBe(1);
102
+ expect(clampAlpha('', 0)).toBe(0);
103
+ });
104
+
105
+ it('clamps out-of-range values into 0..1', () => {
106
+ expect(clampAlpha(150)).toBe(1);
107
+ expect(clampAlpha(-10)).toBe(0);
108
+ });
109
+
110
+ it('returns null for non-numeric values (incl. "50%")', () => {
111
+ expect(clampAlpha('abc')).toBeNull();
112
+ expect(clampAlpha('50%')).toBeNull();
113
+ });
114
+ });
115
+
116
+ describe('resolveThemeCssVariables', () => {
117
+ it('resolves a mapped var() from theme settings', () => {
118
+ expect(
119
+ resolveThemeCssVariables('var(--theme-primary)', {
120
+ primaryColor: '#e63946'
121
+ })
122
+ ).toBe('#e63946');
123
+ });
124
+
125
+ it('uses the inline fallback when the var is unmapped/unresolved', () => {
126
+ expect(resolveThemeCssVariables('var(--whatever, #fff)', {})).toBe('#fff');
127
+ });
128
+
129
+ it('keeps the literal var() when nothing resolves it', () => {
130
+ expect(resolveThemeCssVariables('var(--theme-primary)', {})).toBe(
131
+ 'var(--theme-primary)'
132
+ );
133
+ });
134
+
135
+ it('returns non-var strings unchanged', () => {
136
+ expect(resolveThemeCssVariables('#ffffff', {})).toBe('#ffffff');
137
+ });
138
+ });
@@ -0,0 +1,155 @@
1
+ import {
2
+ evaluateVisibilityRules,
3
+ VisibilityRuleContext
4
+ } from './visibility-rules';
5
+
6
+ const makeContext = (
7
+ overrides: Partial<VisibilityRuleContext> = {}
8
+ ): VisibilityRuleContext => ({
9
+ authState: 'guest',
10
+ breakpoint: 'desktop',
11
+ locale: 'tr',
12
+ currency: 'TRY',
13
+ pathname: '/',
14
+ searchParams: null,
15
+ ...overrides
16
+ });
17
+
18
+ describe('evaluateVisibilityRules', () => {
19
+ it('is visible when rules are missing or disabled', () => {
20
+ expect(evaluateVisibilityRules(undefined, makeContext())).toBe(true);
21
+ expect(evaluateVisibilityRules({ enabled: false }, makeContext())).toBe(
22
+ true
23
+ );
24
+ });
25
+
26
+ describe('authState', () => {
27
+ it('restricts to guest / authenticated', () => {
28
+ expect(
29
+ evaluateVisibilityRules(
30
+ { enabled: true, authState: 'guest' },
31
+ makeContext({ authState: 'guest' })
32
+ )
33
+ ).toBe(true);
34
+ expect(
35
+ evaluateVisibilityRules(
36
+ { enabled: true, authState: 'guest' },
37
+ makeContext({ authState: 'authenticated' })
38
+ )
39
+ ).toBe(false);
40
+ });
41
+
42
+ it('hides auth-restricted elements while auth is still loading (FOUC guard)', () => {
43
+ expect(
44
+ evaluateVisibilityRules(
45
+ { enabled: true, authState: 'authenticated' },
46
+ makeContext({ authState: 'loading' })
47
+ )
48
+ ).toBe(false);
49
+ });
50
+ });
51
+
52
+ it('filters by allowedBreakpoints (empty list = no filter)', () => {
53
+ expect(
54
+ evaluateVisibilityRules(
55
+ { enabled: true, allowedBreakpoints: ['mobile'] },
56
+ makeContext({ breakpoint: 'desktop' })
57
+ )
58
+ ).toBe(false);
59
+ expect(
60
+ evaluateVisibilityRules(
61
+ { enabled: true, allowedBreakpoints: ['mobile'] },
62
+ makeContext({ breakpoint: 'mobile' })
63
+ )
64
+ ).toBe(true);
65
+ });
66
+
67
+ it('matches currencies case-insensitively (rule + context upper-cased)', () => {
68
+ expect(
69
+ evaluateVisibilityRules(
70
+ { enabled: true, allowedCurrencies: ['usd'] },
71
+ makeContext({ currency: 'USD' })
72
+ )
73
+ ).toBe(true);
74
+ expect(
75
+ evaluateVisibilityRules(
76
+ { enabled: true, allowedCurrencies: ['USD'] },
77
+ makeContext({ currency: 'eur' })
78
+ )
79
+ ).toBe(false);
80
+ });
81
+
82
+ describe('pathname matching', () => {
83
+ it('startsWith / equals / contains', () => {
84
+ expect(
85
+ evaluateVisibilityRules(
86
+ {
87
+ enabled: true,
88
+ pathnameMatchType: 'startsWith',
89
+ pathnameValue: '/account'
90
+ },
91
+ makeContext({ pathname: '/account/orders' })
92
+ )
93
+ ).toBe(true);
94
+ expect(
95
+ evaluateVisibilityRules(
96
+ {
97
+ enabled: true,
98
+ pathnameMatchType: 'startsWith',
99
+ pathnameValue: '/account'
100
+ },
101
+ makeContext({ pathname: '/cart' })
102
+ )
103
+ ).toBe(false);
104
+ expect(
105
+ evaluateVisibilityRules(
106
+ {
107
+ enabled: true,
108
+ pathnameMatchType: 'equals',
109
+ pathnameValue: '/x'
110
+ },
111
+ makeContext({ pathname: '/y' })
112
+ )
113
+ ).toBe(false);
114
+ });
115
+
116
+ it('fails closed when match type is set but the value is empty', () => {
117
+ expect(
118
+ evaluateVisibilityRules(
119
+ { enabled: true, pathnameMatchType: 'equals', pathnameValue: '' },
120
+ makeContext({ pathname: '/' })
121
+ )
122
+ ).toBe(false);
123
+ });
124
+ });
125
+
126
+ describe('query params', () => {
127
+ const searchParams = (map: Record<string, string>) => ({
128
+ get: (key: string) => (key in map ? map[key] : null)
129
+ });
130
+
131
+ it('requires the key to be present', () => {
132
+ expect(
133
+ evaluateVisibilityRules(
134
+ { enabled: true, queryParamKey: 'campaign' },
135
+ makeContext({ searchParams: searchParams({}) })
136
+ )
137
+ ).toBe(false);
138
+ expect(
139
+ evaluateVisibilityRules(
140
+ { enabled: true, queryParamKey: 'campaign' },
141
+ makeContext({ searchParams: searchParams({ campaign: 'x' }) })
142
+ )
143
+ ).toBe(true);
144
+ });
145
+
146
+ it('requires the value to match when provided', () => {
147
+ expect(
148
+ evaluateVisibilityRules(
149
+ { enabled: true, queryParamKey: 'campaign', queryParamValue: 'x' },
150
+ makeContext({ searchParams: searchParams({ campaign: 'y' }) })
151
+ )
152
+ ).toBe(false);
153
+ });
154
+ });
155
+ });