diff --git a/docs/USAGE.md b/docs/USAGE.md index 401321c74..42886fda9 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -327,13 +327,13 @@ Supported states by widget family: - **Session Cost**: `zero` hides `$0.00` - **Session Clock**: `zero` hides durations under one minute - **Block Timer**: `no-data` hides the `0hr 0m` / empty-bar display when no block is active -- **Block Reset Timer / Weekly Reset Timer**: `no-data` hides both the `[Loading]` placeholder and the usage-error placeholders while no reset window is available +- **Block Reset Timer / Weekly Reset Timer**: `no-data` hides both the `[Loading]` placeholder and the usage-error placeholders while no reset window is available, `zero` hides the timer while its period's usage (session or weekly) is 0% and no reset window has started - **Input/Output/Total Speed**: `no-data` hides the `—` placeholder when no speed data exists - **Output Style**: `default-value` hides the widget when the style is `default` - **Compaction Counter**: `zero` hides the counter before any compaction occurs - **Skills**: `empty` hides the widget before any skill is used -- **Extra Usage widgets**: `disabled` hides the `n/a` display when extra usage is off, `no-data` hides the error placeholder when usage data is unavailable -- **Session / Weekly / Weekly Sonnet / Weekly Opus / Weekly Fable Usage**: `no-data` hides the error placeholder (`[No credentials]`, `[Timeout]`, `[Rate limited]`, `[API Error]`, `[Parse Error]`) when usage data is unavailable +- **Extra Usage widgets**: `disabled` hides the `n/a` display when extra usage is off, `no-data` hides the error placeholder when usage data is unavailable; on Extra Usage Used and Extra Usage Utilization, `zero` hides the widget while nothing has been spent (Extra Usage Remaining has no `zero` state) +- **Session / Weekly / Weekly Sonnet / Weekly Opus / Weekly Fable Usage**: `no-data` hides the error placeholder (`[No credentials]`, `[Timeout]`, `[Rate limited]`, `[API Error]`, `[Parse Error]`) when usage data is unavailable, `zero` hides the widget while usage is 0% (also when showing remaining) - **Cache widgets** (`Cache Hit Rate`, `Cache Read`, `Cache Write`, `Cache Timer`): `empty` hides the widget when there is no cache activity, and on Cache Timer when no cache anchor is available - **Custom Text / Custom Symbol**: `merge-target-hidden` hides the item when the widget it is merged with renders nothing, so icon prefixes/suffixes disappear together with their widget diff --git a/src/__tests__/cli-smoke.test.ts b/src/__tests__/cli-smoke.test.ts new file mode 100644 index 000000000..1108d54df --- /dev/null +++ b/src/__tests__/cli-smoke.test.ts @@ -0,0 +1,108 @@ +import type * as childProcess from 'child_process'; +import * as fs from 'node:fs'; +import { createRequire } from 'node:module'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + afterAll, + describe, + expect, + it +} from 'vitest'; + +// End-to-end smoke test: spawns the real CLI in piped mode against real git +// fixtures. Must run outside the test process because other suites mock +// child_process globally under Bun. +const require = createRequire(import.meta.url); +const { execFileSync } = require('node:child_process') as typeof childProcess; + +const ANSI_CODES = /\x1b\[[0-9;]*m/g; +// The renderer emits non-breaking spaces inside widget text; normalize them +// so plain-ASCII assertions stay readable. +const NBSP = new RegExp(String.fromCharCode(160), 'g'); +const entryPath = fileURLToPath(new URL('../ccstatusline.ts', import.meta.url)); +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-cli-smoke-')); +const tempHome = path.join(tempRoot, 'home'); +const configPath = path.join(tempRoot, 'settings.json'); +const transcriptPath = path.join(tempRoot, 'transcript.jsonl'); + +// Fixed minimal config so the smoke does not depend on the developer's live +// settings or on the default widget set. +fs.mkdirSync(tempHome, { recursive: true }); +fs.writeFileSync(configPath, JSON.stringify({ + version: 4, + lines: [[ + { id: 'smoke-branch', type: 'git-branch' }, + { id: 'smoke-changes', type: 'git-changes' }, + { id: 'smoke-ahead-behind', type: 'git-ahead-behind' } + ]] +}), 'utf-8'); +fs.writeFileSync(transcriptPath, '', 'utf-8'); + +function git(cwd: string, ...args: string[]): void { + execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', ...args], { + cwd, + stdio: 'pipe' + }); +} + +function initRepo(name: string): string { + const repoPath = path.join(tempRoot, name); + fs.mkdirSync(repoPath, { recursive: true }); + git(repoPath, 'init', '-b', 'main', '--quiet'); + git(repoPath, 'commit', '--allow-empty', '--quiet', '-m', 'init'); + return repoPath; +} + +function createDivergedClone(): string { + const originPath = initRepo('smoke-origin'); + const workPath = path.join(tempRoot, 'smoke-work'); + git(tempRoot, 'clone', '--quiet', originPath, workPath); + git(workPath, 'commit', '--allow-empty', '--quiet', '-m', 'local'); + git(originPath, 'commit', '--allow-empty', '--quiet', '-m', 'remote'); + git(workPath, 'fetch', '--quiet', 'origin'); + return workPath; +} + +function renderLine(cwd: string): string { + const stdout = execFileSync('bun', [entryPath, '--config', configPath], { + input: JSON.stringify({ + model: { id: 'claude-sonnet-4-5' }, + transcript_path: transcriptPath, + cwd + }), + encoding: 'utf8', + timeout: 20000, + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, HOME: tempHome, USERPROFILE: tempHome } + }); + return stdout.replace(ANSI_CODES, '').replace(NBSP, ' '); +} + +afterAll(() => { + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +describe('CLI pipe smoke (spawned process, real git fixtures)', () => { + it('renders the no-git state outside a repository', () => { + expect(renderLine(tempRoot)).toContain('no git'); + }); + + it('renders ahead and behind counts for a diverged branch', () => { + const text = renderLine(createDivergedClone()); + expect(text).toContain('↑1'); + expect(text).toContain('↓1'); + }); + + it('hides the zero state on a synced branch', () => { + const originPath = initRepo('smoke-synced-origin'); + const clonePath = path.join(tempRoot, 'smoke-synced'); + git(tempRoot, 'clone', '--quiet', originPath, clonePath); + expect(renderLine(clonePath)).not.toContain('↑0↓0'); + }); + + it('renders the no-upstream state without an upstream', () => { + expect(renderLine(initRepo('smoke-noupstream'))).toContain('(no upstream)'); + }); +}); diff --git a/src/utils/migrations.ts b/src/utils/migrations.ts index 25700e9b6..039d1212d 100644 --- a/src/utils/migrations.ts +++ b/src/utils/migrations.ts @@ -161,6 +161,8 @@ const GIT_REVIEW_HIDE_RULE: HideFlagRule = { // placeholder. hideIfDisabled never covered that placeholder, so it stays mapped // to 'disabled' alone and only a hand-written list can enable it. const EXTRA_USAGE_HIDE_RULE: HideFlagRule = { legacy: { hideIfDisabled: ['disabled'] }, stateOrder: ['disabled', 'no-data'] }; +// Used and Utilization also offer `zero`, which a hand-written hide list can carry in. +const EXTRA_USAGE_ZERO_HIDE_RULE: HideFlagRule = { legacy: { hideIfDisabled: ['disabled'] }, stateOrder: ['disabled', 'no-data', 'zero'] }; const CACHE_HIDE_RULE: HideFlagRule = { legacy: { hideWhenEmpty: ['empty'] }, stateOrder: ['empty'] }; export const V4_HIDE_FLAG_RULES: Record = { @@ -209,9 +211,9 @@ export const V4_HIDE_FLAG_RULES: Record = { 'cache-write': CACHE_HIDE_RULE, 'cache-hit-rate': CACHE_HIDE_RULE, 'cache-timer': CACHE_HIDE_RULE, - 'extra-usage-utilization': EXTRA_USAGE_HIDE_RULE, + 'extra-usage-utilization': EXTRA_USAGE_ZERO_HIDE_RULE, 'extra-usage-remaining': EXTRA_USAGE_HIDE_RULE, - 'extra-usage-used': EXTRA_USAGE_HIDE_RULE + 'extra-usage-used': EXTRA_USAGE_ZERO_HIDE_RULE }; const V4_LEGACY_HIDE_KEYS = [ diff --git a/src/widgets/BlockResetTimer.ts b/src/widgets/BlockResetTimer.ts index 1a9782adf..19b7e3a3d 100644 --- a/src/widgets/BlockResetTimer.ts +++ b/src/widgets/BlockResetTimer.ts @@ -34,6 +34,7 @@ import { } from './shared/timezone-editor'; import { USAGE_NO_DATA_HIDEABLE_STATE, + USAGE_ZERO_HIDEABLE_STATE, cycleUsageDisplayMode, getUsageDisplayMode, getUsageDisplayModifierText, @@ -71,7 +72,7 @@ export class BlockResetTimerWidget implements Widget { } getHideableStates(): HideableState[] { - return [USAGE_NO_DATA_HIDEABLE_STATE]; + return [USAGE_NO_DATA_HIDEABLE_STATE, USAGE_ZERO_HIDEABLE_STATE]; } handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { @@ -137,6 +138,10 @@ export class BlockResetTimerWidget implements Widget { } const usageData = context.usageData ?? {}; + if (usageData.sessionUsage === 0 && !usageData.sessionResetAt && isHidden(item, USAGE_ZERO_HIDEABLE_STATE.key)) { + return null; + } + const window = resolveUsageWindowWithFallback(usageData, context.blockMetrics); if (!window) { diff --git a/src/widgets/ExtraUsageUsed.ts b/src/widgets/ExtraUsageUsed.ts index 8faaba7fd..02649a233 100644 --- a/src/widgets/ExtraUsageUsed.ts +++ b/src/widgets/ExtraUsageUsed.ts @@ -13,7 +13,10 @@ import { formatUsageCurrency } from './shared/currency'; import { EXTRA_USAGE_DISABLED_HIDEABLE_STATE } from './shared/extra-usage-disabled'; import { isHidden } from './shared/hideable'; import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; -import { USAGE_NO_DATA_HIDEABLE_STATE } from './shared/usage-display'; +import { + USAGE_NO_DATA_HIDEABLE_STATE, + USAGE_ZERO_HIDEABLE_STATE +} from './shared/usage-display'; export class ExtraUsageUsedWidget implements Widget { getDefaultColor(): string { return 'green'; } @@ -26,7 +29,7 @@ export class ExtraUsageUsedWidget implements Widget { } getHideableStates(): HideableState[] { - return [EXTRA_USAGE_DISABLED_HIDEABLE_STATE, USAGE_NO_DATA_HIDEABLE_STATE]; + return [EXTRA_USAGE_DISABLED_HIDEABLE_STATE, USAGE_NO_DATA_HIDEABLE_STATE, USAGE_ZERO_HIDEABLE_STATE]; } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { @@ -50,6 +53,10 @@ export class ExtraUsageUsedWidget implements Widget { return null; } + if (data.extraUsageUsed === 0 && isHidden(item, USAGE_ZERO_HIDEABLE_STATE.key)) { + return null; + } + // extraUsageUsed is in cents const usedDollars = data.extraUsageUsed / 100; const formatted = formatUsageCurrency(usedDollars, data.extraUsageCurrency, format); diff --git a/src/widgets/ExtraUsageUtilization.ts b/src/widgets/ExtraUsageUtilization.ts index 851b7c65f..41e3e7655 100644 --- a/src/widgets/ExtraUsageUtilization.ts +++ b/src/widgets/ExtraUsageUtilization.ts @@ -19,6 +19,7 @@ import { makeTimerProgressBar } from './shared/progress-bar'; import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; import { USAGE_NO_DATA_HIDEABLE_STATE, + USAGE_ZERO_HIDEABLE_STATE, cycleUsageDisplayMode, getUsageDisplayMode, getUsageDisplayModifierText, @@ -45,7 +46,7 @@ export class ExtraUsageUtilizationWidget implements Widget { } getHideableStates(): HideableState[] { - return [EXTRA_USAGE_DISABLED_HIDEABLE_STATE, USAGE_NO_DATA_HIDEABLE_STATE]; + return [EXTRA_USAGE_DISABLED_HIDEABLE_STATE, USAGE_NO_DATA_HIDEABLE_STATE, USAGE_ZERO_HIDEABLE_STATE]; } handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { @@ -101,6 +102,10 @@ export class ExtraUsageUtilizationWidget implements Widget { // extraUsageUtilization is already a percentage (0-100), not a fraction const percent = Math.max(0, Math.min(100, data.extraUsageUtilization)); + if (percent === 0 && isHidden(item, USAGE_ZERO_HIDEABLE_STATE.key)) { + return null; + } + const renderedPercent = inverted ? 100 - percent : percent; if (isUsageProgressMode(displayMode)) { diff --git a/src/widgets/FableWeeklyUsage.ts b/src/widgets/FableWeeklyUsage.ts index 86dc78345..f9cea9bb2 100644 --- a/src/widgets/FableWeeklyUsage.ts +++ b/src/widgets/FableWeeklyUsage.ts @@ -10,6 +10,7 @@ import type { import { USAGE_NO_DATA_HIDEABLE_STATE, + USAGE_ZERO_HIDEABLE_STATE, getUsagePercentCustomKeybinds } from './shared/usage-display'; import { @@ -31,7 +32,7 @@ export class FableWeeklyUsageWidget implements Widget { } getHideableStates(): HideableState[] { - return [USAGE_NO_DATA_HIDEABLE_STATE]; + return [USAGE_NO_DATA_HIDEABLE_STATE, USAGE_ZERO_HIDEABLE_STATE]; } handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { diff --git a/src/widgets/SessionUsage.ts b/src/widgets/SessionUsage.ts index 774231348..91e3ac613 100644 --- a/src/widgets/SessionUsage.ts +++ b/src/widgets/SessionUsage.ts @@ -10,6 +10,7 @@ import type { import { USAGE_NO_DATA_HIDEABLE_STATE, + USAGE_ZERO_HIDEABLE_STATE, getUsagePercentCustomKeybinds } from './shared/usage-display'; import { @@ -31,7 +32,7 @@ export class SessionUsageWidget implements Widget { } getHideableStates(): HideableState[] { - return [USAGE_NO_DATA_HIDEABLE_STATE]; + return [USAGE_NO_DATA_HIDEABLE_STATE, USAGE_ZERO_HIDEABLE_STATE]; } handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { diff --git a/src/widgets/WeeklyOpusUsage.ts b/src/widgets/WeeklyOpusUsage.ts index 150d0d463..bd4308898 100644 --- a/src/widgets/WeeklyOpusUsage.ts +++ b/src/widgets/WeeklyOpusUsage.ts @@ -10,6 +10,7 @@ import type { import { USAGE_NO_DATA_HIDEABLE_STATE, + USAGE_ZERO_HIDEABLE_STATE, getUsagePercentCustomKeybinds } from './shared/usage-display'; import { @@ -31,7 +32,7 @@ export class WeeklyOpusUsageWidget implements Widget { } getHideableStates(): HideableState[] { - return [USAGE_NO_DATA_HIDEABLE_STATE]; + return [USAGE_NO_DATA_HIDEABLE_STATE, USAGE_ZERO_HIDEABLE_STATE]; } handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { diff --git a/src/widgets/WeeklyResetTimer.ts b/src/widgets/WeeklyResetTimer.ts index bdeb4e8d8..0482ff42a 100644 --- a/src/widgets/WeeklyResetTimer.ts +++ b/src/widgets/WeeklyResetTimer.ts @@ -39,6 +39,7 @@ import { } from './shared/timezone-editor'; import { USAGE_NO_DATA_HIDEABLE_STATE, + USAGE_ZERO_HIDEABLE_STATE, cycleUsageDisplayMode, getUsageDisplayMode, getUsageLocale, @@ -141,7 +142,7 @@ export class WeeklyResetTimerWidget implements Widget { } getHideableStates(): HideableState[] { - return [USAGE_NO_DATA_HIDEABLE_STATE]; + return [USAGE_NO_DATA_HIDEABLE_STATE, USAGE_ZERO_HIDEABLE_STATE]; } handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { @@ -221,6 +222,10 @@ export class WeeklyResetTimerWidget implements Widget { } const usageData = context.usageData ?? {}; + if (usageData.weeklyUsage === 0 && !usageData.weeklyResetAt && isHidden(item, USAGE_ZERO_HIDEABLE_STATE.key)) { + return null; + } + const window = resolveWeeklyUsageWindow(usageData); if (!window) { diff --git a/src/widgets/WeeklySonnetUsage.ts b/src/widgets/WeeklySonnetUsage.ts index 96eff8cc7..b36e73421 100644 --- a/src/widgets/WeeklySonnetUsage.ts +++ b/src/widgets/WeeklySonnetUsage.ts @@ -10,6 +10,7 @@ import type { import { USAGE_NO_DATA_HIDEABLE_STATE, + USAGE_ZERO_HIDEABLE_STATE, getUsagePercentCustomKeybinds } from './shared/usage-display'; import { @@ -31,7 +32,7 @@ export class WeeklySonnetUsageWidget implements Widget { } getHideableStates(): HideableState[] { - return [USAGE_NO_DATA_HIDEABLE_STATE]; + return [USAGE_NO_DATA_HIDEABLE_STATE, USAGE_ZERO_HIDEABLE_STATE]; } handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { diff --git a/src/widgets/WeeklyUsage.ts b/src/widgets/WeeklyUsage.ts index 86544d557..ce4c3eb41 100644 --- a/src/widgets/WeeklyUsage.ts +++ b/src/widgets/WeeklyUsage.ts @@ -10,6 +10,7 @@ import type { import { USAGE_NO_DATA_HIDEABLE_STATE, + USAGE_ZERO_HIDEABLE_STATE, getUsagePercentCustomKeybinds } from './shared/usage-display'; import { @@ -31,7 +32,7 @@ export class WeeklyUsageWidget implements Widget { } getHideableStates(): HideableState[] { - return [USAGE_NO_DATA_HIDEABLE_STATE]; + return [USAGE_NO_DATA_HIDEABLE_STATE, USAGE_ZERO_HIDEABLE_STATE]; } handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { diff --git a/src/widgets/__tests__/BlockResetTimer.test.ts b/src/widgets/__tests__/BlockResetTimer.test.ts index a930bcf10..7a372769b 100644 --- a/src/widgets/__tests__/BlockResetTimer.test.ts +++ b/src/widgets/__tests__/BlockResetTimer.test.ts @@ -119,8 +119,28 @@ describe('BlockResetTimerWidget', () => { expect(render(widget, { id: 'reset', type: 'reset-timer', rawValue: true }, { usageData: {} })).toBe('[Loading]'); }); - it('declares the no-data hideable state', () => { - expect(new BlockResetTimerWidget().getHideableStates().map(state => state.key)).toEqual(['no-data']); + it('declares the no-data and zero hideable states', () => { + expect(new BlockResetTimerWidget().getHideableStates().map(state => state.key)).toEqual(['no-data', 'zero']); + }); + + it('hides when sessionUsage is zero and the zero state is enabled', () => { + const widget = new BlockResetTimerWidget(); + const item: WidgetItem = { id: 'reset', type: 'reset-timer', metadata: { hide: 'zero' } }; + + mockResolveUsageWindowWithFallback.mockReturnValue({ + sessionDurationMs: 18000000, + elapsedMs: 3600000, + remainingMs: 14400000, + elapsedPercent: 20, + remainingPercent: 80 + }); + mockFormatUsageDuration.mockReturnValue('4hr'); + + expect(render(widget, item, { usageData: { sessionUsage: 0 } })).toBeNull(); + // A null bucket pins usage to 0 while limits[] still supplies the reset time + expect(render(widget, item, { usageData: { sessionUsage: 0, sessionResetAt: '2030-07-01T00:00:00.000Z' } })).toBe('Reset: 4hr'); + expect(render(widget, item, { usageData: { sessionUsage: 5 } })).toBe('Reset: 4hr'); + expect(render(widget, { id: 'reset', type: 'reset-timer' }, { usageData: { sessionUsage: 0 } })).toBe('Reset: 4hr'); }); // One state covers both placeholders, since either means the same thing to diff --git a/src/widgets/__tests__/ExtraUsageUsed.test.ts b/src/widgets/__tests__/ExtraUsageUsed.test.ts index 70be2b521..71e19beaf 100644 --- a/src/widgets/__tests__/ExtraUsageUsed.test.ts +++ b/src/widgets/__tests__/ExtraUsageUsed.test.ts @@ -95,13 +95,22 @@ describe('ExtraUsageUsedWidget', () => { expect(widget.supportsNumberFormat()).toBe(true); }); - it('declares the disabled and no-data hideable states', () => { + it('declares the disabled, no-data and zero hideable states', () => { const widget = new ExtraUsageUsedWidget(); - expect(widget.getHideableStates().map(state => state.key)).toEqual(['disabled', 'no-data']); + expect(widget.getHideableStates().map(state => state.key)).toEqual(['disabled', 'no-data', 'zero']); expect(widget.getEditorDisplay({ id: 'extra', type: 'extra-usage-used' }).modifierText).toBeUndefined(); }); + it('hides zero spend only when the zero state is enabled', () => { + const widget = new ExtraUsageUsedWidget(); + const hideZeroItem: WidgetItem = { id: 'extra', type: 'extra-usage-used', metadata: { hide: 'zero' } }; + + expect(render(widget, hideZeroItem, { usageData: { extraUsageEnabled: true, extraUsageUsed: 0 } })).toBeNull(); + expect(render(widget, hideZeroItem, { usageData: { extraUsageEnabled: true, extraUsageUsed: 10600 } })).toBe('Overage Used: $106.00'); + expect(render(widget, { id: 'extra', type: 'extra-usage-used' }, { usageData: { extraUsageEnabled: true, extraUsageUsed: 0 } })).toBe('Overage Used: $0.00'); + }); + it('renders available used budget before unrelated usage errors', () => { const widget = new ExtraUsageUsedWidget(); diff --git a/src/widgets/__tests__/ExtraUsageUtilization.test.ts b/src/widgets/__tests__/ExtraUsageUtilization.test.ts index b4f8fe54f..ffbacd1c7 100644 --- a/src/widgets/__tests__/ExtraUsageUtilization.test.ts +++ b/src/widgets/__tests__/ExtraUsageUtilization.test.ts @@ -96,7 +96,18 @@ describe('ExtraUsageUtilizationWidget', () => { metadata: { invert: 'true' } }).modifierText).toBe('(remaining)'); - expect(widget.getHideableStates().map(state => state.key)).toEqual(['disabled', 'no-data']); + expect(widget.getHideableStates().map(state => state.key)).toEqual(['disabled', 'no-data', 'zero']); + }); + + it('hides zero utilization only when the zero state is enabled, regardless of invert', () => { + const widget = new ExtraUsageUtilizationWidget(); + const zeroContext: RenderContext = { usageData: { extraUsageEnabled: true, extraUsageUtilization: 0 } }; + + expect(render(widget, { id: 'extra', type: 'extra-usage-utilization', metadata: { hide: 'zero' } }, zeroContext)).toBeNull(); + expect(render(widget, { id: 'extra', type: 'extra-usage-utilization', metadata: { hide: 'zero', invert: 'true' } }, zeroContext)).toBeNull(); + const nonZeroContext: RenderContext = { usageData: { extraUsageEnabled: true, extraUsageUtilization: 25 } }; + expect(render(widget, { id: 'extra', type: 'extra-usage-utilization', metadata: { hide: 'zero' } }, nonZeroContext)).toBe('Overage: 25.0%'); + expect(render(widget, { id: 'extra', type: 'extra-usage-utilization' }, zeroContext)).toBe('Overage: 0.0%'); }); it('shows usage errors only when required extra usage data is missing', () => { diff --git a/src/widgets/__tests__/WeeklyResetTimer.test.ts b/src/widgets/__tests__/WeeklyResetTimer.test.ts index e35a9d7d3..aeaf08125 100644 --- a/src/widgets/__tests__/WeeklyResetTimer.test.ts +++ b/src/widgets/__tests__/WeeklyResetTimer.test.ts @@ -150,8 +150,28 @@ describe('WeeklyResetTimerWidget', () => { expect(render(widget, { id: 'weekly-reset', type: 'weekly-reset-timer', rawValue: true }, { usageData: {} })).toBe('[Loading]'); }); - it('declares the no-data hideable state', () => { - expect(new WeeklyResetTimerWidget().getHideableStates().map(state => state.key)).toEqual(['no-data']); + it('declares the no-data and zero hideable states', () => { + expect(new WeeklyResetTimerWidget().getHideableStates().map(state => state.key)).toEqual(['no-data', 'zero']); + }); + + it('hides when weeklyUsage is zero and the zero state is enabled', () => { + const widget = new WeeklyResetTimerWidget(); + const item: WidgetItem = { id: 'weekly-reset', type: 'weekly-reset-timer', metadata: { hide: 'zero' } }; + + mockResolveWeeklyUsageWindow.mockReturnValue({ + sessionDurationMs: 18000000, + elapsedMs: 3600000, + remainingMs: 14400000, + elapsedPercent: 20, + remainingPercent: 80 + }); + mockFormatUsageDuration.mockReturnValue('4hr'); + + expect(render(widget, item, { usageData: { weeklyUsage: 0 } })).toBeNull(); + // A null bucket pins usage to 0 while limits[] still supplies the reset time + expect(render(widget, item, { usageData: { weeklyUsage: 0, weeklyResetAt: '2030-07-01T00:00:00.000Z' } })).toBe('Weekly Reset: 4hr'); + expect(render(widget, item, { usageData: { weeklyUsage: 5 } })).toBe('Weekly Reset: 4hr'); + expect(render(widget, { id: 'weekly-reset', type: 'weekly-reset-timer' }, { usageData: { weeklyUsage: 0 } })).toBe('Weekly Reset: 4hr'); }); // One state covers both placeholders, since either means the same thing to diff --git a/src/widgets/__tests__/helpers/usage-widget-suites.ts b/src/widgets/__tests__/helpers/usage-widget-suites.ts index 9ec6790a2..e9fbd39d9 100644 --- a/src/widgets/__tests__/helpers/usage-widget-suites.ts +++ b/src/widgets/__tests__/helpers/usage-widget-suites.ts @@ -8,6 +8,7 @@ import { import type { RenderContext } from '../../../types/RenderContext'; import type { CustomKeybind, + HideableState, WidgetEditorDisplay, WidgetItem } from '../../../types/Widget'; @@ -15,6 +16,7 @@ import type { interface UsageWidgetLike { getCustomKeybinds(item?: WidgetItem): CustomKeybind[]; getEditorDisplay(item: WidgetItem): WidgetEditorDisplay; + getHideableStates(): HideableState[]; handleEditorAction(action: string, item: WidgetItem): WidgetItem | null; supportsRawValue(): boolean; } @@ -149,6 +151,33 @@ export function runUsagePercentWidgetSuite(conf }, { usageData: { error: 'timeout' } })).toBeNull(); }); + it('declares the no-data and zero hideable states', () => { + expect(config.createWidget().getHideableStates().map(state => state.key)).toEqual(['no-data', 'zero']); + }); + + it('hides zero usage when the zero state is enabled, regardless of invert', () => { + const widget = config.createWidget(); + const context = getUsageContext(config.usageField, 0); + + expect(config.render(widget, { ...config.baseItem, metadata: { hide: 'zero' } }, context)).toBeNull(); + expect(config.render(widget, { ...config.baseItem, metadata: { hide: 'zero', invert: 'true' } }, context)).toBeNull(); + }); + + it('renders zero usage when the zero state is off', () => { + const widget = config.createWidget(); + + const expectedZeroTime = config.expectedTime.replace(config.expectedRawTime, '0.0%'); + + expect(config.render(widget, config.baseItem, getUsageContext(config.usageField, 0))).toBe(expectedZeroTime); + }); + + it('renders non-zero usage when the zero state is enabled', () => { + const widget = config.createWidget(); + const context = getUsageContext(config.usageField, config.usageValue); + + expect(config.render(widget, { ...config.baseItem, metadata: { hide: 'zero' } }, context)).toBe(config.expectedTime); + }); + it('renders available usage data before unrelated usage errors', () => { const widget = config.createWidget(); const context: RenderContext = { diff --git a/src/widgets/shared/usage-display.ts b/src/widgets/shared/usage-display.ts index b2ad166a2..32426a54c 100644 --- a/src/widgets/shared/usage-display.ts +++ b/src/widgets/shared/usage-display.ts @@ -20,6 +20,7 @@ export type UsageDisplayMode = 'time' | 'progress' | 'progress-short' | 'slider' // Shared by the usage percentage widgets and the reset timers, which render the // same error placeholders export const USAGE_NO_DATA_HIDEABLE_STATE: HideableState = { key: 'no-data', label: 'when usage data is unavailable' }; +export const USAGE_ZERO_HIDEABLE_STATE: HideableState = { key: 'zero', label: 'when usage is zero' }; const SLIDER_WIDTH = 10; diff --git a/src/widgets/shared/usage-percent-widget.ts b/src/widgets/shared/usage-percent-widget.ts index 8e12728ef..78089a103 100644 --- a/src/widgets/shared/usage-percent-widget.ts +++ b/src/widgets/shared/usage-percent-widget.ts @@ -27,6 +27,7 @@ import { makeTimerProgressBar } from './progress-bar'; import { formatRawOrLabeledValue } from './raw-or-labeled'; import { USAGE_NO_DATA_HIDEABLE_STATE, + USAGE_ZERO_HIDEABLE_STATE, cycleUsageDisplayMode, getUsageDisplayMode, getUsageDisplayModifierText, @@ -194,6 +195,10 @@ export function renderUsagePercentWidgetValue( } const percent = Math.max(0, Math.min(100, usagePercent)); + if (percent === 0 && isHidden(item, USAGE_ZERO_HIDEABLE_STATE.key)) { + return null; + } + const renderedPercent = inverted ? 100 - percent : percent; return renderUsageDisplay(item, config.label, renderedPercent, format, () => {