Redesign the Storage Monitor as a storage-composition breakdown (snippets · datasets · app)

This commit is contained in:
2026-06-10 01:32:22 +03:00
parent e7de0cbb8a
commit 7599acd25e
12 changed files with 438 additions and 481 deletions
+76 -141
View File
@@ -1,40 +1,49 @@
/**
* 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.
* Strategy: seed the snippet + dataset stores (the measured inputs) and mock
* readOriginUsage (the async browser adapter) so tests run in happy-dom without a
* real Storage Manager, then assert on the rendered DOM — legend text, the
* decorative bar, and the absence of a meter/threshold-warning (the redesign).
*
* 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.
* CSS Modules are identity-mapped under Vitest, so class tokens come through as-is.
*/
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 type { Snippet } from '@core/snippet';
import type { Dataset } from '@core/dataset';
import StorageMonitor from './StorageMonitor';
import { useSnippetStore } from '../stores/SnippetStore';
import { useDatasetStore } from '../stores/DatasetStore';
// Mock the browser adapter — tests must not touch navigator.storage.
vi.mock('../infrastructure/storage-estimate', () => ({
readStorageEstimate: vi.fn(),
readOriginUsage: vi.fn(),
}));
import { readStorageEstimate } from '../infrastructure/storage-estimate';
const mockEstimate = vi.mocked(readStorageEstimate);
import { readOriginUsage } from '../infrastructure/storage-estimate';
const mockUsage = vi.mocked(readOriginUsage);
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
// ---- helpers ----------------------------------------------------------------
// ---- fixtures ---------------------------------------------------------------
/** Build a complete StorageSummary for test fixtures. */
function makeSummary(
overrides: Partial<StorageSummary> &
Pick<StorageSummary, 'usedBytes' | 'quotaBytes' | 'fraction' | 'level'>,
): StorageSummary {
return {
available: true,
...overrides,
};
/** A snippet is just serialized for its byte size, so a minimal shape suffices. */
function seedSnippets(count: number): void {
const snippets = Array.from(
{ length: count },
(_, i) => ({ id: `s${i}`, name: `snippet ${i}`, draftSpec: '{"x":1}' }) as unknown as Snippet,
);
useSnippetStore.setState({ snippets });
}
/** Datasets carry their own byte `size`; only that field is read. */
function seedDatasets(...sizes: number[]): void {
const datasets = sizes.map(
(size, i) => ({ id: i + 1, name: `data ${i}`, size }) as unknown as Dataset,
);
useDatasetStore.setState({ datasets });
}
// ---- test setup -------------------------------------------------------------
@@ -51,149 +60,75 @@ beforeEach(() => {
afterEach(() => {
act(() => root.unmount());
container.remove();
useSnippetStore.setState({ snippets: [] });
useDatasetStore.setState({ datasets: [] });
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
await Promise.resolve(); // flush the async readOriginUsage() 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',
});
const MB = 1024 * 1024;
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 ');
});
describe('StorageMonitor (composition)', () => {
test('shows Snippets, Datasets and App segments once past the user-data floor', async () => {
seedSnippets(2);
seedDatasets(12 * MB); // 12 MB of datasets — over the 10 MB floor
mockUsage.mockResolvedValue(20 * MB);
await renderMonitor();
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();
});
const root_ = container.querySelector('[aria-label="Storage in use"]');
expect(root_).not.toBeNull();
const legend = root_!.textContent ?? '';
expect(legend).toContain('Snippets');
expect(legend).toContain('Datasets');
expect(legend).toContain('App'); // usage snippets datasets
expect(legend).toContain('in use');
});
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('is not a meter and shows no threshold warning (redesign)', async () => {
seedSnippets(1);
seedDatasets(11 * MB);
mockUsage.mockResolvedValue(20 * MB);
await renderMonitor();
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');
});
expect(container.querySelector('[role="meter"]')).toBeNull();
expect(container.querySelector('[role="status"]')).toBeNull();
// The bar itself is decorative.
expect(container.querySelector('[aria-hidden="true"]')).not.toBeNull();
});
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('omits the App segment when the Storage Manager API is unavailable', async () => {
seedSnippets(1);
seedDatasets(11 * MB);
mockUsage.mockResolvedValue(undefined); // API absent
await renderMonitor();
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%');
});
const text = container.textContent ?? '';
expect(text).toContain('Snippets');
expect(text).toContain('Datasets');
expect(text).not.toContain('App');
});
describe('graceful unavailable case', () => {
const unavailableSummary: StorageSummary = {
available: false,
usedBytes: 0,
quotaBytes: 0,
fraction: 0,
level: 'ok',
};
test('stays hidden below the user-data floor, even with origin usage', async () => {
seedSnippets(3);
seedDatasets(2 * MB); // ~2 MB of user data — under the 10 MB floor
mockUsage.mockResolvedValue(9 * MB);
await renderMonitor();
expect(container.firstElementChild).toBeNull();
});
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();
});
test('renders nothing for a truly empty workspace', async () => {
seedSnippets(0);
seedDatasets();
mockUsage.mockResolvedValue(undefined);
await renderMonitor();
expect(container.firstElementChild).toBeNull();
});
});