Add component tree renderer, hooks, and tests

Co-authored-by: johndoe6345789 <224850594+johndoe6345789@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-01-08 16:36:32 +00:00
parent 3d502d8ab5
commit a854d3a185
9 changed files with 1165 additions and 0 deletions

7
src/hooks/index.ts Normal file
View File

@@ -0,0 +1,7 @@
/**
* Hooks exports
*/
export { useApiCall } from './useApiCall';
export { useTableData } from './useTableData';
export { useTables } from './useTables';
export { useColumnManagement } from './useColumnManagement';

62
src/hooks/useApiCall.ts Normal file
View File

@@ -0,0 +1,62 @@
/**
* Generic hook for API calls with loading and error states
*/
import { useState, useCallback } from 'react';
type ApiCallState<T> = {
data: T | null;
loading: boolean;
error: string | null;
};
type ApiCallOptions = {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
headers?: HeadersInit;
body?: any;
};
export function useApiCall<T = any>() {
const [state, setState] = useState<ApiCallState<T>>({
data: null,
loading: false,
error: null,
});
const execute = useCallback(async (url: string, options: ApiCallOptions = {}) => {
setState({ data: null, loading: true, error: null });
try {
const response = await fetch(url, {
method: options.method || 'GET',
headers: {
'Content-Type': 'application/json',
...options.headers,
},
body: options.body ? JSON.stringify(options.body) : undefined,
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || `Request failed with status ${response.status}`);
}
setState({ data, loading: false, error: null });
return data;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'An unknown error occurred';
setState({ data: null, loading: false, error: errorMessage });
throw err;
}
}, []);
const reset = useCallback(() => {
setState({ data: null, loading: false, error: null });
}, []);
return {
...state,
execute,
reset,
};
}

View File

@@ -0,0 +1,104 @@
/**
* Hook for column management operations
*/
import { useState, useCallback } from 'react';
export function useColumnManagement() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const addColumn = useCallback(async (tableName: string, columnData: any) => {
setLoading(true);
setError(null);
try {
const response = await fetch('/api/admin/column-manage', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ tableName, ...columnData }),
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || 'Failed to add column');
}
return result;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to add column';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
}, []);
const modifyColumn = useCallback(async (tableName: string, columnData: any) => {
setLoading(true);
setError(null);
try {
const response = await fetch('/api/admin/column-manage', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ tableName, ...columnData }),
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || 'Failed to modify column');
}
return result;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to modify column';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
}, []);
const dropColumn = useCallback(async (tableName: string, columnData: any) => {
setLoading(true);
setError(null);
try {
const response = await fetch('/api/admin/column-manage', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ tableName, ...columnData }),
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || 'Failed to drop column');
}
return result;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to drop column';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
}, []);
return {
loading,
error,
addColumn,
modifyColumn,
dropColumn,
};
}

38
src/hooks/useTableData.ts Normal file
View File

@@ -0,0 +1,38 @@
/**
* Hook for fetching and managing table data
*/
import { useState, useCallback, useEffect } from 'react';
import { useApiCall } from './useApiCall';
export function useTableData(tableName?: string) {
const { data, loading, error, execute } = useApiCall();
const [queryResult, setQueryResult] = useState<any>(null);
const fetchTableData = useCallback(async (table: string) => {
try {
const result = await execute('/api/admin/table-data', {
method: 'POST',
body: { tableName: table },
});
setQueryResult(result);
return result;
} catch (err) {
console.error('Failed to fetch table data:', err);
throw err;
}
}, [execute]);
useEffect(() => {
if (tableName) {
fetchTableData(tableName);
}
}, [tableName, fetchTableData]);
return {
data: queryResult,
loading,
error,
refresh: () => tableName && fetchTableData(tableName),
fetchTableData,
};
}

110
src/hooks/useTables.ts Normal file
View File

@@ -0,0 +1,110 @@
/**
* Hook for managing database tables
*/
import { useState, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
export function useTables() {
const router = useRouter();
const [tables, setTables] = useState<Array<{ table_name: string }>>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchTables = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await fetch('/api/admin/tables');
if (!response.ok) {
if (response.status === 401) {
router.push('/admin/login');
return;
}
throw new Error('Failed to fetch tables');
}
const data = await response.json();
setTables(data.tables);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch tables';
setError(errorMessage);
} finally {
setLoading(false);
}
}, [router]);
const createTable = useCallback(async (tableName: string, columns: any[]) => {
setLoading(true);
setError(null);
try {
const response = await fetch('/api/admin/table-manage', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ tableName, columns }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to create table');
}
await fetchTables();
return data;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to create table';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
}, [fetchTables]);
const dropTable = useCallback(async (tableName: string) => {
setLoading(true);
setError(null);
try {
const response = await fetch('/api/admin/table-manage', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ tableName }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to drop table');
}
await fetchTables();
return data;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to drop table';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
}, [fetchTables]);
useEffect(() => {
fetchTables();
}, [fetchTables]);
return {
tables,
loading,
error,
fetchTables,
createTable,
dropTable,
};
}

View File

@@ -0,0 +1,339 @@
/**
* Component Tree Renderer
* Dynamically renders React component trees from JSON configuration
*/
import React from 'react';
import type { ComponentNode } from './featureConfig';
// Import all atomic components
import {
Alert,
AppBar,
Box,
Button,
Card,
CardContent,
Checkbox,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Drawer,
FormControl,
Grid,
IconButton,
InputLabel,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
MenuItem,
Paper,
Select,
Tab,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Tabs,
TextField,
Toolbar,
Typography,
Chip,
Accordion,
AccordionSummary,
AccordionDetails,
Pagination,
} from '@mui/material';
// Import Material Icons
import * as Icons from '@mui/icons-material';
// Component registry - maps component names to actual components
const componentRegistry: Record<string, React.ComponentType<any>> = {
Alert,
AppBar,
Box,
Button,
Card,
CardContent,
Checkbox,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Drawer,
FormControl,
Grid,
IconButton,
InputLabel,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
MenuItem,
Paper,
Select,
Tab,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Tabs,
TextField,
Toolbar,
Typography,
Chip,
Accordion,
AccordionSummary,
AccordionDetails,
Pagination,
};
type RenderContext = {
data?: Record<string, any>;
actions?: Record<string, (...args: any[]) => any>;
state?: Record<string, any>;
};
/**
* Interpolate template strings like {{variable}} with actual values
*/
function interpolateValue(value: any, context: RenderContext): any {
if (typeof value !== 'string') {
return value;
}
// Check if it's a template string
const templateMatch = value.match(/^{{(.+)}}$/);
if (templateMatch) {
const path = templateMatch[1].trim();
return getNestedValue(context, path);
}
// Replace inline templates
return value.replace(/{{(.+?)}}/g, (_, path) => {
const val = getNestedValue(context, path.trim());
return val !== undefined ? String(val) : '';
});
}
/**
* Get nested value from object using dot notation
*/
function getNestedValue(obj: any, path: string): any {
return path.split('.').reduce((current, key) => {
// Handle array access like array[0]
const arrayMatch = key.match(/(.+)\[(\d+)\]/);
if (arrayMatch) {
const [, arrayKey, index] = arrayMatch;
return current?.[arrayKey]?.[Number.parseInt(index, 10)];
}
return current?.[key];
}, obj);
}
/**
* Evaluate condition expressions
*/
function evaluateCondition(condition: string, context: RenderContext): boolean {
try {
// Simple condition evaluation - can be extended
const value = getNestedValue(context, condition);
// Handle boolean checks
if (typeof value === 'boolean') {
return value;
}
// Handle truthy checks
return Boolean(value);
} catch {
return false;
}
}
/**
* Process props and replace template variables
*/
function processProps(props: Record<string, any> = {}, context: RenderContext): Record<string, any> {
const processed: Record<string, any> = {};
for (const [key, value] of Object.entries(props)) {
// Handle special props
if (key === 'onClick' || key === 'onChange' || key === 'onClose') {
// Map to action functions
if (typeof value === 'string') {
processed[key] = context.actions?.[value];
} else {
processed[key] = value;
}
} else if (key === 'startIcon' || key === 'endIcon' || key === 'icon') {
// Handle icon props
if (typeof value === 'string') {
const iconValue = interpolateValue(value, context);
const IconComponent = (Icons as any)[iconValue];
if (IconComponent) {
processed[key] = React.createElement(IconComponent);
}
}
} else if (typeof value === 'object' && !Array.isArray(value) && value !== null) {
// Recursively process nested objects
processed[key] = processProps(value, context);
} else {
// Interpolate template strings
processed[key] = interpolateValue(value, context);
}
}
return processed;
}
/**
* Render Icon component
*/
function renderIcon(iconName: string, props?: Record<string, any>): React.ReactElement | null {
const IconComponent = (Icons as any)[iconName];
if (!IconComponent) {
return null;
}
return React.createElement(IconComponent, props);
}
/**
* Render a single component node
*/
export function renderComponentNode(
node: ComponentNode,
context: RenderContext,
key?: string | number,
): React.ReactElement | null {
// Check condition if present
if (node.condition && !evaluateCondition(node.condition, context)) {
return null;
}
// Handle forEach loops
if (node.forEach) {
const dataArray = getNestedValue(context, node.forEach);
if (!Array.isArray(dataArray)) {
console.warn(`forEach data is not an array: ${node.forEach}`);
return null;
}
return (
<React.Fragment key={key}>
{dataArray.map((item, index) => {
// Create context for this iteration
const itemContext: RenderContext = {
...context,
data: {
...context.data,
item,
index,
},
};
// Render children with item context
if (node.children) {
return node.children.map((child, childIndex) =>
renderComponentNode(child, itemContext, `${key}-${index}-${childIndex}`)
);
}
return null;
})}
</React.Fragment>
);
}
// Get component from registry
const Component = componentRegistry[node.component];
if (!Component) {
console.warn(`Component not found in registry: ${node.component}`);
return null;
}
// Process props
const props = processProps(node.props, context);
// Handle special text prop for Typography and similar components
let children: React.ReactNode = null;
if (props.text) {
children = props.text;
delete props.text;
}
// Render children
if (node.children && node.children.length > 0) {
children = node.children.map((child, index) =>
renderComponentNode(child, context, `${key}-child-${index}`)
);
}
// Handle Icon component specially
if (node.component === 'Icon' && props.name) {
return renderIcon(props.name, { ...props, key });
}
return React.createElement(Component, { ...props, key }, children);
}
/**
* Main component tree renderer
*/
export function ComponentTreeRenderer({
tree,
context,
}: {
tree: ComponentNode;
context: RenderContext;
}): React.ReactElement | null {
return renderComponentNode(tree, context, 'root');
}
/**
* Hook to use component tree with state management
*/
export function useComponentTree(
tree: ComponentNode,
initialData?: Record<string, any>,
actions?: Record<string, (...args: any[]) => any>,
) {
const [data, setData] = React.useState(initialData || {});
const [state, setState] = React.useState<Record<string, any>>({});
const context: RenderContext = React.useMemo(
() => ({
data,
actions,
state,
}),
[data, actions, state],
);
const updateData = React.useCallback((newData: Record<string, any>) => {
setData(prev => ({ ...prev, ...newData }));
}, []);
const updateState = React.useCallback((newState: Record<string, any>) => {
setState(prev => ({ ...prev, ...newState }));
}, []);
return {
render: () => <ComponentTreeRenderer tree={tree} context={context} />,
data,
state,
updateData,
updateState,
};
}

169
tests/e2e/playbooks.spec.ts Normal file
View File

@@ -0,0 +1,169 @@
/**
* Playwright E2E tests using playbooks from features.json
*/
import { test, expect } from '@playwright/test';
import { getAllPlaywrightPlaybooks, type PlaywrightPlaybook, type PlaywrightStep } from '@/utils/featureConfig';
// Execute a single playbook step
async function executeStep(page: any, step: PlaywrightStep, variables: Record<string, string> = {}) {
// Replace variables in step values
const replaceVars = (value?: string) => {
if (!value) return value;
return Object.entries(variables).reduce((acc, [key, val]) => {
return acc.replace(new RegExp(`{{${key}}}`, 'g'), val);
}, value);
};
switch (step.action) {
case 'goto':
await page.goto(replaceVars(step.url));
break;
case 'click':
await page.click(replaceVars(step.selector));
break;
case 'fill':
await page.fill(replaceVars(step.selector), replaceVars(step.value) || '');
break;
case 'select':
await page.selectOption(replaceVars(step.selector), replaceVars(step.value) || '');
break;
case 'wait':
await page.waitForTimeout(step.timeout || 1000);
break;
case 'expect':
if (step.text === 'visible' && step.selector) {
await expect(page.locator(replaceVars(step.selector))).toBeVisible();
} else if (step.text === 'redirected' && step.url) {
await expect(page).toHaveURL(replaceVars(step.url));
} else if (step.text && step.selector) {
await expect(page.locator(replaceVars(step.selector))).toHaveText(replaceVars(step.text) || '');
} else if (step.text === '401') {
// Check for 401 status
const response = await page.waitForResponse((resp: any) => resp.status() === 401);
expect(response.status()).toBe(401);
}
break;
case 'screenshot':
if (step.selector) {
await page.locator(replaceVars(step.selector)).screenshot();
} else {
await page.screenshot();
}
break;
default:
console.warn(`Unknown action: ${step.action}`);
}
}
// Execute a full playbook
async function executePlaybook(page: any, playbook: PlaywrightPlaybook, variables: Record<string, string> = {}) {
for (const step of playbook.steps) {
await executeStep(page, step, variables);
}
}
// Execute playbook cleanup steps
async function cleanupPlaybook(page: any, playbook: PlaywrightPlaybook, variables: Record<string, string> = {}) {
if (playbook.cleanup) {
for (const step of playbook.cleanup) {
try {
await executeStep(page, step, variables);
} catch (err) {
console.warn('Cleanup step failed:', err);
}
}
}
}
// Load all playbooks
const playbooks = getAllPlaywrightPlaybooks();
// Test: API Security Check
test.describe('API Security', () => {
const playbook = playbooks.securityCheck;
if (playbook) {
test(playbook.name, async ({ page }) => {
await executePlaybook(page, playbook);
});
}
});
// Test: Query Builder
test.describe('Query Builder', () => {
const playbook = playbooks.queryBuilder;
if (playbook) {
test.skip(playbook.name, async ({ page }) => {
// This test requires authentication, skipping for now
const variables = {
tableName: 'users',
columnName: 'id',
};
await executePlaybook(page, playbook, variables);
});
}
});
// Test: Create Table
test.describe('Table Management', () => {
const playbook = playbooks.createTable;
if (playbook) {
test.skip(playbook.name, async ({ page }) => {
// This test requires authentication, skipping for now
const variables = {
tableName: 'test_table_' + Date.now(),
};
await executePlaybook(page, playbook, variables);
// Cleanup
await cleanupPlaybook(page, playbook, variables);
});
}
});
// Test: Add Column
test.describe('Column Management', () => {
const playbook = playbooks.addColumn;
if (playbook) {
test.skip(playbook.name, async ({ page }) => {
// This test requires authentication and an existing table, skipping for now
const variables = {
tableName: 'users',
columnName: 'test_column_' + Date.now(),
dataType: 'VARCHAR',
};
await executePlaybook(page, playbook, variables);
});
}
});
// Test: Create Index
test.describe('Index Management', () => {
const playbook = playbooks.createIndex;
if (playbook) {
test.skip(playbook.name, async ({ page }) => {
// This test requires authentication and an existing table, skipping for now
const variables = {
tableName: 'users',
columnName: 'id',
indexName: 'idx_test_' + Date.now(),
};
await executePlaybook(page, playbook, variables);
});
}
});

View File

@@ -0,0 +1,195 @@
/**
* Unit tests for component tree renderer
*/
import { describe, it, expect, vi } from 'vitest';
import { renderComponentNode } from '@/utils/componentTreeRenderer';
import type { ComponentNode } from '@/utils/featureConfig';
describe('componentTreeRenderer', () => {
it('should render a simple Box component', () => {
const node: ComponentNode = {
component: 'Box',
props: {
sx: { p: 2 },
},
};
const context = { data: {}, actions: {}, state: {} };
const result = renderComponentNode(node, context);
expect(result).toBeTruthy();
expect(result?.type.toString()).toContain('Box');
});
it('should render Typography with text prop', () => {
const node: ComponentNode = {
component: 'Typography',
props: {
variant: 'h5',
text: 'Hello World',
},
};
const context = { data: {}, actions: {}, state: {} };
const result = renderComponentNode(node, context);
expect(result).toBeTruthy();
expect(result?.props.variant).toBe('h5');
});
it('should interpolate template variables', () => {
const node: ComponentNode = {
component: 'Typography',
props: {
text: '{{data.message}}',
},
};
const context = {
data: { message: 'Test Message' },
actions: {},
state: {},
};
const result = renderComponentNode(node, context);
expect(result).toBeTruthy();
});
it('should render children', () => {
const node: ComponentNode = {
component: 'Box',
children: [
{
component: 'Typography',
props: {
text: 'Child 1',
},
},
{
component: 'Typography',
props: {
text: 'Child 2',
},
},
],
};
const context = { data: {}, actions: {}, state: {} };
const result = renderComponentNode(node, context);
expect(result).toBeTruthy();
expect(Array.isArray(result?.props.children)).toBe(true);
});
it('should handle condition and not render when false', () => {
const node: ComponentNode = {
component: 'Box',
condition: 'data.show',
props: {
sx: { p: 2 },
},
};
const context = {
data: { show: false },
actions: {},
state: {},
};
const result = renderComponentNode(node, context);
expect(result).toBeNull();
});
it('should handle condition and render when true', () => {
const node: ComponentNode = {
component: 'Box',
condition: 'data.show',
props: {
sx: { p: 2 },
},
};
const context = {
data: { show: true },
actions: {},
state: {},
};
const result = renderComponentNode(node, context);
expect(result).toBeTruthy();
});
it('should handle forEach loops', () => {
const node: ComponentNode = {
component: 'Box',
forEach: 'data.items',
children: [
{
component: 'Typography',
props: {
text: '{{item.name}}',
},
},
],
};
const context = {
data: {
items: [
{ name: 'Item 1' },
{ name: 'Item 2' },
{ name: 'Item 3' },
],
},
actions: {},
state: {},
};
const result = renderComponentNode(node, context);
expect(result).toBeTruthy();
});
it('should map onClick to action function', () => {
const mockAction = vi.fn();
const node: ComponentNode = {
component: 'Button',
props: {
text: 'Click Me',
onClick: 'handleClick',
},
};
const context = {
data: {},
actions: { handleClick: mockAction },
state: {},
};
const result = renderComponentNode(node, context);
expect(result).toBeTruthy();
expect(result?.props.onClick).toBe(mockAction);
});
it('should handle nested template interpolation', () => {
const node: ComponentNode = {
component: 'Typography',
props: {
text: 'User: {{data.user.name}}, Age: {{data.user.age}}',
},
};
const context = {
data: {
user: {
name: 'John Doe',
age: 30,
},
},
actions: {},
state: {},
};
const result = renderComponentNode(node, context);
expect(result).toBeTruthy();
});
});

View File

@@ -0,0 +1,141 @@
/**
* Unit tests for useApiCall hook
*/
import { renderHook, act, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useApiCall } from '@/hooks/useApiCall';
// Mock fetch
global.fetch = vi.fn();
describe('useApiCall', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should initialize with correct default state', () => {
const { result } = renderHook(() => useApiCall());
expect(result.current.data).toBeNull();
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeNull();
});
it('should handle successful API call', async () => {
const mockData = { message: 'Success', result: 42 };
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => mockData,
});
const { result } = renderHook(() => useApiCall());
await act(async () => {
await result.current.execute('/api/test');
});
expect(result.current.data).toEqual(mockData);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeNull();
});
it('should handle API error', async () => {
const errorMessage = 'Request failed';
(global.fetch as any).mockResolvedValueOnce({
ok: false,
json: async () => ({ error: errorMessage }),
});
const { result } = renderHook(() => useApiCall());
await act(async () => {
try {
await result.current.execute('/api/test');
} catch (err) {
// Expected to throw
}
});
expect(result.current.data).toBeNull();
expect(result.current.loading).toBe(false);
expect(result.current.error).toBe(errorMessage);
});
it('should set loading state during API call', async () => {
(global.fetch as any).mockImplementation(() =>
new Promise(resolve => setTimeout(() => resolve({
ok: true,
json: async () => ({ data: 'test' }),
}), 100))
);
const { result } = renderHook(() => useApiCall());
act(() => {
result.current.execute('/api/test');
});
expect(result.current.loading).toBe(true);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
});
it('should handle POST request with body', async () => {
const requestBody = { name: 'test', value: 123 };
const mockData = { success: true };
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => mockData,
});
const { result } = renderHook(() => useApiCall());
await act(async () => {
await result.current.execute('/api/test', {
method: 'POST',
body: requestBody,
});
});
expect(global.fetch).toHaveBeenCalledWith('/api/test', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});
expect(result.current.data).toEqual(mockData);
});
it('should reset state', async () => {
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => ({ data: 'test' }),
});
const { result } = renderHook(() => useApiCall());
await act(async () => {
await result.current.execute('/api/test');
});
expect(result.current.data).not.toBeNull();
act(() => {
result.current.reset();
});
expect(result.current.data).toBeNull();
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeNull();
});
});