mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-27 01:20:15 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93511350a1 | ||
|
|
010711c358 |
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "home-assistant-frontend"
|
||||
version = "20260826.0"
|
||||
version = "20260729.0"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*"]
|
||||
description = "The Home Assistant frontend"
|
||||
|
||||
@@ -10,16 +10,12 @@ interface MeanFrame {
|
||||
}
|
||||
|
||||
interface MinMaxFrame {
|
||||
// A frame can hold a gap marker before any value lands in it, so the min/max
|
||||
// slots below only mean something once this is true.
|
||||
hasValue: boolean;
|
||||
minPoint: Point;
|
||||
minX: number;
|
||||
minY: number;
|
||||
maxPoint: Point;
|
||||
maxX: number;
|
||||
maxY: number;
|
||||
gapPoint: Point | undefined;
|
||||
}
|
||||
|
||||
const SECOND = 1000;
|
||||
@@ -53,25 +49,6 @@ function snapFrameSize(step: number): number {
|
||||
return snapped;
|
||||
}
|
||||
|
||||
// y is NaN for a frame seeded by a gap marker, which has no value yet.
|
||||
function newFrame(
|
||||
point: Point,
|
||||
x: number,
|
||||
y: number,
|
||||
gapPoint: Point | undefined
|
||||
): MinMaxFrame {
|
||||
return {
|
||||
hasValue: gapPoint === undefined,
|
||||
minPoint: point,
|
||||
minX: x,
|
||||
minY: y,
|
||||
maxPoint: point,
|
||||
maxX: x,
|
||||
maxY: y,
|
||||
gapPoint,
|
||||
};
|
||||
}
|
||||
|
||||
export function downSampleLineData<
|
||||
T extends [number, number] | NonNullable<LineSeriesOption["data"]>[number],
|
||||
>(
|
||||
@@ -105,10 +82,7 @@ export function downSampleLineData<
|
||||
const pointData = getPointData(point);
|
||||
if (!Array.isArray(pointData)) continue;
|
||||
const x = Number(pointData[0]);
|
||||
const rawY = pointData[1] as number | null;
|
||||
// Number(null) is 0, which would drag the mean towards zero
|
||||
if (rawY === null) continue;
|
||||
const y = Number(rawY);
|
||||
const y = Number(pointData[1]);
|
||||
if (isNaN(x) || isNaN(y)) continue;
|
||||
|
||||
const frameIndex = Math.floor(x / step);
|
||||
@@ -146,34 +120,21 @@ export function downSampleLineData<
|
||||
const pointData = getPointData(point);
|
||||
if (!Array.isArray(pointData)) continue;
|
||||
const x = Number(pointData[0]);
|
||||
if (isNaN(x)) continue;
|
||||
const rawY = pointData[1] as number | null;
|
||||
if (rawY === null) {
|
||||
// The chart data modules push a null value to break the line where an
|
||||
// entity was unavailable. Number(null) is 0, so such a marker must stay
|
||||
// out of the comparisons below, where it would win the minimum slot
|
||||
// whenever the readings are positive and discard the frame's real
|
||||
// minimum. One marker per frame is enough to break the line, and keeping
|
||||
// them all would blow up the output on series that are mostly null. The
|
||||
// last one wins: where the break lands only depends on which points it
|
||||
// sits between, not on its own x.
|
||||
const gapIndex = Math.floor(x / step);
|
||||
const gapFrame = frames.get(gapIndex);
|
||||
if (gapFrame) {
|
||||
gapFrame.gapPoint = point;
|
||||
} else {
|
||||
frames.set(gapIndex, newFrame(point, x, NaN, point));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const y = Number(rawY);
|
||||
if (isNaN(y)) continue;
|
||||
const y = Number(pointData[1]);
|
||||
if (isNaN(x) || isNaN(y)) continue;
|
||||
|
||||
const frameIndex = Math.floor(x / step);
|
||||
const frame = frames.get(frameIndex);
|
||||
if (!frame) {
|
||||
frames.set(frameIndex, newFrame(point, x, y, undefined));
|
||||
} else if (frame.hasValue) {
|
||||
frames.set(frameIndex, {
|
||||
minPoint: point,
|
||||
minX: x,
|
||||
minY: y,
|
||||
maxPoint: point,
|
||||
maxX: x,
|
||||
maxY: y,
|
||||
});
|
||||
} else {
|
||||
// Match the original strict-less / strict-greater comparisons so the
|
||||
// first occurrence wins on ties.
|
||||
if (y < frame.minY) {
|
||||
@@ -186,40 +147,18 @@ export function downSampleLineData<
|
||||
frame.maxX = x;
|
||||
frame.maxY = y;
|
||||
}
|
||||
} else {
|
||||
// the frame held nothing but a marker so far
|
||||
frame.hasValue = true;
|
||||
frame.minPoint = point;
|
||||
frame.minX = x;
|
||||
frame.minY = y;
|
||||
frame.maxPoint = point;
|
||||
frame.maxX = x;
|
||||
frame.maxY = y;
|
||||
}
|
||||
}
|
||||
|
||||
const result: T[] = [];
|
||||
for (const frame of frames.values()) {
|
||||
if (frame.hasValue) {
|
||||
// The order of the data must be preserved so max may be before min
|
||||
if (frame.minX > frame.maxX) {
|
||||
result.push(frame.maxPoint as T);
|
||||
}
|
||||
result.push(frame.minPoint as T);
|
||||
if (frame.minX < frame.maxX) {
|
||||
result.push(frame.maxPoint as T);
|
||||
}
|
||||
// The order of the data must be preserved so max may be before min
|
||||
if (frame.minX > frame.maxX) {
|
||||
result.push(frame.maxPoint as T);
|
||||
}
|
||||
if (frame.gapPoint !== undefined) {
|
||||
// A marker followed by a value in its own frame is a gap that closed
|
||||
// within one frame, which is about one device pixel: too narrow to show.
|
||||
// The kept points are exactly min and max, so comparing against the
|
||||
// later of the two catches that without any work on the ingest path. A
|
||||
// marker-only frame compares against its own x and always passes.
|
||||
const lastValueX = frame.minX > frame.maxX ? frame.minX : frame.maxX;
|
||||
if (Number(getPointData(frame.gapPoint)[0]) >= lastValueX) {
|
||||
result.push(frame.gapPoint as T);
|
||||
}
|
||||
result.push(frame.minPoint as T);
|
||||
if (frame.minX < frame.maxX) {
|
||||
result.push(frame.maxPoint as T);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import "@home-assistant/webawesome/dist/components/popover/popover";
|
||||
import type WaPopover from "@home-assistant/webawesome/dist/components/popover/popover";
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiPlaylistPlus } from "@mdi/js";
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
} from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { tinykeys } from "tinykeys";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { configContext } from "../data/context";
|
||||
@@ -115,6 +117,8 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
|
||||
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
|
||||
|
||||
@query("wa-popover") private _popover?: WaPopover;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
private _hassConfig?: ContextType<typeof configContext>;
|
||||
@@ -125,6 +129,8 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
|
||||
@state() private _popoverWidth = 0;
|
||||
|
||||
@state() private _popoverMinHeight = 0;
|
||||
|
||||
@state() private _openedNarrow = false;
|
||||
|
||||
@state() private _unknownValue = false;
|
||||
@@ -232,9 +238,12 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
: html`
|
||||
<wa-popover
|
||||
.open=${this._pickerWrapperOpen}
|
||||
style="--body-width: ${this._popoverWidth}px;"
|
||||
style=${styleMap({
|
||||
"--body-width": `${this._popoverWidth}px`,
|
||||
"--body-min-height": `${this._popoverMinHeight}px`,
|
||||
})}
|
||||
without-arrow
|
||||
distance="-4"
|
||||
distance="0"
|
||||
.placement=${this.popoverPlacement}
|
||||
.for=${this.popoverAnchor ? null : "picker"}
|
||||
.anchor=${this.popoverAnchor ?? null}
|
||||
@@ -257,12 +266,14 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
}
|
||||
|
||||
private _renderComboBox(dialogMode = false) {
|
||||
if (!this._opened) {
|
||||
// The list sizes the popover, so it is rendered as it opens, not once shown.
|
||||
if (!this._pickerWrapperOpen && !this._opened) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<ha-picker-combo-box
|
||||
id="combo-box"
|
||||
.shown=${this._opened}
|
||||
.allowCustomValue=${this.allowCustomValue}
|
||||
.label=${this.searchLabel}
|
||||
.value=${this._selectedValue ?? this.value}
|
||||
@@ -330,6 +341,8 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
|
||||
private _dialogOpened = () => {
|
||||
this._opened = true;
|
||||
// Filtering must not shrink the popover under the size it opened with.
|
||||
this._popoverMinHeight = this._popover?.body.offsetHeight ?? 0;
|
||||
fireEvent(this, "picker-opened");
|
||||
requestAnimationFrame(() => {
|
||||
// Set initial field value if needed
|
||||
@@ -363,6 +376,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
|
||||
this._opened = false;
|
||||
this._pickerWrapperOpen = false;
|
||||
this._popoverMinHeight = 0;
|
||||
this._selectedValue = undefined;
|
||||
this._unsubscribeTinyKeys?.();
|
||||
fireEvent(this, "picker-closed");
|
||||
@@ -465,6 +479,8 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
|
||||
wa-popover {
|
||||
--wa-space-l: 0;
|
||||
/* The surface of a dropdown menu, not of a dialog. */
|
||||
--wa-panel-border-radius: var(--ha-border-radius-md);
|
||||
}
|
||||
|
||||
wa-popover::part(dialog)::backdrop {
|
||||
@@ -477,14 +493,18 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
--ha-generic-picker-max-width,
|
||||
var(--ha-generic-picker-width, max(var(--body-width), 250px))
|
||||
);
|
||||
max-height: 500px;
|
||||
height: 70vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: var(--ha-box-shadow-m);
|
||||
height: fit-content;
|
||||
min-height: var(--body-min-height, 0);
|
||||
max-height: min(70vh, 500px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-height: 1000px) {
|
||||
wa-popover::part(body) {
|
||||
max-height: 400px;
|
||||
max-height: min(70vh, 400px);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { tinykeys } from "tinykeys";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import {
|
||||
fireEvent,
|
||||
type HASSDomCurrentTargetEvent,
|
||||
@@ -74,6 +75,10 @@ type PickerComboBoxRowElement = HTMLDivElement & {
|
||||
value: string;
|
||||
};
|
||||
|
||||
// Under this count the list is rendered without the virtualizer, so it can size the
|
||||
// popover to its content instead of filling a fixed height.
|
||||
const MAX_PLAIN_LIST_ITEMS = 12;
|
||||
|
||||
export const NO_ITEMS_AVAILABLE_ID = "___no_items_available___";
|
||||
const PADDING_ID = "___padding___";
|
||||
|
||||
@@ -153,6 +158,9 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
|
||||
@property({ reflect: true }) public mode: "popover" | "dialog" = "popover";
|
||||
|
||||
/** Set once the surface holding the list is done animating in. */
|
||||
@property({ type: Boolean }) public shown = false;
|
||||
|
||||
/** Section filter buttons for the list, section headers needs to be defined in getItems as strings */
|
||||
@property({ attribute: false }) public sections?: (
|
||||
| {
|
||||
@@ -178,6 +186,8 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
|
||||
@query("lit-virtualizer") public virtualizerElement?: LitVirtualizer;
|
||||
|
||||
@query(".plain-list") private _plainListElement?: HTMLElement;
|
||||
|
||||
@query("ha-input-search") private _searchFieldElement?: HaInputSearch;
|
||||
|
||||
@state()
|
||||
@@ -186,6 +196,8 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
|
||||
@state() private _items: PickerComboBoxItem[] = [];
|
||||
|
||||
@state() private _plainList = false;
|
||||
|
||||
@state() private _selectedSection?: string;
|
||||
|
||||
public setFieldValue(value: string) {
|
||||
@@ -195,7 +207,11 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
}
|
||||
|
||||
protected get scrollableElement(): HTMLElement | null {
|
||||
return this.virtualizerElement as HTMLElement | null;
|
||||
return this._listElement ?? null;
|
||||
}
|
||||
|
||||
private get _listElement(): HTMLElement | undefined {
|
||||
return this._plainList ? this._plainListElement : this.virtualizerElement;
|
||||
}
|
||||
|
||||
@state() private _sectionTitle?: string;
|
||||
@@ -221,10 +237,10 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
|
||||
public willUpdate() {
|
||||
if (!this.hasUpdated) {
|
||||
loadVirtualizer();
|
||||
this._selectedSection = this.selectedSection;
|
||||
this._allItems = this._getItems();
|
||||
this._items = this._allItems;
|
||||
this._updateListMode();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,6 +254,16 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
if (!this._search || this.sections?.length) {
|
||||
this._items = this._allItems;
|
||||
}
|
||||
this._updateListMode();
|
||||
}
|
||||
|
||||
// Filtering keeps the mode it opened with, only the full list decides it.
|
||||
private _updateListMode() {
|
||||
this._plainList =
|
||||
!this.sections?.length && this._allItems.length <= MAX_PLAIN_LIST_ITEMS;
|
||||
if (!this._plainList) {
|
||||
loadVirtualizer();
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
@@ -271,36 +297,61 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<div class="virtualizer-wrapper">
|
||||
<lit-virtualizer
|
||||
.keyFunction=${this._keyFunction}
|
||||
tabindex="0"
|
||||
scroller
|
||||
.items=${this._items}
|
||||
.renderItem=${this._renderItem}
|
||||
style="min-height: 36px;"
|
||||
class=${this._listScrolled ? "scrolled" : ""}
|
||||
.layout=${
|
||||
this.value && this._valuePinned
|
||||
? {
|
||||
pin: {
|
||||
index: this._getInitialSelectedIndex(),
|
||||
block: "center",
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
@unpinned=${this._handleUnpinned}
|
||||
@scroll=${this._onScrollList}
|
||||
@focus=${this._focusList}
|
||||
@blur=${this._resetSelectedItem}
|
||||
@visibilityChanged=${this._visibilityChanged}
|
||||
>
|
||||
</lit-virtualizer>
|
||||
<div class="list-wrapper ${this._plainList ? "" : "virtualized"}">
|
||||
${this._plainList ? this._renderPlainList() : this._renderVirtualList()}
|
||||
${this.renderScrollableFades()}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _renderPlainList() {
|
||||
return html`
|
||||
<div
|
||||
class="plain-list ${this._listScrolled ? "scrolled" : ""}"
|
||||
tabindex="0"
|
||||
@scroll=${this._onScrollList}
|
||||
@focus=${this._focusList}
|
||||
@blur=${this._resetSelectedItem}
|
||||
>
|
||||
${repeat(this._items, this._keyFunction, this._renderItem)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderVirtualList() {
|
||||
// The virtualizer measures its rows, so it must not do it through the scale
|
||||
// the surface animates in with.
|
||||
if (!this.shown) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<lit-virtualizer
|
||||
.keyFunction=${this._keyFunction}
|
||||
tabindex="0"
|
||||
scroller
|
||||
.items=${this._items}
|
||||
.renderItem=${this._renderItem}
|
||||
style="min-height: 36px;"
|
||||
class=${this._listScrolled ? "scrolled" : ""}
|
||||
.layout=${
|
||||
this.value && this._valuePinned
|
||||
? {
|
||||
pin: {
|
||||
index: this._getInitialSelectedIndex(),
|
||||
block: "center",
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
@unpinned=${this._handleUnpinned}
|
||||
@scroll=${this._onScrollList}
|
||||
@focus=${this._focusList}
|
||||
@blur=${this._resetSelectedItem}
|
||||
@visibilityChanged=${this._visibilityChanged}
|
||||
>
|
||||
</lit-virtualizer>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderSectionButtons() {
|
||||
if (!this.sections || this.sections.length === 0) {
|
||||
return nothing;
|
||||
@@ -564,7 +615,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
this._items = this._getItems();
|
||||
|
||||
// Reset scroll position when filter changes
|
||||
this.virtualizerElement?.element(0)?.scrollIntoView();
|
||||
this._resetListScroll();
|
||||
}
|
||||
|
||||
private _registerKeyboardShortcuts() {
|
||||
@@ -578,6 +629,26 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
});
|
||||
}
|
||||
|
||||
private _resetListScroll() {
|
||||
if (this._plainList) {
|
||||
this._listElement?.scrollTo({ top: 0 });
|
||||
return;
|
||||
}
|
||||
this.virtualizerElement?.element(0)?.scrollIntoView();
|
||||
}
|
||||
|
||||
private _scrollRowIntoView(index: number) {
|
||||
if (this._plainList) {
|
||||
this._listElement
|
||||
?.querySelector(`#list-item-${index}`)
|
||||
?.scrollIntoView({ block: "nearest" });
|
||||
return;
|
||||
}
|
||||
this.virtualizerElement?.element(index)?.scrollIntoView({
|
||||
block: "nearest",
|
||||
});
|
||||
}
|
||||
|
||||
private _focusList() {
|
||||
if (this._selectedItemIndex === -1) {
|
||||
this._initializeSelectedIndex();
|
||||
@@ -589,7 +660,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
* or fall back to the first item when searching (skipping section titles).
|
||||
*/
|
||||
private _initializeSelectedIndex(): void {
|
||||
if (!this.virtualizerElement?.items?.length) {
|
||||
if (!this._items.length) {
|
||||
return;
|
||||
}
|
||||
const initialIndex = this._getInitialSelectedIndex();
|
||||
@@ -599,11 +670,11 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
}
|
||||
let index = initialIndex;
|
||||
// Skip section titles (strings)
|
||||
if (typeof this.virtualizerElement.items[index] === "string") {
|
||||
if (typeof this._items[index] === "string") {
|
||||
index += 1;
|
||||
}
|
||||
// Bounds check: ensure index is valid after skipping section title
|
||||
if (index >= this.virtualizerElement.items.length) {
|
||||
if (index >= this._items.length) {
|
||||
return;
|
||||
}
|
||||
this._selectedItemIndex = index;
|
||||
@@ -613,13 +684,13 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
private _selectNextItem = (ev?: KeyboardEvent) => {
|
||||
ev?.stopPropagation();
|
||||
ev?.preventDefault();
|
||||
if (!this.virtualizerElement) {
|
||||
if (!this._listElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._searchFieldElement?.focus();
|
||||
|
||||
const items = this.virtualizerElement.items as PickerComboBoxItem[];
|
||||
const items = this._items;
|
||||
|
||||
const maxItems = items.length - 1;
|
||||
|
||||
@@ -661,14 +732,14 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
private _selectPreviousItem = (ev: KeyboardEvent) => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
if (!this.virtualizerElement) {
|
||||
if (!this._listElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._selectedItemIndex > 0) {
|
||||
const nextIndex = this._selectedItemIndex - 1;
|
||||
|
||||
const items = this.virtualizerElement.items as PickerComboBoxItem[];
|
||||
const items = this._items;
|
||||
|
||||
if (!items[nextIndex]) {
|
||||
return;
|
||||
@@ -690,13 +761,13 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
|
||||
private _selectFirstItem = (ev: KeyboardEvent) => {
|
||||
ev.stopPropagation();
|
||||
if (!this.virtualizerElement || !this.virtualizerElement.items.length) {
|
||||
if (!this._listElement || !this._items.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextIndex = 0;
|
||||
|
||||
if (typeof this.virtualizerElement.items[nextIndex] === "string") {
|
||||
if (typeof this._items[nextIndex] === "string") {
|
||||
this._selectedItemIndex = nextIndex + 1;
|
||||
} else {
|
||||
this._selectedItemIndex = nextIndex;
|
||||
@@ -707,13 +778,13 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
|
||||
private _selectLastItem = (ev: KeyboardEvent) => {
|
||||
ev.stopPropagation();
|
||||
if (!this.virtualizerElement || !this.virtualizerElement.items.length) {
|
||||
if (!this._listElement || !this._items.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextIndex = this.virtualizerElement.items.length - 1;
|
||||
const nextIndex = this._items.length - 1;
|
||||
|
||||
if (typeof this.virtualizerElement.items[nextIndex] === "string") {
|
||||
if (typeof this._items[nextIndex] === "string") {
|
||||
this._selectedItemIndex = nextIndex - 1;
|
||||
} else {
|
||||
this._selectedItemIndex = nextIndex;
|
||||
@@ -723,16 +794,12 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
};
|
||||
|
||||
private _scrollToSelectedItem = () => {
|
||||
this.virtualizerElement
|
||||
?.querySelector(".selected")
|
||||
?.classList.remove("selected");
|
||||
this._listElement?.querySelector(".selected")?.classList.remove("selected");
|
||||
|
||||
this.virtualizerElement
|
||||
?.element(this._selectedItemIndex)
|
||||
?.scrollIntoView({ block: "nearest" });
|
||||
this._scrollRowIntoView(this._selectedItemIndex);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.virtualizerElement
|
||||
this._listElement
|
||||
?.querySelector(`#list-item-${this._selectedItemIndex}`)
|
||||
?.classList.add("selected");
|
||||
});
|
||||
@@ -749,14 +816,10 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
private _pickItem = (ev: KeyboardEvent, newTab: boolean) => {
|
||||
ev.stopPropagation();
|
||||
if (
|
||||
this.virtualizerElement?.items?.length !== undefined &&
|
||||
this.virtualizerElement.items.length < 4 && // it still can have a section title and a padding item
|
||||
this.virtualizerElement.items.filter((item) => typeof item !== "string")
|
||||
.length === 1
|
||||
this._items.length < 4 && // it still can have a section title and a padding item
|
||||
this._items.filter((item) => typeof item !== "string").length === 1
|
||||
) {
|
||||
(
|
||||
this.virtualizerElement?.items as (PickerComboBoxItem | string)[]
|
||||
).forEach((item, index) => {
|
||||
this._items.forEach((item, index) => {
|
||||
if (typeof item !== "string" && !item.disabled) {
|
||||
this._fireSelectedEvents(item.id, index, newTab);
|
||||
}
|
||||
@@ -774,18 +837,14 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
// if filter button is focused
|
||||
ev.preventDefault();
|
||||
|
||||
const item = this.virtualizerElement?.items[
|
||||
this._selectedItemIndex
|
||||
] as PickerComboBoxItem;
|
||||
const item = this._items[this._selectedItemIndex];
|
||||
if (item && !item.disabled) {
|
||||
this._fireSelectedEvents(item.id, this._selectedItemIndex, newTab);
|
||||
}
|
||||
};
|
||||
|
||||
private _resetSelectedItem() {
|
||||
this.virtualizerElement
|
||||
?.querySelector(".selected")
|
||||
?.classList.remove("selected");
|
||||
this._listElement?.querySelector(".selected")?.classList.remove("selected");
|
||||
this._selectedItemIndex = -1;
|
||||
}
|
||||
|
||||
@@ -793,11 +852,11 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
typeof item === "string" ? item : item?.id;
|
||||
|
||||
private _getInitialSelectedIndex() {
|
||||
if (!this.virtualizerElement || this._search || !this.value) {
|
||||
if (this._search || !this.value) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const index = this.virtualizerElement.items.findIndex(
|
||||
const index = this._items.findIndex(
|
||||
(item) =>
|
||||
typeof item !== "string" &&
|
||||
(item as PickerComboBoxItem).id === this.value
|
||||
@@ -820,6 +879,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
flex-direction: column;
|
||||
padding-top: var(--ha-space-4);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
:host([clearable]) {
|
||||
@@ -851,23 +911,41 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
}
|
||||
}
|
||||
|
||||
.virtualizer-wrapper {
|
||||
.list-wrapper {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
flex: 0 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
lit-virtualizer {
|
||||
/* The virtualizer is size contained, so it fills a height rather than
|
||||
providing one. Asking for the whole viewport leaves the popover to cap it. */
|
||||
.list-wrapper.virtualized {
|
||||
flex: 1 1 100vh;
|
||||
}
|
||||
|
||||
/* A sheet has its own height, so the list fills it instead of sizing it. */
|
||||
:host([mode="dialog"]) .list-wrapper {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
lit-virtualizer:focus-visible {
|
||||
lit-virtualizer,
|
||||
.plain-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.plain-list {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
lit-virtualizer:focus-visible,
|
||||
.plain-list:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
lit-virtualizer.scrolled {
|
||||
.scrolled {
|
||||
border-top: 1px solid var(--ha-color-border-neutral-quiet);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { timeCacheEntityPromiseFunc } from "../common/util/time-cache-entity-promise-func";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { MediaPlayerItem, SearchMediaResult } from "./media-player";
|
||||
|
||||
@@ -8,7 +7,7 @@ export interface ResolvedMediaSource {
|
||||
}
|
||||
|
||||
export const resolveMediaSource = (
|
||||
hass: Pick<HomeAssistant, "callWS">,
|
||||
hass: HomeAssistant,
|
||||
media_content_id: string
|
||||
) =>
|
||||
hass.callWS<ResolvedMediaSource>({
|
||||
@@ -16,23 +15,6 @@ export const resolveMediaSource = (
|
||||
media_content_id,
|
||||
});
|
||||
|
||||
// Resolved URLs are signed and valid for 24 hours (CONTENT_AUTH_EXPIRY_TIME in
|
||||
// core). Resolving again returns a different signature, which would defeat the
|
||||
// browser cache, so reuse the resolved URL for just under its validity.
|
||||
export const RESOLVE_CACHE_TIME = 23 * 60 * 60 * 1000; // 23 hours
|
||||
|
||||
export const resolveMediaSourceWithCache = (
|
||||
hass: Pick<HomeAssistant, "callWS" | "hassUrl">,
|
||||
media_content_id: string
|
||||
): Promise<ResolvedMediaSource> =>
|
||||
timeCacheEntityPromiseFunc(
|
||||
"_resolvedMediaSource",
|
||||
RESOLVE_CACHE_TIME,
|
||||
resolveMediaSource,
|
||||
hass,
|
||||
media_content_id
|
||||
);
|
||||
|
||||
export const browseLocalMediaPlayer = (
|
||||
hass: HomeAssistant,
|
||||
mediaContentId?: string
|
||||
|
||||
@@ -17,7 +17,7 @@ import type { HomeAssistant } from "../../../../types";
|
||||
|
||||
import {
|
||||
isMediaSourceContentId,
|
||||
resolveMediaSourceWithCache,
|
||||
resolveMediaSource,
|
||||
} from "../../../../data/media_source";
|
||||
|
||||
export interface BackgroundConfigTarget {
|
||||
@@ -142,35 +142,21 @@ export class HuiViewBackgroundEditor extends LitElement {
|
||||
`${(background.opacity ?? 100) / 100}`
|
||||
);
|
||||
|
||||
const backgroundImage = this._currentBackgroundImage();
|
||||
const backgroundImage =
|
||||
typeof background.image === "object"
|
||||
? background.image.media_content_id
|
||||
: background.image;
|
||||
|
||||
if (backgroundImage && isMediaSourceContentId(backgroundImage)) {
|
||||
resolveMediaSourceWithCache(this.hass, backgroundImage).then(
|
||||
(result) => {
|
||||
// Discard if the image changed while resolving
|
||||
if (this._currentBackgroundImage() === backgroundImage) {
|
||||
this._resolvedImage = result.url;
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (this._currentBackgroundImage() === backgroundImage) {
|
||||
this._resolvedImage = undefined;
|
||||
}
|
||||
}
|
||||
);
|
||||
resolveMediaSource(this.hass, backgroundImage).then((result) => {
|
||||
this._resolvedImage = result.url;
|
||||
});
|
||||
} else {
|
||||
this._resolvedImage = backgroundImage;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _currentBackgroundImage(): string | undefined {
|
||||
const background = this._backgroundData(this._config);
|
||||
return typeof background.image === "object"
|
||||
? background.image.media_content_id
|
||||
: background.image;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.hass) {
|
||||
return nothing;
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { HomeAssistant } from "../../../types";
|
||||
import type { LovelaceViewBackgroundConfig } from "../../../data/lovelace/config/view";
|
||||
import {
|
||||
isMediaSourceContentId,
|
||||
resolveMediaSourceWithCache,
|
||||
resolveMediaSource,
|
||||
} from "../../../data/media_source";
|
||||
|
||||
@customElement("hui-view-background")
|
||||
@@ -21,37 +21,20 @@ export class HUIViewBackground extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
private _getBackgroundImage(
|
||||
background?: string | LovelaceViewBackgroundConfig
|
||||
): string | undefined {
|
||||
if (typeof background === "string") {
|
||||
return background;
|
||||
}
|
||||
if (typeof background?.image === "object") {
|
||||
return background.image.media_content_id;
|
||||
}
|
||||
return background?.image;
|
||||
}
|
||||
private _fetchMedia() {
|
||||
const backgroundImage =
|
||||
typeof this.background === "string"
|
||||
? this.background
|
||||
: typeof this.background?.image === "object"
|
||||
? this.background.image.media_content_id
|
||||
: this.background?.image;
|
||||
|
||||
private async _fetchMedia() {
|
||||
const backgroundImage = this._getBackgroundImage(this.background);
|
||||
|
||||
if (!backgroundImage || !isMediaSourceContentId(backgroundImage)) {
|
||||
if (backgroundImage && isMediaSourceContentId(backgroundImage)) {
|
||||
resolveMediaSource(this.hass, backgroundImage).then((result) => {
|
||||
this.resolvedImage = result.url;
|
||||
});
|
||||
} else {
|
||||
this.resolvedImage = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
let resolvedUrl: string | undefined;
|
||||
try {
|
||||
resolvedUrl = (
|
||||
await resolveMediaSourceWithCache(this.hass, backgroundImage)
|
||||
).url;
|
||||
} catch {
|
||||
resolvedUrl = undefined;
|
||||
}
|
||||
// Discard if the background changed while resolving
|
||||
if (this._getBackgroundImage(this.background) === backgroundImage) {
|
||||
this.resolvedImage = resolvedUrl;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +73,10 @@ export class HUIViewBackground extends LitElement {
|
||||
background?: string | LovelaceViewBackgroundConfig
|
||||
) {
|
||||
if (typeof background === "object" && background.image) {
|
||||
const image = this._getBackgroundImage(background) || "";
|
||||
const image =
|
||||
typeof background.image === "object"
|
||||
? background.image.media_content_id || ""
|
||||
: background.image;
|
||||
if (isMediaSourceContentId(image) && !this.resolvedImage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -17,24 +17,10 @@ const generatePoints = (seed: number, count: number): [number, number][] => {
|
||||
return points;
|
||||
};
|
||||
|
||||
// The chart data modules break the line with a null value. A handful of them
|
||||
// stands for an entity that went unavailable; a series that is mostly null
|
||||
// stands for the climate heating dataset, which emits one per inactive state.
|
||||
const withGaps = (
|
||||
points: [number, number][],
|
||||
isGap: (index: number) => boolean
|
||||
): [number, number | null][] =>
|
||||
points.map(([x, y], index) => (isGap(index) ? [x, null] : [x, y]));
|
||||
|
||||
const small = generatePoints(1, SCALES.small);
|
||||
const medium = generatePoints(2, SCALES.medium);
|
||||
const large = generatePoints(3, SCALES.large);
|
||||
const largeObjects = large.map((value) => ({ value }));
|
||||
const largeFewGaps = withGaps(large, (index) => index % 20_000 === 0);
|
||||
const largeMostlyGaps = withGaps(
|
||||
large,
|
||||
(index) => Math.floor(index / 50) % 3 !== 0
|
||||
);
|
||||
|
||||
describe("downSampleLineData", () => {
|
||||
bench("min/max small (1k points)", () => {
|
||||
@@ -68,20 +54,4 @@ describe("downSampleLineData", () => {
|
||||
},
|
||||
{ time: 1000, warmupIterations: 2 }
|
||||
);
|
||||
|
||||
bench(
|
||||
"min/max large with a few gaps (100k points)",
|
||||
() => {
|
||||
downSampleLineData(largeFewGaps, MAX_DETAILS);
|
||||
},
|
||||
{ time: 1000, warmupIterations: 2 }
|
||||
);
|
||||
|
||||
bench(
|
||||
"min/max large mostly gaps (100k points)",
|
||||
() => {
|
||||
downSampleLineData(largeMostlyGaps, MAX_DETAILS);
|
||||
},
|
||||
{ time: 1000, warmupIterations: 2 }
|
||||
);
|
||||
});
|
||||
|
||||
@@ -19,52 +19,9 @@ const generatePoints = (
|
||||
return points;
|
||||
};
|
||||
|
||||
// Gap markers: the chart data modules push a null value to break the line
|
||||
// where an entity was unavailable.
|
||||
type GappedPoint = [number, number | null | undefined];
|
||||
|
||||
const toObjectPoints = (points: GappedPoint[]) =>
|
||||
const toObjectPoints = (points: [number, number][]) =>
|
||||
points.map((value) => ({ value }));
|
||||
|
||||
const expectXOrdered = (result: { [0]: number }[]) => {
|
||||
for (let i = 1; i < result.length; i++) {
|
||||
expect(result[i][0]).toBeGreaterThanOrEqual(result[i - 1][0]);
|
||||
}
|
||||
};
|
||||
|
||||
// A series whose readings are all positive, with an unavailable stretch that
|
||||
// starts inside the first frame. Mirrors the point sequence
|
||||
// state-history-chart-line-data.ts emits for a gap.
|
||||
const gappedPoints: GappedPoint[] = [
|
||||
[FIXED_EPOCH_MS, 50],
|
||||
[FIXED_EPOCH_MS + 1_000, 90], // frame maximum
|
||||
[FIXED_EPOCH_MS + 2_000, 60],
|
||||
[FIXED_EPOCH_MS + 3_000, 10], // frame minimum
|
||||
[FIXED_EPOCH_MS + 4_000, 20], // last reading before the gap
|
||||
[FIXED_EPOCH_MS + 4_001, null], // gap marker
|
||||
[FIXED_EPOCH_MS + 30_000, 55],
|
||||
[FIXED_EPOCH_MS + 31_000, 45],
|
||||
];
|
||||
|
||||
// A generated series with three unavailable stretches of different lengths.
|
||||
const generateGappedPoints = (seed: number, count: number) => {
|
||||
const points: GappedPoint[] = generatePoints(seed, count);
|
||||
for (const [start, length] of [
|
||||
[Math.floor(count * 0.13), 3],
|
||||
[Math.floor(count * 0.4), 25],
|
||||
[Math.floor(count * 0.83), 1],
|
||||
]) {
|
||||
const gapStart = points[start][0];
|
||||
points.splice(
|
||||
start + 1,
|
||||
length,
|
||||
[gapStart + 1, points[start][1]],
|
||||
[gapStart + 1, null]
|
||||
);
|
||||
}
|
||||
return points;
|
||||
};
|
||||
|
||||
describe("downSampleLineData", () => {
|
||||
it("returns empty array for undefined data", () => {
|
||||
expect(downSampleLineData(undefined, 100)).toEqual([]);
|
||||
@@ -109,7 +66,11 @@ describe("downSampleLineData", () => {
|
||||
});
|
||||
|
||||
it("min/max mode preserves x-order for sorted input", () => {
|
||||
expectXOrdered(downSampleLineData(generatePoints(4, 1000), 50));
|
||||
const points = generatePoints(4, 1000);
|
||||
const result = downSampleLineData(points, 50);
|
||||
for (let i = 1; i < result.length; i++) {
|
||||
expect(result[i][0]).toBeGreaterThanOrEqual(result[i - 1][0]);
|
||||
}
|
||||
});
|
||||
|
||||
it("min/max mode matches characterization snapshot", () => {
|
||||
@@ -232,165 +193,6 @@ describe("downSampleLineData", () => {
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("keeps the frame minimum when a gap marker shares the frame", () => {
|
||||
// Without special handling the marker becomes y=0, wins the minimum slot
|
||||
// and the real minimum (10) is dropped.
|
||||
expect(downSampleLineData(gappedPoints, 3)).toEqual([
|
||||
[FIXED_EPOCH_MS + 1_000, 90],
|
||||
[FIXED_EPOCH_MS + 3_000, 10],
|
||||
[FIXED_EPOCH_MS + 4_001, null],
|
||||
[FIXED_EPOCH_MS + 30_000, 55],
|
||||
[FIXED_EPOCH_MS + 31_000, 45],
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops a marker whose gap closes within the same frame", () => {
|
||||
// A frame spans about one device pixel, so a gap that opens and closes
|
||||
// inside one is too narrow to show. The values around it stay.
|
||||
const points: GappedPoint[] = [
|
||||
[FIXED_EPOCH_MS, 50],
|
||||
[FIXED_EPOCH_MS + 1_000, 10], // frame minimum, before the marker
|
||||
[FIXED_EPOCH_MS + 1_001, null],
|
||||
[FIXED_EPOCH_MS + 2_000, 90], // frame maximum, after the marker
|
||||
[FIXED_EPOCH_MS + 3_000, 60],
|
||||
[FIXED_EPOCH_MS + 30_000, 55],
|
||||
[FIXED_EPOCH_MS + 31_000, 45],
|
||||
];
|
||||
expect(downSampleLineData(points, 3)).toEqual([
|
||||
[FIXED_EPOCH_MS + 1_000, 10],
|
||||
[FIXED_EPOCH_MS + 2_000, 90],
|
||||
[FIXED_EPOCH_MS + 30_000, 55],
|
||||
[FIXED_EPOCH_MS + 31_000, 45],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a marker sharing its x with a value after that value", () => {
|
||||
// statistics-chart-data.ts ends the line and breaks it at the same x
|
||||
const points: GappedPoint[] = [
|
||||
[FIXED_EPOCH_MS, 50],
|
||||
[FIXED_EPOCH_MS + 1_000, 90],
|
||||
[FIXED_EPOCH_MS + 2_000, 10],
|
||||
[FIXED_EPOCH_MS + 2_000, null],
|
||||
[FIXED_EPOCH_MS + 30_000, 55],
|
||||
[FIXED_EPOCH_MS + 31_000, 45],
|
||||
];
|
||||
expect(downSampleLineData(points, 3)).toEqual([
|
||||
[FIXED_EPOCH_MS + 1_000, 90],
|
||||
[FIXED_EPOCH_MS + 2_000, 10],
|
||||
[FIXED_EPOCH_MS + 2_000, null],
|
||||
[FIXED_EPOCH_MS + 30_000, 55],
|
||||
[FIXED_EPOCH_MS + 31_000, 45],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a gap marker whose frame holds no values", () => {
|
||||
const points: GappedPoint[] = [
|
||||
[FIXED_EPOCH_MS, 50],
|
||||
[FIXED_EPOCH_MS + 1_000, 90],
|
||||
[FIXED_EPOCH_MS + 2_000, 60],
|
||||
[FIXED_EPOCH_MS + 15_000, null],
|
||||
[FIXED_EPOCH_MS + 30_000, 55],
|
||||
[FIXED_EPOCH_MS + 31_000, 45],
|
||||
];
|
||||
expect(downSampleLineData(points, 3)).toEqual([
|
||||
[FIXED_EPOCH_MS, 50],
|
||||
[FIXED_EPOCH_MS + 1_000, 90],
|
||||
[FIXED_EPOCH_MS + 15_000, null],
|
||||
[FIXED_EPOCH_MS + 30_000, 55],
|
||||
[FIXED_EPOCH_MS + 31_000, 45],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a single gap marker per frame", () => {
|
||||
// A run of nulls inside one frame renders the same as a single null: the
|
||||
// break only depends on which points the marker sits between.
|
||||
const points: GappedPoint[] = [
|
||||
[FIXED_EPOCH_MS, 50],
|
||||
[FIXED_EPOCH_MS + 1_000, 90],
|
||||
[FIXED_EPOCH_MS + 2_000, 10],
|
||||
[FIXED_EPOCH_MS + 3_000, null],
|
||||
[FIXED_EPOCH_MS + 4_000, null],
|
||||
[FIXED_EPOCH_MS + 5_000, null],
|
||||
[FIXED_EPOCH_MS + 30_000, 55],
|
||||
[FIXED_EPOCH_MS + 31_000, 45],
|
||||
];
|
||||
expect(downSampleLineData(points, 3)).toEqual([
|
||||
[FIXED_EPOCH_MS + 1_000, 90],
|
||||
[FIXED_EPOCH_MS + 2_000, 10],
|
||||
[FIXED_EPOCH_MS + 5_000, null],
|
||||
[FIXED_EPOCH_MS + 30_000, 55],
|
||||
[FIXED_EPOCH_MS + 31_000, 45],
|
||||
]);
|
||||
});
|
||||
|
||||
it("bounds the output on a series where most points are null", () => {
|
||||
// The climate heating/cooling datasets push a null for every state where
|
||||
// the mode is inactive, so markers must not escape the frame budget.
|
||||
const values = generatePoints(20, SCALES.medium);
|
||||
const random = createSeededRandom(21);
|
||||
const gapped: GappedPoint[] = values.map(([x, y]) =>
|
||||
random() < 0.65 ? [x, null] : [x, y]
|
||||
);
|
||||
const gapless = downSampleLineData(values, 500);
|
||||
const result = downSampleLineData(gapped, 500);
|
||||
// Both series share an x grid, so they share frames, and the gapless one
|
||||
// emits at least one point per frame. A gapped frame emits at most three:
|
||||
// min, max and a single marker.
|
||||
expect(result.length).toBeLessThanOrEqual(3 * gapless.length);
|
||||
expect(
|
||||
result.filter((point) => point[1] === null).length
|
||||
).toBeLessThanOrEqual(gapless.length);
|
||||
});
|
||||
|
||||
it("handles gap markers on object-shaped points", () => {
|
||||
const points = toObjectPoints(gappedPoints);
|
||||
expect(downSampleLineData(points, 3)).toEqual([
|
||||
points[1],
|
||||
points[3],
|
||||
points[5],
|
||||
points[6],
|
||||
points[7],
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles gap markers on Date x values", () => {
|
||||
// statistics charts use Date objects for x
|
||||
const points = gappedPoints.map(
|
||||
([x, y]) => [new Date(x), y] as [Date, number | null | undefined]
|
||||
);
|
||||
expect(downSampleLineData(points, 3)).toEqual([
|
||||
points[1],
|
||||
points[3],
|
||||
points[5],
|
||||
points[6],
|
||||
points[7],
|
||||
]);
|
||||
});
|
||||
|
||||
it("mean mode leaves gap markers out of the average", () => {
|
||||
const points: GappedPoint[] = [
|
||||
[FIXED_EPOCH_MS, 10],
|
||||
[FIXED_EPOCH_MS + 1_000, 20],
|
||||
[FIXED_EPOCH_MS + 1_001, null],
|
||||
[FIXED_EPOCH_MS + 2_000, 30],
|
||||
[FIXED_EPOCH_MS + 30_000, 100],
|
||||
[FIXED_EPOCH_MS + 31_000, 100],
|
||||
];
|
||||
expect(downSampleLineData(points, 3, undefined, undefined, true)).toEqual([
|
||||
// (10 + 20 + 30) / 3, not (10 + 20 + 0 + 30) / 4
|
||||
[FIXED_EPOCH_MS + 1_000, 20],
|
||||
[FIXED_EPOCH_MS + 30_500, 100],
|
||||
]);
|
||||
});
|
||||
|
||||
it("min/max mode preserves x-order for gapped input", () => {
|
||||
const result = downSampleLineData(generateGappedPoints(22, 1000), 50);
|
||||
// Of the three gaps only the 25 point one outlasts its frame; the one and
|
||||
// three point gaps close within theirs and are dropped.
|
||||
expect(result.filter((point) => point[1] === null)).toHaveLength(1);
|
||||
expectXOrdered(result);
|
||||
});
|
||||
|
||||
it("large scale mean-mode digest is stable", () => {
|
||||
expect(
|
||||
digestResult(
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
RESOLVE_CACHE_TIME,
|
||||
resolveMediaSourceWithCache,
|
||||
} from "../../src/data/media_source";
|
||||
import type { HomeAssistant } from "../../src/types";
|
||||
|
||||
const CONTENT_ID = "media-source://image_upload/background";
|
||||
const OTHER_CONTENT_ID = "media-source://image_upload/other";
|
||||
|
||||
// Core signs every resolution with a fresh timestamp, so an uncached resolve of
|
||||
// the same id yields a different url each time. The mock reproduces that: the
|
||||
// urls only stay equal if the resolution itself was reused.
|
||||
const mockHass = () => {
|
||||
let signature = 0;
|
||||
return {
|
||||
callWS: vi.fn(({ media_content_id }: { media_content_id: string }) => {
|
||||
signature += 1;
|
||||
return Promise.resolve({
|
||||
url: `/api/image/serve/${media_content_id.split("/").pop()}/original?authSig=sig${signature}`,
|
||||
mime_type: "image/jpeg",
|
||||
});
|
||||
}),
|
||||
} as unknown as HomeAssistant;
|
||||
};
|
||||
|
||||
describe("resolveMediaSourceWithCache", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns the same url when the same content id is resolved again", async () => {
|
||||
const hass = mockHass();
|
||||
|
||||
const first = await resolveMediaSourceWithCache(hass, CONTENT_ID);
|
||||
const second = await resolveMediaSourceWithCache(hass, CONTENT_ID);
|
||||
|
||||
expect(second.url).toBe(first.url);
|
||||
expect(hass.callWS).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shares a single request between concurrent callers", async () => {
|
||||
const hass = mockHass();
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
resolveMediaSourceWithCache(hass, CONTENT_ID),
|
||||
resolveMediaSourceWithCache(hass, CONTENT_ID),
|
||||
]);
|
||||
|
||||
expect(second.url).toBe(first.url);
|
||||
expect(hass.callWS).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("resolves each content id to its own url", async () => {
|
||||
const hass = mockHass();
|
||||
|
||||
const first = await resolveMediaSourceWithCache(hass, CONTENT_ID);
|
||||
const other = await resolveMediaSourceWithCache(hass, OTHER_CONTENT_ID);
|
||||
|
||||
expect(first.url).toContain("/background/");
|
||||
expect(other.url).toContain("/other/");
|
||||
expect(hass.callWS).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps returning the same url when hass is updated", async () => {
|
||||
const hass = mockHass();
|
||||
|
||||
const first = await resolveMediaSourceWithCache(hass, CONTENT_ID);
|
||||
// State updates replace hass with a shallow copy
|
||||
const second = await resolveMediaSourceWithCache(
|
||||
{ ...hass } as HomeAssistant,
|
||||
CONTENT_ID
|
||||
);
|
||||
|
||||
expect(second.url).toBe(first.url);
|
||||
expect(hass.callWS).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not cache failures", async () => {
|
||||
const hass = mockHass();
|
||||
vi.mocked(hass.callWS).mockRejectedValueOnce(new Error("unresolvable"));
|
||||
|
||||
await expect(
|
||||
resolveMediaSourceWithCache(hass, CONTENT_ID)
|
||||
).rejects.toThrowError("unresolvable");
|
||||
const retried = await resolveMediaSourceWithCache(hass, CONTENT_ID);
|
||||
|
||||
expect(retried.url).toContain("authSig=");
|
||||
expect(hass.callWS).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("resolves a fresh url once the cached one is about to expire", async () => {
|
||||
const hass = mockHass();
|
||||
|
||||
const first = await resolveMediaSourceWithCache(hass, CONTENT_ID);
|
||||
vi.advanceTimersByTime(RESOLVE_CACHE_TIME);
|
||||
const second = await resolveMediaSourceWithCache(hass, CONTENT_ID);
|
||||
|
||||
expect(second.url).not.toBe(first.url);
|
||||
expect(hass.callWS).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("caches for less than the 24 hour signature validity", () => {
|
||||
expect(RESOLVE_CACHE_TIME).toBeLessThan(24 * 60 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
@@ -1,129 +0,0 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import "../../../../src/panels/lovelace/views/hui-view-background";
|
||||
import type { HUIViewBackground } from "../../../../src/panels/lovelace/views/hui-view-background";
|
||||
import type { LovelaceViewBackgroundConfig } from "../../../../src/data/lovelace/config/view";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
|
||||
const IMAGE_A = "media-source://image_upload/a";
|
||||
const IMAGE_B = "media-source://image_upload/b";
|
||||
|
||||
// Resolutions are answered by hand so a slow one can land after a later,
|
||||
// already-resolved one — the ordering a cache hit makes easy to hit.
|
||||
const deferredHass = () => {
|
||||
const pending = new Map<string, (url: string) => void>();
|
||||
const hass = {
|
||||
callWS: vi.fn(
|
||||
({ media_content_id }: { media_content_id: string }) =>
|
||||
new Promise((resolve) => {
|
||||
pending.set(media_content_id, (url: string) =>
|
||||
resolve({ url, mime_type: "image/jpeg" })
|
||||
);
|
||||
})
|
||||
),
|
||||
hassUrl: (path?: string) => path ?? "",
|
||||
} as unknown as HomeAssistant;
|
||||
return { hass, pending };
|
||||
};
|
||||
|
||||
let elements: HUIViewBackground[] = [];
|
||||
|
||||
const mount = async (
|
||||
hass: HomeAssistant,
|
||||
background: string | LovelaceViewBackgroundConfig
|
||||
) => {
|
||||
const el = document.createElement("hui-view-background") as HUIViewBackground;
|
||||
el.hass = hass;
|
||||
el.background = background;
|
||||
document.body.appendChild(el);
|
||||
elements.push(el);
|
||||
await el.updateComplete;
|
||||
return el;
|
||||
};
|
||||
|
||||
const setBackground = async (
|
||||
el: HUIViewBackground,
|
||||
background: string | LovelaceViewBackgroundConfig
|
||||
) => {
|
||||
el.background = background;
|
||||
await el.updateComplete;
|
||||
};
|
||||
|
||||
// Let the resolve chain drain before checking what was applied
|
||||
const settle = async (el: HUIViewBackground) => {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
await el.updateComplete;
|
||||
};
|
||||
|
||||
const imageBackground = (
|
||||
mediaContentId: string
|
||||
): LovelaceViewBackgroundConfig => ({
|
||||
image: { media_content_id: mediaContentId },
|
||||
});
|
||||
|
||||
const backgroundUrl = (el: HUIViewBackground) =>
|
||||
el.style.getPropertyValue("--view-background");
|
||||
|
||||
afterEach(() => {
|
||||
elements.forEach((el) => el.remove());
|
||||
elements = [];
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("hui-view-background", () => {
|
||||
it("applies the resolved url of a media source background", async () => {
|
||||
const { hass, pending } = deferredHass();
|
||||
const el = await mount(hass, imageBackground(IMAGE_A));
|
||||
|
||||
pending.get(IMAGE_A)!("/a.jpg?authSig=a");
|
||||
await settle(el);
|
||||
|
||||
expect(backgroundUrl(el)).toContain("/a.jpg?authSig=a");
|
||||
});
|
||||
|
||||
it("ignores a resolution that arrives after the background changed", async () => {
|
||||
const { hass, pending } = deferredHass();
|
||||
const el = await mount(hass, imageBackground(IMAGE_A));
|
||||
await setBackground(el, imageBackground(IMAGE_B));
|
||||
|
||||
// B resolves first, then the stale A resolution lands
|
||||
pending.get(IMAGE_B)!("/b.jpg?authSig=b");
|
||||
await settle(el);
|
||||
pending.get(IMAGE_A)!("/a.jpg?authSig=a");
|
||||
await settle(el);
|
||||
|
||||
expect(backgroundUrl(el)).toContain("/b.jpg?authSig=b");
|
||||
expect(backgroundUrl(el)).not.toContain("/a.jpg");
|
||||
});
|
||||
|
||||
it("keeps a plain css background untouched", async () => {
|
||||
const { hass } = deferredHass();
|
||||
const el = await mount(hass, "#3f51b5");
|
||||
|
||||
expect(hass.callWS).not.toHaveBeenCalled();
|
||||
expect(backgroundUrl(el)).toBe("#3f51b5");
|
||||
});
|
||||
|
||||
it("clears the resolved image when the background is replaced by a color", async () => {
|
||||
const { hass, pending } = deferredHass();
|
||||
const el = await mount(hass, imageBackground(IMAGE_A));
|
||||
pending.get(IMAGE_A)!("/a.jpg?authSig=a");
|
||||
await settle(el);
|
||||
|
||||
await setBackground(el, "#3f51b5");
|
||||
|
||||
expect(backgroundUrl(el)).toBe("#3f51b5");
|
||||
});
|
||||
|
||||
it("falls back to the theme background when resolving fails", async () => {
|
||||
const hass = {
|
||||
callWS: vi.fn().mockRejectedValue(new Error("unresolvable")),
|
||||
hassUrl: (path?: string) => path ?? "",
|
||||
} as unknown as HomeAssistant;
|
||||
const el = await mount(hass, imageBackground(IMAGE_A));
|
||||
await settle(el);
|
||||
|
||||
expect(backgroundUrl(el)).toBe("");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user