mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-28 00:33:55 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c17edb1ed | |||
| 669eab4d31 | |||
| ea179bd7fe | |||
| ef50cc0332 | |||
| ab0ce2a3b7 | |||
| 23bc2cf1c1 | |||
| 85e94841a9 | |||
| 1de7230d9e |
@@ -38,7 +38,7 @@ jobs:
|
||||
name: Prepare container dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
image: mcr.microsoft.com/playwright:v1.62.0-noble
|
||||
options: --user 1001
|
||||
defaults:
|
||||
run:
|
||||
@@ -154,7 +154,7 @@ jobs:
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
image: mcr.microsoft.com/playwright:v1.62.0-noble
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
@@ -206,7 +206,7 @@ jobs:
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
image: mcr.microsoft.com/playwright:v1.62.0-noble
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
@@ -260,7 +260,7 @@ jobs:
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
image: mcr.microsoft.com/playwright:v1.62.0-noble
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
|
||||
@@ -52,6 +52,23 @@ gulp.task("fetch-nightly-translations", async function () {
|
||||
currentArtifact = null;
|
||||
}
|
||||
|
||||
try {
|
||||
await fetchTranslations(currentArtifact);
|
||||
} catch (err) {
|
||||
// Local builds should work offline or without valid GitHub credentials,
|
||||
// so fall back to English only. CI must fail instead of silently
|
||||
// building without translations.
|
||||
if (process.env.CI) {
|
||||
throw err;
|
||||
}
|
||||
console.warn(
|
||||
"Failed to fetch nightly translations, continuing with English only:",
|
||||
err?.message || err
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
async function fetchTranslations(currentArtifact) {
|
||||
// To store file writing promises
|
||||
const createExtractDir = mkdir(EXTRACT_DIR, { recursive: true });
|
||||
const writings = [];
|
||||
@@ -130,11 +147,6 @@ gulp.task("fetch-nightly-translations", async function () {
|
||||
if (!latestArtifact) {
|
||||
throw Error("Latest nightly workflow run has no translations artifact");
|
||||
}
|
||||
writings.push(
|
||||
createExtractDir.then(
|
||||
writeFile(ARTIFACT_FILE, JSON.stringify(latestArtifact, null, 2))
|
||||
)
|
||||
);
|
||||
|
||||
// Remove the current translations
|
||||
const deleteCurrent = Promise.all(writings).then(
|
||||
@@ -160,7 +172,12 @@ gulp.task("fetch-nightly-translations", async function () {
|
||||
await new Promise((resolve, reject) => {
|
||||
extractStream.on("close", resolve).on("error", reject);
|
||||
});
|
||||
});
|
||||
|
||||
// Record the artifact only after successful extraction, so a failed fetch
|
||||
// is retried by the next build instead of being considered current.
|
||||
await createExtractDir;
|
||||
await writeFile(ARTIFACT_FILE, JSON.stringify(latestArtifact, null, 2));
|
||||
}
|
||||
|
||||
gulp.task(
|
||||
"setup-and-fetch-nightly-translations",
|
||||
|
||||
+1
-1
@@ -150,7 +150,7 @@
|
||||
"@octokit/auth-oauth-device": "8.0.3",
|
||||
"@octokit/plugin-retry": "8.1.0",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.61.1",
|
||||
"@playwright/test": "1.62.0",
|
||||
"@rsdoctor/rspack-plugin": "1.6.1",
|
||||
"@rspack/core": "2.1.5",
|
||||
"@rspack/dev-server": "2.1.0",
|
||||
|
||||
+2
-1
@@ -106,7 +106,8 @@
|
||||
{
|
||||
"description": "Group Playwright package and CI container updates",
|
||||
"groupName": "Playwright",
|
||||
"matchPackageNames": ["@playwright/test", "mcr.microsoft.com/playwright"]
|
||||
"matchPackageNames": ["@playwright/test", "mcr.microsoft.com/playwright"],
|
||||
"minimumGroupSize": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -542,20 +542,14 @@ export class HaServiceControl extends LitElement {
|
||||
}
|
||||
${
|
||||
serviceData && "target" in serviceData
|
||||
? html`<ha-settings-row
|
||||
.narrow=${this.narrow || isFullWidthSelector(targetSelector)}
|
||||
>
|
||||
<span slot="heading"
|
||||
>${this.hass.localize("ui.components.service-control.target")}</span
|
||||
>
|
||||
<ha-selector
|
||||
.hass=${this.hass}
|
||||
.selector=${targetSelector}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._targetChanged}
|
||||
.value=${this._value?.target}
|
||||
></ha-selector
|
||||
></ha-settings-row>`
|
||||
? html`<ha-selector
|
||||
class="target-selector"
|
||||
.hass=${this.hass}
|
||||
.selector=${targetSelector}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._targetChanged}
|
||||
.value=${this._value?.target}
|
||||
></ha-selector>`
|
||||
: entityId
|
||||
? html`<ha-entity-picker
|
||||
.disabled=${this.disabled}
|
||||
@@ -1059,6 +1053,14 @@ export class HaServiceControl extends LitElement {
|
||||
display: block;
|
||||
margin: var(--service-control-padding, 0 var(--ha-space-4));
|
||||
}
|
||||
ha-selector.target-selector {
|
||||
display: block;
|
||||
padding: var(--ha-space-2) var(--ha-space-4);
|
||||
border-top: var(
|
||||
--service-control-items-border-top,
|
||||
1px solid var(--divider-color)
|
||||
);
|
||||
}
|
||||
ha-yaml-editor {
|
||||
padding: var(--ha-space-4) 0;
|
||||
}
|
||||
|
||||
@@ -1342,7 +1342,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
.item-groups {
|
||||
overflow: hidden;
|
||||
border: 2px solid var(--divider-color);
|
||||
border: var(--ha-border-width-sm) solid var(--divider-color);
|
||||
border-radius: var(--ha-border-radius-lg);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -8,6 +8,13 @@ import "../ha-expansion-panel";
|
||||
import "../list/ha-list-base";
|
||||
import "./ha-target-picker-item-row";
|
||||
|
||||
const TYPE_PLURAL = {
|
||||
entity: "entities",
|
||||
device: "devices",
|
||||
area: "areas",
|
||||
label: "labels",
|
||||
} as const satisfies Record<TargetTypeFloorless, string>;
|
||||
|
||||
@customElement("ha-target-picker-item-group")
|
||||
export class HaTargetPickerItemGroup extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -60,11 +67,11 @@ export class HaTargetPickerItemGroup extends LitElement {
|
||||
>
|
||||
<div slot="header" class="heading">
|
||||
${this.hass.localize(
|
||||
`ui.components.target-picker.selected.${this.type}`,
|
||||
{
|
||||
count,
|
||||
}
|
||||
`ui.components.target-picker.type.${TYPE_PLURAL[this.type]}`
|
||||
)}
|
||||
${
|
||||
this.collapsed ? html`<span class="count">(${count})</span>` : nothing
|
||||
}
|
||||
</div>
|
||||
<ha-list-base>
|
||||
${Object.entries(this.items).map(([type, items]) =>
|
||||
@@ -106,6 +113,10 @@ export class HaTargetPickerItemGroup extends LitElement {
|
||||
justify-content: space-between;
|
||||
min-height: unset;
|
||||
}
|
||||
.count {
|
||||
color: var(--secondary-text-color);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -135,11 +135,14 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
|
||||
this.update = super.update;
|
||||
// Apps with a native splash screen keep covering the frontend until
|
||||
// frontend/loaded, so the launch screen stays up (invisibly) until the
|
||||
// first panel has rendered and partial-panel-resolver removes it.
|
||||
if (!this.hass.auth.external?.config.hasSplashscreen) {
|
||||
removeLaunchScreen();
|
||||
// first panel has rendered and partial-panel-resolver removes it and
|
||||
// fires frontend/loaded.
|
||||
if (
|
||||
!this.hass.auth.external?.config.hasSplashscreen &&
|
||||
removeLaunchScreen()
|
||||
) {
|
||||
this.hass.auth.external?.fireMessage({ type: "frontend/loaded" });
|
||||
}
|
||||
this.hass.auth.external?.fireMessage({ type: "frontend/loaded" });
|
||||
}
|
||||
super.update(changedProps);
|
||||
}
|
||||
|
||||
@@ -220,8 +220,13 @@ class PartialPanelResolver extends HassRouterPage {
|
||||
) {
|
||||
await this.rebuild();
|
||||
await this.pageRendered;
|
||||
removeLaunchScreen(!!this.hass.auth.external?.config.hasSplashscreen);
|
||||
this.hass.auth.external?.fireMessage({ type: "frontend/loaded" });
|
||||
// Only fire frontend/loaded when this call actually removed the launch
|
||||
// screen, so later panel updates do not fire it again.
|
||||
if (
|
||||
removeLaunchScreen(!!this.hass.auth.external?.config.hasSplashscreen)
|
||||
) {
|
||||
this.hass.auth.external?.fireMessage({ type: "frontend/loaded" });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { LitVirtualizer } from "@lit-labs/virtualizer";
|
||||
import { consume } from "@lit/context";
|
||||
import "@material/mwc-list/mwc-list";
|
||||
import { mdiPlus, mdiTextureBox } from "@mdi/js";
|
||||
import { mdiPlus, mdiTextureBox, mdiUnfoldMoreHorizontal } from "@mdi/js";
|
||||
import Fuse from "fuse.js";
|
||||
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
import {
|
||||
@@ -98,8 +98,16 @@ export const ITEM_SEARCH_KEYS: FuseWeightedKey[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const MAX_SEARCH_ITEMS_PER_SECTION = 5;
|
||||
|
||||
type SearchSection = "item" | "block" | "entity" | "device" | "area" | "label";
|
||||
|
||||
interface SearchMoreComboBoxItem extends PickerComboBoxItem {
|
||||
type: "more";
|
||||
section: "entity" | "device" | "item";
|
||||
label: string;
|
||||
}
|
||||
|
||||
@customElement("ha-automation-add-search")
|
||||
export class HaAutomationAddSearch extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -288,7 +296,8 @@ export class HaAutomationAddSearch extends LitElement {
|
||||
| (FloorComboBoxItem & { last?: boolean | undefined })
|
||||
| EntityComboBoxItem
|
||||
| DevicePickerItem
|
||||
| AutomationItemComboBoxItem,
|
||||
| AutomationItemComboBoxItem
|
||||
| SearchMoreComboBoxItem,
|
||||
index: number
|
||||
) => {
|
||||
if (!item) {
|
||||
@@ -299,6 +308,24 @@ export class HaAutomationAddSearch extends LitElement {
|
||||
return html`<ha-section-title>${item}</ha-section-title>`;
|
||||
}
|
||||
|
||||
if ("type" in item && item.type === "more") {
|
||||
return html`<ha-combo-box-item
|
||||
id=${`search-list-item-${index}`}
|
||||
type="button"
|
||||
tabindex="-1"
|
||||
.section-id=${item.section}
|
||||
.value=${`more-${item.section}`}
|
||||
@click=${this._toggleSection}
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiUnfoldMoreHorizontal}
|
||||
></ha-svg-icon>
|
||||
<span slot="headline"></span>
|
||||
<span slot="supporting-text">${item.label}</span>
|
||||
</ha-combo-box-item>`;
|
||||
}
|
||||
|
||||
const type = ["trigger", "condition", "action", "block"].includes(
|
||||
(item as AutomationItemComboBoxItem).type
|
||||
)
|
||||
@@ -512,10 +539,17 @@ export class HaAutomationAddSearch extends LitElement {
|
||||
selectedSection?: SearchSection,
|
||||
relatedIdSets?: RelatedIdSets
|
||||
) => {
|
||||
const resultItems: (
|
||||
string | FloorComboBoxItem | EntityComboBoxItem | PickerComboBoxItem
|
||||
)[] = [];
|
||||
|
||||
type ResultItem =
|
||||
| string
|
||||
| FloorComboBoxItem
|
||||
| EntityComboBoxItem
|
||||
| PickerComboBoxItem
|
||||
| SearchMoreComboBoxItem;
|
||||
const resultSections: {
|
||||
title: string;
|
||||
type: string;
|
||||
items: ResultItem[];
|
||||
}[] = [];
|
||||
if (!selectedSection || selectedSection === "item") {
|
||||
let items = this._convertItemsToComboBoxItems(automationItems, type);
|
||||
if (searchTerm) {
|
||||
@@ -526,15 +560,13 @@ export class HaAutomationAddSearch extends LitElement {
|
||||
ITEM_SEARCH_KEYS
|
||||
) as AutomationItemComboBoxItem[];
|
||||
}
|
||||
|
||||
if (!selectedSection && items.length) {
|
||||
// show group title
|
||||
resultItems.push(
|
||||
localize(`ui.panel.config.automation.editor.${type}s.name`)
|
||||
);
|
||||
if (items.length) {
|
||||
resultSections.push({
|
||||
title: localize(`ui.panel.config.automation.editor.${type}s.name`),
|
||||
type: "item",
|
||||
items: items,
|
||||
});
|
||||
}
|
||||
|
||||
resultItems.push(...items);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -564,13 +596,13 @@ export class HaAutomationAddSearch extends LitElement {
|
||||
) as AutomationItemComboBoxItem[];
|
||||
}
|
||||
|
||||
if (!selectedSection && blocks.length) {
|
||||
// show group title
|
||||
resultItems.push(
|
||||
localize("ui.panel.config.automation.editor.blocks")
|
||||
);
|
||||
if (blocks.length) {
|
||||
resultSections.push({
|
||||
title: localize("ui.panel.config.automation.editor.blocks"),
|
||||
type: "block",
|
||||
items: blocks,
|
||||
});
|
||||
}
|
||||
resultItems.push(...blocks);
|
||||
}
|
||||
|
||||
if (!selectedSection || selectedSection === "entity") {
|
||||
@@ -601,14 +633,13 @@ export class HaAutomationAddSearch extends LitElement {
|
||||
entityItems = sortRelatedFirst(entityItems) as EntityComboBoxItem[];
|
||||
}
|
||||
|
||||
if (!selectedSection && entityItems.length) {
|
||||
// show group title
|
||||
resultItems.push(
|
||||
localize("ui.components.target-picker.type.entities")
|
||||
);
|
||||
if (entityItems.length) {
|
||||
resultSections.push({
|
||||
title: localize("ui.components.target-picker.type.entities"),
|
||||
type: "entity",
|
||||
items: entityItems,
|
||||
});
|
||||
}
|
||||
|
||||
resultItems.push(...entityItems);
|
||||
}
|
||||
|
||||
if (!selectedSection || selectedSection === "device") {
|
||||
@@ -640,14 +671,13 @@ export class HaAutomationAddSearch extends LitElement {
|
||||
deviceItems = sortRelatedFirst(deviceItems);
|
||||
}
|
||||
|
||||
if (!selectedSection && deviceItems.length) {
|
||||
// show group title
|
||||
resultItems.push(
|
||||
localize("ui.components.target-picker.type.devices")
|
||||
);
|
||||
if (deviceItems.length) {
|
||||
resultSections.push({
|
||||
title: localize("ui.components.target-picker.type.devices"),
|
||||
type: "device",
|
||||
items: deviceItems,
|
||||
});
|
||||
}
|
||||
|
||||
resultItems.push(...deviceItems);
|
||||
}
|
||||
|
||||
if (!selectedSection || selectedSection === "area") {
|
||||
@@ -697,15 +727,9 @@ export class HaAutomationAddSearch extends LitElement {
|
||||
) as FloorComboBoxItem[];
|
||||
}
|
||||
|
||||
if (!selectedSection && areasAndFloors.length) {
|
||||
// show group title
|
||||
resultItems.push(localize("ui.components.target-picker.type.areas"));
|
||||
}
|
||||
|
||||
resultItems.push(
|
||||
...areasAndFloors.map((item, index) => {
|
||||
if (areasAndFloors.length) {
|
||||
const areaItems = areasAndFloors.map((item, index) => {
|
||||
const nextItem = areasAndFloors[index + 1];
|
||||
|
||||
if (
|
||||
!nextItem ||
|
||||
(item.type === "area" && nextItem.type === "floor")
|
||||
@@ -715,10 +739,15 @@ export class HaAutomationAddSearch extends LitElement {
|
||||
last: true,
|
||||
};
|
||||
}
|
||||
|
||||
return item;
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
resultSections.push({
|
||||
title: localize("ui.components.target-picker.type.areas"),
|
||||
type: "area",
|
||||
items: areaItems,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedSection || selectedSection === "label") {
|
||||
@@ -746,14 +775,48 @@ export class HaAutomationAddSearch extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedSection && labels.length) {
|
||||
// show group title
|
||||
resultItems.push(localize("ui.components.target-picker.type.labels"));
|
||||
if (labels.length) {
|
||||
resultSections.push({
|
||||
title: localize("ui.components.target-picker.type.labels"),
|
||||
type: "label",
|
||||
items: labels,
|
||||
});
|
||||
}
|
||||
|
||||
resultItems.push(...labels);
|
||||
}
|
||||
|
||||
const resultItems: ResultItem[] = [];
|
||||
resultSections.forEach((section, index) => {
|
||||
if (selectedSection) {
|
||||
resultItems.push(...section.items);
|
||||
return;
|
||||
}
|
||||
resultItems.push(section.title);
|
||||
if (
|
||||
index !== resultSections.length - 1 &&
|
||||
(section.type === "item" ||
|
||||
section.type === "entity" ||
|
||||
section.type === "device") &&
|
||||
section.items.length > MAX_SEARCH_ITEMS_PER_SECTION + 1
|
||||
) {
|
||||
resultItems.push(
|
||||
...section.items.slice(0, MAX_SEARCH_ITEMS_PER_SECTION)
|
||||
);
|
||||
const typeKey = section.type === "item" ? type : section.type;
|
||||
resultItems.push({
|
||||
primary: "",
|
||||
id: `search-more-${section.type}`,
|
||||
type: "more",
|
||||
section: section.type,
|
||||
label: localize(
|
||||
`ui.panel.config.automation.editor.show_more_search.${typeKey}`,
|
||||
{ count: section.items.length - MAX_SEARCH_ITEMS_PER_SECTION }
|
||||
),
|
||||
});
|
||||
} else {
|
||||
resultItems.push(...section.items);
|
||||
}
|
||||
});
|
||||
|
||||
return resultItems;
|
||||
}
|
||||
);
|
||||
@@ -790,18 +853,22 @@ export class HaAutomationAddSearch extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
private _toggleSection(ev: Event) {
|
||||
private _toggleSection = (ev: Event) => {
|
||||
ev.stopPropagation();
|
||||
// this._resetSelectedItem();
|
||||
this._searchSectionTitle = undefined;
|
||||
const section = (ev.target as HTMLElement)["section-id"] as string;
|
||||
const section = (ev.currentTarget as HTMLElement)["section-id"] as string;
|
||||
if (!section) {
|
||||
return;
|
||||
}
|
||||
if (this._selectedSearchSection === section) {
|
||||
this._toggleSectionType(section);
|
||||
};
|
||||
|
||||
private _toggleSectionType(type: string) {
|
||||
if (this._selectedSearchSection === type) {
|
||||
this._selectedSearchSection = undefined;
|
||||
} else {
|
||||
this._selectedSearchSection = section as SearchSection;
|
||||
this._selectedSearchSection = type as SearchSection;
|
||||
}
|
||||
|
||||
// Reset scroll position when filter changes
|
||||
@@ -1019,9 +1086,13 @@ export class HaAutomationAddSearch extends LitElement {
|
||||
|
||||
const item = this._virtualizerElement?.items[
|
||||
this._selectedSearchItemIndex
|
||||
] as PickerComboBoxItem;
|
||||
] as PickerComboBoxItem | SearchMoreComboBoxItem;
|
||||
if (item) {
|
||||
this._selectSearchItem(item);
|
||||
if ("type" in item && item.type === "more") {
|
||||
this._toggleSectionType((item as SearchMoreComboBoxItem).section);
|
||||
} else {
|
||||
this._selectSearchItem(item as PickerComboBoxItem);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -218,20 +218,14 @@ export class HaPlatformCondition extends LitElement {
|
||||
</div>
|
||||
${
|
||||
conditionDesc && "target" in conditionDesc
|
||||
? html`<ha-settings-row narrow>
|
||||
<span slot="heading"
|
||||
>${this.hass.localize(
|
||||
"ui.components.service-control.target"
|
||||
)}</span
|
||||
>
|
||||
<ha-selector
|
||||
.hass=${this.hass}
|
||||
.selector=${this._targetSelector(conditionDesc.target)}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._targetChanged}
|
||||
.value=${this.condition?.target}
|
||||
></ha-selector
|
||||
></ha-settings-row>`
|
||||
? html`<ha-selector
|
||||
class="target-selector"
|
||||
.hass=${this.hass}
|
||||
.selector=${this._targetSelector(conditionDesc.target)}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._targetChanged}
|
||||
.value=${this.condition?.target}
|
||||
></ha-selector>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
@@ -670,6 +664,14 @@ export class HaPlatformCondition extends LitElement {
|
||||
display: block;
|
||||
margin: 0 var(--ha-space-4);
|
||||
}
|
||||
ha-selector.target-selector {
|
||||
display: block;
|
||||
padding: var(--ha-space-2) var(--ha-space-4);
|
||||
border-top: var(
|
||||
--service-control-items-border-top,
|
||||
1px solid var(--divider-color)
|
||||
);
|
||||
}
|
||||
ha-yaml-editor {
|
||||
padding: var(--ha-space-4) 0;
|
||||
}
|
||||
|
||||
@@ -213,19 +213,14 @@ export class HaPlatformTrigger extends LitElement {
|
||||
</div>
|
||||
${
|
||||
triggerDesc && "target" in triggerDesc
|
||||
? html`<ha-settings-row narrow>
|
||||
<span slot="heading"
|
||||
>${this.hass.localize(
|
||||
"ui.components.service-control.target"
|
||||
)}</span
|
||||
><ha-selector
|
||||
.hass=${this.hass}
|
||||
.selector=${this._targetSelector(triggerDesc.target)}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._targetChanged}
|
||||
.value=${this.trigger?.target}
|
||||
></ha-selector
|
||||
></ha-settings-row>`
|
||||
? html`<ha-selector
|
||||
class="target-selector"
|
||||
.hass=${this.hass}
|
||||
.selector=${this._targetSelector(triggerDesc.target)}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._targetChanged}
|
||||
.value=${this.trigger?.target}
|
||||
></ha-selector>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
@@ -548,6 +543,14 @@ export class HaPlatformTrigger extends LitElement {
|
||||
display: block;
|
||||
margin: 0 var(--ha-space-4);
|
||||
}
|
||||
ha-selector.target-selector {
|
||||
display: block;
|
||||
padding: var(--ha-space-2) var(--ha-space-4);
|
||||
border-top: var(
|
||||
--service-control-items-border-top,
|
||||
1px solid var(--divider-color)
|
||||
);
|
||||
}
|
||||
ha-yaml-editor {
|
||||
padding: var(--ha-space-4) 0;
|
||||
}
|
||||
|
||||
@@ -54,9 +54,6 @@ export interface BuildSankeyDeviceNodesOptions {
|
||||
getValue: (id: string) => number;
|
||||
getLabel: (id: string, name: string | undefined) => string;
|
||||
getEntityId: (id: string) => string | undefined;
|
||||
findEffectiveParent: (
|
||||
includedInStat: string | undefined
|
||||
) => string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,7 +83,6 @@ export const buildSankeyDeviceNodes = (
|
||||
getValue,
|
||||
getLabel,
|
||||
getEntityId,
|
||||
findEffectiveParent,
|
||||
} = options;
|
||||
|
||||
const unavailableColor = computedStyle
|
||||
@@ -100,19 +96,62 @@ export const buildSankeyDeviceNodes = (
|
||||
const smallConsumerStats = new Set<string>();
|
||||
let untrackedConsumption = initialUntracked;
|
||||
|
||||
// Parent chain in stat_consumption space, used to detect nested small consumers
|
||||
const deviceParents = new Map<string, string | undefined>();
|
||||
// Resolve every device's node id and value once. `included_in_stat` always
|
||||
// names a stat_consumption, so the hierarchy is keyed by that, while the
|
||||
// rendered set holds node ids (stat_consumption or stat_rate, per `getId`).
|
||||
const deviceByStat = new Map<string, DeviceConsumptionEnergyPreference>();
|
||||
const deviceValues = new Map<string, number>();
|
||||
const renderedIds = new Set<string>();
|
||||
devices.forEach((device) => {
|
||||
deviceParents.set(device.stat_consumption, device.included_in_stat);
|
||||
});
|
||||
|
||||
devices.forEach((device, idx) => {
|
||||
deviceByStat.set(device.stat_consumption, device);
|
||||
const id = getId(device);
|
||||
// Falsy check (not `=== undefined`) mirrors the cards' original `!stat_rate` guard.
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const value = getValue(id);
|
||||
deviceValues.set(id, value);
|
||||
if (value >= minThreshold) {
|
||||
renderedIds.add(id);
|
||||
}
|
||||
});
|
||||
|
||||
/** Node id of a device rendered as its own node, else undefined. */
|
||||
const renderedId = (statConsumption: string): string | undefined => {
|
||||
const device = deviceByStat.get(statConsumption);
|
||||
if (!device) {
|
||||
return undefined;
|
||||
}
|
||||
const id = getId(device);
|
||||
return id && renderedIds.has(id) ? id : undefined;
|
||||
};
|
||||
|
||||
// Walk up the included_in_stat chain to the first ancestor that is rendered.
|
||||
// Bounded because a hand-edited config can make included_in_stat cyclic.
|
||||
const findEffectiveParent = (
|
||||
includedInStat: string | undefined
|
||||
): string | undefined => {
|
||||
let current = includedInStat;
|
||||
for (let hops = 0; current && hops < devices.length; hops++) {
|
||||
const rendered = renderedId(current);
|
||||
if (rendered) {
|
||||
return rendered;
|
||||
}
|
||||
const device = deviceByStat.get(current);
|
||||
if (!device) {
|
||||
return undefined;
|
||||
}
|
||||
current = device.included_in_stat;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
devices.forEach((device, idx) => {
|
||||
const id = getId(device);
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const value = deviceValues.get(id)!;
|
||||
const effectiveParent = findEffectiveParent(device.included_in_stat);
|
||||
|
||||
if (value < minThreshold) {
|
||||
@@ -155,14 +194,19 @@ export const buildSankeyDeviceNodes = (
|
||||
smallConsumersByParent.forEach((allConsumers, parentKey) => {
|
||||
// A small consumer whose included_in_stat chain reaches another small
|
||||
// consumer is already counted inside that ancestor's value - drop it so
|
||||
// totals don't double-count nested devices.
|
||||
// totals don't double-count nested devices. A rendered ancestor ends the
|
||||
// walk: the consumer links to it and never touches untracked, so it can't
|
||||
// be double-counted through anything further up.
|
||||
const consumers = allConsumers.filter((consumer) => {
|
||||
let ancestor = consumer.includedInStat;
|
||||
for (let hops = 0; ancestor && hops < devices.length; hops++) {
|
||||
if (renderedId(ancestor)) {
|
||||
return true;
|
||||
}
|
||||
if (smallConsumerStats.has(ancestor)) {
|
||||
return false;
|
||||
}
|
||||
ancestor = deviceParents.get(ancestor);
|
||||
ancestor = deviceByStat.get(ancestor)?.included_in_stat;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -272,35 +272,6 @@ class HuiEnergySankeyCard
|
||||
? calculateStatisticSumGrowth(this._data!.stats[statConsumption]) || 0
|
||||
: 0;
|
||||
|
||||
// Set of device stats that will be rendered as their own node
|
||||
const renderedStats = new Set<string>();
|
||||
prefs.device_consumption.forEach((device) => {
|
||||
if (deviceValue(device.stat_consumption) >= minEnergyThreshold) {
|
||||
renderedStats.add(device.stat_consumption);
|
||||
}
|
||||
});
|
||||
|
||||
// Walk up the included_in_stat chain to the first ancestor that is rendered
|
||||
const deviceMap = new Map<string, string | undefined>();
|
||||
prefs.device_consumption.forEach((device) => {
|
||||
deviceMap.set(device.stat_consumption, device.included_in_stat);
|
||||
});
|
||||
const findEffectiveParent = (
|
||||
includedInStat: string | undefined
|
||||
): string | undefined => {
|
||||
let currentParent = includedInStat;
|
||||
while (currentParent) {
|
||||
if (renderedStats.has(currentParent)) {
|
||||
return currentParent;
|
||||
}
|
||||
if (!deviceMap.has(currentParent)) {
|
||||
return undefined;
|
||||
}
|
||||
currentParent = deviceMap.get(currentParent);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const deviceLabel = (statConsumption: string, name?: string) =>
|
||||
name ||
|
||||
getStatisticLabel(
|
||||
@@ -327,7 +298,6 @@ class HuiEnergySankeyCard
|
||||
getValue: deviceValue,
|
||||
getLabel: deviceLabel,
|
||||
getEntityId: (id) => (isExternalStatistic(id) ? undefined : id),
|
||||
findEffectiveParent,
|
||||
});
|
||||
links.push(...deviceLinks);
|
||||
|
||||
|
||||
@@ -278,54 +278,6 @@ class HuiPowerSankeyCard
|
||||
}
|
||||
}
|
||||
|
||||
// Build a map of device relationships for hierarchy resolution
|
||||
// Key: stat_consumption (energy), Value: { stat_rate, included_in_stat }
|
||||
const deviceMap = new Map<
|
||||
string,
|
||||
{ stat_rate?: string; included_in_stat?: string }
|
||||
>();
|
||||
prefs.device_consumption.forEach((device) => {
|
||||
deviceMap.set(device.stat_consumption, {
|
||||
stat_rate: device.stat_rate,
|
||||
included_in_stat: device.included_in_stat,
|
||||
});
|
||||
});
|
||||
|
||||
// Set of stat_rate entities that will be rendered as nodes
|
||||
const renderedStatRates = new Set<string>();
|
||||
prefs.device_consumption.forEach((device) => {
|
||||
if (device.stat_rate) {
|
||||
const value = this._getCurrentPower(device.stat_rate);
|
||||
if (value >= minPowerThreshold) {
|
||||
renderedStatRates.add(device.stat_rate);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Find the effective parent for power hierarchy
|
||||
// Walks up the chain to find an ancestor with stat_rate that will be rendered
|
||||
const findEffectiveParent = (
|
||||
includedInStat: string | undefined
|
||||
): string | undefined => {
|
||||
let currentParent = includedInStat;
|
||||
while (currentParent) {
|
||||
const parentDevice = deviceMap.get(currentParent);
|
||||
if (!parentDevice) {
|
||||
return undefined;
|
||||
}
|
||||
// If this parent has a stat_rate and will be rendered, use it
|
||||
if (
|
||||
parentDevice.stat_rate &&
|
||||
renderedStatRates.has(parentDevice.stat_rate)
|
||||
) {
|
||||
return parentDevice.stat_rate;
|
||||
}
|
||||
// Otherwise, continue up the chain
|
||||
currentParent = parentDevice.included_in_stat;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const {
|
||||
deviceNodes,
|
||||
parentLinks,
|
||||
@@ -344,7 +296,6 @@ class HuiPowerSankeyCard
|
||||
getValue: (id) => this._getCurrentPower(id),
|
||||
getLabel: (id, name) => name || this._getEntityLabel(id),
|
||||
getEntityId: (id) => id,
|
||||
findEffectiveParent,
|
||||
});
|
||||
links.push(...deviceLinks);
|
||||
|
||||
|
||||
@@ -241,48 +241,6 @@ class HuiWaterFlowSankeyCard
|
||||
}
|
||||
}
|
||||
|
||||
// Build a map of device relationships for hierarchy resolution
|
||||
const deviceMap = new Map<
|
||||
string,
|
||||
{ stat_rate?: string; included_in_stat?: string }
|
||||
>();
|
||||
prefs.device_consumption_water.forEach((device) => {
|
||||
deviceMap.set(device.stat_consumption, {
|
||||
stat_rate: device.stat_rate,
|
||||
included_in_stat: device.included_in_stat,
|
||||
});
|
||||
});
|
||||
|
||||
// Set of stat_rate entities that will be rendered as nodes
|
||||
const renderedStatRates = new Set<string>();
|
||||
prefs.device_consumption_water.forEach((device) => {
|
||||
if (device.stat_rate) {
|
||||
const value = this._getCurrentFlowRate(device.stat_rate);
|
||||
if (value >= minFlowThreshold) {
|
||||
renderedStatRates.add(device.stat_rate);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Find the effective parent for hierarchy
|
||||
const findEffectiveParent = (
|
||||
includedInStat: string | undefined
|
||||
): string | undefined => {
|
||||
let currentParent = includedInStat;
|
||||
while (currentParent) {
|
||||
const parentDevice = deviceMap.get(currentParent);
|
||||
if (!parentDevice) return undefined;
|
||||
if (
|
||||
parentDevice.stat_rate &&
|
||||
renderedStatRates.has(parentDevice.stat_rate)
|
||||
) {
|
||||
return parentDevice.stat_rate;
|
||||
}
|
||||
currentParent = parentDevice.included_in_stat;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const {
|
||||
deviceNodes,
|
||||
parentLinks,
|
||||
@@ -301,7 +259,6 @@ class HuiWaterFlowSankeyCard
|
||||
getValue: (id) => this._getCurrentFlowRate(id),
|
||||
getLabel: (id, name) => name || this._getEntityLabel(id),
|
||||
getEntityId: (id) => id,
|
||||
findEffectiveParent,
|
||||
});
|
||||
links.push(...deviceLinks);
|
||||
|
||||
|
||||
@@ -215,35 +215,6 @@ class HuiWaterSankeyCard
|
||||
? calculateStatisticSumGrowth(this._data!.stats[statConsumption]) || 0
|
||||
: 0;
|
||||
|
||||
// Set of device stats that will be rendered as their own node
|
||||
const renderedStats = new Set<string>();
|
||||
prefs.device_consumption_water.forEach((device) => {
|
||||
if (deviceValue(device.stat_consumption) >= minWaterThreshold) {
|
||||
renderedStats.add(device.stat_consumption);
|
||||
}
|
||||
});
|
||||
|
||||
// Walk up the included_in_stat chain to the first ancestor that is rendered
|
||||
const deviceMap = new Map<string, string | undefined>();
|
||||
prefs.device_consumption_water.forEach((device) => {
|
||||
deviceMap.set(device.stat_consumption, device.included_in_stat);
|
||||
});
|
||||
const findEffectiveParent = (
|
||||
includedInStat: string | undefined
|
||||
): string | undefined => {
|
||||
let currentParent = includedInStat;
|
||||
while (currentParent) {
|
||||
if (renderedStats.has(currentParent)) {
|
||||
return currentParent;
|
||||
}
|
||||
if (!deviceMap.has(currentParent)) {
|
||||
return undefined;
|
||||
}
|
||||
currentParent = deviceMap.get(currentParent);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const deviceLabel = (statConsumption: string, name?: string) =>
|
||||
name ||
|
||||
getStatisticLabel(
|
||||
@@ -270,7 +241,6 @@ class HuiWaterSankeyCard
|
||||
getValue: deviceValue,
|
||||
getLabel: deviceLabel,
|
||||
getEntityId: (id) => (isExternalStatistic(id) ? undefined : id),
|
||||
findEffectiveParent,
|
||||
});
|
||||
links.push(...deviceLinks);
|
||||
|
||||
|
||||
@@ -797,13 +797,6 @@
|
||||
"target_details": "Target details",
|
||||
"no_targets": "No targets",
|
||||
"no_target_found": "No target found for {term}",
|
||||
"selected": {
|
||||
"entity": "Entities: {count}",
|
||||
"device": "Devices: {count}",
|
||||
"area": "Areas: {count}",
|
||||
"label": "Labels: {count}",
|
||||
"floor": "Floors: {count}"
|
||||
},
|
||||
"type": {
|
||||
"area": "Area",
|
||||
"areas": "Areas",
|
||||
@@ -5184,6 +5177,13 @@
|
||||
"area": "No areas or floors found for {term}",
|
||||
"label": "No labels found for {term}"
|
||||
},
|
||||
"show_more_search": {
|
||||
"trigger": "{count} more triggers...",
|
||||
"condition": "{count} more conditions...",
|
||||
"action": "{count} more actions...",
|
||||
"entity": "{count} more entities...",
|
||||
"device": "{count} more devices..."
|
||||
},
|
||||
"load_target_items_failed": "Failed to load target items for",
|
||||
"other_areas": "Other areas",
|
||||
"services": "Services",
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
import { readFileSync } from "fs";
|
||||
|
||||
const REPORT_PATH = "test/e2e/reports/combined/results.json";
|
||||
const COMMENT_MARKER = "<!-- playwright-e2e-report -->";
|
||||
const COMMENT_HEADING = "## Playwright E2E tests failed";
|
||||
|
||||
// GitHub comment bodies cap at 65536 chars; leave headroom.
|
||||
const MAX_BODY = 60000;
|
||||
@@ -76,6 +78,19 @@ export default async function postReportComment({ github, context, core }) {
|
||||
const { owner, repo } = context.repo;
|
||||
const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`;
|
||||
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number: context.issue.number,
|
||||
per_page: 100,
|
||||
});
|
||||
const previousComments = comments.filter(
|
||||
(comment) =>
|
||||
comment.user?.login === "github-actions[bot]" &&
|
||||
(comment.body?.includes(COMMENT_MARKER) ||
|
||||
comment.body?.startsWith(COMMENT_HEADING))
|
||||
);
|
||||
|
||||
let stats = { expected: 0, unexpected: 0, flaky: 0, skipped: 0 };
|
||||
let failures = [];
|
||||
|
||||
@@ -97,7 +112,8 @@ export default async function postReportComment({ github, context, core }) {
|
||||
: "_No failing tests were captured in the report._";
|
||||
|
||||
let body = [
|
||||
"## Playwright E2E tests failed",
|
||||
COMMENT_MARKER,
|
||||
COMMENT_HEADING,
|
||||
"",
|
||||
summaryLine,
|
||||
"",
|
||||
@@ -118,4 +134,40 @@ export default async function postReportComment({ github, context, core }) {
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
|
||||
const batches = Array.from(
|
||||
{ length: Math.ceil(previousComments.length / 100) },
|
||||
(_, index) => previousComments.slice(index * 100, (index + 1) * 100)
|
||||
);
|
||||
const commentsToMinimize = (
|
||||
await Promise.all(
|
||||
batches.map(async (batch) => {
|
||||
const { nodes } = await github.graphql(
|
||||
`query($ids: [ID!]!) {
|
||||
nodes(ids: $ids) {
|
||||
... on IssueComment {
|
||||
id
|
||||
isMinimized
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ ids: batch.map((comment) => comment.node_id) }
|
||||
);
|
||||
return nodes.filter((node) => !node.isMinimized);
|
||||
})
|
||||
)
|
||||
).flat();
|
||||
|
||||
await Promise.all(
|
||||
commentsToMinimize.map((comment) =>
|
||||
github.graphql(
|
||||
`mutation($id: ID!) {
|
||||
minimizeComment(input: { subjectId: $id, classifier: OUTDATED }) {
|
||||
clientMutationId
|
||||
}
|
||||
}`,
|
||||
{ id: comment.id }
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,7 +46,6 @@ const cumulativeOpts = (
|
||||
getValue: (id) => values[id] ?? 0,
|
||||
getLabel: (id, name) => name || id,
|
||||
getEntityId: (id) => id,
|
||||
findEffectiveParent: () => undefined,
|
||||
...rest,
|
||||
};
|
||||
};
|
||||
@@ -168,6 +167,56 @@ describe("buildSankeyDeviceNodes", () => {
|
||||
expect(result.untrackedConsumption).toBe(0);
|
||||
});
|
||||
|
||||
it("resolves the parent to its node id, not its stat_consumption", () => {
|
||||
// Instantaneous cards key the hierarchy by stat_consumption but render
|
||||
// stat_rate, so the effective parent must come back as the stat_rate.
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: devices(
|
||||
{ stat_consumption: "p", stat_rate: "sensor.p" },
|
||||
{
|
||||
stat_consumption: "c",
|
||||
stat_rate: "sensor.c",
|
||||
included_in_stat: "p",
|
||||
}
|
||||
),
|
||||
values: { "sensor.p": 100, "sensor.c": 40 },
|
||||
minThreshold: 1,
|
||||
initialUntracked: 100,
|
||||
getId: (device) => device.stat_rate,
|
||||
})
|
||||
);
|
||||
expect(result.parentLinks["sensor.c"]).toBe("sensor.p");
|
||||
expect(result.links).toContainEqual({
|
||||
source: "sensor.p",
|
||||
target: "sensor.c",
|
||||
});
|
||||
expect(result.untrackedConsumption).toBe(0); // only the top-level parent
|
||||
});
|
||||
|
||||
it("walks through an intermediate device that has no node id", () => {
|
||||
// "middle" has no stat_rate, so the child must attach to the grandparent.
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: devices(
|
||||
{ stat_consumption: "p", stat_rate: "sensor.p" },
|
||||
{ stat_consumption: "middle", included_in_stat: "p" },
|
||||
{
|
||||
stat_consumption: "c",
|
||||
stat_rate: "sensor.c",
|
||||
included_in_stat: "middle",
|
||||
}
|
||||
),
|
||||
values: { "sensor.p": 100, "sensor.c": 40 },
|
||||
minThreshold: 1,
|
||||
initialUntracked: 100,
|
||||
getId: (device) => device.stat_rate,
|
||||
})
|
||||
);
|
||||
expect(result.parentLinks["sensor.c"]).toBe("sensor.p");
|
||||
expect(result.untrackedConsumption).toBe(0);
|
||||
});
|
||||
|
||||
it("adds a per-parent untracked residual above the floor", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
@@ -177,7 +226,6 @@ describe("buildSankeyDeviceNodes", () => {
|
||||
),
|
||||
values: { parent: 10, child: 4 },
|
||||
initialUntracked: 10,
|
||||
findEffectiveParent: (includedInStat) => includedInStat,
|
||||
})
|
||||
);
|
||||
expect(result.parentLinks.child).toBe("parent");
|
||||
@@ -199,7 +247,6 @@ describe("buildSankeyDeviceNodes", () => {
|
||||
values: { parent: 10, child: 9.5 },
|
||||
untrackedFloor: 1,
|
||||
initialUntracked: 10,
|
||||
findEffectiveParent: (includedInStat) => includedInStat,
|
||||
})
|
||||
);
|
||||
expect(result.deviceNodes.some((n) => n.id.startsWith("untracked_"))).toBe(
|
||||
@@ -217,7 +264,6 @@ describe("buildSankeyDeviceNodes", () => {
|
||||
),
|
||||
values: { parent: 10, s1: 0.003, s2: 0.004 },
|
||||
initialUntracked: 10,
|
||||
findEffectiveParent: (includedInStat) => includedInStat,
|
||||
})
|
||||
);
|
||||
const other = result.deviceNodes.find((n) => n.id === "other_parent");
|
||||
@@ -307,6 +353,62 @@ describe("buildSankeyDeviceNodes", () => {
|
||||
expect(result.untrackedConsumption).toBeCloseTo(10 - 0.008, 10);
|
||||
});
|
||||
|
||||
it("excludes a nested small device from a rendered parent's cluster", () => {
|
||||
// big(rendered) > b(small) > c(small): c is inside b's value, so only b
|
||||
// may be attributed under big.
|
||||
const devs = devices(
|
||||
{ stat_consumption: "big" },
|
||||
{ stat_consumption: "b", included_in_stat: "big" },
|
||||
{ stat_consumption: "c", included_in_stat: "b" }
|
||||
);
|
||||
const values = { big: 10, b: 0.004, c: 0.003 };
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: devs,
|
||||
values,
|
||||
initialUntracked: 10,
|
||||
})
|
||||
);
|
||||
// b is the lone survivor, so it is shown directly instead of "Other"
|
||||
expect(result.deviceNodes.map((n) => n.id)).toEqual([
|
||||
"big",
|
||||
"b",
|
||||
"untracked_big",
|
||||
]);
|
||||
expect(result.parentLinks.b).toBe("big");
|
||||
expect(
|
||||
result.deviceNodes.find((n) => n.id === "untracked_big")?.value
|
||||
).toBeCloseTo(10 - 0.004, 10);
|
||||
expect(result.untrackedConsumption).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps a small device whose chain reaches a small ancestor past a rendered one", () => {
|
||||
// g(small) > a(rendered) > c(small): c hangs off rendered a, so it never
|
||||
// touches untracked and cannot be double-counted through g.
|
||||
const devs = devices(
|
||||
{ stat_consumption: "g" },
|
||||
{ stat_consumption: "a", included_in_stat: "g" },
|
||||
{ stat_consumption: "c", included_in_stat: "a" }
|
||||
);
|
||||
const values = { g: 0.0005, a: 0.5, c: 0.0004 };
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
devices: devs,
|
||||
values,
|
||||
initialUntracked: 1,
|
||||
})
|
||||
);
|
||||
expect(result.deviceNodes.map((n) => n.id)).toEqual([
|
||||
"a",
|
||||
"g",
|
||||
"c",
|
||||
"untracked_a",
|
||||
]);
|
||||
expect(result.parentLinks.c).toBe("a");
|
||||
// only the two top-level values (a and g) come off untracked
|
||||
expect(result.untrackedConsumption).toBeCloseTo(1 - 0.5 - 0.0005, 10);
|
||||
});
|
||||
|
||||
it("leaves a cyclic small-device cluster in untracked instead of looping", () => {
|
||||
const result = buildSankeyDeviceNodes(
|
||||
cumulativeOpts({
|
||||
|
||||
@@ -4223,14 +4223,14 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@playwright/test@npm:1.61.1":
|
||||
version: 1.61.1
|
||||
resolution: "@playwright/test@npm:1.61.1"
|
||||
"@playwright/test@npm:1.62.0":
|
||||
version: 1.62.0
|
||||
resolution: "@playwright/test@npm:1.62.0"
|
||||
dependencies:
|
||||
playwright: "npm:1.61.1"
|
||||
playwright: "npm:1.62.0"
|
||||
bin:
|
||||
playwright: cli.js
|
||||
checksum: 10/08915357031e1bc273a19bb428fb7e51031ca44f25750832e389bcd69c399f6a243f5f832e7832d46153b57b1305f1f5f5dd63f3a3261f928f61e464c6b3c64e
|
||||
checksum: 10/90b504f3933b97687580d7e6ba42ce9e73a9cdd41da5ae59441104b3dfa1508142ed17e5742acc70fdc2aef79078365859c6a463cebf46a8f7db9506a3ece1d6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -9952,7 +9952,7 @@ __metadata:
|
||||
"@octokit/auth-oauth-device": "npm:8.0.3"
|
||||
"@octokit/plugin-retry": "npm:8.1.0"
|
||||
"@octokit/rest": "npm:22.0.1"
|
||||
"@playwright/test": "npm:1.61.1"
|
||||
"@playwright/test": "npm:1.62.0"
|
||||
"@replit/codemirror-indentation-markers": "npm:6.5.3"
|
||||
"@rsdoctor/rspack-plugin": "npm:1.6.1"
|
||||
"@rspack/core": "npm:2.1.5"
|
||||
@@ -12763,27 +12763,27 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"playwright-core@npm:1.61.1":
|
||||
version: 1.61.1
|
||||
resolution: "playwright-core@npm:1.61.1"
|
||||
"playwright-core@npm:1.62.0":
|
||||
version: 1.62.0
|
||||
resolution: "playwright-core@npm:1.62.0"
|
||||
bin:
|
||||
playwright-core: cli.js
|
||||
checksum: 10/b0e7b1d4de7ca6c1a57eb88f1709609a8b7637092f149e703d84d6c8e01835992ec5ca26b86f1a32fb5f5bc1aad042c3ae27311e131b3dda8a27824a543eb3c7
|
||||
checksum: 10/1cf036e9ed51ea80d311ac25a871dddbc57d5e7e7a5ad23fc2741452aa8406c95e820901ec38aeecc6fca2ddc383ccc8acb0e2f29a4c12942dd0a1a63e7134a2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"playwright@npm:1.61.1":
|
||||
version: 1.61.1
|
||||
resolution: "playwright@npm:1.61.1"
|
||||
"playwright@npm:1.62.0":
|
||||
version: 1.62.0
|
||||
resolution: "playwright@npm:1.62.0"
|
||||
dependencies:
|
||||
fsevents: "npm:2.3.2"
|
||||
playwright-core: "npm:1.61.1"
|
||||
playwright-core: "npm:1.62.0"
|
||||
dependenciesMeta:
|
||||
fsevents:
|
||||
optional: true
|
||||
bin:
|
||||
playwright: cli.js
|
||||
checksum: 10/0cd1a8a7e106202e785450763973bf326d4aa112ed195bbd78b17af43dcfd12e29bc8204ae61c88f748dd0f2bdf69a7827bc3bbf8c1ec4c71b8e35586e05f13c
|
||||
checksum: 10/52401b5f2b67abcb555c1b1c229e5204a3ac72a604343a99e60b00bd0cd46ad661a9471ced4e6c3ff39de957a0b1f413810d191ce81cd40e455700d73a6a80a5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user