Compare commits

...
Author SHA1 Message Date
Petar Petrov a83a2d3227 Simplify the item-switch reset
Drop the staleness guards that only kept a stale value from painting a
still-live element, the scene loading state, and the field resets the
save flow already owns. Clearing entityRegCreated mid-save left a
successful save waiting on a promise nobody resolves.

Snapshot entityRegistryUpdate for the duration of a save so a concurrent
reset cannot clear it, and disable the scene YAML toggle while the config
loads so it cannot open an empty editor.
2026-08-27 08:02:16 +03:00
Petar Petrov 8061ccbee9 Ignore stale async loads after the edited item changes 2026-08-26 12:15:04 +03:00
Petar Petrov 459c501510 Deduplicate item-switch guards and scene live-mode teardown 2026-08-26 11:55:15 +03:00
Petar Petrov 177e5afd4c Add regression tests for editor item switching 2026-08-26 11:25:22 +03:00
Petar Petrov c91b9dfa69 Reset scene editor state when switching scenes 2026-08-26 11:25:22 +03:00
Petar Petrov 9537ada261 Reset automation and script editor state when switching items 2026-08-26 11:25:12 +03:00
8 changed files with 594 additions and 31 deletions
@@ -150,6 +150,11 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
return super.isDirtyState || !!this.yamlErrors;
}
protected override resetEditorState() {
super.resetEditorState();
this._undoRedoController.reset();
}
protected willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
@@ -657,6 +662,9 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
);
const oldAutomationId = changedProps.get("automationId");
this.resetOnItemSwitch(oldAutomationId, this.automationId);
this.resetOnItemSwitch(changedProps.get("entityId"), this.entityId);
if (
changedProps.has("automationId") &&
this.automationId &&
@@ -704,7 +712,11 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
}
if (changedProps.has("entityId") && this.entityId) {
const generation = ++this.loadGeneration;
getAutomationStateConfig(this.hass, this.entityId).then((c) => {
if (generation !== this.loadGeneration) {
return;
}
this.config = normalizeAutomationConfig(c.config);
this._initDirtyTracking(
{ type: "deep" },
@@ -919,6 +931,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
private async _takeControl() {
const config = this.config as BlueprintAutomationConfig;
const generation = this.loadGeneration;
try {
const result = await substituteBlueprint(
@@ -927,6 +940,9 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
config.use_blueprint.path,
config.use_blueprint.input || {}
);
if (generation !== this.loadGeneration) {
return;
}
const newConfig = {
...normalizeAutomationConfig(result.substituted_config),
@@ -1050,6 +1066,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
await this._saveAutomation(id);
if (!this.automationId) {
this.justSavedId = id;
navigate(`/config/automation/edit/${id}`, { replace: true });
}
}
@@ -1059,7 +1076,8 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
this.validationErrors = undefined;
let entityRegPromise: Promise<EntityRegistryEntry> | undefined;
if (this.entityRegistryUpdate !== undefined && !this.currentEntityId) {
const entityRegistryUpdate = this.entityRegistryUpdate;
if (entityRegistryUpdate !== undefined && !this.currentEntityId) {
this._newAutomationId = id;
entityRegPromise = new Promise<EntityRegistryEntry>((resolve) => {
this.entityRegCreated = resolve;
@@ -1069,7 +1087,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
try {
await saveAutomationConfig(this.hass, id, this.config!);
if (this.entityRegistryUpdate !== undefined) {
if (entityRegistryUpdate !== undefined) {
let entityId = this.currentEntityId;
// wait for automation to appear in entity registry when creating a new automation
@@ -1104,10 +1122,10 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
if (entityId) {
await updateEntityRegistryEntry(this.hass, entityId, {
categories: {
automation: this.entityRegistryUpdate.category || null,
automation: entityRegistryUpdate.category || null,
},
labels: this.entityRegistryUpdate.labels || [],
area_id: this.entityRegistryUpdate.area || null,
labels: entityRegistryUpdate.labels || [],
area_id: entityRegistryUpdate.area || null,
});
}
}
@@ -155,6 +155,16 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
value: PromiseLike<EntityRegistryEntry> | EntityRegistryEntry
) => void;
/**
* Id of an item that was just saved from the "new" editor. The follow-up
* navigation from edit/new to edit/<id> reuses this element and must not
* reset the editor state.
*/
protected justSavedId?: string;
/** Bumped when the edited item changes so stale item loads are ignored. */
protected loadGeneration = 0;
private _relatedContextAreaId?: string;
protected willUpdate(changedProps: PropertyValues): void {
@@ -273,11 +283,56 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
return Promise.resolve(true);
}
/**
* Reset per-item state when the edited item changes while this element is
* reused (same route page, different id), emulating a freshly opened
* editor. `saving` and `entityRegCreated` are deliberately absent: they
* are owned by an in-flight save.
*/
protected resetEditorState() {
this.loadGeneration++;
this.config = undefined;
this.mode = "gui";
this.readOnly = false;
this.errors = undefined;
this.yamlErrors = undefined;
this.validationErrors = undefined;
this.blueprintConfig = undefined;
this.deprecatedConfigMigrated = false;
this.entityRegistryUpdate = undefined;
this.currentEntityId = undefined;
this._initDirtyTracking({ type: "deep" });
}
/**
* Invoke from `updated()` with a changed id property's old and new value.
* Resets the editor state on a switch to a different item, except when
* the old value is `undefined` (the first assignment on a fresh element)
* or the id was just assigned by saving a new item.
*/
protected resetOnItemSwitch(
oldId: string | null | undefined,
newId: string | null
) {
if (oldId === undefined || oldId === newId) {
return;
}
if (newId && newId === this.justSavedId) {
this.justSavedId = undefined;
} else {
this.resetEditorState();
}
}
protected async loadConfig(id: string) {
const hooks = this.domainHooks;
const domain = hooks.domain;
const generation = ++this.loadGeneration;
try {
const config = await hooks.fetchFileConfig(this.hass, id);
if (generation !== this.loadGeneration) {
return;
}
this.readOnly = false;
const report: AutomationMigrationReport = { deprecated: false };
this.config = hooks.normalizeConfig(config, report);
@@ -294,6 +349,9 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
);
hooks.checkValidation();
} catch (err: any) {
if (generation !== this.loadGeneration) {
return;
}
if (err.status_code !== 404) {
const alertText =
err.body?.message || err.body || err.error || "Unknown error";
+75 -21
View File
@@ -164,6 +164,16 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
private _newSceneId?: string;
/**
* Id of a scene that was just saved from the "new" editor. The follow-up
* navigation from edit/new to edit/<id> reuses this element and must not
* reset the editor state.
*/
private _justSavedId?: string;
/** Bumped when the edited scene changes so stale scene loads are ignored. */
private _loadGeneration = 0;
private _entityRegCreated?: (
value: PromiseLike<EntityRegistryEntry> | EntityRegistryEntry
) => void;
@@ -288,7 +298,7 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
<ha-svg-icon slot="icon" .path=${mdiPencil}></ha-svg-icon>
</ha-dropdown-item>
<ha-dropdown-item value="toggle-yaml">
<ha-dropdown-item value="toggle-yaml" .disabled=${!this._config}>
${this.hass.localize(
`ui.panel.config.automation.editor.edit_${this._mode !== "yaml" ? "yaml" : "ui"}`
)}
@@ -667,6 +677,14 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
const oldscene = changedProps.get("sceneId");
if (oldscene !== undefined && oldscene !== this.sceneId) {
if (this.sceneId && this.sceneId === this._justSavedId) {
this._justSavedId = undefined;
} else {
this._resetEditorState();
}
}
if (
changedProps.has("sceneId") &&
this.sceneId &&
@@ -839,15 +857,20 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
private _enterYamlMode() {
if (this._mode === "live") {
this._generateConfigFromLive();
if (this._unsubscribeEvents) {
this._unsubscribeEvents();
this._unsubscribeEvents = undefined;
}
applyScene(this.hass, this._storedStates);
this._stopLiveMode();
}
this._mode = "yaml";
}
/** Unsubscribe from live updates and restore the pre-live entity states. */
private _stopLiveMode() {
if (this._unsubscribeEvents) {
this._unsubscribeEvents();
this._unsubscribeEvents = undefined;
}
applyScene(this.hass, this._storedStates);
}
private async _toggleLiveMode() {
if (this._mode === "live") {
this._exitLiveMode();
@@ -881,11 +904,7 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
private _exitLiveMode() {
this._generateConfigFromLive();
if (this._unsubscribeEvents) {
this._unsubscribeEvents();
this._unsubscribeEvents = undefined;
}
applyScene(this.hass, this._storedStates);
this._stopLiveMode();
this._mode = "review";
}
@@ -911,11 +930,15 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
}
private async _subscribeEvents() {
this._unsubscribeEvents =
await this.hass!.connection.subscribeEvents<HassEvent>(
(event) => this._stateChanged(event),
"state_changed"
);
const unsubscribe = await this.hass!.connection.subscribeEvents<HassEvent>(
(event) => this._stateChanged(event),
"state_changed"
);
if (this._mode !== "live") {
unsubscribe();
return;
}
this._unsubscribeEvents = unsubscribe;
}
private _showMoreInfo(ev: Event) {
@@ -924,10 +947,14 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
}
private async _loadConfig() {
const generation = ++this._loadGeneration;
let config: SceneConfig;
try {
config = await getSceneConfig(this.hass, this.sceneId!);
} catch (err: any) {
if (generation !== this._loadGeneration) {
return;
}
await showAlertDialog(this, {
text:
err.status_code === 404
@@ -943,6 +970,10 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
return;
}
if (generation !== this._loadGeneration) {
return;
}
if (!config.entities) {
config.entities = {};
}
@@ -958,6 +989,27 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
this._config = config;
}
/** Emulate a freshly opened editor when the edited scene changes. */
private _resetEditorState() {
this._loadGeneration++;
if (this._mode === "live") {
this._stopLiveMode();
}
this._config = undefined;
this._mode = "review";
this._errors = undefined;
this._yamlErrors = undefined;
this._scene = undefined;
this._entities = [];
this._single_entities = [];
this._devices = [];
this._storedStates = {};
this._activateContextId = undefined;
this._entityRegistryUpdate = undefined;
this._sceneRevision = 0;
this._initDirtyTracking({ type: "shallow" }, 0);
}
private _initEntities(config: SceneConfig) {
this._entities = Object.keys(config.entities);
this._single_entities = [];
@@ -1255,7 +1307,8 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
this._saving = true;
let entityRegPromise: Promise<EntityRegistryEntry> | undefined;
if (this._entityRegistryUpdate !== undefined && !this.sceneId) {
const entityRegistryUpdate = this._entityRegistryUpdate;
if (entityRegistryUpdate !== undefined && !this.sceneId) {
this._newSceneId = id;
entityRegPromise = new Promise<EntityRegistryEntry>((resolve) => {
this._entityRegCreated = resolve;
@@ -1266,7 +1319,7 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
await saveScene(this.hass, id, this._config!);
this._errors = undefined;
if (this._entityRegistryUpdate !== undefined) {
if (entityRegistryUpdate !== undefined) {
let entityId = this._scene?.entity_id;
// wait for scene to appear in entity registry when creating a new scene
@@ -1300,10 +1353,10 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
if (entityId) {
await updateEntityRegistryEntry(this.hass, entityId, {
area_id: this._entityRegistryUpdate.area || null,
labels: this._entityRegistryUpdate.labels || [],
area_id: entityRegistryUpdate.area || null,
labels: entityRegistryUpdate.labels || [],
categories: {
scene: this._entityRegistryUpdate.category || null,
scene: entityRegistryUpdate.category || null,
},
});
}
@@ -1311,6 +1364,7 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
this._markDirtyStateClean();
if (isNewScene) {
this._justSavedId = id;
navigate(`/config/scene/edit/${id}`, { replace: true });
}
} catch (err: any) {
+26 -5
View File
@@ -111,6 +111,11 @@ export class HaScriptEditor extends SubscribeMixin(
return super.isDirtyState || !!this.yamlErrors;
}
protected override resetEditorState() {
super.resetEditorState();
this._undoRedoController.reset();
}
protected willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
@@ -564,6 +569,12 @@ export class HaScriptEditor extends SubscribeMixin(
super.updated(changedProps);
const oldScript = changedProps.get("scriptId");
if (!this.entityId) {
// the show route assigns scriptId internally
this.resetOnItemSwitch(oldScript, this.scriptId);
}
this.resetOnItemSwitch(changedProps.get("entityId"), this.entityId);
if (
changedProps.has("scriptId") &&
this.scriptId &&
@@ -609,7 +620,11 @@ export class HaScriptEditor extends SubscribeMixin(
}
if (changedProps.has("entityId") && this.entityId) {
const generation = ++this.loadGeneration;
getScriptStateConfig(this.hass, this.entityId).then((c) => {
if (generation !== this.loadGeneration) {
return;
}
this.config = normalizeScriptConfig(c.config);
this._initDirtyTracking(
{ type: "deep" },
@@ -827,6 +842,7 @@ export class HaScriptEditor extends SubscribeMixin(
private async _takeControl() {
const config = this.config as BlueprintScriptConfig;
const generation = this.loadGeneration;
try {
const result = await substituteBlueprint(
@@ -835,6 +851,9 @@ export class HaScriptEditor extends SubscribeMixin(
config.use_blueprint.path,
config.use_blueprint.input || {}
);
if (generation !== this.loadGeneration) {
return;
}
const newConfig = {
...normalizeScriptConfig(result.substituted_config),
@@ -964,6 +983,7 @@ export class HaScriptEditor extends SubscribeMixin(
await this._saveScript(id);
if (!this.scriptId) {
this.justSavedId = String(id);
navigate(`/config/script/edit/${id}`, { replace: true });
}
}
@@ -972,7 +992,8 @@ export class HaScriptEditor extends SubscribeMixin(
this.saving = true;
let entityRegPromise: Promise<EntityRegistryEntry> | undefined;
if (this.entityRegistryUpdate !== undefined && !this.scriptId) {
const entityRegistryUpdate = this.entityRegistryUpdate;
if (entityRegistryUpdate !== undefined && !this.scriptId) {
this._newScriptId = id.toString();
entityRegPromise = new Promise<EntityRegistryEntry>((resolve) => {
this.entityRegCreated = resolve;
@@ -986,7 +1007,7 @@ export class HaScriptEditor extends SubscribeMixin(
this.config
);
if (this.entityRegistryUpdate !== undefined) {
if (entityRegistryUpdate !== undefined) {
let entityId = this.currentEntityId;
// wait for new script to appear in entity registry
@@ -1022,10 +1043,10 @@ export class HaScriptEditor extends SubscribeMixin(
if (entityId) {
await updateEntityRegistryEntry(this.hass, entityId, {
categories: {
script: this.entityRegistryUpdate.category || null,
script: entityRegistryUpdate.category || null,
},
labels: this.entityRegistryUpdate.labels || [],
area_id: this.entityRegistryUpdate.area || null,
labels: entityRegistryUpdate.labels || [],
area_id: entityRegistryUpdate.area || null,
});
}
}
+14
View File
@@ -0,0 +1,14 @@
/** Wait a macrotask so async work started by a lifecycle hook settles. */
export const flush = () =>
new Promise((resolve) => {
setTimeout(resolve, 0);
});
/**
* Simulate the router re-pointing a reused editor element at another item:
* set the property, then invoke the lifecycle hook with the old value the
* way Lit would after an update.
*/
export const runUpdated = (el: any, changed: Record<string, unknown>) => {
el.updated(new Map(Object.entries(changed)));
};
@@ -0,0 +1,152 @@
import { describe, expect, test, vi } from "vitest";
import "../../../../src/panels/config/automation/ha-automation-editor";
import type { HaAutomationEditor } from "../../../../src/panels/config/automation/ha-automation-editor";
import type { AutomationConfig } from "../../../../src/data/automation";
import { createMockHass } from "../../../fixtures/hass";
import { flush, runUpdated } from "../../../fixtures/lit";
const configFor = (alias: string): AutomationConfig => ({
alias,
triggers: [],
conditions: [],
actions: [],
});
const createEditor = (): any => {
const el = document.createElement(
"ha-automation-editor"
) as HaAutomationEditor;
const hass = createMockHass();
(hass as any).callApi = vi.fn(async (_method: string, path: string) =>
configFor(path.split("/").pop()!)
);
(hass as any).callWS = vi.fn(async () => ({ config: configFor("state") }));
el.hass = hass;
el.automations = [];
return el;
};
describe("automation editor item switch", () => {
test("switching to another automation resets the editor state", async () => {
const el = createEditor();
el.automationId = "a";
runUpdated(el, { automationId: undefined });
await flush();
expect(el.config?.alias).toBe("a");
el.mode = "yaml";
el.yamlErrors = "unparseable";
el.errors = "save failed";
el._undoRedoController.commit(configFor("a-edited"));
expect(el._undoRedoController.canUndo).toBe(true);
el.automationId = "b";
runUpdated(el, { automationId: "a" });
expect(el.mode).toBe("gui");
expect(el.yamlErrors).toBeUndefined();
expect(el.errors).toBeUndefined();
expect(el._undoRedoController.canUndo).toBe(false);
await flush();
expect(el.config?.alias).toBe("b");
expect(el.isDirtyState).toBe(false);
});
test("keeps state when the id change comes from saving a new automation", async () => {
const el = createEditor();
el.automationId = null;
runUpdated(el, { automationId: undefined });
expect(el.config).toBeDefined();
el.mode = "yaml";
el.justSavedId = "123";
el.automationId = "123";
runUpdated(el, { automationId: null });
expect(el.mode).toBe("yaml");
expect(el.justSavedId).toBeUndefined();
await flush();
expect(el.config?.alias).toBe("123");
});
test("resets when leaving an unsaved new automation for an existing one", () => {
const el = createEditor();
el.automationId = null;
runUpdated(el, { automationId: undefined });
el.mode = "yaml";
el.automationId = "b";
runUpdated(el, { automationId: null });
expect(el.mode).toBe("gui");
});
test("resets when switching from an existing automation to a new one", async () => {
const el = createEditor();
el.automationId = "a";
runUpdated(el, { automationId: undefined });
await flush();
el.mode = "yaml";
el.automationId = null;
runUpdated(el, { automationId: "a" });
expect(el.mode).toBe("gui");
expect(el.config?.triggers).toEqual([]);
});
test("does not reset on the first id assignment of a fresh element", async () => {
const el = createEditor();
el.yamlErrors = "sentinel";
el.automationId = "a";
runUpdated(el, { automationId: undefined });
expect(el.yamlErrors).toBe("sentinel");
await flush();
expect(el.config?.alias).toBe("a");
});
test("ignores a config fetch that resolves after switching items", async () => {
const el = createEditor();
let resolveA!: (value: AutomationConfig) => void;
(el.hass.callApi as any).mockImplementation(
(_method: string, path: string) => {
const id = path.split("/").pop();
if (id === "a") {
return new Promise((resolve) => {
resolveA = resolve;
});
}
return Promise.resolve(configFor(id!));
}
);
el.automationId = "a";
runUpdated(el, { automationId: undefined });
el.automationId = "b";
runUpdated(el, { automationId: "a" });
await flush();
expect(el.config?.alias).toBe("b");
resolveA(configFor("a"));
await flush();
expect(el.config?.alias).toBe("b");
});
test("switching to another entity in the read-only view resets the editor state", async () => {
const el = createEditor();
el.entityId = "automation.one";
runUpdated(el, { entityId: undefined });
await flush();
el.mode = "yaml";
el.entityId = "automation.two";
runUpdated(el, { entityId: "automation.one" });
expect(el.mode).toBe("gui");
expect(el.readOnly).toBe(true);
await flush();
expect(el.config?.alias).toBe("state");
});
});
@@ -0,0 +1,155 @@
import { describe, expect, test, vi } from "vitest";
import "../../../../src/panels/config/scene/ha-scene-editor";
import type { HaSceneEditor } from "../../../../src/panels/config/scene/ha-scene-editor";
import type { SceneConfig } from "../../../../src/data/scene";
import { showSceneEditor } from "../../../../src/data/scene";
import { createMockHass } from "../../../fixtures/hass";
import { flush, runUpdated } from "../../../fixtures/lit";
const createEditor = (): any => {
const el = document.createElement("ha-scene-editor") as HaSceneEditor;
const hass = createMockHass();
(hass as any).callApi = vi.fn(async (_method: string, path: string) => ({
name: `${path.split("/").pop()} name`,
entities: {},
}));
(hass as any).callService = vi.fn(async () => ({}));
(hass as any).connection = { subscribeEvents: vi.fn(async () => vi.fn()) };
el.hass = hass;
el.scenes = [];
return el;
};
describe("scene editor item switch", () => {
test("switching to another scene resets the editor state", async () => {
const el = createEditor();
el.sceneId = "scene_a";
runUpdated(el, { sceneId: undefined });
await flush();
expect(el._config?.name).toBe("scene_a name");
el._mode = "yaml";
el._yamlErrors = "unparseable";
el.sceneId = "scene_b";
runUpdated(el, { sceneId: "scene_a" });
expect(el._mode).toBe("review");
expect(el._yamlErrors).toBeUndefined();
await flush();
expect(el._config?.name).toBe("scene_b name");
});
test("leaving live mode restores stored states and unsubscribes", async () => {
const el = createEditor();
el.sceneId = "scene_a";
runUpdated(el, { sceneId: undefined });
await flush();
const unsubscribe = vi.fn();
const storedStates = { "light.kitchen": "on" };
el._mode = "live";
el._storedStates = storedStates;
el._unsubscribeEvents = unsubscribe;
el.sceneId = "scene_b";
runUpdated(el, { sceneId: "scene_a" });
expect(el.hass.callService).toHaveBeenCalledWith("scene", "apply", {
entities: storedStates,
});
expect(unsubscribe).toHaveBeenCalled();
expect(el._unsubscribeEvents).toBeUndefined();
expect(el._mode).toBe("review");
});
test("duplicating a scene opens the copy in review mode", async () => {
const el = createEditor();
el.sceneId = "scene_a";
runUpdated(el, { sceneId: undefined });
await flush();
el._mode = "yaml";
showSceneEditor({ name: "scene_a name (Duplicate)", entities: {} });
el.sceneId = null;
runUpdated(el, { sceneId: "scene_a" });
expect(el._mode).toBe("review");
expect(el._config?.name).toBe("scene_a name (Duplicate)");
expect(el.hass.connection.subscribeEvents).not.toHaveBeenCalled();
expect(el.isDirtyState).toBe(true);
});
test("ignores a scene fetch that resolves after switching scenes", async () => {
const el = createEditor();
let resolveA!: (value: SceneConfig) => void;
(el.hass.callApi as any).mockImplementation(
(_method: string, path: string) => {
if (path.endsWith("/scene_a")) {
return new Promise((resolve) => {
resolveA = resolve;
});
}
return Promise.resolve({
name: `${path.split("/").pop()} name`,
entities: {},
});
}
);
el.sceneId = "scene_a";
runUpdated(el, { sceneId: undefined });
el.sceneId = "scene_b";
runUpdated(el, { sceneId: "scene_a" });
await flush();
expect(el._config?.name).toBe("scene_b name");
resolveA({ name: "scene_a name", entities: {} });
await flush();
expect(el._config?.name).toBe("scene_b name");
});
test("discards a live subscription that resolves after switching scenes", async () => {
const el = createEditor();
el.sceneId = "scene_a";
runUpdated(el, { sceneId: undefined });
await flush();
let resolveSubscribe!: (value: () => void) => void;
el.hass.connection.subscribeEvents = vi.fn(
() =>
new Promise((resolve) => {
resolveSubscribe = resolve;
})
);
el._mode = "live";
el._subscribeEvents();
el.sceneId = "scene_b";
runUpdated(el, { sceneId: "scene_a" });
const unsubscribe = vi.fn();
resolveSubscribe(unsubscribe);
await flush();
expect(unsubscribe).toHaveBeenCalled();
expect(el._unsubscribeEvents).toBeUndefined();
});
test("keeps state when the id change comes from saving a new scene", async () => {
const el = createEditor();
el.sceneId = null;
runUpdated(el, { sceneId: undefined });
expect(el._config).toBeDefined();
el._mode = "yaml";
el._justSavedId = "123";
el.sceneId = "123";
runUpdated(el, { sceneId: null });
expect(el._mode).toBe("yaml");
expect(el._justSavedId).toBeUndefined();
await flush();
expect(el._config?.name).toBe("123 name");
});
});
@@ -0,0 +1,91 @@
import { describe, expect, test, vi } from "vitest";
import "../../../../src/panels/config/script/ha-script-editor";
import type { HaScriptEditor } from "../../../../src/panels/config/script/ha-script-editor";
import type { ScriptConfig } from "../../../../src/data/script";
import { createMockHass } from "../../../fixtures/hass";
import { flush, runUpdated } from "../../../fixtures/lit";
const configFor = (alias: string): ScriptConfig => ({
alias,
sequence: [],
});
const createEditor = (): any => {
const el = document.createElement("ha-script-editor") as HaScriptEditor;
const hass = createMockHass();
(hass as any).callApi = vi.fn(async (_method: string, path: string) =>
configFor(path.split("/").pop()!)
);
(hass as any).callWS = vi.fn(async () => ({ config: configFor("state") }));
el.hass = hass;
return el;
};
describe("script editor item switch", () => {
test("switching to another script resets the editor state", async () => {
const el = createEditor();
el.scriptId = "a";
runUpdated(el, { scriptId: undefined });
await flush();
expect(el.config?.alias).toBe("a");
el.mode = "yaml";
el.yamlErrors = "unparseable";
el._undoRedoController.commit(configFor("a-edited"));
expect(el._undoRedoController.canUndo).toBe(true);
el.scriptId = "b";
runUpdated(el, { scriptId: "a" });
expect(el.mode).toBe("gui");
expect(el.yamlErrors).toBeUndefined();
expect(el._undoRedoController.canUndo).toBe(false);
await flush();
expect(el.config?.alias).toBe("b");
});
test("keeps state when the id change comes from saving a new script", async () => {
const el = createEditor();
el.scriptId = null;
runUpdated(el, { scriptId: undefined });
expect(el.config).toBeDefined();
el.mode = "yaml";
el.justSavedId = "123";
el.scriptId = "123";
runUpdated(el, { scriptId: null });
expect(el.mode).toBe("yaml");
expect(el.justSavedId).toBeUndefined();
await flush();
expect(el.config?.alias).toBe("123");
});
test("switching to another entity in the read-only view resets the editor state", async () => {
const el = createEditor();
el.entityRegistry = [
{ entity_id: "script.one", unique_id: "one", platform: "script" },
{ entity_id: "script.two", unique_id: "two", platform: "script" },
];
el.entityId = "script.one";
runUpdated(el, { entityId: undefined });
expect(el.scriptId).toBe("one");
await flush();
// the show route assigns scriptId internally; that must not reset
el.yamlErrors = "sentinel";
runUpdated(el, { scriptId: null });
expect(el.yamlErrors).toBe("sentinel");
el.mode = "yaml";
el.entityId = "script.two";
runUpdated(el, { entityId: "script.one" });
expect(el.mode).toBe("gui");
expect(el.readOnly).toBe(true);
expect(el.scriptId).toBe("two");
await flush();
expect(el.config?.alias).toBe("state");
});
});