mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-03 13:42:17 +00:00
Compare commits
7 Commits
20260624.3
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| e79cd0c5b2 | |||
| 52379b39e0 | |||
| 656e1bea8e | |||
| 4ef3ed2f02 | |||
| fbad0ba885 | |||
| c892691344 | |||
| 811b7c7d98 |
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "home-assistant-frontend"
|
||||
version = "20260624.3"
|
||||
version = "20260624.4"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*"]
|
||||
description = "The Home Assistant frontend"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { NumberSelector } from "../../data/selector";
|
||||
import { isSafari } from "../../util/is_safari";
|
||||
import "../ha-input-helper-text";
|
||||
import "../ha-slider";
|
||||
import "../input/ha-input";
|
||||
@@ -66,6 +67,16 @@ export class HaNumberSelector extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
// On iOS/iPadOS the numeric and decimal on-screen keypads have no minus key,
|
||||
// so negatives can only be typed with the full "text" keyboard. Other
|
||||
// platforms include a minus on their number keypads, so restrict this
|
||||
// workaround to Safari/WebKit and only when the selector allows negatives
|
||||
// (e.g. numeric_state triggers/conditions).
|
||||
const useTextInputMode =
|
||||
isSafari &&
|
||||
this.selector.number?.min !== undefined &&
|
||||
this.selector.number.min < 0;
|
||||
|
||||
const translationKey = this.selector.number?.translation_key;
|
||||
let unit = this.selector.number?.unit_of_measurement;
|
||||
if (isBox && unit && this.localizeValue && translationKey) {
|
||||
@@ -96,10 +107,12 @@ export class HaNumberSelector extends LitElement {
|
||||
`
|
||||
: nothing}
|
||||
<ha-input
|
||||
.inputMode=${this.selector.number?.step === "any" ||
|
||||
(this.selector.number?.step ?? 1) % 1 !== 0
|
||||
? "decimal"
|
||||
: "numeric"}
|
||||
.inputmode=${useTextInputMode
|
||||
? "text"
|
||||
: this.selector.number?.step === "any" ||
|
||||
(this.selector.number?.step ?? 1) % 1 !== 0
|
||||
? "decimal"
|
||||
: "numeric"}
|
||||
.label=${!isBox ? undefined : this.label}
|
||||
.placeholder=${this.placeholder !== undefined
|
||||
? this.placeholder.toString()
|
||||
|
||||
@@ -521,10 +521,10 @@ export class HaServiceControl extends LitElement {
|
||||
${description ? html`<p>${description}</p>` : ""}
|
||||
${this._manifest
|
||||
? html` <a
|
||||
href=${this._manifest.is_built_in
|
||||
href=${this._manifest.is_built_in && this._value?.action
|
||||
? documentationUrl(
|
||||
this.hass,
|
||||
`/integrations/${this._manifest.domain}`
|
||||
`/actions/${this._value.action}`
|
||||
)
|
||||
: this._manifest.documentation}
|
||||
title=${this.hass.localize(
|
||||
|
||||
@@ -5,6 +5,7 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import { navigate } from "../../../common/navigate";
|
||||
import { extractSearchParam } from "../../../common/url/search-params";
|
||||
import "../../../components/ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
|
||||
import "../../../components/ha-dropdown-item";
|
||||
@@ -23,8 +24,14 @@ import type {
|
||||
StoreAddon,
|
||||
SupervisorStore,
|
||||
} from "../../../data/supervisor/store";
|
||||
import { fetchSupervisorStore } from "../../../data/supervisor/store";
|
||||
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
|
||||
import {
|
||||
addStoreRepository,
|
||||
fetchSupervisorStore,
|
||||
} from "../../../data/supervisor/store";
|
||||
import {
|
||||
showAlertDialog,
|
||||
showConfirmationDialog,
|
||||
} from "../../../dialogs/generic/show-dialog-box";
|
||||
import "../../../layouts/hass-error-screen";
|
||||
import "../../../layouts/hass-loading-screen";
|
||||
import "../../../layouts/hass-subpage";
|
||||
@@ -82,7 +89,15 @@ export class HaConfigAppsAvailable extends LitElement {
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
this._loadData();
|
||||
const repositoryUrl = extractSearchParam("repository_url");
|
||||
if (repositoryUrl) {
|
||||
navigate("/config/apps/available", { replace: true });
|
||||
}
|
||||
this._loadData().then(() => {
|
||||
if (repositoryUrl) {
|
||||
this._addRepository(repositoryUrl);
|
||||
}
|
||||
});
|
||||
this.addEventListener("hass-api-called", (ev) => this._apiCalled(ev));
|
||||
}
|
||||
|
||||
@@ -226,6 +241,40 @@ export class HaConfigAppsAvailable extends LitElement {
|
||||
navigate("/config/apps/registries");
|
||||
}
|
||||
|
||||
private async _addRepository(repositoryUrl: string): Promise<void> {
|
||||
if (
|
||||
!this._store ||
|
||||
this._store.repositories.some((repo) => repo.source === repositoryUrl)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!(await showConfirmationDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.apps.my.add_repository_title"
|
||||
),
|
||||
text: this.hass.localize(
|
||||
"ui.panel.config.apps.my.add_repository_store_description",
|
||||
{ repository: repositoryUrl }
|
||||
),
|
||||
confirmText: this.hass.localize("ui.common.add"),
|
||||
dismissText: this.hass.localize("ui.common.cancel"),
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await addStoreRepository(this.hass, repositoryUrl);
|
||||
await this._loadData();
|
||||
} catch (err: any) {
|
||||
showAlertDialog(this, {
|
||||
text: extractApiErrorMessage(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async _loadData(): Promise<void> {
|
||||
try {
|
||||
const [addon, store] = await Promise.all([
|
||||
|
||||
@@ -5,7 +5,6 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
|
||||
import { extractSearchParam } from "../../../common/url/search-params";
|
||||
import "../../../components/data-table/ha-data-table";
|
||||
import type { DataTableColumnContainer } from "../../../components/data-table/ha-data-table";
|
||||
import "../../../components/ha-button";
|
||||
@@ -56,12 +55,7 @@ export class HaConfigAppsRepositories extends LitElement {
|
||||
@state() private _error?: string;
|
||||
|
||||
protected firstUpdated() {
|
||||
this._loadData().then(() => {
|
||||
const repositoryUrl = extractSearchParam("repository_url");
|
||||
if (repositoryUrl) {
|
||||
this._addRepository(repositoryUrl);
|
||||
}
|
||||
});
|
||||
this._loadData();
|
||||
}
|
||||
|
||||
private _columns = memoizeOne(
|
||||
@@ -224,18 +218,6 @@ export class HaConfigAppsRepositories extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private async _addRepository(url: string) {
|
||||
try {
|
||||
await addStoreRepository(this.hass, url);
|
||||
await this._loadData();
|
||||
fireEvent(this, "apps-collection-refresh", { collection: "store" });
|
||||
} catch (err: any) {
|
||||
showAlertDialog(this, {
|
||||
text: extractApiErrorMessage(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _removeRepository = async (ev: Event) => {
|
||||
const slug = (ev.currentTarget as any).slug;
|
||||
const repo = this._repositories?.find((r) => r.slug === slug);
|
||||
|
||||
@@ -195,7 +195,7 @@ export class HaPlatformCondition extends LitElement {
|
||||
href=${this._manifest.is_built_in
|
||||
? documentationUrl(
|
||||
this.hass,
|
||||
`/integrations/${this._manifest.domain}`
|
||||
`/conditions/${this.condition.condition}`
|
||||
)
|
||||
: this._manifest.documentation}
|
||||
title=${this.hass.localize(
|
||||
|
||||
@@ -189,7 +189,7 @@ export class HaPlatformTrigger extends LitElement {
|
||||
href=${this._manifest.is_built_in
|
||||
? documentationUrl(
|
||||
this.hass,
|
||||
`/integrations/${this._manifest.domain}`
|
||||
`/triggers/${this.trigger.trigger}`
|
||||
)
|
||||
: this._manifest.documentation}
|
||||
title=${this.hass.localize(
|
||||
|
||||
@@ -4,6 +4,7 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
|
||||
import type { LocalizeKeys } from "../../../../common/translations/localize";
|
||||
import { debounce } from "../../../../common/util/debounce";
|
||||
import "../../../../components/ha-alert";
|
||||
import "../../../../components/ha-button";
|
||||
@@ -40,6 +41,15 @@ For loop example getting entity values in the weather domain:
|
||||
{{ state.name | lower }} is {{state.state_with_unit}}
|
||||
{%- endfor %}.`;
|
||||
|
||||
// key resolves the label/description translation keys; path is passed through
|
||||
// documentationUrl().
|
||||
const TEMPLATE_DOCS_LINKS: { key: string; path: string }[] = [
|
||||
{ key: "docs_introduction", path: "/docs/templating/introduction/" },
|
||||
{ key: "docs_states", path: "/docs/templating/states/" },
|
||||
{ key: "docs_debugging", path: "/docs/templating/debugging/" },
|
||||
{ key: "docs_functions", path: "/template-functions/" },
|
||||
];
|
||||
|
||||
@customElement("developer-tools-template")
|
||||
class HaPanelDevTemplate extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -120,31 +130,36 @@ class HaPanelDevTemplate extends LitElement {
|
||||
"ui.panel.config.developer-tools.tabs.templates.description"
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.templates.engine_info"
|
||||
)}
|
||||
</p>
|
||||
<h3>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.templates.learn_more"
|
||||
)}
|
||||
</h3>
|
||||
<ul>
|
||||
<li>
|
||||
<a
|
||||
href="https://jinja.palletsprojects.com/en/latest/templates/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.templates.jinja_documentation"
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href=${documentationUrl(
|
||||
this.hass,
|
||||
"/docs/configuration/templating/"
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.templates.template_extensions"
|
||||
)}</a
|
||||
>
|
||||
</li>
|
||||
${TEMPLATE_DOCS_LINKS.map(
|
||||
(link) => html`
|
||||
<li>
|
||||
<a
|
||||
href=${documentationUrl(this.hass, link.path)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>${this.hass.localize(
|
||||
`ui.panel.config.developer-tools.tabs.templates.${link.key}` as LocalizeKeys
|
||||
)}</a
|
||||
>
|
||||
<span class="link-description"
|
||||
>${this.hass.localize(
|
||||
`ui.panel.config.developer-tools.tabs.templates.${link.key}_description` as LocalizeKeys
|
||||
)}</span
|
||||
>
|
||||
</li>
|
||||
`
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</ha-expansion-panel>
|
||||
@@ -430,6 +445,17 @@ ${type === "object"
|
||||
margin-block-start: var(--ha-space-1);
|
||||
margin-block-end: var(--ha-space-1);
|
||||
}
|
||||
.description > h3 {
|
||||
font-size: var(--ha-font-size-m);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
margin-block-end: var(--ha-space-1);
|
||||
}
|
||||
.description li {
|
||||
margin-block-end: var(--ha-space-1);
|
||||
}
|
||||
.description .link-description {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.render-pane .card-content {
|
||||
user-select: text;
|
||||
|
||||
@@ -369,6 +369,22 @@ export function computeStatMidpoint(
|
||||
return (start + end) / 2;
|
||||
}
|
||||
|
||||
const PERIOD_MS: Record<string, number> = {
|
||||
"5minute": 5 * 60 * 1000,
|
||||
hour: 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
/**
|
||||
* Offset from a period's start to its midpoint, for centering sub-daily bars
|
||||
* (and forecast lines) between axis ticks — 0 for daily+ periods, which sit at
|
||||
* the start. Derived from the period, not from the data, so the first/only
|
||||
* bucket centers identically to every other bucket. (Previously estimated from
|
||||
* the gap between the first two entries, which collapsed to 0 with one bucket.)
|
||||
*/
|
||||
export function getPeriodMidpointOffset(period: string): number {
|
||||
return (PERIOD_MS[period] ?? 0) / 2;
|
||||
}
|
||||
|
||||
export interface UntrackedSplit {
|
||||
/** Untracked consumption per timestamp, clamped to >= 0. */
|
||||
positive: Record<number, number>;
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type EnergyDataPoint,
|
||||
fillDataGapsAndRoundCaps,
|
||||
getCompareTransform,
|
||||
getPeriodMidpointOffset,
|
||||
splitUntrackedConsumption,
|
||||
} from "./common/energy-chart-options";
|
||||
import { getEnergyColor } from "./common/color";
|
||||
@@ -237,12 +238,15 @@ function processUntracked(
|
||||
const sortedTimes = Object.keys(consumptionData.used_total).sort(
|
||||
(a, b) => Number(a) - Number(b)
|
||||
);
|
||||
// Only start timestamps available here, so estimate midpoint from the gap
|
||||
// between the first two entries. Assumes uniform period spacing.
|
||||
// Only start timestamps available here, so center sub-daily bars using the
|
||||
// gap between the first two entries. With a lone first-of-day bucket there is
|
||||
// no gap to measure, so fall back to the nominal period midpoint — which
|
||||
// matches the device bars' computeStatMidpoint instead of collapsing to the
|
||||
// period start and splitting into a second stack.
|
||||
const periodOffset =
|
||||
(period === "hour" || period === "5minute") && sortedTimes.length >= 2
|
||||
? (Number(sortedTimes[1]) - Number(sortedTimes[0])) / 2
|
||||
: 0;
|
||||
: getPeriodMidpointOffset(period);
|
||||
sortedTimes.forEach((time) => {
|
||||
const ts = Number(time);
|
||||
const x = compare
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type EnergyDataPoint,
|
||||
fillDataGapsAndRoundCaps,
|
||||
getCompareTransform,
|
||||
getPeriodMidpointOffset,
|
||||
} from "./common/energy-chart-options";
|
||||
|
||||
export interface EnergySolarGraphDataParams {
|
||||
@@ -323,7 +324,8 @@ function processForecast(
|
||||
const solarForecastData: LineSeriesOption["data"] = [];
|
||||
// Only center forecast points for sub-daily periods to align with bars.
|
||||
// Only start timestamps available, so estimate midpoint from the gap
|
||||
// between the first two entries. Assumes uniform spacing.
|
||||
// between the first two entries; with a lone first bucket there is no
|
||||
// gap to measure, so fall back to the nominal period midpoint.
|
||||
let forecastOffset = 0;
|
||||
if (period === "hour" || period === "5minute") {
|
||||
const forecastTimes = Object.keys(forecastsData)
|
||||
@@ -332,7 +334,7 @@ function processForecast(
|
||||
forecastOffset =
|
||||
forecastTimes.length >= 2
|
||||
? (forecastTimes[1] - forecastTimes[0]) / 2
|
||||
: 0;
|
||||
: getPeriodMidpointOffset(period);
|
||||
}
|
||||
for (const [time, value] of Object.entries(forecastsData)) {
|
||||
const kWh = value / 1000;
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
fillDataGapsAndRoundCaps,
|
||||
getCommonOptions,
|
||||
getCompareTransform,
|
||||
getPeriodMidpointOffset,
|
||||
} from "./common/energy-chart-options";
|
||||
import type { HaECOption } from "../../../../resources/echarts/echarts";
|
||||
import type { CustomLegendOption } from "../../../../components/chart/ha-chart-base";
|
||||
@@ -585,14 +586,15 @@ export class HuiEnergyUsageGraphCard
|
||||
|
||||
const uniqueKeys = summedData.timestamps;
|
||||
|
||||
// Only center bars for sub-daily periods (hour/5min).
|
||||
// Only start timestamps available here, so estimate midpoint from the gap
|
||||
// between the first two entries. Assumes uniform period spacing.
|
||||
// Only center bars for sub-daily periods (hour/5min). Only start timestamps
|
||||
// available here, so estimate midpoint from the gap between the first two
|
||||
// entries; with a lone first-of-day bucket there is no gap to measure, so
|
||||
// fall back to the nominal period midpoint so the bar stays centered.
|
||||
const period = getSuggestedPeriod(this._start, this._end);
|
||||
const periodOffset =
|
||||
(period === "hour" || period === "5minute") && uniqueKeys.length >= 2
|
||||
? (uniqueKeys[1] - uniqueKeys[0]) / 2
|
||||
: 0;
|
||||
: getPeriodMidpointOffset(period);
|
||||
|
||||
const compareTransform = getCompareTransform(
|
||||
this._start,
|
||||
|
||||
@@ -149,6 +149,13 @@ export const getMyRedirects = (): Redirects => ({
|
||||
component: "energy",
|
||||
redirect: "/config/energy",
|
||||
},
|
||||
config_infrared: {
|
||||
redirect: "/config/infrared",
|
||||
},
|
||||
config_radiofrequency: {
|
||||
component: "radio_frequency",
|
||||
redirect: "/config/radio-frequency",
|
||||
},
|
||||
config_ssdp: {
|
||||
component: "ssdp",
|
||||
redirect: "/config/ssdp",
|
||||
|
||||
@@ -2853,6 +2853,7 @@
|
||||
"my": {
|
||||
"add_repository_title": "Add app repository?",
|
||||
"add_repository_description": "This app requires a repository that is currently not known. Do you want to add the repository {repository}?",
|
||||
"add_repository_store_description": "Do you want to add the app repository {repository}?",
|
||||
"error_repository_not_found": "The repository for this app was not found"
|
||||
},
|
||||
"panel": {
|
||||
@@ -3904,7 +3905,9 @@
|
||||
},
|
||||
"templates": {
|
||||
"title": "Template",
|
||||
"description": "Templates are rendered using the Jinja2 template engine with some Home Assistant specific extensions.",
|
||||
"description": "Templates let you generate dynamic content from your Home Assistant data, such as a notification that lists which lights are on, or a sensor whose value is calculated from several other entities.",
|
||||
"engine_info": "Home Assistant uses the Jinja templating engine, extended with functions for working with your entities, areas, devices, and more. Write a template in the editor below and its result updates live as your states change.",
|
||||
"learn_more": "Learn more",
|
||||
"about": "About templates",
|
||||
"editor": "Template editor",
|
||||
"result": "Result",
|
||||
@@ -3912,8 +3915,14 @@
|
||||
"confirm_reset": "Do you want to reset your current template back to the demo template?",
|
||||
"confirm_clear": "Do you want to clear your current template?",
|
||||
"result_type": "Result type",
|
||||
"jinja_documentation": "Jinja2 template documentation",
|
||||
"template_extensions": "Home Assistant template extensions",
|
||||
"docs_introduction": "Introduction to templating",
|
||||
"docs_introduction_description": "Start here for a step-by-step guide.",
|
||||
"docs_states": "Working with states",
|
||||
"docs_states_description": "Read entity states and attributes in templates.",
|
||||
"docs_debugging": "Debugging templates",
|
||||
"docs_debugging_description": "Find and fix problems in your templates.",
|
||||
"docs_functions": "Template functions reference",
|
||||
"docs_functions_description": "Search every available function, filter, and test.",
|
||||
"unknown_error_template": "Unknown error rendering template",
|
||||
"time": "This template updates at the start of each minute.",
|
||||
"all_listeners": "This template listens for all state changed events.",
|
||||
|
||||
Vendored
+5
-1
@@ -89,6 +89,8 @@ export interface EnergyDataOptions {
|
||||
period?: "5minute" | "hour" | "day";
|
||||
compare?: boolean;
|
||||
prefs?: EnergyPreferences;
|
||||
/** Probability a period is missing (creates gaps); 0 for a dense dataset. */
|
||||
gapChance?: number;
|
||||
}
|
||||
|
||||
const statisticIdsForPrefs = (prefs: EnergyPreferences): string[] => {
|
||||
@@ -115,7 +117,7 @@ export const generateEnergyData = (
|
||||
seed: number,
|
||||
options: EnergyDataOptions
|
||||
): EnergyData => {
|
||||
const { days, period = "hour", compare = false } = options;
|
||||
const { days, period = "hour", compare = false, gapChance } = options;
|
||||
const prefs = options.prefs ?? generateEnergyPreferences();
|
||||
const ids = statisticIdsForPrefs(prefs);
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
@@ -124,6 +126,7 @@ export const generateEnergyData = (
|
||||
ids,
|
||||
period,
|
||||
days,
|
||||
gapChance,
|
||||
sumStatistics: true,
|
||||
});
|
||||
const statsCompare = compare
|
||||
@@ -132,6 +135,7 @@ export const generateEnergyData = (
|
||||
period,
|
||||
days,
|
||||
startMs: FIXED_EPOCH_MS - days * dayMs,
|
||||
gapChance,
|
||||
sumStatistics: true,
|
||||
})
|
||||
: ({} as EnergyData["statsCompare"]);
|
||||
|
||||
@@ -164,6 +164,57 @@ describe("generateEnergyDevicesDetailGraphData", () => {
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
|
||||
// Regression test for #52937: at the start of the day only the first hour
|
||||
// has data. The untracked/over-reported bars must center on the same period
|
||||
// midpoint as the device bars so they stack as one bar instead of splitting
|
||||
// into a second stack at the period start.
|
||||
it("stacks untracked bars on the device bars for a lone first-of-day bucket", () => {
|
||||
// Full-day range (so getSuggestedPeriod stays "hour") but keep only the
|
||||
// first hourly bucket in every stat. gapChance: 0 makes the bucket dense.
|
||||
const full = generateEnergyData(1, {
|
||||
days: 1,
|
||||
period: "hour",
|
||||
gapChance: 0,
|
||||
prefs: buildPrefs(false),
|
||||
});
|
||||
const firstStart = full.start.getTime();
|
||||
const energyData = {
|
||||
...full,
|
||||
stats: Object.fromEntries(
|
||||
Object.entries(full.stats).map(
|
||||
([id, values]) =>
|
||||
[id, values.filter((s) => s.start === firstStart)] as const
|
||||
)
|
||||
),
|
||||
};
|
||||
|
||||
const result = generateEnergyDevicesDetailGraphData({
|
||||
...baseParams,
|
||||
energyData,
|
||||
});
|
||||
|
||||
// Collect the display x of every bar across all series.
|
||||
const xs = new Set<number>();
|
||||
let nonEmptySeries = 0;
|
||||
for (const series of result.chartData) {
|
||||
const points = series.data ?? [];
|
||||
if (points.length) {
|
||||
nonEmptySeries++;
|
||||
}
|
||||
for (const point of points as any[]) {
|
||||
const x = Array.isArray(point) ? point[0] : point?.value?.[0];
|
||||
if (x != null) {
|
||||
xs.add(Number(x));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Device bars + at least one untracked series are present...
|
||||
assert.isAtLeast(nonEmptySeries, 2);
|
||||
// ...and they all share a single x, so they render as one full stack.
|
||||
assert.equal(xs.size, 1);
|
||||
});
|
||||
|
||||
// The seeded fixtures above all happen to produce fully-negative untracked
|
||||
// (devices reference the source stats, so they consume all of used_total).
|
||||
// These two cases pin the branches those snapshots can't reach.
|
||||
|
||||
Reference in New Issue
Block a user