mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Add storage-estimate core and adapter (M6 Storage Monitor groundwork)
Portable summarizer (clamped fraction, ok/warning/critical level, availability flag, humanizeBytes) with the browser StorageManager.estimate() feature-detect isolated to the infrastructure adapter. No UI consumer yet — core-first per the AI developer protocol; the Storage Monitor panel (spec §02) wires to it next.
This commit is contained in:
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Storage estimate adapter (spec §02 → Storage Monitor; docs/architecture/02 §6).
|
||||||
|
*
|
||||||
|
* This is the ONLY module that touches the browser's Storage Manager API
|
||||||
|
* (`navigator.storage.estimate()`). It feature-detects the API, then defers all
|
||||||
|
* presentation math to the portable core (`src/core/storage-estimate.ts`). When
|
||||||
|
* the API is unavailable — older browsers, insecure contexts — it returns an
|
||||||
|
* unavailable summary rather than throwing, so callers get a total function.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { summarizeStorage, type StorageSummary } from '../../core/storage-estimate';
|
||||||
|
|
||||||
|
/** The unavailable summary, returned when the Storage Manager API is absent. */
|
||||||
|
const UNAVAILABLE: StorageSummary = {
|
||||||
|
usedBytes: 0,
|
||||||
|
quotaBytes: 0,
|
||||||
|
fraction: 0,
|
||||||
|
level: 'ok',
|
||||||
|
available: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the browser's storage estimate and summarize it. Returns an unavailable
|
||||||
|
* summary when `navigator.storage.estimate` is missing or the call rejects;
|
||||||
|
* never throws. All math lives in `summarizeStorage`.
|
||||||
|
*/
|
||||||
|
export async function readStorageEstimate(): Promise<StorageSummary> {
|
||||||
|
const storage = typeof navigator !== 'undefined' ? navigator.storage : undefined;
|
||||||
|
if (!storage || typeof storage.estimate !== 'function') {
|
||||||
|
return UNAVAILABLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { usage, quota } = await storage.estimate();
|
||||||
|
return summarizeStorage({ usage, quota });
|
||||||
|
} catch {
|
||||||
|
return UNAVAILABLE;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
import {
|
||||||
|
CRITICAL_THRESHOLD,
|
||||||
|
WARNING_THRESHOLD,
|
||||||
|
humanizeBytes,
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
|
||||||
|
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('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');
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('availability', () => {
|
||||||
|
test('missing usage -> unavailable', () => {
|
||||||
|
const s = summarizeStorage({ quota: 1000 });
|
||||||
|
expect(s.available).toBe(false);
|
||||||
|
expect(s.fraction).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing quota -> unavailable', () => {
|
||||||
|
const s = summarizeStorage({ usage: 1000 });
|
||||||
|
expect(s.available).toBe(false);
|
||||||
|
expect(s.fraction).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('both missing (empty input) -> unavailable', () => {
|
||||||
|
const s = summarizeStorage({});
|
||||||
|
expect(s.available).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
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('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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('humanizeBytes', () => {
|
||||||
|
test('zero renders as whole bytes', () => {
|
||||||
|
expect(humanizeBytes(0)).toBe('0 B');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('small values stay in whole bytes', () => {
|
||||||
|
expect(humanizeBytes(1)).toBe('1 B');
|
||||||
|
expect(humanizeBytes(512)).toBe('512 B');
|
||||||
|
expect(humanizeBytes(1023)).toBe('1023 B');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KB boundary (1024) and within KB', () => {
|
||||||
|
expect(humanizeBytes(1024)).toBe('1.0 KB');
|
||||||
|
expect(humanizeBytes(1536)).toBe('1.5 KB');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('MB boundary and within MB', () => {
|
||||||
|
expect(humanizeBytes(1024 * 1024)).toBe('1.0 MB');
|
||||||
|
expect(humanizeBytes(5 * 1024 * 1024)).toBe('5.0 MB');
|
||||||
|
expect(humanizeBytes(2.5 * 1024 * 1024)).toBe('2.5 MB');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GB boundary', () => {
|
||||||
|
expect(humanizeBytes(1024 * 1024 * 1024)).toBe('1.0 GB');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('caps at the largest unit (TB)', () => {
|
||||||
|
expect(humanizeBytes(1024 ** 4)).toBe('1.0 TB');
|
||||||
|
expect(humanizeBytes(1024 ** 5)).toBe('1024.0 TB');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('negative and non-finite inputs degrade to 0 B', () => {
|
||||||
|
expect(humanizeBytes(-1)).toBe('0 B');
|
||||||
|
expect(humanizeBytes(NaN)).toBe('0 B');
|
||||||
|
expect(humanizeBytes(Infinity)).toBe('0 B');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* Storage estimate summarization (spec §02 → Storage Monitor; spec §10 → "Warn
|
||||||
|
* before storage failure"; 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.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
|
||||||
|
/** A non-finite or negative number is not a usable byte count. */
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export function summarizeStorage(input: StorageEstimateInput): StorageSummary {
|
||||||
|
const usage = usableBytes(input.usage);
|
||||||
|
const quota = usableBytes(input.quota);
|
||||||
|
|
||||||
|
// 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 fraction = Math.min(1, usage / quota);
|
||||||
|
return {
|
||||||
|
usedBytes: usage,
|
||||||
|
quotaBytes: quota,
|
||||||
|
fraction,
|
||||||
|
level: levelFor(fraction),
|
||||||
|
available: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a byte count as a human-readable string using binary (1024) units.
|
||||||
|
* Bytes are shown as a whole number with the `B` unit; KB and up get one
|
||||||
|
* decimal place (e.g. `1.5 MB`). Negative/non-finite inputs degrade to `0 B`.
|
||||||
|
*/
|
||||||
|
export function humanizeBytes(n: number): string {
|
||||||
|
if (!Number.isFinite(n) || n < 0) return '0 B';
|
||||||
|
if (n < 1024) return `${Math.round(n)} B`;
|
||||||
|
|
||||||
|
let value = n;
|
||||||
|
let unit = 0;
|
||||||
|
while (value >= 1024 && unit < BYTE_UNITS.length - 1) {
|
||||||
|
value /= 1024;
|
||||||
|
unit += 1;
|
||||||
|
}
|
||||||
|
return `${value.toFixed(1)} ${BYTE_UNITS[unit]}`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user