Files
advanced-camera-card/tests/card-controller/issues/issue-manager.test.ts
T

885 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
import { afterEach, assert, beforeEach, describe, expect, it, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { CardController } from '../../../src/card-controller/controller';
import {
IssueManager,
RETRY_EXPONENTIAL_BASE_SECONDS,
RETRY_EXPONENTIAL_MAX_SECONDS,
} from '../../../src/card-controller/issues/issue-manager';
import {
Issue,
IssueDescription,
IssueKey,
} from '../../../src/card-controller/issues/types';
import { ConditionStateManager } from '../../../src/condition-trigger/conditions/state-manager';
import { InteractionMode } from '../../../src/config/schema/view';
import {
createCardAPI,
createConfig,
createHASS,
flushPromises,
} from '../../test-utils';
const DEFAULT_RETRY_SECONDS = 1;
const createIssue = (key: IssueKey, overrides?: Partial<Issue>): Issue =>
mock({
key,
hasIssue: vi.fn().mockReturnValue(false),
getIssue: vi.fn().mockReturnValue(null),
needsRetry: vi.fn().mockReturnValue(false),
...overrides,
});
const createIssueDescription = (
overrides?: Partial<IssueDescription>,
): IssueDescription => ({
icon: 'mdi:alert',
severity: 'high',
notification: { body: { text: 'test' } },
...overrides,
});
const createRetriableSetup = (options?: {
retrySeconds?: 'auto' | number;
interactionMode?: InteractionMode;
hasInteraction?: boolean;
}): {
api: CardController;
manager: IssueManager;
issue: Issue;
} => {
const api = createCardAPI();
vi.mocked(api.getConditionStateManager().getState).mockReturnValue({});
if (options?.hasInteraction !== undefined) {
vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(
options.hasInteraction,
);
}
const config = createConfig();
vi.mocked(api.getConfigManager().getConfig).mockReturnValue({
...config,
view: {
...config.view,
issues: {
interaction_mode: options?.interactionMode ?? 'inactive',
retry_seconds: options?.retrySeconds ?? DEFAULT_RETRY_SECONDS,
},
},
});
const manager = new IssueManager(api);
const issue = createIssue('media_load', {
hasIssue: vi.fn().mockReturnValueOnce(false).mockReturnValue(true),
needsRetry: vi.fn().mockReturnValue(true),
retry: vi.fn().mockReturnValue(false),
});
manager.addIssue(issue);
return { api, manager, issue };
};
describe('IssueManager', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('should register a listener on the condition state manager on construction', () => {
const api = createCardAPI();
const stateManager = new ConditionStateManager();
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
const manager = new IssueManager(api);
const issue = createIssue('config_error', {
detectDynamic: vi.fn(),
});
manager.addIssue(issue);
stateManager.setState({ view: 'live' });
expect(issue.detectDynamic).toBeCalled();
});
describe('addIssue / getStateManager', () => {
it('should make added issues accessible via getManager', () => {
const manager = new IssueManager(createCardAPI());
const issue = createIssue('config_error');
manager.addIssue(issue);
expect(manager.getStateManager().getIssuePresence().has('config_error')).toBe(
false,
);
});
});
describe('static detection via condition-state listener', () => {
it('should run static detection when mandatory init completes', async () => {
const api = createCardAPI();
const conditionStateManager = new ConditionStateManager();
vi.mocked(api.getConditionStateManager).mockReturnValue(conditionStateManager);
const manager = new IssueManager(api);
const detectStatic = vi.fn().mockResolvedValue(undefined);
const issue = createIssue('legacy_resource', { detectStatic });
manager.addIssue(issue);
const hass = createHASS();
conditionStateManager.setState({ hass });
conditionStateManager.setState({ initialized: true });
await flushPromises();
expect(detectStatic).toBeCalledWith(hass);
});
it('should not run static detection when hass is unset', () => {
const api = createCardAPI();
const conditionStateManager = new ConditionStateManager();
vi.mocked(api.getConditionStateManager).mockReturnValue(conditionStateManager);
const manager = new IssueManager(api);
const detectStatic = vi.fn().mockResolvedValue(undefined);
const issue = createIssue('legacy_resource', { detectStatic });
manager.addIssue(issue);
conditionStateManager.setState({ initialized: true });
expect(detectStatic).not.toBeCalled();
});
it('should not run static detection on unrelated state changes', () => {
const api = createCardAPI();
const conditionStateManager = new ConditionStateManager();
vi.mocked(api.getConditionStateManager).mockReturnValue(conditionStateManager);
const manager = new IssueManager(api);
const detectStatic = vi.fn().mockResolvedValue(undefined);
const issue = createIssue('legacy_resource', { detectStatic });
manager.addIssue(issue);
const hass = createHASS();
conditionStateManager.setState({ hass });
conditionStateManager.setState({ view: 'live' });
expect(detectStatic).not.toBeCalled();
});
});
describe('trigger', () => {
it('should trigger the issue and call evaluate', () => {
const api = createCardAPI();
vi.mocked(api.getConditionStateManager().getState).mockReturnValue({});
const manager = new IssueManager(api);
const issue = createIssue('config_error', {
trigger: vi.fn(),
});
manager.addIssue(issue);
manager.trigger('config_error', { error: new Error('cfg') });
expect(issue.trigger).toBeCalledWith({ error: expect.any(Error) });
});
it('should update presence even when state was mutated before detectDynamic', () => {
const api = createCardAPI();
vi.mocked(api.getConditionStateManager().getState).mockReturnValue({});
vi.mocked(api.getConditionStateManager().setState).mockReturnValue(true);
const manager = new IssueManager(api);
// hasIssue returns true from the start -- simulates trigger() having
// already mutated state before detectDynamic snapshots. The before/after
// check inside detectDynamic sees true→true (no transition), but the
// presence comparison against ConditionState must still detect the
// change.
const description = createIssueDescription();
const issue = createIssue('config_error', {
hasIssue: vi.fn().mockReturnValue(true),
getIssue: vi.fn().mockReturnValue(description),
trigger: vi.fn(),
});
manager.addIssue(issue);
manager.trigger('config_error', { error: new Error('cfg') });
expect(api.getConditionStateManager().setState).toBeCalledWith({
issues: new Map([['config_error', description]]),
});
expect(api.getCardElementManager().update).toBeCalled();
});
it('should never auto-popup on trigger -- non-full-card issues surface via the status-bar icon; user clicks to open', () => {
const api = createCardAPI();
vi.mocked(api.getConditionStateManager().getState).mockReturnValue({});
const manager = new IssueManager(api);
const issue = createIssue('view_incompatible', {
hasIssue: vi.fn().mockReturnValue(true),
isFullCardIssue: vi.fn().mockReturnValue(false),
getIssue: vi.fn().mockReturnValue(createIssueDescription()),
getNotification: vi.fn().mockReturnValue({ body: { text: 'noop' } }),
trigger: vi.fn(),
});
manager.addIssue(issue);
manager.trigger('view_incompatible', { error: new Error('mismatch') });
expect(api.getNotificationManager().setNotification).not.toBeCalled();
});
});
describe('retry', () => {
it('should call retry on the manager and reset the timer', () => {
const { manager, issue } = createRetriableSetup();
// Start the timer via evaluate, then immediately retry.
manager.evaluate();
manager.retry('media_load');
expect(issue.retry).toBeCalled();
// Timer should have been reset -- advancing less than retrySeconds should
// not fire it again.
assert(issue.retry);
vi.mocked(issue.retry).mockClear();
vi.advanceTimersByTime(500);
expect(issue.retry).not.toBeCalled();
});
it('should force retry even when needsRetry is false', () => {
const api = createCardAPI();
vi.mocked(api.getConditionStateManager().getState).mockReturnValue({});
const manager = new IssueManager(api);
const issue = createIssue('media_load', {
retry: vi.fn().mockReturnValue(false),
});
manager.addIssue(issue);
manager.retry('media_load', true);
expect(issue.retry).toBeCalled();
});
});
describe('evaluate', () => {
it('should update condition state and card when presence differs from state', () => {
const api = createCardAPI();
vi.mocked(api.getConditionStateManager().getState).mockReturnValue({});
vi.mocked(api.getConditionStateManager().setState).mockReturnValue(true);
const manager = new IssueManager(api);
const description = createIssueDescription();
const issue = createIssue('config_error', {
hasIssue: vi.fn().mockReturnValue(true),
getIssue: vi.fn().mockReturnValue(description),
});
manager.addIssue(issue);
manager.evaluate();
expect(api.getConditionStateManager().setState).toBeCalledWith({
issues: new Map([['config_error', description]]),
});
expect(api.getCardElementManager().update).toBeCalled();
});
it('should sync presence to condition state without update when unchanged', () => {
const api = createCardAPI();
vi.mocked(api.getConditionStateManager().getState).mockReturnValue({});
const manager = new IssueManager(api);
const issue = createIssue('config_error');
manager.addIssue(issue);
manager.evaluate();
expect(api.getConditionStateManager().setState).toBeCalledWith({
issues: new Map(),
});
expect(api.getCardElementManager().update).not.toBeCalled();
});
it('should call update when an active issue swaps sub-states without changing the key set', () => {
// Simulates ConnectionIssue going from 'lost' to 'starting': the presence
// key set ({connection}) is identical, but the description value differs.
// Because IssuePresence is a Map<key, description>, the condition state
// diff sees the value-level change and fires listeners -- the
// IssueManager's own listener calls update().
const api = createCardAPI();
// Real ConditionStateManager so its isEqual-based diff actually runs.
const stateManager = new ConditionStateManager();
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
const manager = new IssueManager(api);
const getIssue = vi
.fn()
.mockReturnValue(
createIssueDescription({ notification: { body: { text: 'lost' } } }),
);
const issue = createIssue('connection', {
hasIssue: vi.fn().mockReturnValue(true),
getIssue,
});
manager.addIssue(issue);
manager.evaluate();
vi.mocked(api.getCardElementManager().update).mockClear();
// Same key set ({connection}), different description value.
getIssue.mockReturnValue(
createIssueDescription({ notification: { body: { text: 'starting' } } }),
);
manager.evaluate();
expect(api.getCardElementManager().update).toBeCalled();
});
it('should not call update when content is identical across evaluations', () => {
const api = createCardAPI();
const stateManager = new ConditionStateManager();
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
const manager = new IssueManager(api);
const issue = createIssue('connection', {
hasIssue: vi.fn().mockReturnValue(true),
getIssue: vi.fn().mockReturnValue(createIssueDescription()),
});
manager.addIssue(issue);
manager.evaluate();
vi.mocked(api.getCardElementManager().update).mockClear();
// Re-evaluate without any change.
manager.evaluate();
expect(api.getCardElementManager().update).not.toBeCalled();
});
it('should trigger evaluate from listener on condition state manager', () => {
const api = createCardAPI();
const stateManager = new ConditionStateManager();
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
const manager = new IssueManager(api);
const issue = createIssue('config_error', {
hasIssue: vi.fn().mockReturnValue(true),
detectDynamic: vi.fn(),
});
manager.addIssue(issue);
stateManager.setState({ view: 'live' });
expect(issue.detectDynamic).toBeCalled();
});
it('should not re-enter evaluate when setState triggers listener', () => {
const api = createCardAPI();
const stateManager = new ConditionStateManager();
vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager);
const manager = new IssueManager(api);
const issue = createIssue('config_error', {
hasIssue: vi.fn().mockReturnValue(true),
detectDynamic: vi.fn(),
});
manager.addIssue(issue);
// Calling evaluate() will call setState() on the real
// ConditionStateManager, which fires listeners synchronously. The
// reentrancy guard must prevent detectDynamic from running twice.
manager.evaluate();
expect(issue.detectDynamic).toBeCalledTimes(1);
});
});
describe('showNotification', () => {
it('should call setNotification when a notification is available', () => {
const api = createCardAPI();
vi.mocked(api.getConditionStateManager().getState).mockReturnValue({});
const manager = new IssueManager(api);
const notification = { body: { text: 'test notification' } };
const issue = createIssue('media_query', {
getNotification: vi.fn().mockReturnValue(notification),
});
manager.addIssue(issue);
manager.showNotification('media_query');
expect(api.getNotificationManager().setNotification).toBeCalledWith(notification);
});
it('should not call setNotification when no notification exists for key', () => {
const manager = new IssueManager(createCardAPI());
manager.showNotification('initialization');
expect(createCardAPI().getNotificationManager().setNotification).not.toBeCalled();
});
});
describe('scheduled retries', () => {
it('should not schedule a retry when no issue wants retry', () => {
const api = createCardAPI();
vi.mocked(api.getConditionStateManager().getState).mockReturnValue({});
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
const manager = new IssueManager(api);
const issue = createIssue('config_error');
manager.addIssue(issue);
manager.evaluate();
vi.runAllTimers();
expect(api.getViewManager().setViewWithMergedContext).not.toBeCalled();
});
it('should not schedule a retry when config is null', () => {
const api = createCardAPI();
vi.mocked(api.getConditionStateManager().getState).mockReturnValue({});
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(null);
const manager = new IssueManager(api);
const issue = createIssue('media_load', {
hasIssue: vi.fn().mockReturnValueOnce(false).mockReturnValue(true),
needsRetry: vi.fn().mockReturnValue(true),
retry: vi.fn().mockReturnValue(false),
});
manager.addIssue(issue);
manager.evaluate();
vi.runAllTimers();
expect(issue.retry).not.toBeCalled();
});
it('should not schedule a retry when retry_seconds is 0', () => {
const { manager, issue } = createRetriableSetup({ retrySeconds: 0 });
manager.evaluate();
vi.runAllTimers();
expect(issue.retry).not.toBeCalled();
});
it('should schedule a retry when an issue wants retry and retry_seconds > 0', () => {
const { manager, issue } = createRetriableSetup({ retrySeconds: 5 });
manager.evaluate();
vi.advanceTimersByTime(5000);
expect(issue.retry).toBeCalled();
});
it('should call retry on the issue when the timer fires', () => {
const { manager, issue } = createRetriableSetup();
manager.evaluate();
vi.advanceTimersByTime(DEFAULT_RETRY_SECONDS * 1000);
expect(issue.retry).toBeCalled();
});
it('should not schedule a second timer if one is already running', () => {
const { manager, issue } = createRetriableSetup({ retrySeconds: 10 });
manager.evaluate();
manager.evaluate();
vi.advanceTimersByTime(10000);
expect(issue.retry).toBeCalledTimes(1);
});
it('should stop repeated timer when needsRetry becomes false', () => {
const { manager, issue } = createRetriableSetup();
manager.evaluate();
vi.advanceTimersByTime(DEFAULT_RETRY_SECONDS * 1000);
expect(issue.retry).toBeCalledTimes(1);
assert(issue.needsRetry);
vi.mocked(issue.needsRetry).mockReturnValue(false);
vi.advanceTimersByTime(DEFAULT_RETRY_SECONDS * 1000);
expect(issue.retry).toBeCalledTimes(1);
vi.advanceTimersByTime(5000);
expect(issue.retry).toBeCalledTimes(1);
});
it('should skip scheduled retry when user is interacting and mode is inactive', () => {
const { manager, issue } = createRetriableSetup({
hasInteraction: true,
});
manager.evaluate();
vi.advanceTimersByTime(DEFAULT_RETRY_SECONDS * 1000);
expect(issue.retry).not.toBeCalled();
});
it('should allow scheduled retry when user is not interacting and mode is inactive', () => {
const { manager, issue } = createRetriableSetup({
hasInteraction: false,
});
manager.evaluate();
vi.advanceTimersByTime(DEFAULT_RETRY_SECONDS * 1000);
expect(issue.retry).toBeCalled();
});
it('should allow scheduled retry when mode is all regardless of interaction', () => {
const { manager, issue } = createRetriableSetup({
interactionMode: 'all',
hasInteraction: true,
});
manager.evaluate();
vi.advanceTimersByTime(DEFAULT_RETRY_SECONDS * 1000);
expect(issue.retry).toBeCalled();
});
it('should retry on next interval after interaction ends', () => {
const { api, manager, issue } = createRetriableSetup({
hasInteraction: true,
});
manager.evaluate();
vi.advanceTimersByTime(DEFAULT_RETRY_SECONDS * 1000);
expect(issue.retry).not.toBeCalled();
vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(false);
vi.advanceTimersByTime(DEFAULT_RETRY_SECONDS * 1000);
expect(issue.retry).toBeCalled();
});
});
describe('auto retry (exponential backoff)', () => {
it('should schedule the first retry within the 15s–30s jitter range', () => {
// Math.random returns 0 → jitter = 0.5 → delay = base * 0.5 = 15s.
vi.spyOn(Math, 'random').mockReturnValue(0);
const { manager, issue } = createRetriableSetup({ retrySeconds: 'auto' });
manager.evaluate();
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 0.5 * 1000 - 1);
expect(issue.retry).not.toBeCalled();
vi.advanceTimersByTime(1);
expect(issue.retry).toBeCalledTimes(1);
});
it('should schedule the first retry at the upper bound when jitter is max', () => {
// Math.random returns 1 → jitter = 1.0 → delay = base * 1.0 = 30s.
vi.spyOn(Math, 'random').mockReturnValue(1);
const { manager, issue } = createRetriableSetup({ retrySeconds: 'auto' });
manager.evaluate();
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 1.0 * 1000 - 1);
expect(issue.retry).not.toBeCalled();
vi.advanceTimersByTime(1);
expect(issue.retry).toBeCalledTimes(1);
});
it('should double the base delay on each successive attempt', () => {
// Math.random returns 0.5 → jitter = 0.75 → delays: 22.5, 45, 90 seconds.
vi.spyOn(Math, 'random').mockReturnValue(0.5);
const { manager, issue } = createRetriableSetup({ retrySeconds: 'auto' });
manager.evaluate();
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 0.75 * 1000);
expect(issue.retry).toBeCalledTimes(1);
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 2 * 0.75 * 1000);
expect(issue.retry).toBeCalledTimes(2);
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 4 * 0.75 * 1000);
expect(issue.retry).toBeCalledTimes(3);
});
it('should cap the backoff at the max delay', () => {
// Drive 5 pre-cap attempts (30, 60, 120, 240, 480 seconds), then assert
// the 6th attempt clamps to MAX instead of the would-be 960.
vi.spyOn(Math, 'random').mockReturnValue(1);
const { manager, issue } = createRetriableSetup({ retrySeconds: 'auto' });
manager.evaluate();
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 1 * 1000);
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 2 * 1000);
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 4 * 1000);
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 8 * 1000);
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 16 * 1000);
expect(issue.retry).toBeCalledTimes(5);
vi.advanceTimersByTime(RETRY_EXPONENTIAL_MAX_SECONDS * 1000);
expect(issue.retry).toBeCalledTimes(6);
});
it('should reset the attempt counter when the issue clears', () => {
vi.spyOn(Math, 'random').mockReturnValue(0.5);
const { manager, issue } = createRetriableSetup({ retrySeconds: 'auto' });
manager.evaluate();
// Run two retries -- second delay should be 2x the first.
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 0.75 * 1000);
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 2 * 0.75 * 1000);
expect(issue.retry).toBeCalledTimes(2);
// Clear the issue: needsRetry returns false. The next timer fire sees
// it cleared and resets the attempt counter.
assert(issue.needsRetry);
vi.mocked(issue.needsRetry).mockReturnValue(false);
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 4 * 0.75 * 1000);
expect(issue.retry).toBeCalledTimes(2);
// Re-arm: needsRetry returns true again, evaluate to re-schedule.
vi.mocked(issue.needsRetry).mockReturnValue(true);
manager.evaluate();
// Next delay should be back at the base (attempt 0), not continuing
// from where we left off.
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 0.75 * 1000);
expect(issue.retry).toBeCalledTimes(3);
});
it('should not grow the delay while retries are gated by user interaction', () => {
// Auto mode + interaction gating: when the timer fires while the user
// is interacting, the retry is skipped (not counted as an attempt) and
// the timer re-arms at the *same* delay, not the next exponential step.
vi.spyOn(Math, 'random').mockReturnValue(0.5);
const { api, manager, issue } = createRetriableSetup({
retrySeconds: 'auto',
hasInteraction: true,
});
manager.evaluate();
// Three gated firings -- each at the base delay (22.5s with 0.75 jitter).
// If the counter were incrementing on gated fires, the second would be at
// 45s and we'd never reach it after only 22.5s.
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 0.75 * 1000);
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 0.75 * 1000);
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 0.75 * 1000);
expect(issue.retry).not.toBeCalled();
// Clear the interaction. The next firing -- still at the base delay -- is
// now allowed and the retry runs.
vi.mocked(api.getInteractionManager().hasInteraction).mockReturnValue(false);
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 0.75 * 1000);
expect(issue.retry).toBeCalledTimes(1);
});
it('should reset the attempt counter when retries are disabled and re-enabled', () => {
// Drive auto-mode retries to push _retryAttempt > 0, then disable
// retries (retry_seconds=0) and re-enable. The next retry must fire at
// the base delay, not at the inflated delay the prior counter implies.
vi.spyOn(Math, 'random').mockReturnValue(0.5);
const { api, manager, issue } = createRetriableSetup({ retrySeconds: 'auto' });
manager.evaluate();
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 0.75 * 1000);
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 2 * 0.75 * 1000);
expect(issue.retry).toBeCalledTimes(2);
// Disable retries via config.
const config = createConfig();
vi.mocked(api.getConfigManager().getConfig).mockReturnValue({
...config,
view: {
...config.view,
issues: { interaction_mode: 'inactive', retry_seconds: 0 },
},
});
// Let the pending timer fire. The retry runs (#3), then evaluate sees
// retry_seconds=0 and resets _retryAttempt.
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 4 * 0.75 * 1000);
expect(issue.retry).toBeCalledTimes(3);
// Re-enable.
vi.mocked(api.getConfigManager().getConfig).mockReturnValue({
...config,
view: {
...config.view,
issues: { interaction_mode: 'inactive', retry_seconds: 'auto' },
},
});
manager.evaluate();
// Without the reset, _retryAttempt would be 3 here, making the next
// delay BASE * 8 * 0.75 = 180s. With the reset, it's BASE * 0.75 = 22.5s,
// so advancing only the base interval triggers the next retry.
vi.advanceTimersByTime(RETRY_EXPONENTIAL_BASE_SECONDS * 0.75 * 1000);
expect(issue.retry).toBeCalledTimes(4);
});
});
describe('reset', () => {
it('should reset a specific issue and re-evaluate', () => {
const api = createCardAPI();
vi.mocked(api.getConditionStateManager().getState).mockReturnValue({});
const manager = new IssueManager(api);
const issue = createIssue('config_error', {
hasIssue: vi.fn().mockReturnValue(true),
getIssue: vi.fn().mockReturnValue(createIssueDescription()),
reset: vi.fn(),
});
manager.addIssue(issue);
manager.reset('config_error');
expect(issue.reset).toBeCalled();
});
it('should skip reset when targeted key has no active issue', () => {
const api = createCardAPI();
vi.mocked(api.getConditionStateManager().getState).mockReturnValue({});
const manager = new IssueManager(api);
const issue = createIssue('config_error', {
hasIssue: vi.fn().mockReturnValue(false),
detectDynamic: vi.fn(),
reset: vi.fn(),
});
manager.addIssue(issue);
manager.reset('config_error');
expect(issue.reset).not.toBeCalled();
expect(issue.detectDynamic).not.toBeCalled();
});
});
describe('suspend / resume', () => {
it('should stop the retry timer on suspend', () => {
const { manager, issue } = createRetriableSetup({ retrySeconds: 5 });
manager.evaluate();
manager.suspend();
vi.advanceTimersByTime(5000);
expect(issue.retry).not.toBeCalled();
});
it('should gate evaluate while suspended', () => {
const api = createCardAPI();
vi.mocked(api.getConditionStateManager().getState).mockReturnValue({});
const manager = new IssueManager(api);
const issue = createIssue('config_error', {
hasIssue: vi.fn().mockReturnValue(true),
detectDynamic: vi.fn(),
});
manager.addIssue(issue);
manager.suspend();
manager.evaluate();
expect(issue.detectDynamic).not.toBeCalled();
});
it('should preserve issue state across suspend', () => {
const api = createCardAPI();
vi.mocked(api.getConditionStateManager().getState).mockReturnValue({});
const manager = new IssueManager(api);
const issue = createIssue('config_error', {
hasIssue: vi.fn().mockReturnValue(true),
getIssue: vi.fn().mockReturnValue(createIssueDescription()),
});
manager.addIssue(issue);
manager.suspend();
expect(manager.getStateManager().getIssuePresence().has('config_error')).toBe(
true,
);
});
it('should resume evaluation on resume', () => {
const api = createCardAPI();
vi.mocked(api.getConditionStateManager().getState).mockReturnValue({});
vi.mocked(api.getConditionStateManager().setState).mockReturnValue(true);
const manager = new IssueManager(api);
const issue = createIssue('config_error', {
hasIssue: vi.fn().mockReturnValue(true),
detectDynamic: vi.fn(),
});
manager.addIssue(issue);
manager.suspend();
manager.resume();
expect(issue.detectDynamic).toBeCalled();
expect(api.getCardElementManager().update).toBeCalled();
});
it('should invoke Issue.suspend on timer-backed issues when suspended', () => {
const api = createCardAPI();
vi.mocked(api.getConditionStateManager().getState).mockReturnValue({});
const manager = new IssueManager(api);
const issue = createIssue('media_load', { suspend: vi.fn() });
manager.addIssue(issue);
manager.suspend();
expect(issue.suspend).toBeCalled();
});
it('should tolerate issues without a suspend hook', () => {
const api = createCardAPI();
vi.mocked(api.getConditionStateManager().getState).mockReturnValue({});
const manager = new IssueManager(api);
// Plain Issue implementation -- no optional methods installed.
const issue: Issue = {
key: 'config_error',
hasIssue: () => false,
getIssue: () => null,
};
manager.addIssue(issue);
// Must not throw.
manager.suspend();
});
});
describe('destroy', () => {
it('should stop the retry timer and destroy the manager', () => {
const { manager, issue } = createRetriableSetup({ retrySeconds: 5 });
assert(issue.reset);
manager.evaluate();
manager.destroy();
vi.advanceTimersByTime(5000);
expect(issue.retry).not.toBeCalled();
expect(issue.reset).toBeCalled();
});
});
});