Add snippet-library search, sort, empty states, storage monitor (M6, §02/§09D)

This commit is contained in:
2026-06-07 20:00:40 +03:00
parent 9f7bf27b7a
commit 800a313be2
19 changed files with 1461 additions and 16 deletions
+199
View File
@@ -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();
});
});
});