Compare commits

..
7 changed files with 425 additions and 729 deletions
+104 -17
View File
@@ -27,26 +27,57 @@ const SONIFICATION_LANGUAGES = new Set(["de", "en", "es", "fr", "hmn", "it"]);
// lead nowhere.
const MIN_NAVIGABLE_POINTS = 2;
// Mirrors the extension's own reading of a point: it takes `value` as [x, y] and
// drops anything whose y is not a real number. That rejects gap-only series, and
// also value-first pairs like the energy device charts' [amount, "sensor.foo"].
// Counts no further than `limit` so this stays cheap on charts with many points.
const itemValues = (raw: unknown): unknown[] | null => {
if (Array.isArray(raw)) {
return raw;
}
if (raw && typeof raw === "object") {
const { value } = raw as { value?: unknown };
if (Array.isArray(value)) {
return value;
}
}
return null;
};
// ECharts spells empty values and numbers as strings too, and neither names a
// category.
const NON_CATEGORY_STRINGS = new Set(["-", "NaN", "null", "undefined"]);
const isCategoryKey = (value: unknown): boolean =>
typeof value === "string" &&
!NON_CATEGORY_STRINGS.has(value) &&
Number.isNaN(Number(value));
// A chart with the value axis on x, like the energy device charts, encodes its
// items value-first: [amount, "sensor.foo"]. The extension only reads a series
// that way when every item has that shape, so mirror the same gate.
const isValueFirstSeries = (data: readonly unknown[]): boolean =>
data.length > 0 &&
data.every((raw) => {
const values = itemValues(raw);
return (
!!values && typeof values[0] === "number" && isCategoryKey(values[1])
);
});
// Mirrors the extension's own reading of a point: it takes `value` as [x, y]
// (or [y, category] in a value-first series) and drops anything whose y is not
// a real number, which rejects gap-only series. Counts no further than `limit`
// so this stays cheap on charts with many points.
const countNumericPoints = (data: unknown, limit: number): number => {
if (!Array.isArray(data)) {
return 0;
}
const valueFirst = isValueFirstSeries(data);
let found = 0;
for (const raw of data) {
let y: unknown = raw;
if (Array.isArray(raw)) {
y = raw.length > 1 ? raw[1] : raw[0];
const values = itemValues(raw);
if (values) {
y = valueFirst ? values[0] : values.length > 1 ? values[1] : values[0];
} else if (raw && typeof raw === "object") {
const { value } = raw as { value?: unknown };
y = Array.isArray(value)
? value.length > 1
? value[1]
: value[0]
: value;
y = (raw as { value?: unknown }).value;
}
if (typeof y === "number" && !Number.isNaN(y)) {
found += 1;
@@ -95,6 +126,10 @@ interface SonifyChartOptions {
localize: LocalizeFunc;
locale: FrontendLocaleData;
config: HassConfig;
// Maps a category key or item name to what should be announced for it, so
// cards that key their data on ids (like the energy device charts) can have
// the display names read out instead. Returning undefined keeps the original.
formatLabel?: (label: string) => string | undefined;
onError: (error: string) => void;
}
@@ -163,6 +198,47 @@ const appendSonificationStyles = () => {
document.head.append(style);
};
// Rebuilds the labels the extension would announce — the category axis's data,
// or the item names on pies, which ignore whatever vestigial axes the chart
// options carry — with each one run through the card's formatter. Returns
// undefined when there is nothing to reword, so the extension's own labels
// stay untouched.
const buildValueLabels = (
categoryAxis: { type?: string; data?: unknown } | undefined,
firstSeries: { type?: string; data?: unknown } | undefined,
formatLabel?: (label: string) => string | undefined
): string[] | undefined => {
if (!formatLabel) {
return undefined;
}
const axisData =
categoryAxis?.type === "category" &&
Array.isArray(categoryAxis.data) &&
categoryAxis.data.length
? categoryAxis.data
: undefined;
const labels = axisData
? axisData.map((entry) =>
entry && typeof entry === "object"
? String((entry as { value?: unknown }).value ?? "")
: String(entry ?? "")
)
: firstSeries?.type === "pie" && Array.isArray(firstSeries.data)
? firstSeries.data.map((raw) => {
const name = (raw as { name?: unknown } | null)?.name;
if (typeof name === "string") {
return name;
}
const values = itemValues(raw);
return values && isCategoryKey(values[1]) ? String(values[1]) : "";
})
: undefined;
if (!labels?.length || labels.every((label) => !label)) {
return undefined;
}
return labels.map((label) => formatLabel(label) ?? label);
};
export const sonifyChart = async (
chart: EChartsType,
options: SonifyChartOptions
@@ -179,8 +255,8 @@ export const sonifyChart = async (
if (!chartOptions) {
return null;
}
const xAxis = ensureArray(chartOptions.xAxis)[0] as XAXisOption | undefined;
const yAxis = ensureArray(chartOptions.yAxis)[0] as YAXisOption | undefined;
const xAxis = ensureArray(chartOptions.xAxis)?.[0] as XAXisOption | undefined;
const yAxis = ensureArray(chartOptions.yAxis)?.[0] as YAXisOption | undefined;
// Chart2Music throws while validating a group with no points, which is what
// placeholder, legend-hidden and all-null series turn into, so only offer it
@@ -196,16 +272,25 @@ export const sonifyChart = async (
const seriesIndex = readable.map((s) => allSeries.indexOf(s));
// Chart2Music always reads out an axis label, and the extension picks the wrong
// axis to name when there is no category axis, so label both explicitly.
// axis to name when there is no category axis, so label both explicitly. On a
// horizontal chart the announced x is the category from the y axis and the
// announced y is the value from the x axis, so the sources swap.
const isTimeAxis = xAxis?.type === "time";
const isHorizontal = xAxis?.type === "value" && yAxis?.type === "category";
const valueLabels = buildValueLabels(
isHorizontal ? yAxis : xAxis,
readable[0],
options.formatLabel
);
const x = {
label:
xAxis?.name ||
(isHorizontal ? yAxis?.name : xAxis?.name) ||
localize(
isTimeAxis
? "ui.components.history_charts.time"
: "ui.components.history_charts.category"
),
...(valueLabels ? { valueLabels } : {}),
// Time series carry raw timestamps, which would otherwise be announced as
// epoch milliseconds.
format: isTimeAxis
@@ -213,7 +298,9 @@ export const sonifyChart = async (
: undefined,
};
const y = {
label: yAxis?.name || localize("ui.components.history_charts.value"),
label:
(isHorizontal ? xAxis?.name : yAxis?.name) ||
localize("ui.components.history_charts.value"),
};
let connection: ReturnType<typeof connect>;
+6
View File
@@ -117,6 +117,11 @@ export class HaChartBase extends LitElement {
@property({ type: String }) public height?: string;
// Lets cards that key their data on ids have display names announced
// instead when the chart is navigated with Chart2Music.
@property({ attribute: false })
public sonificationLabelFormatter?: (label: string) => string | undefined;
@property({ attribute: "expand-legend", type: Boolean })
public expandLegend?: boolean;
@@ -583,6 +588,7 @@ export class HaChartBase extends LitElement {
localize: this.hass.localize,
locale: this.hass.locale,
config: this.hass.config,
formatLabel: this.sonificationLabelFormatter,
onError: () => {
// Charts the extension cannot describe stay silent rather than
// dropping an error on someone who only pressed Tab.
+61 -177
View File
@@ -1,39 +1,29 @@
import { css, html, LitElement, nothing } from "lit";
import {
customElement,
property,
query,
queryAll,
state,
} from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import deepClone from "deep-clone-simple";
import { deepActiveElement } from "../../common/dom/deep-active-element";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-button";
import "../../components/ha-dialog";
import "../../components/ha-dialog-footer";
import "../../components/ha-form/ha-form";
import type { HaDialog } from "../../components/ha-dialog";
import type { HaForm } from "../../components/ha-form/ha-form";
import "../../components/ha-dialog-footer";
import "../../components/ha-dialog";
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import type { HassDialog, ShowDialogParams } from "../make-dialog-manager";
import type { FormDialogData, FormDialogParams } from "./show-form-dialog";
import type { HaForm } from "../../components/ha-form/ha-form";
interface StackEntry {
params: FormDialogParams;
initialData: FormDialogData;
data: FormDialogData;
scrollTop: number;
focusTarget?: Element;
nestedField?: string;
error?: Record<string, string>;
}
@customElement("dialog-form")
export class DialogForm
extends DirtyStateProviderMixin<FormDialogData[]>()(LitElement)
extends DirtyStateProviderMixin<FormDialogData>()(LitElement)
implements HassDialog<FormDialogData>
{
@property({ attribute: false }) public hass?: HomeAssistant;
@@ -42,8 +32,6 @@ export class DialogForm
@state() private _data: FormDialogData = {};
private _initialData: FormDialogData = {};
@state() private _open = false;
@state() private _closeState?: "canceled" | "submitted";
@@ -52,19 +40,14 @@ export class DialogForm
@state() private _error?: Record<string, string>;
@query("ha-dialog") private _dialog?: HaDialog;
@query("ha-form:not([hidden])") private _form?: HaForm;
@queryAll("ha-form") private _forms!: NodeListOf<HaForm>;
@query("ha-form") private _form?: HaForm;
public async showDialog(params: FormDialogParams): Promise<void> {
this._params = params;
this._data = params.data || {};
this._initialData = deepClone(this._data);
this._open = true;
this._error = undefined;
this._resetDirtyTracking();
this._initDirtyTracking({ type: "deep" }, this._data);
}
public closeDialog(): boolean {
@@ -72,119 +55,54 @@ export class DialogForm
return true;
}
private _initialDirtyState(): FormDialogData[] {
return [
...this._stack.map((entry) => entry.initialData),
this._initialData,
];
}
private _currentDirtyState(): FormDialogData[] {
return [...this._stack.map((entry) => entry.data), this._data];
}
private _resetDirtyTracking(): void {
this._initDirtyTracking({ type: "deep" }, this._initialDirtyState());
this._updateDirtyState(this._currentDirtyState());
}
private _handleNestedShowDialog = (
ev: HASSDomEvent<ShowDialogParams<unknown>>
) => {
if (
ev.detail.dialogTag !== "dialog-form" ||
ev.currentTarget !== this._form
) {
if (ev.detail.dialogTag !== "dialog-form") {
return;
}
const nested = ev.detail.dialogParams as FormDialogParams;
if (!nested.submit || !nested.cancel) {
return;
}
ev.stopPropagation();
const focusTarget = deepActiveElement();
const origin = ev.composedPath()[0] as HTMLElement & { name?: string };
this._stack = [
...this._stack,
{
params: this._params!,
initialData: this._initialData,
data: this._data,
scrollTop: this._dialog?.bodyContainer.scrollTop ?? 0,
focusTarget: focusTarget ?? undefined,
nestedField: origin?.name,
error: this._error,
},
];
const nested = ev.detail.dialogParams as FormDialogParams;
this._params = nested;
this._data = nested.data || {};
this._initialData = deepClone(this._data);
this._data = nested?.data || {};
this._error = undefined;
this._resetDirtyTracking();
this._initDirtyTracking({ type: "deep" }, this._data);
};
private _popStack(): StackEntry | undefined {
private _popStack(): string | undefined {
if (!this._stack.length) {
return undefined;
}
const prev = this._stack[this._stack.length - 1];
this._stack = this._stack.slice(0, -1);
this._params = prev.params;
this._initialData = prev.initialData;
this._data = prev.data;
this._error = prev.error;
this._resetDirtyTracking();
return prev;
}
private async _restoreFocusAndScroll(
scrollTop: number,
expectedParams: FormDialogParams,
focusTarget?: Element
): Promise<void> {
await this.updateComplete;
await this._form?.updateComplete;
await new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
});
if (!this._open || this._params !== expectedParams || !this._dialog) {
return;
}
if (focusTarget instanceof HTMLElement && focusTarget.isConnected) {
focusTarget.focus();
}
this._dialog.bodyContainer.scrollTop = scrollTop;
this._initDirtyTracking({ type: "deep" }, this._data);
return prev.nestedField;
}
private _dialogClosed(): void {
if (!this._closeState) {
this._params?.cancel?.();
for (let index = this._stack.length - 1; index >= 0; index--) {
this._stack[index].params.cancel?.();
}
}
if (this._closeState !== "submitted") {
this._discardDirtyStateChanges();
}
this._closeState = undefined;
this._stack = [];
this._params = undefined;
this._initialData = {};
this._data = {};
this._open = false;
this._error = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -196,69 +114,53 @@ export class DialogForm
return;
}
this._closeState = "submitted";
const submit = this._params?.submit;
const data = this._data;
const stackEntry = this._popStack();
const nestedField = this._popStack();
if (!stackEntry) {
this._closeState = "submitted";
submit?.(data);
this._markDirtyStateClean();
submit?.(data);
if (!nestedField) {
this.closeDialog();
return;
}
submit!(data);
void this._restoreFocusAndScroll(
stackEntry.scrollTop,
stackEntry.params,
stackEntry.focusTarget
const schemaField = this._params?.schema.find(
(f) => "selector" in f && f.name === nestedField
);
const isMultiple =
schemaField &&
"selector" in schemaField &&
"object" in schemaField.selector &&
schemaField.selector.object?.multiple === true;
const current = this._data[nestedField];
const newValue = isMultiple
? [...(Array.isArray(current) ? current : []), data]
: data;
this._data = deepClone({ ...this._data, [nestedField]: newValue });
this._error = undefined;
this._updateDirtyState(this._data);
}
private _cancel(): void {
this._closeState = "canceled";
const cancel = this._params?.cancel;
const stackEntry = this._popStack();
const nestedField = this._popStack();
if (!stackEntry) {
this._closeState = "canceled";
cancel?.();
cancel?.();
if (!nestedField) {
this.closeDialog();
return;
}
cancel!();
void this._restoreFocusAndScroll(
stackEntry.scrollTop,
stackEntry.params,
stackEntry.focusTarget
);
}
private _valueChanged(ev: CustomEvent): void {
const levelIndex = Array.from(this._forms).indexOf(
ev.currentTarget as HaForm
);
if (levelIndex === -1) {
return;
}
const data = ev.detail.value as FormDialogData;
if (levelIndex === this._stack.length) {
this._data = data;
this._error = undefined;
this._updateDirtyState(this._currentDirtyState());
return;
}
if (levelIndex < this._stack.length) {
this._stack = this._stack.map((entry, index) =>
index === levelIndex ? { ...entry, data, error: undefined } : entry
);
this._updateDirtyState(this._currentDirtyState());
}
this._data = ev.detail.value;
this._error = undefined;
this._updateDirtyState(this._data);
}
protected render() {
@@ -266,53 +168,35 @@ export class DialogForm
return nothing;
}
const params = this._params;
const levels = [
...this._stack,
{
params,
initialData: this._initialData,
data: this._data,
error: this._error,
},
];
return html`
<ha-dialog
.open=${this._open}
header-title=${params.title}
header-title=${this._params.title}
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
${levels.map((level, index) => {
const isActive = index === levels.length - 1;
return html`
<ha-form
?hidden=${!isActive}
?autofocus=${isActive}
.hass=${this.hass}
.computeLabel=${level.params.computeLabel}
.computeHelper=${level.params.computeHelper}
.data=${level.data}
.schema=${level.params.schema}
.error=${level.error}
@value-changed=${this._valueChanged}
@show-dialog=${this._handleNestedShowDialog}
>
</ha-form>
`;
})}
<ha-form
autofocus
.hass=${this.hass}
.computeLabel=${this._params.computeLabel}
.computeHelper=${this._params.computeHelper}
.data=${this._data}
.schema=${this._params.schema}
.error=${this._error}
@value-changed=${this._valueChanged}
@show-dialog=${this._handleNestedShowDialog}
>
</ha-form>
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this._cancel}
>
${params.cancelText || this.hass.localize("ui.common.cancel")}
${this._params.cancelText || this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button slot="primaryAction" @click=${this._submit}>
${params.submitText || this.hass.localize("ui.common.save")}
${this._params.submitText || this.hass.localize("ui.common.save")}
</ha-button>
</ha-dialog-footer>
</ha-dialog>
@@ -194,6 +194,7 @@ export class HuiEnergyDevicesGraphCard
this._legendData
)}
.height=${`${Math.max(modes.includes("pie") ? 300 : 100, (this._legendData?.length || 0) * 28 + 50)}px`}
.sonificationLabelFormatter=${this._sonificationLabel}
.extraComponents=${[PieChart]}
.expandLegend=${this._config.expand_legend}
click-label-for-more-info
@@ -293,6 +294,14 @@ export class HuiEnergyDevicesGraphCard
}
);
// The chart data is keyed on statistic ids, which is what Chart2Music would
// otherwise announce. Names that aren't statistics — the untracked slice —
// are already display text, so those stay as they are.
private _sonificationLabel = (label: string): string | undefined =>
this._deviceLabels[label] || this._data?.statsMetadata[label]
? this._getDeviceName(label)
: undefined;
private _getDeviceName(statisticId: string): string {
const suffix = this._compoundStats.includes(statisticId)
? ` (${this.hass.localize("ui.panel.lovelace.cards.energy.energy_devices_graph.untracked")})`
+245 -11
View File
@@ -1,7 +1,22 @@
import { describe, expect, it } from "vitest";
import { canSonifyChart } from "../../../src/components/chart/chart-sonification";
import type { EChartsType } from "echarts/core";
import { connect } from "echarts-extension-chart2music";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
canSonifyChart,
sonifyChart,
} from "../../../src/components/chart/chart-sonification";
import type { LocalizeFunc } from "../../../src/common/translations/localize";
import type { FrontendLocaleData } from "../../../src/data/translation";
import type { HomeAssistant } from "../../../src/types";
import type { HaECSeries } from "../../../src/resources/echarts/echarts";
vi.mock("echarts-extension-chart2music", () => ({
connect: vi.fn((_chart, options) => {
options.cc.setAttribute("aria-live", "assertive");
return { update: vi.fn(), dispose: vi.fn() };
}),
}));
const series = (
items: { type: string; id?: string; name?: string; data?: unknown[] }[]
) => items as unknown as HaECSeries;
@@ -95,9 +110,9 @@ describe("canSonifyChart", () => {
expect(canSonifyChart(series([{ type: "line" }]))).toBe(false);
});
it("rejects value-first pairs, which the extension reads as a non-numeric y", () => {
// The energy device charts encode [amount, categoryName]. Chart2Music takes
// value as [x, y], so every one of those points is dropped.
it("accepts value-first pairs, like the energy device charts", () => {
// The energy device charts encode [amount, categoryName]. Since v0.1.1 the
// extension reads a series that way when every item has that shape.
expect(
canSonifyChart(
series([
@@ -110,18 +125,77 @@ describe("canSonifyChart", () => {
},
])
)
).toBe(true);
expect(
canSonifyChart(
series([
{
type: "bar",
data: [
[12.5, "sensor.a"],
[8.25, "sensor.b"],
],
},
])
)
).toBe(true);
});
it("does not read empty markers or numeric strings as value-first pairs", () => {
// [2, "-"] is an ECharts gap inside an ordinary series and [1, "8"] is a
// numeric string y; treating either as [y, category] would fabricate
// points the chart never plotted.
expect(
canSonifyChart(
series([
{
type: "line",
data: [
[1, "-"],
[2, "-"],
],
},
])
)
).toBe(false);
expect(
canSonifyChart(
series([
{
type: "line",
data: [
[1, "8"],
[2, "12"],
],
},
])
)
).toBe(false);
});
it("reads a series value-first only when every item has that shape", () => {
// A lone [number, string] among ordinary pairs is an unreadable value, not
// a transposed one.
expect(
canSonifyChart(
series([
{
type: "bar",
data: [
[12.5, "sensor.a"],
["-", "sensor.b"],
],
},
])
)
).toBe(false);
});
it("rejects a chart left with a single readable point", () => {
// The device pie's slices are all unreadable, leaving only its one-number
// total series — nothing the arrow keys could move between.
// One device slice alone offers nothing the arrow keys could move between.
expect(
canSonifyChart(
series([
{ type: "pie", data: [{ value: [12.5, "sensor.a"] }] },
{ type: "pie", data: [24.5] },
])
series([{ type: "pie", data: [{ value: [12.5, "sensor.a"] }] }])
)
).toBe(false);
});
@@ -190,3 +264,163 @@ describe("canSonifyChart", () => {
).toBe(false);
});
});
describe("sonifyChart", () => {
const mockedConnect = vi.mocked(connect);
const fakeChart = (option: Record<string, unknown>) =>
({
getOption: () => option,
on: vi.fn(),
off: vi.fn(),
}) as unknown as EChartsType;
const sonify = (
option: Record<string, unknown>,
formatLabel?: (label: string) => string | undefined
) =>
sonifyChart(fakeChart(option), {
cc: document.createElement("div"),
localize: ((key: string) => key) as LocalizeFunc,
locale: { language: "en" } as FrontendLocaleData,
config: {} as HomeAssistant["config"],
formatLabel,
onError: () => undefined,
});
const connectedAxes = () => mockedConnect.mock.lastCall![1]!.axes!;
beforeEach(() => {
mockedConnect.mockClear();
});
it("swaps the axis label sources on horizontal charts", async () => {
const sonification = await sonify({
xAxis: [{ type: "value", name: "kWh" }],
yAxis: [{ type: "category", name: "Device", data: ["a", "b"] }],
series: [
{
type: "bar",
data: [
[12.5, "a"],
[7.25, "b"],
],
},
],
});
expect(sonification).not.toBeNull();
// The announced x walks the categories of the y axis, and the announced y
// is the value from the x axis.
expect(connectedAxes()).toMatchObject({
x: { label: "Device" },
y: { label: "kWh" },
});
});
it("keeps the axis label sources on vertical charts", async () => {
await sonify({
xAxis: [{ type: "category", name: "Month", data: ["Jan", "Feb"] }],
yAxis: [{ type: "value", name: "kWh" }],
series: [{ type: "bar", data: [5, 9] }],
});
expect(connectedAxes()).toMatchObject({
x: { label: "Month" },
y: { label: "kWh" },
});
// Without a formatter the extension's own labels stay untouched.
expect(connectedAxes().x.valueLabels).toBeUndefined();
});
it("announces category keys through the label formatter", async () => {
await sonify(
{
xAxis: [{ type: "value", name: "kWh" }],
yAxis: [{ type: "category", data: ["sensor.a", "sensor.b"] }],
series: [
{
type: "bar",
data: [
{ name: "sensor.a", value: [12.5, "sensor.a"] },
{ name: "sensor.b", value: [7.25, "sensor.b"] },
],
},
],
},
(label) => (label === "sensor.a" ? "Dishwasher" : "Oven")
);
expect(connectedAxes().x.valueLabels).toEqual(["Dishwasher", "Oven"]);
});
it("formats pie slice names and keeps the ones the formatter declines", async () => {
await sonify(
{
series: [
{
type: "pie",
data: [
{ name: "sensor.a", value: [12.5, "sensor.a"] },
{ name: "Untracked consumption", value: [7.25, "untracked"] },
],
},
],
},
(label) => (label === "sensor.a" ? "Dishwasher" : undefined)
);
expect(connectedAxes().x.valueLabels).toEqual([
"Dishwasher",
"Untracked consumption",
]);
});
it("labels pie slices even when the options carry hidden empty axes", async () => {
// ha-chart-base's merged options include default axes even for pies, so
// an empty category axis must not block the item-name labels.
await sonify(
{
xAxis: [{ type: "category", show: false, data: [] }],
yAxis: [{ type: "value", show: false }],
series: [
{
type: "pie",
data: [
{ name: "sensor.a", value: [12.5, "sensor.a"] },
{ name: "sensor.b", value: [7.25, "sensor.b"] },
],
},
],
},
(label) => (label === "sensor.a" ? "Dishwasher" : "Oven")
);
expect(connectedAxes().x.valueLabels).toEqual(["Dishwasher", "Oven"]);
});
it("never overrides labels on time axis charts", async () => {
await sonify(
{
xAxis: [{ type: "time" }],
yAxis: [{ type: "value", name: "°C" }],
series: [
{
type: "line",
data: [
[1700000000000, 21.5],
[1700003600000, 22.1],
],
},
],
},
() => "wrong"
);
expect(connectedAxes()).toMatchObject({
x: { label: "ui.components.history_charts.time" },
y: { label: "°C" },
});
expect(connectedAxes().x.valueLabels).toBeUndefined();
});
});
@@ -1,154 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { HomeAssistant } from "../../../src/types";
import type { HaObjectSelector } from "../../../src/components/ha-selector/ha-selector-object";
import type { FormDialogParams } from "../../../src/dialogs/form/show-form-dialog";
import "../../../src/components/ha-selector/ha-selector-object";
vi.mock("../../../src/components/ha-input-helper-text", () => {
customElements.define("ha-input-helper-text", class extends HTMLElement {});
return {};
});
vi.mock("../../../src/components/ha-md-list", () => {
customElements.define("ha-md-list", class extends HTMLElement {});
return {};
});
vi.mock("../../../src/components/ha-md-list-item", () => {
customElements.define("ha-md-list-item", class extends HTMLElement {});
return {};
});
vi.mock("../../../src/components/ha-sortable", () => {
customElements.define("ha-sortable", class extends HTMLElement {});
return {};
});
vi.mock("../../../src/components/ha-yaml-editor", () => {
customElements.define("ha-yaml-editor", class extends HTMLElement {});
return {};
});
const hass = {
localize: (key: string) => key,
locale: "en-US",
floors: {},
areas: {},
devices: {},
states: {},
formatEntityName: () => "",
} as unknown as HomeAssistant;
const selectorConfig = {
object: {
multiple: true,
fields: {
name: { selector: { text: {} } },
},
},
};
const getInternals = (selector: HaObjectSelector) =>
selector as unknown as Record<string, unknown>;
const mountSelector = async (value: Record<string, string>[]) => {
const selector = document.createElement(
"ha-selector-object"
) as HaObjectSelector;
selector.hass = hass;
selector.selector = selectorConfig;
selector.value = value;
document.body.append(selector);
await selector.updateComplete;
return selector;
};
const resolveFormDialog = async (
selector: HaObjectSelector,
action: "_addItem" | "_editItem",
result: Record<string, string> | null,
item?: Record<string, string>,
index?: number
) => {
let params: FormDialogParams | undefined;
const dialogShown = new Promise<void>((resolve) => {
selector.addEventListener(
"show-dialog",
(event) => {
params = event.detail.dialogParams as FormDialogParams;
if (result === null) {
params.cancel!();
} else {
params.submit!(result);
}
resolve();
},
{ once: true }
);
});
const event = {
stopPropagation: vi.fn(),
currentTarget: { item, index },
};
const operation = (
getInternals(selector)[action] as (ev: typeof event) => Promise<void>
)(event);
await dialogShown;
await operation;
return params!;
};
afterEach(() => {
document.body.replaceChildren();
vi.clearAllMocks();
});
describe("ha-selector-object form dialog flow", () => {
it("appends an item through the real Add flow", async () => {
const first = { name: "A" };
const selector = await mountSelector([first]);
const valueChanged = vi.fn();
selector.addEventListener("value-changed", valueChanged);
await resolveFormDialog(selector, "_addItem", { name: "B" });
expect(valueChanged).toHaveBeenCalledWith(
expect.objectContaining({
detail: { value: [first, { name: "B" }] },
})
);
});
it("replaces an item at its original index through the real Edit flow", async () => {
const first = { name: "A" };
const second = { name: "B" };
const selector = await mountSelector([first, second]);
const valueChanged = vi.fn();
selector.addEventListener("value-changed", valueChanged);
await resolveFormDialog(
selector,
"_editItem",
{ name: "B updated" },
second,
1
);
const value = valueChanged.mock.calls[0][0].detail.value;
expect(value).toEqual([first, { name: "B updated" }]);
expect(value).toHaveLength(2);
});
it("leaves the array unchanged when Add or Edit is canceled", async () => {
const first = { name: "A" };
const selector = await mountSelector([first]);
const valueChanged = vi.fn();
selector.addEventListener("value-changed", valueChanged);
await resolveFormDialog(selector, "_addItem", null);
await resolveFormDialog(selector, "_editItem", null, first, 0);
expect(valueChanged).not.toHaveBeenCalled();
});
});
-370
View File
@@ -1,370 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { deepActiveElement } from "../../../src/common/dom/deep-active-element";
import type {
FormDialogData,
FormDialogParams,
} from "../../../src/dialogs/form/show-form-dialog";
import type { DialogForm } from "../../../src/dialogs/form/dialog-form";
import "../../../src/dialogs/form/dialog-form";
vi.mock("../../../src/components/ha-button", () => {
customElements.define("ha-button", class extends HTMLElement {});
return {};
});
vi.mock("../../../src/components/ha-dialog", () => {
customElements.define(
"ha-dialog",
class extends HTMLElement {
public bodyContainer = document.createElement("div");
}
);
return {};
});
vi.mock("../../../src/components/ha-dialog-footer", () => {
customElements.define("ha-dialog-footer", class extends HTMLElement {});
return {};
});
vi.mock("../../../src/components/ha-form/ha-form", () => {
customElements.define(
"ha-form",
class extends HTMLElement {
public reportValidity = vi.fn(() => true);
}
);
return {};
});
const getInternals = (dialog: DialogForm) =>
dialog as unknown as Record<string, unknown>;
const getForms = (dialog: DialogForm): HTMLElement[] =>
Array.from(dialog.shadowRoot!.querySelectorAll("ha-form"));
const outerParams = (data: FormDialogData = {}): FormDialogParams => ({
title: "Outer",
schema: [{ name: "value", selector: { text: {} } }],
data,
submit: vi.fn(),
cancel: vi.fn(),
});
const nestedParams = (data: FormDialogData = {}): FormDialogParams => ({
title: "Nested",
schema: [{ name: "value", selector: { text: {} } }],
data,
submit: vi.fn(),
cancel: vi.fn(),
});
const hass = {
localize: (key: string) => key,
} as never;
const openDialog = async (params = outerParams()) => {
const dialog = document.createElement("dialog-form") as DialogForm;
dialog.hass = hass;
document.body.append(dialog);
await dialog.showDialog(params);
await dialog.updateComplete;
return dialog;
};
const showNestedDialog = async (
dialog: DialogForm,
form: Element,
params: FormDialogParams,
dialogTag = "dialog-form",
origin: Element = form
) => {
origin.dispatchEvent(
new CustomEvent("show-dialog", {
bubbles: true,
composed: true,
detail: { dialogTag, dialogParams: params },
})
);
await dialog.updateComplete;
};
const submit = (dialog: DialogForm) =>
(getInternals(dialog)["_submit"] as () => void)();
const cancel = (dialog: DialogForm) =>
(getInternals(dialog)["_cancel"] as () => void)();
afterEach(() => {
document.body.replaceChildren();
vi.clearAllMocks();
});
describe("dialog-form mounted nested forms", () => {
it("keeps parent forms mounted while nested", async () => {
const dialog = await openDialog();
const parent = getForms(dialog)[0];
await showNestedDialog(dialog, parent, nestedParams());
const forms = getForms(dialog);
expect(forms).toHaveLength(2);
expect(forms[0].hidden).toBe(true);
expect(forms[1].hidden).toBe(false);
expect(forms[0].hasAttribute("autofocus")).toBe(false);
expect(forms[1].hasAttribute("autofocus")).toBe(true);
});
it("returns to the parent after nested submit", async () => {
const dialog = await openDialog();
const nested = nestedParams({ value: "nested" });
const parent = getForms(dialog)[0];
await showNestedDialog(dialog, parent, nested);
submit(dialog);
await dialog.updateComplete;
expect(nested.submit).toHaveBeenCalledWith({ value: "nested" });
expect(getInternals(dialog)["_open"]).toBe(true);
expect(getInternals(dialog)["_stack"]).toHaveLength(0);
expect(getForms(dialog)[0].hidden).toBe(false);
});
it.each(["submit", "cancel"] as const)(
"restores focus to the opener after nested %s",
async (action) => {
const dialog = await openDialog();
const parent = getForms(dialog)[0];
const opener = document.createElement("button");
parent.append(opener);
opener.focus();
await showNestedDialog(dialog, parent, nestedParams());
const child = getForms(dialog)[1];
const childFocusTarget = document.createElement("button");
child.append(childFocusTarget);
childFocusTarget.focus();
expect(deepActiveElement()).toBe(childFocusTarget);
if (action === "submit") {
submit(dialog);
} else {
cancel(dialog);
}
await vi.waitUntil(() => deepActiveElement() === opener);
}
);
it("keeps the parent open after a custom object selector nested save", async () => {
const dialog = await openDialog();
const nested = {
...nestedParams({ items: [] }),
schema: [
{
name: "items",
selector: {
object: {
multiple: true,
fields: { name: { selector: { text: {} } } },
},
},
},
],
} satisfies FormDialogParams;
const descendant = document.createElement("div");
getForms(dialog)[0].append(descendant);
await showNestedDialog(
dialog,
getForms(dialog)[0],
nested,
"dialog-form",
descendant
);
submit(dialog);
await dialog.updateComplete;
expect(nested.submit).toHaveBeenCalledWith({ items: [] });
expect(getInternals(dialog)["_open"]).toBe(true);
expect(getInternals(dialog)["_stack"]).toHaveLength(0);
expect(getForms(dialog)[0].hidden).toBe(false);
});
it("returns to the parent after nested cancel", async () => {
const parentData = { value: "parent" };
const dialog = await openDialog(outerParams(parentData));
const nested = nestedParams({ value: "nested" });
await showNestedDialog(dialog, getForms(dialog)[0], nested);
cancel(dialog);
await dialog.updateComplete;
expect(nested.cancel).toHaveBeenCalledOnce();
expect(getInternals(dialog)["_open"]).toBe(true);
expect((getInternals(dialog)["_data"] as FormDialogData).value).toBe(
"parent"
);
expect(getForms(dialog)[0].hidden).toBe(false);
});
it("routes hidden parent value changes to its stack entry", async () => {
const dialog = await openDialog(outerParams({ value: "original" }));
const parent = getForms(dialog)[0];
await showNestedDialog(dialog, parent, nestedParams());
parent.dispatchEvent(
new CustomEvent("value-changed", {
detail: { value: { value: "updated" } },
})
);
expect(
(getInternals(dialog)["_stack"] as Record<string, unknown>[])[0].data
).toEqual({ value: "updated" });
cancel(dialog);
expect((getInternals(dialog)["_data"] as FormDialogData).value).toBe(
"updated"
);
});
it("keeps multiple nested levels mounted and pops them in order", async () => {
const dialog = await openDialog();
await showNestedDialog(dialog, getForms(dialog)[0], nestedParams());
await showNestedDialog(dialog, getForms(dialog)[1], nestedParams());
expect(getForms(dialog)).toHaveLength(3);
expect(getForms(dialog).map((form) => form.hidden)).toEqual([
true,
true,
false,
]);
cancel(dialog);
await dialog.updateComplete;
expect(getForms(dialog).map((form) => form.hidden)).toEqual([true, false]);
cancel(dialog);
await dialog.updateComplete;
expect(getForms(dialog).map((form) => form.hidden)).toEqual([false]);
});
it("accepts active show-dialog events only", async () => {
const dialog = await openDialog();
const parent = getForms(dialog)[0];
const nested = nestedParams();
await showNestedDialog(dialog, parent, nested);
const child = getForms(dialog)[1];
await showNestedDialog(dialog, parent, nestedParams());
expect(getInternals(dialog)["_stack"]).toHaveLength(1);
await showNestedDialog(dialog, child, nestedParams(), "not-dialog-form");
expect(getInternals(dialog)["_stack"]).toHaveLength(1);
});
it("cancels all pending levels when physically closed", async () => {
const cancelOrder: string[] = [];
const root = outerParams();
const nested = nestedParams();
const grandchild = nestedParams();
root.cancel = vi.fn(() => cancelOrder.push("root"));
nested.cancel = vi.fn(() => cancelOrder.push("nested"));
grandchild.cancel = vi.fn(() => cancelOrder.push("grandchild"));
const dialog = await openDialog(root);
getForms(dialog)[0].dispatchEvent(
new CustomEvent("value-changed", {
detail: { value: { value: "dirty" } },
})
);
expect(dialog.isDirtyState).toBe(true);
await showNestedDialog(dialog, getForms(dialog)[0], nested);
await showNestedDialog(dialog, getForms(dialog)[1], grandchild);
(getInternals(dialog)["_dialogClosed"] as () => void)();
expect(cancelOrder).toEqual(["grandchild", "nested", "root"]);
expect(grandchild.cancel).toHaveBeenCalledOnce();
expect(nested.cancel).toHaveBeenCalledOnce();
expect(root.cancel).toHaveBeenCalledOnce();
expect(getInternals(dialog)["_stack"]).toHaveLength(0);
expect(getInternals(dialog)["_params"]).toBeUndefined();
expect(getInternals(dialog)["_data"]).toEqual({});
expect(getInternals(dialog)["_initialData"]).toEqual({});
expect(getInternals(dialog)["_open"]).toBe(false);
expect(dialog.isDirtyState).toBe(false);
});
it("tracks dirty state across nested levels", async () => {
const dialog = await openDialog();
expect(dialog.isDirtyState).toBe(false);
getForms(dialog)[0].dispatchEvent(
new CustomEvent("value-changed", {
detail: { value: { value: "changed" } },
})
);
expect(dialog.isDirtyState).toBe(true);
await showNestedDialog(dialog, getForms(dialog)[0], nestedParams());
cancel(dialog);
expect(dialog.isDirtyState).toBe(true);
const cleanDialog = await openDialog();
getForms(cleanDialog)[0].dispatchEvent(
new CustomEvent("value-changed", {
detail: { value: { value: "changed" } },
})
);
expect(cleanDialog.isDirtyState).toBe(true);
cancel(cleanDialog);
(getInternals(cleanDialog)["_dialogClosed"] as () => void)();
expect(cleanDialog.isDirtyState).toBe(false);
const cleanNestedDialog = await openDialog();
await showNestedDialog(
cleanNestedDialog,
getForms(cleanNestedDialog)[0],
nestedParams()
);
cancel(cleanNestedDialog);
expect(cleanNestedDialog.isDirtyState).toBe(false);
submit(cleanNestedDialog);
expect(cleanNestedDialog.isDirtyState).toBe(false);
submit(dialog);
expect(dialog.isDirtyState).toBe(false);
});
it("validates the active form before submitting", async () => {
const dialog = await openDialog();
const parent = getForms(dialog)[0];
const nested = nestedParams();
await showNestedDialog(dialog, parent, nested);
const child = getForms(dialog)[1];
const parentReportValidity = vi.fn(() => true);
const childReportValidity = vi.fn(() => false);
Object.defineProperty(parent, "reportValidity", {
value: parentReportValidity,
});
Object.defineProperty(child, "reportValidity", {
value: childReportValidity,
});
submit(dialog);
expect(parentReportValidity).not.toHaveBeenCalled();
expect(childReportValidity).toHaveBeenCalledOnce();
expect(nested.submit).not.toHaveBeenCalled();
expect(getInternals(dialog)["_open"]).toBe(true);
});
});