fix: Move upgrade button to top of editor (#2687)

This commit is contained in:
Dermot Duffy
2026-08-14 21:13:10 -07:00
committed by GitHub
parent c2f25861ea
commit 858c5d0610
13 changed files with 297 additions and 55 deletions
@@ -277,7 +277,15 @@ export class CardElementManager {
const toggleDiagnostics = (): void => { const toggleDiagnostics = (): void => {
const viewManager = this._api.getViewManager(); const viewManager = this._api.getViewManager();
if (viewManager.getView()?.view === 'diagnostics') { if (viewManager.getView()?.view === 'diagnostics') {
if (viewManager.canSetViewDefault()) {
// Clear the view when no default one can be built, to return the card
// to what it showed before diagnostics (e.g. an initialization error).
viewManager.setViewDefault(); viewManager.setViewDefault();
} else {
// Won't be a blank card: will show the issue that stopped camera
// initialization, or the loading indicator if that is still running.
viewManager.reset();
}
} else { } else {
viewManager.setViewByParameters({ viewManager.setViewByParameters({
params: { view: 'diagnostics' }, params: { view: 'diagnostics' },
+1
View File
@@ -74,6 +74,7 @@ export interface ViewManagerInterface {
hasView(): boolean; hasView(): boolean;
reset(): void; reset(): void;
canSetViewDefault(): boolean;
setViewDefault(options?: ViewFactoryOptions): void; setViewDefault(options?: ViewFactoryOptions): void;
setViewByParameters(options?: ViewFactoryOptions): void; setViewByParameters(options?: ViewFactoryOptions): void;
+16 -1
View File
@@ -62,6 +62,12 @@ export class ViewManager implements ViewManagerInterface {
} }
} }
// Whether a default view will be built at all. A view-creation request made
// while this is false is silently ignored.
public canSetViewDefault(): boolean {
return this._isAllowedToProposeView();
}
setViewDefault = (options?: ViewFactoryOptions): void => setViewDefault = (options?: ViewFactoryOptions): void =>
this._setViewGeneric( this._setViewGeneric(
this._viewFactory.getViewDefault.bind(this._viewFactory), this._viewFactory.getViewDefault.bind(this._viewFactory),
@@ -116,7 +122,11 @@ export class ViewManager implements ViewManagerInterface {
viewFactoryFunc: (options?: ViewFactoryOptions) => View | null, viewFactoryFunc: (options?: ViewFactoryOptions) => View | null,
options?: ViewFactoryOptions, options?: ViewFactoryOptions,
): void { ): void {
if (!this._isAllowedToProposeView()) { const isDiagnosticsRequest = options?.params?.view === 'diagnostics';
// Diagnostics requires no camera, and is what the user is asked to open
// when the card is broken.
if (!isDiagnosticsRequest && !this._isAllowedToProposeView()) {
return; return;
} }
@@ -131,8 +141,13 @@ export class ViewManager implements ViewManagerInterface {
// does not linger invisibly and re-pop on the next evaluation cycle, // does not linger invisibly and re-pop on the next evaluation cycle,
// and that a stale media_query failure from an abandoned gallery / // and that a stale media_query failure from an abandoned gallery /
// viewer doesn't follow the user into an unrelated view. // viewer doesn't follow the user into an unrelated view.
//
// Diagnostics is the exception: those issues are part of what its report
// shows, so opening it must not erase them.
if (!isDiagnosticsRequest) {
this._api.getIssueManager().reset('view_incompatible'); this._api.getIssueManager().reset('view_incompatible');
this._api.getIssueManager().reset('media_query'); this._api.getIssueManager().reset('media_query');
}
} catch (e) { } catch (e) {
if (!this._view) { if (!this._view) {
view = this._getFailSafeView(viewFactoryFunc); view = this._getFailSafeView(viewFactoryFunc);
+8 -2
View File
@@ -413,6 +413,12 @@ export class AdvancedCameraCard extends LitElement {
this._config?.performance?.features.card_loading_indicator !== false && this._config?.performance?.features.card_loading_indicator !== false &&
!fullCardIssue; !fullCardIssue;
// Always render diagnostics. The issue itself remains and will be rendered
// outside of the diagostics view.
const issueToRender = this._controller.getViewManager().getView()?.is('diagnostics')
? null
: fullCardIssue;
// Caution: Keep the main div and the menu next to one another in order to // Caution: Keep the main div and the menu next to one another in order to
// ensure the hover menu styling continues to work. // ensure the hover menu styling continues to work.
return this._renderInDialogIfNecessary( return this._renderInDialogIfNecessary(
@@ -485,7 +491,7 @@ export class AdvancedCameraCard extends LitElement {
.cardWideConfig=${this._controller.getConfigManager().getCardWideConfig()} .cardWideConfig=${this._controller.getConfigManager().getCardWideConfig()}
.rawConfig=${this._controller.getConfigManager().getRawConfig()} .rawConfig=${this._controller.getConfigManager().getRawConfig()}
.configManager=${this._controller.getConfigManager()} .configManager=${this._controller.getConfigManager()}
.hide=${!!fullCardIssue} .hide=${!!issueToRender}
.microphoneManager=${this._controller.getMicrophoneManager()} .microphoneManager=${this._controller.getMicrophoneManager()}
.microphoneState=${this._controller.getMicrophoneManager().getState()} .microphoneState=${this._controller.getMicrophoneManager().getState()}
.call=${this._controller.getCallManager().getCall() ?? undefined} .call=${this._controller.getCallManager().getCall() ?? undefined}
@@ -500,7 +506,7 @@ export class AdvancedCameraCard extends LitElement {
.getStateManager() .getStateManager()
.getIssuePresence()} .getIssuePresence()}
></advanced-camera-card-views> ></advanced-camera-card-views>
${fullCardIssue ? renderNotificationBlock(fullCardIssue.notification) : ''} ${issueToRender ? renderNotificationBlock(issueToRender.notification) : ''}
</div> </div>
${this._renderMenuStatusContainer('bottom')} ${this._renderMenuStatusContainer('bottom')}
${this._config?.elements && ${this._config?.elements &&
+25 -4
View File
@@ -13,7 +13,10 @@ import {
import { setProfiles } from '../../config/profiles/set-profiles'; import { setProfiles } from '../../config/profiles/set-profiles';
import type { EditorMode } from '../../config/schema/editor'; import type { EditorMode } from '../../config/schema/editor';
import { profilesSchema, type ProfileType } from '../../config/schema/profiles'; import { profilesSchema, type ProfileType } from '../../config/schema/profiles';
import { configDefaults } from '../../config/schema/types'; import {
advancedCameraCardConfigSchema,
configDefaults,
} from '../../config/schema/types';
import type { import type {
RawAdvancedCameraCardConfig, RawAdvancedCameraCardConfig,
RawAdvancedCameraCardConfigArray, RawAdvancedCameraCardConfigArray,
@@ -35,9 +38,14 @@ import { getEditorCameraTitle, getEditorFolderTitle } from './titles';
type EditorControllerHost = ReactiveControllerHost & EventTarget; type EditorControllerHost = ReactiveControllerHost & EventTarget;
// A card-state notice the editor shows as a banner above the sections. // A card-state notice the editor shows as a banner above the sections.
interface EditorNotice { export interface EditorNotice {
type: 'info' | 'warning'; type: 'info' | 'warning';
message: string; message: string;
button?: {
label: string;
callback: () => void;
};
} }
// Interpret a raw configuration value as an array of configuration objects. // Interpret a raw configuration value as an array of configuration objects.
@@ -65,6 +73,7 @@ export class EditorController implements ReactiveController {
private _defaults = copyConfig(configDefaults); private _defaults = copyConfig(configDefaults);
private _configUpgradeable = false; private _configUpgradeable = false;
private _configValid = false;
private _initialized = false; private _initialized = false;
private _hass?: HomeAssistant; private _hass?: HomeAssistant;
@@ -108,6 +117,7 @@ export class EditorController implements ReactiveController {
private _applyConfig(config: RawAdvancedCameraCardConfig): void { private _applyConfig(config: RawAdvancedCameraCardConfig): void {
this._config = config; this._config = config;
this._configUpgradeable = isConfigUpgradeable(config); this._configUpgradeable = isConfigUpgradeable(config);
this._configValid = advancedCameraCardConfigSchema.safeParse(config).success;
this._editorMode = getEditorMode(config); this._editorMode = getEditorMode(config);
// The defaults are rebuilt from scratch so that removing (or breaking) a // The defaults are rebuilt from scratch so that removing (or breaking) a
@@ -124,6 +134,17 @@ export class EditorController implements ReactiveController {
public getNotices(): EditorNotice[] { public getNotices(): EditorNotice[] {
const notices: EditorNotice[] = []; const notices: EditorNotice[] = [];
if (this._configUpgradeable) {
notices.push({
type: 'warning',
message: localize('editor.upgrade_available'),
button: {
label: localize('editor.upgrade'),
callback: () => this.upgrade(),
},
});
}
if (this._profiles.includes('low-performance')) { if (this._profiles.includes('low-performance')) {
notices.push({ notices.push({
type: 'warning', type: 'warning',
@@ -196,8 +217,8 @@ export class EditorController implements ReactiveController {
return this._hass; return this._hass;
} }
public isConfigUpgradeable(): boolean { public isConfigValid(): boolean {
return this._configUpgradeable; return this._configValid;
} }
public upgrade(): void { public upgrade(): void {
+24 -23
View File
@@ -7,6 +7,7 @@ import {
type TemplateResult, type TemplateResult,
} from 'lit'; } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import './components/editor/cameras.js'; import './components/editor/cameras.js';
import './components/editor/doc-link.js'; import './components/editor/doc-link.js';
@@ -248,7 +249,7 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
${this._controller.getEditorMode() === 'simple' ${this._controller.getEditorMode() === 'simple'
? this._renderSimple(hass, input) ? this._renderSimple(hass, input)
: this._renderFull(hass, input)} : this._renderFull(hass, input)}
${this._renderActionButtons()} ${this._renderDiagnosticsButton()}
</div> </div>
`; `;
} }
@@ -318,29 +319,29 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
// Card-state notices rendered as banners above the sections (matching how // Card-state notices rendered as banners above the sections (matching how
// native Home Assistant editors surface such notes). // native Home Assistant editors surface such notes).
private _renderNotices(): TemplateResult { private _renderNotices(): TemplateResult {
return html`${this._controller return html`${this._controller.getNotices().map((notice) => {
.getNotices() const button = notice.button;
.map( return html`<ha-alert alert-type=${notice.type}>
(notice) => ${notice.message}
html`<ha-alert alert-type=${notice.type}>${notice.message}</ha-alert>`, ${button
)}`;
}
private _renderActionButtons(): TemplateResult {
return html`
<div class="action-buttons">
${this._controller.isConfigUpgradeable()
? html`<ha-button ? html`<ha-button
appearance="filled" slot="action"
variant="warning" appearance="outlined"
title=${localize('editor.upgrade_available')} variant=${ifDefined(notice.type === 'warning' ? 'warning' : undefined)}
aria-label=${localize('editor.upgrade_available')} @click=${() => button.callback()}
@click=${() => this._controller.upgrade()}
> >
${localize('editor.upgrade')} ${button.label}
</ha-button>` </ha-button>`
: ''} : ''}
<ha-button </ha-alert>`;
})}`;
}
private _renderDiagnosticsButton(): TemplateResult {
return html`${
// Diagnostics won't render on a card with a broken config.
this._controller.isConfigValid()
? html`<ha-button
title=${localize('editor.toggle_diagnostics')} title=${localize('editor.toggle_diagnostics')}
aria-label=${localize('editor.toggle_diagnostics')} aria-label=${localize('editor.toggle_diagnostics')}
@click=${() => { @click=${() => {
@@ -348,9 +349,9 @@ export class AdvancedCameraCardEditor extends LitElement implements LovelaceCard
}} }}
> >
${localize('editor.toggle_diagnostics')} ${localize('editor.toggle_diagnostics')}
</ha-button> </ha-button>`
</div> : ''
`; }`;
} }
// `ha-form` and `ha-selector` lazily `import()` their per-type sub-elements // `ha-form` and `ha-selector` lazily `import()` their per-type sub-elements
+1 -1
View File
@@ -784,7 +784,7 @@
"timeline": "Timeline", "timeline": "Timeline",
"timeline_secondary": "Event timeline options", "timeline_secondary": "Event timeline options",
"toggle_diagnostics": "Toggle diagnostics", "toggle_diagnostics": "Toggle diagnostics",
"upgrade": "Automatic Upgrade", "upgrade": "Upgrade",
"upgrade_available": "An automatic card configuration upgrade is available", "upgrade_available": "An automatic card configuration upgrade is available",
"view": "View", "view": "View",
"view_secondary": "What the card should show and how to show it" "view_secondary": "What the card should show and how to show it"
+8 -6
View File
@@ -13,6 +13,13 @@
margin-bottom: 8px; margin-bottom: 8px;
} }
// The alert sizes the slot its button sits in to `min-content`, which would
// otherwise break the label across lines.
.card-config > ha-alert ha-button {
width: max-content;
white-space: nowrap;
}
// The switch between the editors, set apart from what it switches: it is a // The switch between the editors, set apart from what it switches: it is a
// control over the editor rather than one of the card settings below it. Its // control over the editor rather than one of the card settings below it. Its
// icon sits beside the form so the row reads as one of the editor's rows, each // icon sits beside the form so the row reads as one of the editor's rows, each
@@ -60,11 +67,6 @@
margin-bottom: 8px; margin-bottom: 8px;
} }
.action-buttons { .card-config > ha-button {
display: flex;
flex-wrap: wrap;
}
ha-button {
margin: 10px; margin: 10px;
} }
+6 -1
View File
@@ -370,6 +370,11 @@ export interface MountOptions {
width?: string; width?: string;
height?: string; height?: string;
// The element the container is created as, for a test about behavior the card
// offers only *within* a particular Home Assistant element (e.g. its card
// edit dialog, which the card looks for among its ancestors).
containerTagName?: string;
// Where that container is placed, as CSS lengths from the page's top left // Where that container is placed, as CSS lengths from the page's top left
// corner. The page grows to reach it, so a card put beyond the window can // corner. The page grows to reach it, so a card put beyond the window can
// only be brought into view by scrolling. // only be brought into view by scrolling.
@@ -424,7 +429,7 @@ export class MountedCard {
) { ) {
this._hass = hass; this._hass = hass;
this._container = document.createElement('div'); this._container = document.createElement(options?.containerTagName ?? 'div');
this._container.style.width = options?.width ?? DEFAULT_CONTAINER_WIDTH; this._container.style.width = options?.width ?? DEFAULT_CONTAINER_WIDTH;
if (options?.height) { if (options?.height) {
this._container.style.height = options.height; this._container.style.height = options.height;
@@ -1,13 +1,23 @@
import { describe, expect, it, onTestFinished } from 'vitest'; import { describe, expect, it, onTestFinished } from 'vitest';
import type { RawAdvancedCameraCardConfig } from '../../src/config/types';
import { createLogAction } from '../../src/utils/action'; import { createLogAction } from '../../src/utils/action';
import { deepQueryAll, getFocusedElement, pressKey, pressTab } from '../browser/dom'; import {
deepQuery,
deepQueryAll,
getFocusedElement,
pressKey,
pressTab,
} from '../browser/dom';
import { MountedCardFactory, type MountedCard } from '../browser/mounted-card'; import { MountedCardFactory, type MountedCard } from '../browser/mounted-card';
import { import {
CARD_INITIALIZED_MESSAGE, CARD_INITIALIZED_MESSAGE,
createGenericCameraHASS, createGenericCameraHASS,
createInitializedAutomation, createInitializedAutomation,
createStillImageCameraConfig,
createStillImageCardConfig, createStillImageCardConfig,
getBlockNotificationText,
isLiveMediaShowing,
} from '../browser/test-utils'; } from '../browser/test-utils';
// What the automation writes when it runs, written as the pattern the console // What the automation writes when it runs, written as the pattern the console
@@ -68,7 +78,100 @@ const addTrailingControl = (): HTMLElement => {
return control; return control;
}; };
// The Home Assistant dialog the card is previewed in while its configuration is
// edited, which is what the card looks for before answering the editor's
// diagnostics button.
const EDIT_DIALOG_TAG_NAME = 'hui-dialog-edit-card';
const INIT_FAILED_ISSUE_HEADING = 'Initialization failed';
const DIAGNOSTICS_SELECTOR = 'advanced-camera-card-diagnostics';
const mountCardInEditDialog = async (
config: RawAdvancedCameraCardConfig,
): Promise<MountedCard> =>
await MountedCardFactory.createFromSource(config, createGenericCameraHASS(), {
containerTagName: EDIT_DIALOG_TAG_NAME,
});
/**
* Press the editor's diagnostics button. The editor is elsewhere in the dialog
* rather than within the card, so the event is fired from a sibling of it.
*/
const toggleDiagnostics = (card: MountedCard): void => {
const editor = document.createElement('div');
card.card.parentElement?.append(editor);
editor.dispatchEvent(
new CustomEvent('advanced-camera-card:editor:diagnostics', {
bubbles: true,
composed: true,
}),
);
editor.remove();
};
const isDiagnosticsShowing = (card: MountedCard): boolean =>
!!deepQuery(card.card, DIAGNOSTICS_SELECTOR);
describe('CardElementManager', () => { describe('CardElementManager', () => {
describe('should toggle diagnostics from the editor', () => {
it('should show diagnostics over a card that could not be started', async () => {
// A camera Home Assistant has never heard of, so the card cannot start
// and shows an issue in place of its views.
const card = await mountCardInEditDialog(
createStillImageCardConfig({
cameras: [createStillImageCameraConfig('camera.missing')],
view: { issues: { retry_seconds: 0 } },
}),
);
await card.waitForRender(
() =>
getBlockNotificationText(card.card).includes(INIT_FAILED_ISSUE_HEADING) ||
null,
'the initialization issue',
);
toggleDiagnostics(card);
// Diagnostics is what the user is asked for when the card is broken, so
// it must be reachable in the state the issue describes.
await card.waitForSelector(DIAGNOSTICS_SELECTOR);
expect(getBlockNotificationText(card.card)).not.toContain(
INIT_FAILED_ISSUE_HEADING,
);
toggleDiagnostics(card);
// Toggling diagnostics again just puts the issue back in front of the
// user.
await card.waitForRender(
() =>
getBlockNotificationText(card.card).includes(INIT_FAILED_ISSUE_HEADING) ||
null,
'the initialization issue',
);
expect(isDiagnosticsShowing(card)).toBe(false);
});
it('should return a started card to its default view', async () => {
const card = await mountCardInEditDialog(createStillImageCardConfig());
await card.events.waitForFirst('advanced-camera-card:media:loaded');
toggleDiagnostics(card);
await card.waitForSelector(DIAGNOSTICS_SELECTOR);
toggleDiagnostics(card);
await card.waitForRender(
() => isLiveMediaShowing(card.card) || null,
'the live view',
);
expect(isDiagnosticsShowing(card)).toBe(false);
});
});
it('should be reachable by tabbing', async () => { it('should be reachable by tabbing', async () => {
const card = await mountCard(); const card = await mountCard();
@@ -439,6 +439,7 @@ describe('CardElementManager', () => {
vi.mocked(api.getViewManager().getView).mockReturnValue( vi.mocked(api.getViewManager().getView).mockReturnValue(
new View({ view: 'diagnostics' }), new View({ view: 'diagnostics' }),
); );
vi.mocked(api.getViewManager().canSetViewDefault).mockReturnValue(true);
const dialog = createDialogWithCard(element); const dialog = createDialogWithCard(element);
document.body.append(dialog); document.body.append(dialog);
@@ -449,6 +450,31 @@ describe('CardElementManager', () => {
expect(api.getViewManager().setViewDefault).toHaveBeenCalled(); expect(api.getViewManager().setViewDefault).toHaveBeenCalled();
}); });
it('should reset the view when leaving diagnostics with no default view available', () => {
const api = createCardAPI();
const element = createCardHTMLElement();
const manager = new CardElementManager(
api,
element,
() => undefined,
() => undefined,
);
vi.mocked(api.getViewManager().getView).mockReturnValue(
new View({ view: 'diagnostics' }),
);
vi.mocked(api.getViewManager().canSetViewDefault).mockReturnValue(false);
const dialog = createDialogWithCard(element);
document.body.append(dialog);
manager.elementConnected();
fireFromDialog(dialog);
expect(api.getViewManager().reset).toHaveBeenCalled();
expect(api.getViewManager().setViewDefault).not.toHaveBeenCalled();
});
it('does not set view to diagnostics if card is not in editor', () => { it('does not set view to diagnostics if card is not in editor', () => {
const api = createCardAPI(); const api = createCardAPI();
const element = createCardHTMLElement(); const element = createCardHTMLElement();
@@ -203,6 +203,26 @@ describe('should not set view without cameras being initialized', () => {
expect(manager.getView()).toBeNull(); expect(manager.getView()).toBeNull();
}); });
it('should report whether a default view can be set', () => {
expect(new ViewManager(createInitializedCardAPI()).canSetViewDefault()).toBe(true);
expect(new ViewManager(createInitializedCardAPI(false)).canSetViewDefault()).toBe(
false,
);
});
it('should set the diagnostics view without cameras being initialized', () => {
const view = createView({ view: 'diagnostics' });
const factory = mock<ViewFactory>();
factory.getViewByParameters.mockReturnValue(view);
const manager = new ViewManager(createInitializedCardAPI(false), {
viewFactory: factory,
});
manager.setViewByParameters({ params: { view: 'diagnostics' } });
expect(manager.getView()).toBe(view);
});
}); });
describe('should respect microphone navigation lock', () => { describe('should respect microphone navigation lock', () => {
@@ -494,6 +514,19 @@ describe('should handle exceptions', () => {
expect(api.getIssueManager().reset).toHaveBeenCalledWith('view_incompatible'); expect(api.getIssueManager().reset).toHaveBeenCalledWith('view_incompatible');
}); });
it('should retain issues when the diagnostics view is requested', () => {
const viewFactory = mock<ViewFactory>();
viewFactory.getViewByParameters.mockReturnValue(createView({ view: 'diagnostics' }));
const api = createInitializedCardAPI();
const manager = new ViewManager(api, { viewFactory });
manager.setViewByParameters({ params: { view: 'diagnostics' } });
expect(manager.getView()?.is('diagnostics')).toBeTruthy();
expect(api.getIssueManager().reset).not.toHaveBeenCalledWith('view_incompatible');
expect(api.getIssueManager().reset).not.toHaveBeenCalledWith('media_query');
});
it('should return null when failSafe view factory also throws', () => { it('should return null when failSafe view factory also throws', () => {
const viewFactory = mock<ViewFactory>(); const viewFactory = mock<ViewFactory>();
viewFactory.getViewDefault.mockImplementation(() => { viewFactory.getViewDefault.mockImplementation(() => {
+27 -6
View File
@@ -10,7 +10,10 @@ import {
type Mock, type Mock,
} from 'vitest'; } from 'vitest';
import { EditorController } from '../../../src/components-lib/editor/controller'; import {
EditorController,
type EditorNotice,
} from '../../../src/components-lib/editor/controller';
import { getConfigValue } from '../../../src/config/management'; import { getConfigValue } from '../../../src/config/management';
import { configDefaults } from '../../../src/config/schema/types'; import { configDefaults } from '../../../src/config/schema/types';
import type { RawAdvancedCameraCardConfig } from '../../../src/config/types'; import type { RawAdvancedCameraCardConfig } from '../../../src/config/types';
@@ -62,6 +65,11 @@ const createUpgradeableConfig = (): RawAdvancedCameraCardConfig => ({
], ],
}); });
const getUpgradeNotice = (controller: EditorController): EditorNotice | null =>
controller
.getNotices()
.find((notice) => notice.message === localize('editor.upgrade_available')) ?? null;
// @vitest-environment jsdom // @vitest-environment jsdom
describe('EditorController', () => { describe('EditorController', () => {
beforeEach(() => { beforeEach(() => {
@@ -118,13 +126,24 @@ describe('EditorController', () => {
it('should detect an upgradeable configuration', () => { it('should detect an upgradeable configuration', () => {
const { controller } = createController(); const { controller } = createController();
expect(controller.isConfigUpgradeable()).toBeFalsy(); expect(getUpgradeNotice(controller)).toBeNull();
controller.setConfig(createUpgradeableConfig()); controller.setConfig(createUpgradeableConfig());
expect(controller.isConfigUpgradeable()).toBeTruthy(); expect(getUpgradeNotice(controller)).toBeTruthy();
controller.setConfig({ cameras: [] }); controller.setConfig({ cameras: [] });
expect(controller.isConfigUpgradeable()).toBeFalsy(); expect(getUpgradeNotice(controller)).toBeNull();
});
it('should detect an invalid configuration', () => {
const { controller } = createController();
expect(controller.isConfigValid()).toBeFalsy();
controller.setConfig({ type: 'custom:advanced-camera-card', cameras: [] });
expect(controller.isConfigValid()).toBeTruthy();
controller.setConfig({ type: 'custom:advanced-camera-card', cameras: 'nope' });
expect(controller.isConfigValid()).toBeFalsy();
}); });
it('should apply profile defaults', () => { it('should apply profile defaults', () => {
@@ -278,13 +297,15 @@ describe('EditorController', () => {
const { controller, configListener } = createController(); const { controller, configListener } = createController();
controller.setConfig(createUpgradeableConfig()); controller.setConfig(createUpgradeableConfig());
controller.upgrade(); const notice = getUpgradeNotice(controller);
assert(notice?.button);
notice.button.callback();
const config = getLastConfig(configListener); const config = getLastConfig(configListener);
expect(getConfigValue(config, 'elements.0.tap_action.data')).toEqual({ expect(getConfigValue(config, 'elements.0.tap_action.data')).toEqual({
message: 'Hello', message: 'Hello',
}); });
expect(controller.isConfigUpgradeable()).toBeFalsy(); expect(getUpgradeNotice(controller)).toBeNull();
}); });
it('should do nothing without a configuration', () => { it('should do nothing without a configuration', () => {