mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 10:12:34 +00:00
Redesign the Storage Monitor as a storage-composition breakdown (snippets · datasets · app)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user