Skip to content

Commit 9f49988

Browse files
os-zhuangCopilot
andcommitted
test(plugin-detail): regression suite for record:alert renderer
Covers severity → icon mapping, accessibility roles, CTA dispatch via useActionEngine, predicate gating, loading state, per-record dismiss persistence with localStorage scoping, and the flat-properties legacy schema fallback. 14 tests, all green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 10625d6 commit 9f49988

1 file changed

Lines changed: 281 additions & 0 deletions

File tree

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* Regression tests for the `record:alert` renderer. We lock in the
6+
* contract that the renderer is a thin, predictable wrapper over the
7+
* shared ActionEngine: visibility predicates gate the banner, the CTA
8+
* is resolved from object metadata and dispatched through
9+
* `useActionEngine`, dismiss state persists per (object, record, key)
10+
* in localStorage, and severity → tailwind classes never silently
11+
* swap default icons.
12+
*
13+
* The renderer relies on several `@object-ui/react` hooks that normally
14+
* require a live provider tree (RecordContext, ActionProvider,
15+
* MetadataProvider). We stub them surgically so the tests stay focused
16+
* on renderer behaviour, not on provider wiring — the same approach
17+
* the live render in `RecordDetailView` relies on at runtime.
18+
*/
19+
20+
import * as React from 'react';
21+
import { describe, it, expect, vi, beforeEach } from 'vitest';
22+
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
23+
24+
const mockExecuteAction = vi.fn(async () => ({ success: true }));
25+
26+
const stub = {
27+
recordCtx: undefined as any,
28+
metadataItem: undefined as any,
29+
predicate: { input: undefined as any, passes: true },
30+
};
31+
32+
vi.mock('@object-ui/react', () => ({
33+
useRecordContext: () => stub.recordCtx,
34+
useMetadataItem: (_type: string, _name: string | null) => ({ item: stub.metadataItem }),
35+
useCondition: (_input: unknown, _scope: unknown) => stub.predicate.passes,
36+
toPredicateInput: (visible: unknown) => {
37+
stub.predicate.input = visible;
38+
return visible;
39+
},
40+
useActionEngine: (_opts: unknown) => ({
41+
executeAction: mockExecuteAction,
42+
getActionsForLocation: () => [],
43+
getBulkActions: () => [],
44+
handleShortcut: async () => null,
45+
engine: {} as any,
46+
}),
47+
}));
48+
49+
vi.mock('@object-ui/components', () => ({
50+
Alert: ({ children, className, role, ...rest }: any) => (
51+
<div data-testid="alert" role={role} className={className} {...rest}>
52+
{children}
53+
</div>
54+
),
55+
AlertTitle: ({ children }: any) => <h5 data-testid="alert-title">{children}</h5>,
56+
AlertDescription: ({ children }: any) => <div data-testid="alert-body">{children}</div>,
57+
Button: ({ children, onClick, variant, ...rest }: any) => (
58+
<button data-testid="alert-cta" data-variant={variant} onClick={onClick} {...rest}>
59+
{children}
60+
</button>
61+
),
62+
cn: (...args: any[]) => args.filter(Boolean).join(' '),
63+
LazyIcon: ({ name, className }: any) => (
64+
<svg data-testid="alert-icon" data-name={name} className={className} />
65+
),
66+
}));
67+
68+
import { RecordAlertRenderer } from '../record-alert';
69+
70+
const RECORD_DEFAULTS = {
71+
recordCtx: {
72+
data: { id: 'rec_1', name: 'Acme', email_verified: false },
73+
objectName: 'sys_user',
74+
recordId: 'rec_1',
75+
},
76+
metadataItem: {
77+
actions: [
78+
{
79+
name: 'resend_verification_email',
80+
label: 'Resend Verification Email',
81+
type: 'api',
82+
target: '/api/v1/auth/send-verification-email',
83+
successMessage: 'Verification email sent — check your inbox.',
84+
},
85+
],
86+
},
87+
};
88+
89+
beforeEach(() => {
90+
mockExecuteAction.mockClear();
91+
stub.recordCtx = RECORD_DEFAULTS.recordCtx;
92+
stub.metadataItem = RECORD_DEFAULTS.metadataItem;
93+
stub.predicate = { input: undefined, passes: true };
94+
// happy-dom doesn't always ship a working localStorage; install a
95+
// minimal Storage shim so the renderer's persistence path is
96+
// exercised the same way as in the browser.
97+
const store: Record<string, string> = {};
98+
const shim = {
99+
getItem: (k: string) => (k in store ? store[k] : null),
100+
setItem: (k: string, v: string) => { store[k] = String(v); },
101+
removeItem: (k: string) => { delete store[k]; },
102+
clear: () => { for (const k of Object.keys(store)) delete store[k]; },
103+
key: (i: number) => Object.keys(store)[i] ?? null,
104+
get length() { return Object.keys(store).length; },
105+
} as Storage;
106+
Object.defineProperty(window, 'localStorage', { value: shim, configurable: true, writable: true });
107+
cleanup();
108+
});
109+
110+
describe('RecordAlertRenderer', () => {
111+
it('renders title + body + default info icon when severity is omitted', () => {
112+
render(
113+
<RecordAlertRenderer
114+
schema={{ properties: { title: 'Heads up', body: 'Pay attention.' } }}
115+
/>,
116+
);
117+
expect(screen.getByTestId('alert-title').textContent).toBe('Heads up');
118+
expect(screen.getByTestId('alert-body').textContent).toContain('Pay attention.');
119+
expect(screen.getByTestId('alert-icon').getAttribute('data-name')).toBe('Info');
120+
});
121+
122+
it('applies warning severity classes + warning icon', () => {
123+
render(<RecordAlertRenderer schema={{ properties: { severity: 'warning', title: 'Verify' } }} />);
124+
const alert = screen.getByTestId('alert');
125+
expect(alert.className).toMatch(/amber/);
126+
expect(screen.getByTestId('alert-icon').getAttribute('data-name')).toBe('AlertTriangle');
127+
});
128+
129+
it('respects an explicit icon override', () => {
130+
render(
131+
<RecordAlertRenderer schema={{ properties: { severity: 'warning', icon: 'mail', title: 'X' } }} />,
132+
);
133+
expect(screen.getByTestId('alert-icon').getAttribute('data-name')).toBe('mail');
134+
});
135+
136+
it('sets role=alert + aria-live=assertive for error severity', () => {
137+
render(<RecordAlertRenderer schema={{ properties: { severity: 'error', title: 'Oops' } }} />);
138+
const alert = screen.getByTestId('alert');
139+
expect(alert.getAttribute('role')).toBe('alert');
140+
expect(alert.getAttribute('aria-live')).toBe('assertive');
141+
});
142+
143+
it('renders the CTA when action.actionName resolves to an object action', () => {
144+
render(
145+
<RecordAlertRenderer
146+
schema={{
147+
properties: {
148+
title: 'Email unverified',
149+
action: { actionName: 'resend_verification_email' },
150+
},
151+
}}
152+
/>,
153+
);
154+
expect(screen.getByTestId('alert-cta').textContent).toBe('Resend Verification Email');
155+
});
156+
157+
it('CTA label override takes precedence over action.label', () => {
158+
render(
159+
<RecordAlertRenderer
160+
schema={{
161+
properties: {
162+
title: 'X',
163+
action: { actionName: 'resend_verification_email', label: 'Send again' },
164+
},
165+
}}
166+
/>,
167+
);
168+
expect(screen.getByTestId('alert-cta').textContent).toBe('Send again');
169+
});
170+
171+
it('does NOT render a CTA when the action name fails to resolve in metadata', () => {
172+
render(
173+
<RecordAlertRenderer
174+
schema={{
175+
properties: { title: 'X', action: { actionName: 'no_such_action' } },
176+
}}
177+
/>,
178+
);
179+
expect(screen.queryByTestId('alert-cta')).toBeNull();
180+
});
181+
182+
it('clicking the CTA dispatches through useActionEngine by action name', () => {
183+
render(
184+
<RecordAlertRenderer
185+
schema={{
186+
properties: { title: 'X', action: { actionName: 'resend_verification_email' } },
187+
}}
188+
/>,
189+
);
190+
fireEvent.click(screen.getByTestId('alert-cta'));
191+
expect(mockExecuteAction).toHaveBeenCalledTimes(1);
192+
expect(mockExecuteAction).toHaveBeenCalledWith('resend_verification_email');
193+
});
194+
195+
it('hides while the record is still loading (empty record)', () => {
196+
stub.recordCtx = { data: {}, objectName: 'sys_user', recordId: 'rec_1' };
197+
const { container } = render(<RecordAlertRenderer schema={{ properties: { title: 'X' } }} />);
198+
expect(container.firstChild).toBeNull();
199+
});
200+
201+
it('hides when the visibility predicate evaluates to false', () => {
202+
stub.predicate = { input: 'record.email_verified == false', passes: false };
203+
const { container } = render(
204+
<RecordAlertRenderer
205+
schema={{ properties: { title: 'X', visible: 'record.email_verified == false' } }}
206+
/>,
207+
);
208+
expect(container.firstChild).toBeNull();
209+
expect(stub.predicate.input).toBe('record.email_verified == false');
210+
});
211+
212+
it('renders unconditionally when no `visible` predicate is supplied', () => {
213+
render(<RecordAlertRenderer schema={{ properties: { title: 'Always visible' } }} />);
214+
expect(screen.getByTestId('alert')).toBeTruthy();
215+
});
216+
217+
it('dismissible: click hides + localStorage persists across remounts (scoped by object+id+key)', () => {
218+
const { container } = render(
219+
<RecordAlertRenderer
220+
schema={{
221+
properties: {
222+
title: 'Verify',
223+
body: 'Verify your email',
224+
dismissible: true,
225+
dismissKey: 'unverified_email',
226+
},
227+
}}
228+
/>,
229+
);
230+
fireEvent.click(screen.getByRole('button', { name: /dismiss/i }));
231+
expect(container.firstChild).toBeNull();
232+
233+
expect(window.localStorage.getItem('os.record-alert:sys_user:rec_1:unverified_email')).toBe('1');
234+
235+
// Fresh mount of the same alert against the same record → reads
236+
// localStorage and stays hidden without further interaction.
237+
cleanup();
238+
const fresh = render(
239+
<RecordAlertRenderer
240+
schema={{
241+
properties: {
242+
title: 'Verify',
243+
body: 'Verify your email',
244+
dismissible: true,
245+
dismissKey: 'unverified_email',
246+
},
247+
}}
248+
/>,
249+
);
250+
expect(fresh.container.firstChild).toBeNull();
251+
});
252+
253+
it('dismiss is scoped per record — dismissing record A does NOT silence record B', () => {
254+
render(
255+
<RecordAlertRenderer
256+
schema={{ properties: { title: 'X', dismissible: true, dismissKey: 'k' } }}
257+
/>,
258+
);
259+
fireEvent.click(screen.getByRole('button', { name: /dismiss/i }));
260+
cleanup();
261+
262+
// Different record id → alert should render fresh.
263+
stub.recordCtx = {
264+
data: { id: 'rec_2', email_verified: false },
265+
objectName: 'sys_user',
266+
recordId: 'rec_2',
267+
};
268+
const other = render(
269+
<RecordAlertRenderer
270+
schema={{ properties: { title: 'X', dismissible: true, dismissKey: 'k' } }}
271+
/>,
272+
);
273+
expect(other.getByTestId('alert')).toBeTruthy();
274+
});
275+
276+
it('flat properties (legacy shape) are read as a fallback to nested .properties', () => {
277+
render(<RecordAlertRenderer schema={{ severity: 'success', title: 'Saved' } as any} />);
278+
expect(screen.getByTestId('alert-title').textContent).toBe('Saved');
279+
expect(screen.getByTestId('alert-icon').getAttribute('data-name')).toBe('CheckCircle2');
280+
});
281+
});

0 commit comments

Comments
 (0)