mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 10:12:34 +00:00
Add snippet-library search, sort, empty states, storage monitor (M6, §02/§09D)
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* StorageMonitor render tests.
|
||||
*
|
||||
* Strategy: mock readStorageEstimate (the async browser adapter) so tests run
|
||||
* in happy-dom without a real Storage Manager, then assert on the rendered DOM
|
||||
* — text content, ARIA attributes, and CSS-module class presence for each level.
|
||||
*
|
||||
* CSS Modules are identity-mapped in Vitest's happy-dom environment (class names
|
||||
* come through as-is), so we match on the raw class name tokens from the .module.css.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import type { StorageSummary } from '@core/storage-estimate';
|
||||
import StorageMonitor from './StorageMonitor';
|
||||
|
||||
// Mock the browser adapter — tests must not touch navigator.storage.
|
||||
vi.mock('../infrastructure/storage-estimate', () => ({
|
||||
readStorageEstimate: vi.fn(),
|
||||
}));
|
||||
import { readStorageEstimate } from '../infrastructure/storage-estimate';
|
||||
const mockEstimate = vi.mocked(readStorageEstimate);
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
// ---- helpers ----------------------------------------------------------------
|
||||
|
||||
/** Build a complete StorageSummary for test fixtures. */
|
||||
function makeSummary(
|
||||
overrides: Partial<StorageSummary> &
|
||||
Pick<StorageSummary, 'usedBytes' | 'quotaBytes' | 'fraction' | 'level'>,
|
||||
): StorageSummary {
|
||||
return {
|
||||
available: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- test setup -------------------------------------------------------------
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
/** Render the component and wait for the async estimate to resolve. */
|
||||
async function renderMonitor() {
|
||||
await act(async () => {
|
||||
root.render(<StorageMonitor />);
|
||||
await Promise.resolve(); // flush the async readStorageEstimate() resolution
|
||||
});
|
||||
}
|
||||
|
||||
// ---- tests ------------------------------------------------------------------
|
||||
|
||||
describe('StorageMonitor', () => {
|
||||
describe('when the estimate is available and at ok level', () => {
|
||||
const okSummary = makeSummary({
|
||||
usedBytes: 1.2 * 1024 * 1024, // ~1.2 MB
|
||||
quotaBytes: 5 * 1024 * 1024, // 5 MB
|
||||
fraction: 0.24,
|
||||
level: 'ok',
|
||||
});
|
||||
|
||||
test('renders usage text: used of quota', async () => {
|
||||
mockEstimate.mockResolvedValue(okSummary);
|
||||
await renderMonitor();
|
||||
const label = container.querySelector('[aria-label="Storage usage"]');
|
||||
expect(label).not.toBeNull();
|
||||
// The humanizeBytes output for 1.2 MB appears somewhere in the label area.
|
||||
expect(label!.textContent).toContain('MB');
|
||||
expect(label!.textContent).toContain(' of ');
|
||||
});
|
||||
|
||||
test('renders a meter element with ARIA attributes', async () => {
|
||||
mockEstimate.mockResolvedValue(okSummary);
|
||||
await renderMonitor();
|
||||
const meter = container.querySelector('[role="meter"]');
|
||||
expect(meter).not.toBeNull();
|
||||
expect(meter!.getAttribute('aria-valuemin')).toBe('0');
|
||||
expect(meter!.getAttribute('aria-valuemax')).toBe('100');
|
||||
// aria-valuenow should be 24 (24% from fraction 0.24)
|
||||
expect(meter!.getAttribute('aria-valuenow')).toBe('24');
|
||||
expect(meter!.getAttribute('aria-valuetext')).toContain('%');
|
||||
});
|
||||
|
||||
test('applies the ok class (no warning/critical) to the root element', async () => {
|
||||
mockEstimate.mockResolvedValue(okSummary);
|
||||
await renderMonitor();
|
||||
const root_ = container.firstElementChild as HTMLElement | null;
|
||||
expect(root_).not.toBeNull();
|
||||
expect(root_!.className).toContain('ok');
|
||||
expect(root_!.className).not.toContain('warning');
|
||||
expect(root_!.className).not.toContain('critical');
|
||||
});
|
||||
|
||||
test('does not render a live-region announcement at ok level', async () => {
|
||||
mockEstimate.mockResolvedValue(okSummary);
|
||||
await renderMonitor();
|
||||
expect(container.querySelector('[role="status"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the estimate is at warning level', () => {
|
||||
const warnSummary = makeSummary({
|
||||
usedBytes: 4.2 * 1024 * 1024,
|
||||
quotaBytes: 5 * 1024 * 1024,
|
||||
fraction: 0.84,
|
||||
level: 'warning',
|
||||
});
|
||||
|
||||
test('applies the warning class to the root element', async () => {
|
||||
mockEstimate.mockResolvedValue(warnSummary);
|
||||
await renderMonitor();
|
||||
const root_ = container.firstElementChild as HTMLElement | null;
|
||||
expect(root_!.className).toContain('warning');
|
||||
expect(root_!.className).not.toContain('critical');
|
||||
});
|
||||
|
||||
test('renders a polite live-region with warning copy', async () => {
|
||||
mockEstimate.mockResolvedValue(warnSummary);
|
||||
await renderMonitor();
|
||||
const status = container.querySelector('[role="status"]');
|
||||
expect(status).not.toBeNull();
|
||||
expect(status!.getAttribute('aria-live')).toBe('polite');
|
||||
expect(status!.textContent).toBeTruthy();
|
||||
});
|
||||
|
||||
test('meter aria-valuenow reflects the fraction', async () => {
|
||||
mockEstimate.mockResolvedValue(warnSummary);
|
||||
await renderMonitor();
|
||||
const meter = container.querySelector('[role="meter"]');
|
||||
expect(meter!.getAttribute('aria-valuenow')).toBe('84');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the estimate is at critical level', () => {
|
||||
const critSummary = makeSummary({
|
||||
usedBytes: 4.9 * 1024 * 1024,
|
||||
quotaBytes: 5 * 1024 * 1024,
|
||||
fraction: 0.98,
|
||||
level: 'critical',
|
||||
});
|
||||
|
||||
test('applies the critical class to the root element', async () => {
|
||||
mockEstimate.mockResolvedValue(critSummary);
|
||||
await renderMonitor();
|
||||
const root_ = container.firstElementChild as HTMLElement | null;
|
||||
expect(root_!.className).toContain('critical');
|
||||
expect(root_!.className).not.toContain('warning');
|
||||
});
|
||||
|
||||
test('renders a polite live-region with critical copy mentioning deletion', async () => {
|
||||
mockEstimate.mockResolvedValue(critSummary);
|
||||
await renderMonitor();
|
||||
const status = container.querySelector('[role="status"]');
|
||||
expect(status).not.toBeNull();
|
||||
expect(status!.getAttribute('aria-live')).toBe('polite');
|
||||
// Critical copy must direct user to delete snippets.
|
||||
expect(status!.textContent?.toLowerCase()).toContain('delete');
|
||||
});
|
||||
|
||||
test('meter aria-valuenow is 98 and aria-valuetext contains the used bytes', async () => {
|
||||
mockEstimate.mockResolvedValue(critSummary);
|
||||
await renderMonitor();
|
||||
const meter = container.querySelector('[role="meter"]');
|
||||
expect(meter!.getAttribute('aria-valuenow')).toBe('98');
|
||||
expect(meter!.getAttribute('aria-valuetext')).toContain('98%');
|
||||
});
|
||||
});
|
||||
|
||||
describe('graceful unavailable case', () => {
|
||||
const unavailableSummary: StorageSummary = {
|
||||
available: false,
|
||||
usedBytes: 0,
|
||||
quotaBytes: 0,
|
||||
fraction: 0,
|
||||
level: 'ok',
|
||||
};
|
||||
|
||||
test('renders nothing when the Storage Manager API is unavailable', async () => {
|
||||
mockEstimate.mockResolvedValue(unavailableSummary);
|
||||
await renderMonitor();
|
||||
// The component should return null — no DOM output.
|
||||
expect(container.firstElementChild).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user