File size: 12,168 Bytes
a20a23c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
/**
 * T1 (#5634) β€” DOM-behavioral coverage for `ExportGateControl`.
 *
 * Before this file the locked export control was covered only by
 * source-extraction assertions in `tests/export-gate.test.mts`
 * ("setupExportPanel wiring"), which can go green with the rendered markup
 * broken. What is asserted here is what a locked user actually gets: the copy
 * in the menu, the ARIA the control exposes, when the gate-hit metric fires,
 * and that the CTA runs the injected action.
 *
 * The copy is compared against the real `en.json` strings via the shared
 * i18next singleton β€” see `helpers/i18n.mts` for why an uninitialised
 * i18next would otherwise make every one of these assertions a false pass.
 */

import { afterEach, beforeAll, beforeEach, describe, expect, it, vi, type Mock } from 'vitest';

import { ExportGateControl } from '@/components/ExportGateControl';
import { PanelGateReason } from '@/services/panel-gating';

import { initTestI18n, tt } from './helpers/i18n.mts';

beforeAll(async () => {
  await initTestI18n();
});

/** Every reason a locked export control can be constructed with. */
const LOCKED_REASONS = [
  {
    reason: PanelGateReason.ANONYMOUS,
    desc: 'components.exportGate.signedOutDesc',
    cta: 'premium.signIn',
    icon: 'lock',
  },
  {
    reason: PanelGateReason.FREE_TIER,
    desc: 'components.exportGate.upgradeDesc',
    cta: 'components.exportGate.upgradeCta',
    icon: 'upgrade',
  },
  {
    reason: PanelGateReason.PAYMENT_ON_HOLD,
    desc: 'components.billingState.onHoldDesc',
    cta: 'components.billingState.updatePayment',
    icon: 'lock',
  },
  {
    reason: PanelGateReason.RENEWAL_PENDING,
    desc: 'components.billingState.renewalPendingDesc',
    cta: 'components.billingState.refreshStatus',
    icon: 'lock',
  },
  {
    reason: PanelGateReason.RENEWAL_FAILED,
    desc: 'components.billingState.renewalFailedDesc',
    cta: 'components.billingState.manageBilling',
    icon: 'lock',
  },
  {
    reason: PanelGateReason.LAPSED,
    desc: 'components.billingState.lapsedDesc',
    cta: 'components.billingState.resubscribe',
    icon: 'upgrade',
  },
] as const;

interface Harness {
  control: ExportGateControl;
  el: HTMLElement;
  onOpen: Mock<() => void>;
  onAction: Mock<() => void>;
}

const live: ExportGateControl[] = [];

function mount(reason: PanelGateReason): Harness {
  const onOpen = vi.fn<() => void>();
  const onAction = vi.fn<() => void>();
  const control = new ExportGateControl({ reason, onOpen, onAction });
  live.push(control);
  const el = control.getElement();
  document.body.appendChild(el);
  return { control, el, onOpen, onAction };
}

const trigger = (el: HTMLElement) => el.querySelector<HTMLButtonElement>('.export-btn')!;
const menu = (el: HTMLElement) => el.querySelector<HTMLElement>('.export-menu')!;
const ctaOf = (el: HTMLElement) => el.querySelector<HTMLButtonElement>('.export-locked-cta')!;
const descOf = (el: HTMLElement) => el.querySelector<HTMLElement>('.export-locked-desc')!;
const iconOf = (el: HTMLElement) => el.querySelector<HTMLElement>('.export-locked-icon')!;

beforeEach(() => {
  document.body.replaceChildren();
});

afterEach(() => {
  // destroy() detaches the document-level click listener; leaking one across
  // tests would let an earlier control close a later one's menu.
  while (live.length) live.pop()?.destroy();
  document.body.replaceChildren();
});

describe('ExportGateControl β€” rendered locked state', () => {
  it.each(LOCKED_REASONS)(
    '$reason renders its own reason copy and CTA',
    ({ reason, desc, cta, icon }) => {
      const { el } = mount(reason);

      expect(descOf(el).textContent).toBe(tt(desc));
      expect(ctaOf(el).textContent).toBe(tt(cta));

      // lockSvg is a padlock (<rect> body), upgradeSvg is a circled arrow.
      const iconEl = iconOf(el);
      expect(iconEl.querySelector('svg')).not.toBeNull();
      expect(iconEl.querySelector('rect') !== null).toBe(icon === 'lock');
      expect(iconEl.querySelector('circle') !== null).toBe(icon === 'upgrade');
    },
  );

  it('gives each reason distinct, non-empty copy', () => {
    // Guards the failure mode this suite exists to catch: if the dictionary
    // silently stops resolving, every desc collapses to the same value and
    // the per-reason assertions above all still "pass".
    const descs = LOCKED_REASONS.map(({ reason }) => descOf(mount(reason).el).textContent);
    const ctas = LOCKED_REASONS.map(({ reason }) => ctaOf(mount(reason).el).textContent);

    for (const value of [...descs, ...ctas]) {
      expect(value).toBeTruthy();
      expect(value).not.toMatch(/^components\./);
    }
    expect(new Set(descs).size).toBe(LOCKED_REASONS.length);
  });

  it('renders exactly one locked row β€” never the format options a locked user must not get', () => {
    const { el } = mount(PanelGateReason.FREE_TIER);

    expect(menu(el).querySelectorAll('.export-locked-state')).toHaveLength(1);
    expect(menu(el).querySelectorAll('.export-option')).toHaveLength(0);
  });

  it('starts closed, with the menu hidden', () => {
    const { el } = mount(PanelGateReason.FREE_TIER);

    expect(menu(el).classList.contains('hidden')).toBe(true);
    expect(trigger(el).getAttribute('aria-expanded')).toBe('false');
  });
});

describe('ExportGateControl β€” ARIA contract', () => {
  it('announces the lock reason on the trigger rather than hiding the control', () => {
    const { el } = mount(PanelGateReason.PAYMENT_ON_HOLD);

    expect(trigger(el).getAttribute('aria-label')).toBe(
      tt('components.exportGate.lockedAriaLabel', {
        reason: tt('components.billingState.onHoldDesc'),
      }),
    );
    // The reason must be IN the label, not just adjacent to it.
    expect(trigger(el).getAttribute('aria-label')).toContain(
      tt('components.billingState.onHoldDesc'),
    );
  });

  it('marks the trigger as a dialog opener and tracks expansion', () => {
    const { el } = mount(PanelGateReason.FREE_TIER);

    expect(trigger(el).getAttribute('aria-haspopup')).toBe('dialog');
    expect(trigger(el).getAttribute('aria-expanded')).toBe('false');

    trigger(el).click();
    expect(trigger(el).getAttribute('aria-expanded')).toBe('true');

    trigger(el).click();
    expect(trigger(el).getAttribute('aria-expanded')).toBe('false');
  });

  it('re-labels the trigger when the reason changes mid-session', () => {
    const { control, el } = mount(PanelGateReason.FREE_TIER);

    control.update(PanelGateReason.LAPSED);

    expect(trigger(el).getAttribute('aria-label')).toContain(
      tt('components.billingState.lapsedDesc'),
    );
  });
});

describe('ExportGateControl β€” gate-hit metric', () => {
  it('does not fire on render: a control nobody reached for is not a gate hit', () => {
    const { onOpen } = mount(PanelGateReason.FREE_TIER);

    expect(onOpen).not.toHaveBeenCalled();
  });

  it('fires once per open, and never on close', () => {
    const { el, onOpen } = mount(PanelGateReason.FREE_TIER);

    trigger(el).click();
    expect(onOpen).toHaveBeenCalledTimes(1);

    trigger(el).click(); // close
    expect(onOpen).toHaveBeenCalledTimes(1);

    trigger(el).click(); // re-open
    expect(onOpen).toHaveBeenCalledTimes(2);
  });

  it('does not fire when a re-render happens while the menu is open', () => {
    const { control, el, onOpen } = mount(PanelGateReason.FREE_TIER);

    trigger(el).click();
    control.update(PanelGateReason.LAPSED);

    expect(onOpen).toHaveBeenCalledTimes(1);
  });
});

describe('ExportGateControl β€” CTA routing', () => {
  it('runs the injected action and closes the menu', () => {
    const { el, onAction } = mount(PanelGateReason.ANONYMOUS);
    trigger(el).click();

    ctaOf(el).click();

    expect(onAction).toHaveBeenCalledTimes(1);
    expect(menu(el).classList.contains('hidden')).toBe(true);
    expect(trigger(el).getAttribute('aria-expanded')).toBe('false');
  });

  it('routes through the CTA rebuilt by update(), not the reason captured at construction', () => {
    // The control is long-lived: billing state can flip under an open menu.
    // A CTA still wired to the old row would send an on-hold customer to the
    // pricing page instead of the billing portal.
    const { control, el, onAction } = mount(PanelGateReason.ANONYMOUS);
    control.update(PanelGateReason.RENEWAL_FAILED);
    trigger(el).click();

    expect(ctaOf(el).textContent).toBe(tt('components.billingState.manageBilling'));
    ctaOf(el).click();
    expect(onAction).toHaveBeenCalledTimes(1);
  });
});

describe('ExportGateControl β€” open/close behaviour', () => {
  it('closes on a click outside the control', () => {
    const { el } = mount(PanelGateReason.FREE_TIER);
    trigger(el).click();
    expect(menu(el).classList.contains('hidden')).toBe(false);

    const outside = document.createElement('button');
    document.body.appendChild(outside);
    outside.click();

    expect(menu(el).classList.contains('hidden')).toBe(true);
  });

  it('stays open when the click lands inside the menu', () => {
    const { el } = mount(PanelGateReason.FREE_TIER);
    trigger(el).click();

    descOf(el).click();

    expect(menu(el).classList.contains('hidden')).toBe(false);
  });

  it('update() to the same reason does not rebuild the menu', () => {
    const { control, el } = mount(PanelGateReason.FREE_TIER);
    const before = ctaOf(el);

    control.update(PanelGateReason.FREE_TIER);

    expect(ctaOf(el)).toBe(before);
  });

  it('update() to a new reason swaps the copy in place', () => {
    const { control, el } = mount(PanelGateReason.FREE_TIER);
    const before = ctaOf(el);

    control.update(PanelGateReason.PAYMENT_ON_HOLD);

    expect(ctaOf(el)).not.toBe(before);
    expect(descOf(el).textContent).toBe(tt('components.billingState.onHoldDesc'));
  });
});

describe('ExportGateControl β€” teardown', () => {
  it('removes its element and the document listener it registered', () => {
    type Listener = EventListenerOrEventListenerObject;
    const added: Array<[string, Listener]> = [];
    const removed: Array<[string, Listener]> = [];
    const realAdd = document.addEventListener.bind(document);
    const realRemove = document.removeEventListener.bind(document);

    const addSpy = vi.spyOn(document, 'addEventListener').mockImplementation(((
      type: string,
      handler: Listener,
      options?: boolean | AddEventListenerOptions,
    ) => {
      added.push([type, handler]);
      realAdd(type, handler, options);
    }) as typeof document.addEventListener);
    const removeSpy = vi.spyOn(document, 'removeEventListener').mockImplementation(((
      type: string,
      handler: Listener,
      options?: boolean | EventListenerOptions,
    ) => {
      removed.push([type, handler]);
      realRemove(type, handler, options);
    }) as typeof document.removeEventListener);

    const { control, el } = mount(PanelGateReason.FREE_TIER);
    const registered = added.filter(([type]) => type === 'click');
    expect(registered).toHaveLength(1);

    control.destroy();

    expect(el.isConnected).toBe(false);
    // Reference identity, not shape: removeEventListener with a re-bound copy
    // of the handler silently leaves the original attached, which is the
    // classic listener leak this assertion exists to catch.
    const removedClickHandlers = removed.filter(([type]) => type === 'click').map(([, fn]) => fn);
    expect(removedClickHandlers).toContain(registered[0]![1]);

    addSpy.mockRestore();
    removeSpy.mockRestore();
  });

  it('a destroyed control no longer reacts to outside clicks', () => {
    const { control, el } = mount(PanelGateReason.FREE_TIER);
    trigger(el).click();

    control.destroy();
    // Re-attach the (now inert) element so contains() still resolves β€” this
    // isolates "the listener is gone" from "the node left the tree".
    document.body.appendChild(el);

    const outside = document.createElement('button');
    document.body.appendChild(outside);
    outside.click();

    // Still open: the listener is gone, so nothing closed it.
    expect(menu(el).classList.contains('hidden')).toBe(false);
  });
});