mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Redesign the Storage Monitor as a storage-composition breakdown (snippets · datasets · app)
This commit is contained in:
@@ -1,89 +1,67 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
CRITICAL_THRESHOLD,
|
||||
WARNING_THRESHOLD,
|
||||
humanizeBytes,
|
||||
summarizeStorage,
|
||||
} from './storage-estimate';
|
||||
import { humanizeBytes, jsonByteSize, summarizeStorage } from './storage-estimate';
|
||||
|
||||
describe('summarizeStorage', () => {
|
||||
test('computes the fraction and passes usage/quota through', () => {
|
||||
const s = summarizeStorage({ usage: 250, quota: 1000 });
|
||||
expect(s.available).toBe(true);
|
||||
expect(s.usedBytes).toBe(250);
|
||||
expect(s.quotaBytes).toBe(1000);
|
||||
expect(s.fraction).toBeCloseTo(0.25);
|
||||
expect(s.level).toBe('ok');
|
||||
describe('summarizeStorage (composition)', () => {
|
||||
test('with origin usage: app = usage − snippets − datasets, total = usage', () => {
|
||||
const c = summarizeStorage({ usageBytes: 1000, snippetBytes: 100, datasetBytes: 300 });
|
||||
expect(c.originMeasured).toBe(true);
|
||||
expect(c.totalBytes).toBe(1000);
|
||||
expect(c.segments).toEqual([
|
||||
{ key: 'snippets', label: 'Snippets', bytes: 100 },
|
||||
{ key: 'datasets', label: 'Datasets', bytes: 300 },
|
||||
{ key: 'app', label: 'App', bytes: 600 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('clamps fraction to 1 when usage exceeds quota', () => {
|
||||
const s = summarizeStorage({ usage: 1500, quota: 1000 });
|
||||
expect(s.fraction).toBe(1);
|
||||
expect(s.level).toBe('critical');
|
||||
test('without origin usage: snippets + datasets only, total is their sum', () => {
|
||||
const c = summarizeStorage({ snippetBytes: 100, datasetBytes: 300 });
|
||||
expect(c.originMeasured).toBe(false);
|
||||
expect(c.totalBytes).toBe(400);
|
||||
expect(c.segments.map((s) => s.key)).toEqual(['snippets', 'datasets']);
|
||||
});
|
||||
|
||||
test('guards divide-by-zero: quota of 0 is unusable', () => {
|
||||
const s = summarizeStorage({ usage: 10, quota: 0 });
|
||||
expect(s.available).toBe(false);
|
||||
expect(s.fraction).toBe(0);
|
||||
expect(s.usedBytes).toBe(0);
|
||||
expect(s.quotaBytes).toBe(0);
|
||||
expect(s.level).toBe('ok');
|
||||
test('usage smaller than measured data falls back to snippets+datasets (estimate lag)', () => {
|
||||
const c = summarizeStorage({ usageBytes: 50, snippetBytes: 100, datasetBytes: 300 });
|
||||
expect(c.originMeasured).toBe(false);
|
||||
expect(c.totalBytes).toBe(400);
|
||||
expect(c.segments).toHaveLength(2);
|
||||
});
|
||||
|
||||
describe('level boundaries', () => {
|
||||
test('just below warning (0.79) is ok', () => {
|
||||
expect(summarizeStorage({ usage: 79, quota: 100 }).level).toBe('ok');
|
||||
});
|
||||
|
||||
test('exactly at warning (0.80) is warning', () => {
|
||||
const s = summarizeStorage({ usage: 80, quota: 100 });
|
||||
expect(s.fraction).toBeCloseTo(WARNING_THRESHOLD);
|
||||
expect(s.level).toBe('warning');
|
||||
});
|
||||
|
||||
test('just below critical (0.94) is warning', () => {
|
||||
expect(summarizeStorage({ usage: 94, quota: 100 }).level).toBe('warning');
|
||||
});
|
||||
|
||||
test('exactly at critical (0.95) is critical', () => {
|
||||
const s = summarizeStorage({ usage: 95, quota: 100 });
|
||||
expect(s.fraction).toBeCloseTo(CRITICAL_THRESHOLD);
|
||||
expect(s.level).toBe('critical');
|
||||
});
|
||||
test('usage exactly equal to measured data yields a zero-byte app segment', () => {
|
||||
const c = summarizeStorage({ usageBytes: 400, snippetBytes: 100, datasetBytes: 300 });
|
||||
expect(c.originMeasured).toBe(true);
|
||||
expect(c.segments.find((s) => s.key === 'app')?.bytes).toBe(0);
|
||||
});
|
||||
|
||||
describe('availability', () => {
|
||||
test('missing usage -> unavailable', () => {
|
||||
const s = summarizeStorage({ quota: 1000 });
|
||||
expect(s.available).toBe(false);
|
||||
expect(s.fraction).toBe(0);
|
||||
});
|
||||
test('invalid / negative / non-finite inputs degrade to 0 bytes', () => {
|
||||
const c = summarizeStorage({ usageBytes: NaN, snippetBytes: -5, datasetBytes: Infinity });
|
||||
expect(c.segments[0].bytes).toBe(0); // snippets
|
||||
expect(c.segments[1].bytes).toBe(0); // datasets
|
||||
expect(c.originMeasured).toBe(false); // usage NaN → unusable
|
||||
expect(c.totalBytes).toBe(0);
|
||||
});
|
||||
|
||||
test('missing quota -> unavailable', () => {
|
||||
const s = summarizeStorage({ usage: 1000 });
|
||||
expect(s.available).toBe(false);
|
||||
expect(s.fraction).toBe(0);
|
||||
});
|
||||
test('empty workspace: every segment is zero, total zero', () => {
|
||||
const c = summarizeStorage({ snippetBytes: 0, datasetBytes: 0 });
|
||||
expect(c.totalBytes).toBe(0);
|
||||
expect(c.segments.every((s) => s.bytes === 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('both missing (empty input) -> unavailable', () => {
|
||||
const s = summarizeStorage({});
|
||||
expect(s.available).toBe(false);
|
||||
});
|
||||
describe('jsonByteSize', () => {
|
||||
test('measures the UTF-8 byte length of the JSON serialization', () => {
|
||||
expect(jsonByteSize({ a: 1 })).toBe(new TextEncoder().encode('{"a":1}').length);
|
||||
});
|
||||
|
||||
test('non-finite or negative values -> unavailable', () => {
|
||||
expect(summarizeStorage({ usage: NaN, quota: 1000 }).available).toBe(false);
|
||||
expect(summarizeStorage({ usage: Infinity, quota: 1000 }).available).toBe(false);
|
||||
expect(summarizeStorage({ usage: -1, quota: 1000 }).available).toBe(false);
|
||||
expect(summarizeStorage({ usage: 10, quota: -5 }).available).toBe(false);
|
||||
});
|
||||
test('counts multi-byte characters by their UTF-8 size', () => {
|
||||
// "é" is 2 UTF-8 bytes; JSON.stringify("é") => the 4-byte string «"é"».
|
||||
expect(jsonByteSize('é')).toBe(4);
|
||||
});
|
||||
|
||||
test('zero usage with a real quota is available and ok', () => {
|
||||
const s = summarizeStorage({ usage: 0, quota: 1000 });
|
||||
expect(s.available).toBe(true);
|
||||
expect(s.fraction).toBe(0);
|
||||
expect(s.level).toBe('ok');
|
||||
});
|
||||
test('an unserializable (circular) value degrades to 0', () => {
|
||||
const a: Record<string, unknown> = {};
|
||||
a.self = a;
|
||||
expect(jsonByteSize(a)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,89 +1,104 @@
|
||||
/**
|
||||
* Storage estimate summarization (spec §02 → Storage Monitor; spec §10 → "Warn
|
||||
* before storage failure"; docs/architecture/02 §6).
|
||||
* Storage composition summarization (spec §02 → Storage Monitor; docs/architecture/02 §6).
|
||||
*
|
||||
* Portable core: no browser APIs, no React. The browser's Storage Manager API
|
||||
* (`navigator.storage.estimate()`) yields a raw `{ usage, quota }` pair in
|
||||
* bytes — both optional, since a browser may omit them. This module turns that
|
||||
* raw pair into presentation-ready facts: a clamped fraction, an escalating
|
||||
* level, and an availability flag. The infrastructure adapter
|
||||
* (`src/app/infrastructure/storage-estimate.ts`) feature-detects the API and
|
||||
* feeds its output through here; all the math lives in this pure function so it
|
||||
* is trivially testable.
|
||||
* Portable core: no browser APIs, no React. The Storage Monitor is **not** a "used
|
||||
* of quota" gauge — the browser's `quota` from `navigator.storage.estimate()` is a
|
||||
* padded, browser-decided approximation, not a real free-space figure, so a budget
|
||||
* fraction would be false precision (web.dev → storage-for-the-web). Instead we show
|
||||
* what storage is *made of*: Snippets, Datasets, and App (everything else — the
|
||||
* precached app shell + IndexedDB overhead). This module turns three measured byte
|
||||
* counts into ordered segments; the math lives here so it is trivially testable.
|
||||
*
|
||||
* Thresholds: warning at >= 0.8 is the documented value (arch 02 §6 `WARN_AT`).
|
||||
* Critical at >= 0.95 is not specified by spec/arch; it is this module's default
|
||||
* for the second escalation step ("nearly full") and is the single source of
|
||||
* truth here.
|
||||
* The only reliable figure from the estimate is `usage` (bytes actually stored for
|
||||
* the whole origin). Snippet and dataset bytes we measure ourselves, so
|
||||
* `App = usage − snippets − datasets`. When the estimate is missing we still show
|
||||
* snippets + datasets from our own data.
|
||||
*/
|
||||
|
||||
/** Raw input shape, mirroring the browser's `StorageEstimate` (bytes, both optional). */
|
||||
export interface StorageEstimateInput {
|
||||
/** Bytes currently used, or undefined when the browser omits it. */
|
||||
usage?: number;
|
||||
/** Total bytes available (quota), or undefined when the browser omits it. */
|
||||
quota?: number;
|
||||
/** A category of stored data and its measured size in bytes. */
|
||||
export interface StorageSegment {
|
||||
key: 'snippets' | 'datasets' | 'app';
|
||||
/** User-facing label, centralized here so the component stays presentational. */
|
||||
label: string;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
/** Escalating fullness level driving the storage monitor's warning copy/colour. */
|
||||
export type StorageLevel = 'ok' | 'warning' | 'critical';
|
||||
|
||||
/** Presentation-ready summary derived from a raw estimate. */
|
||||
export interface StorageSummary {
|
||||
/** Bytes used (0 when unusable). */
|
||||
usedBytes: number;
|
||||
/** Quota in bytes (0 when unusable). */
|
||||
quotaBytes: number;
|
||||
/** usedBytes / quotaBytes, clamped to 0..1; 0 when quota is 0/undefined. */
|
||||
fraction: number;
|
||||
/** Escalating fullness level derived from `fraction`. */
|
||||
level: StorageLevel;
|
||||
/** False when usage/quota are missing or non-finite — the estimate is unusable. */
|
||||
available: boolean;
|
||||
/** Measured inputs: snippet/dataset bytes (always known) + origin usage (when the API is present). */
|
||||
export interface StorageInput {
|
||||
/** Whole-origin bytes from `navigator.storage.estimate().usage`, if available. */
|
||||
usageBytes?: number;
|
||||
/** Summed serialized size of all snippets. */
|
||||
snippetBytes: number;
|
||||
/** Summed size of all datasets (each `Dataset` carries its byte `size`). */
|
||||
datasetBytes: number;
|
||||
}
|
||||
|
||||
/** Fraction at which the monitor begins warning (arch 02 §6 `WARN_AT`). */
|
||||
export const WARNING_THRESHOLD = 0.8;
|
||||
/** Fraction at which the monitor escalates to critical ("nearly full"). */
|
||||
export const CRITICAL_THRESHOLD = 0.95;
|
||||
/** Presentation-ready storage composition. */
|
||||
export interface StorageComposition {
|
||||
/** Ordered segments to display: Snippets, Datasets, then App when measurable. */
|
||||
segments: StorageSegment[];
|
||||
/** Sum of the segments shown — origin `usage` when measured, else snippets+datasets. */
|
||||
totalBytes: number;
|
||||
/**
|
||||
* True when the origin estimate was available, so the `app` segment (the precached
|
||||
* shell + overhead) is meaningful and `totalBytes` is whole-origin usage. False when
|
||||
* the Storage Manager API is absent — we still show snippets + datasets, and
|
||||
* `totalBytes` is just their sum.
|
||||
*/
|
||||
originMeasured: boolean;
|
||||
}
|
||||
|
||||
/** A non-finite or negative number is not a usable byte count. */
|
||||
/**
|
||||
* The monitor stays hidden until the user's own data — snippets + datasets — reaches
|
||||
* this size (spec §02). Below it storage is noise (nothing to manage), and the bar
|
||||
* would be dominated by the immovable precached app shell anyway. Deliberately keyed
|
||||
* off *user* bytes, not total: the ~precache baseline is roughly constant, so gating
|
||||
* on total would make the monitor always-visible and the breakdown unbalanced.
|
||||
*/
|
||||
export const STORAGE_MONITOR_MIN_USER_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
/** A finite, non-negative byte count, or null when the value is unusable. */
|
||||
function usableBytes(n: number | undefined): number | null {
|
||||
if (typeof n !== 'number' || !Number.isFinite(n) || n < 0) return null;
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Map a clamped fraction to its escalation level. */
|
||||
function levelFor(fraction: number): StorageLevel {
|
||||
if (fraction >= CRITICAL_THRESHOLD) return 'critical';
|
||||
if (fraction >= WARNING_THRESHOLD) return 'warning';
|
||||
return 'ok';
|
||||
/** A finite, non-negative byte count; junk degrades to 0. */
|
||||
function nonNeg(n: number): number {
|
||||
return usableBytes(n) ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a raw storage estimate into a presentation-ready summary. Pure: no I/O.
|
||||
* When usage is missing/invalid, or quota is missing/invalid/zero, the estimate
|
||||
* is unusable — `available: false`, everything zeroed, level `ok` (we don't warn
|
||||
* on data we don't have). Otherwise the fraction is `usage / quota` clamped to
|
||||
* 0..1 and the level escalates at the thresholds above.
|
||||
* Decompose measured storage into ordered segments. The `app` segment (everything
|
||||
* beyond our snippets+datasets) is included only when the origin `usage` is available
|
||||
* and at least our measured data — a smaller estimate is the browser's approximation
|
||||
* lagging, not real, so we fall back to a snippets+datasets-only view.
|
||||
*/
|
||||
export function summarizeStorage(input: StorageEstimateInput): StorageSummary {
|
||||
const usage = usableBytes(input.usage);
|
||||
const quota = usableBytes(input.quota);
|
||||
export function summarizeStorage(input: StorageInput): StorageComposition {
|
||||
const snippets = nonNeg(input.snippetBytes);
|
||||
const datasets = nonNeg(input.datasetBytes);
|
||||
const usage = usableBytes(input.usageBytes);
|
||||
const known = snippets + datasets;
|
||||
|
||||
// Quota of 0 can't yield a meaningful fraction; treat as unusable.
|
||||
if (usage === null || quota === null || quota === 0) {
|
||||
return { usedBytes: 0, quotaBytes: 0, fraction: 0, level: 'ok', available: false };
|
||||
const segments: StorageSegment[] = [
|
||||
{ key: 'snippets', label: 'Snippets', bytes: snippets },
|
||||
{ key: 'datasets', label: 'Datasets', bytes: datasets },
|
||||
];
|
||||
|
||||
if (usage !== null && usage >= known) {
|
||||
segments.push({ key: 'app', label: 'App', bytes: usage - known });
|
||||
return { segments, totalBytes: usage, originMeasured: true };
|
||||
}
|
||||
return { segments, totalBytes: known, originMeasured: false };
|
||||
}
|
||||
|
||||
const fraction = Math.min(1, usage / quota);
|
||||
return {
|
||||
usedBytes: usage,
|
||||
quotaBytes: quota,
|
||||
fraction,
|
||||
level: levelFor(fraction),
|
||||
available: true,
|
||||
};
|
||||
/** UTF-8 byte length of a value's JSON serialization (0 when it can't be serialized). */
|
||||
export function jsonByteSize(value: unknown): number {
|
||||
try {
|
||||
return new TextEncoder().encode(JSON.stringify(value) ?? '').length;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const;
|
||||
|
||||
Reference in New Issue
Block a user