Compare commits

..

9 Commits

Author SHA1 Message Date
Aidan Timson 865b5b1b80 Localize hardcoded UI strings in lovelace, logs, cloud, and media browse (#52869)
* Localize hardcoded UI strings in lovelace, logs, cloud, and media browse

Wire existing translation keys where available and add scoped keys for lovelace error sections and cloud support package privacy text.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Keep loading ellipsis outside translatable strings

Localize the loading and preview labels without dots, then append ellipsis in the template so translators are not asked to copy punctuation.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix manual entry localize key path

Use ui.components.selectors.selector.types.manual so the key resolves in en.json and TypeScript.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Use property bindings for localized dialog and badge labels

Bind headerTitle and label as properties so localized strings pass correctly to ha-dialog and ha-badge.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Format

* Use better path

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 05:08:55 +02:00
Aidan Timson b44c69b1b0 Add more info view smoke tests to e2e app spec (#52862)
* Add more info views to e2e app spec

* Add registry for light more info test

* Improve tests
2026-06-26 05:06:44 +02:00
Aidan Timson 27787e51f8 Add test:e2e:app:dev to not need to build for every test run (#52865)
* Add test:e2e:app:dev to not need to build for every test run

* Stop browser open

* Add test:e2e:app:dev
2026-06-26 05:05:09 +02:00
renovate[bot] dc7daf3156 Update dependency typescript-eslint to v8.62.0 (#52876)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-26 05:03:59 +02:00
renovate[bot] b898468193 Update dependency globals to v17.7.0 (#52875)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-26 05:03:42 +02:00
renovate[bot] 781aa116b8 Update dependency eslint-plugin-import-x to v4.17.0 (#52874)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-26 05:03:24 +02:00
Simon Lamon 60c86899f3 Swap google-timezones-json to @vvo/tzdb (#52770) 2026-06-25 16:14:42 +02:00
Petar Petrov f8d870d6bb Group Sankey flow siblings under their parent to fix segment crossovers (#52867) 2026-06-25 16:12:52 +02:00
Copilot 4d82b352a9 Localize "(default)" label in Edit sidebar dialog (#52868)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-25 16:02:29 +02:00
23 changed files with 992 additions and 229 deletions
+1
View File
@@ -240,6 +240,7 @@ gulp.task("rspack-dev-server-e2e-test-app", () =>
),
contentBase: paths.e2eTestApp_output_root,
port: 8095,
open: false,
})
);
+6 -5
View File
@@ -28,6 +28,7 @@
"test:e2e:show-report": "yarn playwright show-report test/e2e/reports/combined",
"test:e2e:demo": "playwright test --config test/e2e/playwright.demo.config.ts",
"test:e2e:app": "playwright test --config test/e2e/playwright.app.config.ts",
"test:e2e:app:dev": "test/e2e/app/script/develop_app",
"test:e2e:gallery": "playwright test --config test/e2e/playwright.gallery.config.ts"
},
"author": "Paulus Schoutsen <Paulus@PaulusSchoutsen.nl> (http://paulusschoutsen.nl)",
@@ -81,6 +82,7 @@
"@tsparticles/engine": "4.2.1",
"@tsparticles/preset-links": "4.2.1",
"@vibrant/color": "4.0.4",
"@vvo/tzdb": "6.198.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"@webcomponents/webcomponentsjs": "2.8.0",
"barcode-detector": "3.2.0",
@@ -97,7 +99,6 @@
"echarts": "6.1.0",
"element-internals-polyfill": "3.0.2",
"fuse.js": "7.4.2",
"google-timezones-json": "1.2.0",
"gulp-zopfli-green": "7.0.0",
"hls.js": "1.6.16",
"home-assistant-js-websocket": "9.6.0",
@@ -172,7 +173,7 @@
"eslint": "10.5.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.16.2",
"eslint-plugin-import-x": "4.17.0",
"eslint-plugin-lit": "2.3.1",
"eslint-plugin-lit-a11y": "5.1.1",
"eslint-plugin-unused-imports": "4.4.1",
@@ -181,7 +182,7 @@
"fs-extra": "11.3.5",
"generate-license-file": "4.2.1",
"glob": "13.0.6",
"globals": "17.6.0",
"globals": "17.7.0",
"gulp": "5.0.1",
"gulp-brotli": "3.0.0",
"gulp-json-transform": "0.5.0",
@@ -206,7 +207,7 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.61.1",
"typescript-eslint": "8.62.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.9",
"webpack-stats-plugin": "1.1.3",
@@ -219,7 +220,7 @@
"clean-css": "5.3.3",
"@lit/reactive-element": "2.1.2",
"@fullcalendar/daygrid": "6.1.21",
"globals": "17.6.0",
"globals": "17.7.0",
"tslib": "2.8.1",
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"glob@^10.2.2": "^10.5.0"
+2 -2
View File
@@ -1,4 +1,4 @@
import timezones from "google-timezones-json";
import { timeZonesNames } from "@vvo/tzdb";
import { TimeZone } from "../../data/translation";
const RESOLVED_RAW = Intl.DateTimeFormat?.().resolvedOptions?.().timeZone;
@@ -10,7 +10,7 @@ const RESOLVED_TIME_ZONE =
RESOLVED_RAW &&
(RESOLVED_RAW === "UTC" ||
RESOLVED_RAW === "Etc/UTC" ||
RESOLVED_RAW in timezones)
timeZonesNames.includes(RESOLVED_RAW))
? RESOLVED_RAW
: undefined;
+25 -23
View File
@@ -1,4 +1,4 @@
import timezones from "google-timezones-json";
import { getTimeZones, timeZonesNames } from "@vvo/tzdb";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
@@ -13,38 +13,40 @@ const SEARCH_KEYS = [
{ name: "secondary", weight: 8 },
];
// google-timezones-json is missing the bare "UTC" and "Etc/UTC" zones, even
// though both are valid IANA identifiers and common server defaults. Without
// them a "UTC" configuration shows up as an unknown time zone. Add them back.
// @vvo/tzdb is missing the bare "UTC" zone, even though it is a valid IANA
// identifier and a common server default. Add UTC back so a
// "UTC" configuration can be selected.
const ADDITIONAL_TIMEZONES: PickerComboBoxItem[] = [
{ id: "UTC", primary: "(GMT+00:00) UTC", secondary: "UTC" },
{ id: "Etc/UTC", primary: "(GMT+00:00) UTC", secondary: "Etc/UTC" },
{ id: "UTC", primary: "+00:00 UTC", secondary: "UTC" },
];
// google-timezones-json also ships an invalid IANA identifier. Correct it so
// the zone can be selected (the backend rejects the invalid id).
const TIMEZONE_ID_CORRECTIONS: Record<string, string> = {
"Asia/Yuzhno-Sakhalinsk": "Asia/Sakhalin",
};
export const getTimezoneOptions = (): PickerComboBoxItem[] => {
const options: PickerComboBoxItem[] = Object.entries(
timezones as Record<string, string>
).map(([key, value]) => {
const id = TIMEZONE_ID_CORRECTIONS[key] ?? key;
return {
id,
primary: value,
secondary: id,
};
});
const options: PickerComboBoxItem[] = Array.from(
new Map(
getTimeZones({ includeUtc: true })
.flatMap((timezone) => {
const groupArray = Array.isArray(timezone.group)
? timezone.group
: [timezone.group];
const filteredGroup = groupArray.filter((gName) =>
timeZonesNames.includes(gName)
);
return [timezone.name, ...filteredGroup].map((nameString) => ({
id: nameString,
primary: timezone.rawFormat,
secondary: nameString,
}));
})
.map((item) => [item.id, item])
).values()
);
for (const timezone of ADDITIONAL_TIMEZONES) {
if (!options.some((option) => option.id === timezone.id)) {
options.push(timezone);
}
}
return options;
};
@@ -74,7 +74,7 @@ export interface MediaPlayerItemId {
media_content_type?: string | undefined;
}
const MANUAL_ITEM: MediaPlayerItem = {
const MANUAL_ITEM_BASE: Omit<MediaPlayerItem, "title"> = {
can_expand: true,
can_play: false,
can_search: false,
@@ -83,7 +83,6 @@ const MANUAL_ITEM: MediaPlayerItem = {
media_content_id: MANUAL_MEDIA_SOURCE_PREFIX,
media_content_type: "",
iconPath: mdiKeyboard,
title: "Manual entry",
};
@customElement("ha-media-player-browse")
@@ -240,7 +239,7 @@ export class HaMediaPlayerBrowse extends LitElement {
currentId.media_content_id &&
isManualMediaSourceContentId(currentId.media_content_id)
) {
this._currentItem = MANUAL_ITEM;
this._currentItem = this._manualItem();
fireEvent(this, "media-browsed", {
ids: navigateIds,
current: this._currentItem,
@@ -801,12 +800,21 @@ export class HaMediaPlayerBrowse extends LitElement {
return prom.then((item) => {
if (!mediaContentId && this.action === "pick") {
item.children = item.children || [];
item.children.push(MANUAL_ITEM);
item.children.push(this._manualItem());
}
return item;
});
}
private _manualItem(): MediaPlayerItem {
return {
...MANUAL_ITEM_BASE,
title: this.hass.localize(
"ui.components.selectors.selector.types.manual"
),
};
}
private _measureCard(): void {
this.narrow = (this.dialog ? window.innerWidth : this.offsetWidth) < 450;
}
+6 -16
View File
@@ -27,7 +27,6 @@ export interface LogbookEntry {
source?: string; // The trigger source (English phrase, parsed for the cause)
domain?: string;
state?: string; // The state of the entity
attributes?: { event_type?: string }; // Selected attributes the backend surfaces
// Context data
context_id?: string;
context_user_id?: string;
@@ -242,13 +241,13 @@ export const parseTriggerSource = (source: string): ParsedTriggerSource => {
};
// Short label shown instead of the bare timestamp for each timestamp-state
// domain. Typed to TIMESTAMP_STATE_DOMAINS minus datetime (a real value) and
// event (handled separately via its event type), so a new timestamp domain
// won't compile until it gets a label here.
// domain. Typed to TIMESTAMP_STATE_DOMAINS minus datetime (a real value), so a
// new timestamp domain won't compile until it gets a label here.
type LogbookActionMessage =
| "pressed"
| "activated"
| "scanned"
| "detected_event_no_type"
| "updated"
| "sent"
| "detected"
@@ -259,13 +258,14 @@ type LogbookActionMessage =
| "command_sent";
const STATE_ACTION_MESSAGES: Record<
Exclude<TimestampStateDomain, "datetime" | "event">,
Exclude<TimestampStateDomain, "datetime">,
LogbookActionMessage
> = {
button: "pressed",
input_button: "pressed",
scene: "activated",
tag: "scanned",
event: "detected_event_no_type",
image: "updated",
notify: "sent",
wake_word: "detected",
@@ -281,18 +281,8 @@ export const localizeStateMessage = (
hass: HomeAssistant,
state: string,
stateObj: HassEntity,
domain: string,
attributes?: LogbookEntry["attributes"]
domain: string
): string => {
// Events show the triggered event type, falling back to a generic label when
// the type is unknown (the timestamp state is meaningless on its own).
if (domain === "event") {
const eventType = attributes?.event_type;
if (eventType != null) {
return hass.formatEntityAttributeValue(stateObj, "event_type", eventType);
}
return hass.localize(`${LOGBOOK_LOCALIZE_PATH}.detected_event_no_type`);
}
const actionKey: LogbookActionMessage | undefined =
STATE_ACTION_MESSAGES[domain as keyof typeof STATE_ACTION_MESSAGES];
if (actionKey) {
+1 -1
View File
@@ -159,7 +159,7 @@ class DialogEditSidebar extends DirtyStateProviderMixin<SidebarState>()(
value: panel.url_path,
label:
(getPanelTitle(this.hass, panel) || panel.url_path) +
`${defaultPanel === panel.url_path ? " (default)" : ""}`,
`${defaultPanel === panel.url_path ? ` (${this.hass.localize("ui.sidebar.default")})` : ""}`,
icon: getPanelIcon(panel),
iconPath: getPanelIconPath(panel),
disableHiding: panel.url_path === defaultPanel,
@@ -41,7 +41,9 @@ export class DialogSupportPackage extends LitElement {
<ha-dialog
.open=${this._open}
width="full"
header-title="Download support package"
.headerTitle=${this.hass.localize(
"ui.panel.config.cloud.account.download_support_package"
)}
@closed=${this._dialogClosed}
>
${this._supportPackage
@@ -52,13 +54,16 @@ export class DialogSupportPackage extends LitElement {
: html`
<div class="progress-container">
<ha-spinner></ha-spinner>
Generating preview...
${this.hass.localize(
"ui.panel.config.cloud.account.support_package_generating_preview"
)}...
</div>
`}
<div slot="footer" class="footer">
<ha-alert>
This file may contain personal data about your home. Avoid sharing
them with unverified or untrusted parties.
${this.hass.localize(
"ui.panel.config.cloud.account.support_package_privacy_warning"
)}
</ha-alert>
<hr />
<ha-dialog-footer>
@@ -67,10 +72,10 @@ export class DialogSupportPackage extends LitElement {
appearance="plain"
@click=${this.closeDialog}
>
Close
${this.hass.localize("ui.common.close")}
</ha-button>
<ha-button slot="primaryAction" @click=${this._download}>
Download
${this.hass.localize("ui.common.download")}
</ha-button>
</ha-dialog-footer>
</div>
+4 -1
View File
@@ -109,7 +109,10 @@ export class SystemLogCard extends LitElement {
`
: html`
<div class="header">
<h1 class="card-header">${this.header || "Logs"}</h1>
<h1 class="card-header">
${this.header ||
this.hass.localize("ui.panel.config.logs.caption")}
</h1>
<div class="header-buttons">
<ha-icon-button
.path=${mdiDownload}
+3 -1
View File
@@ -197,7 +197,9 @@ export class HaScriptTrace extends LitElement {
</div>
${this._traces === undefined
? html`<div class="container">Loading…</div>`
? html`<div class="container">
${this.hass.localize("ui.panel.config.script.trace.loading")}
</div>`
: this._traces.length === 0
? html`<div class="container">
${this.hass!.localize(
+1 -7
View File
@@ -266,13 +266,7 @@ const computeLogbookValue = (
if (item.entity_id && item.state) {
return {
text: stateObj
? localizeStateMessage(
hass,
item.state,
stateObj,
domain!,
item.attributes
)
? localizeStateMessage(hass, item.state, stateObj, domain!)
: item.state,
type: "state",
};
@@ -58,7 +58,9 @@ export class HuiErrorBadge extends LitElement implements LovelaceBadge {
class="error"
@click=${this._viewDetail}
type="button"
label="Error"
.label=${this.hass?.localize(
"ui.panel.lovelace.editor.error_section.title"
) ?? ""}
>
<ha-svg-icon slot="icon" .path=${mdiAlertCircle}></ha-svg-icon>
<div class="content">${this._config.error}</div>
@@ -135,7 +135,11 @@ class HuiHistoryChartCardFeature
if (this._coordinates && !this._coordinates.length) {
return html`
<div class="container">
<div class="info">No state history found.</div>
<div class="info">
${this.hass!.localize(
"ui.components.history_charts.no_history_found"
)}
</div>
</div>
`;
}
@@ -122,7 +122,11 @@ export class HuiGraphHeaderFooter
if (this._coordinates && !this._coordinates.length) {
return html`
<div class="container">
<div class="info">No state history found.</div>
<div class="info">
${this.hass!.localize(
"ui.components.history_charts.no_history_found"
)}
</div>
</div>
`;
}
@@ -47,7 +47,9 @@ export class HuiErrorSection
// Todo improve
return html`
<h1>Error</h1>
<h1>
${this.hass!.localize("ui.panel.lovelace.editor.error_section.title")}
</h1>
<p>${this._config.error}</p>
`;
}
@@ -331,6 +331,28 @@ export function computeBarycenter(
return totalWeight > 0 ? weightedSum / totalWeight : fallback;
}
// Index of the single highest-weight neighbor present in the reference
// section (ties on weight broken by the earliest edge). Used only as a
// barycenter tie-break so a node stays beside its dominant neighbor's group
// instead of falling back to a stale seed index. Falls back to the node's own
// index when it has no resolvable neighbor, matching computeBarycenter.
export function dominantNeighborIndex(
neighbors: WeightedNeighbor[],
referenceIdIndexMap: Map<string, number>,
fallback: number
): number {
let bestIdx = fallback;
let bestWeight = -Infinity;
neighbors.forEach(({ id, weight }) => {
const idx = referenceIdIndexMap.get(id);
if (idx !== undefined && weight > bestWeight) {
bestWeight = weight;
bestIdx = idx;
}
});
return bestIdx;
}
function buildIdIndexMap(section: Node[]): Map<string, number> {
const map = new Map<string, number>();
section.forEach((node, index) => map.set(node.id, index));
@@ -342,12 +364,20 @@ function sortSectionByBarycenter(
referenceMap: Map<string, number>,
getNeighbors: (node: Node) => WeightedNeighbor[]
): { sorted: Node[]; changed: boolean } {
const decorated = section.map((node, index) => ({
node,
index,
barycenter: computeBarycenter(getNeighbors(node), referenceMap, index),
}));
decorated.sort((a, b) => a.barycenter - b.barycenter || a.index - b.index);
const decorated = section.map((node, index) => {
const neighbors = getNeighbors(node);
return {
node,
index,
barycenter: computeBarycenter(neighbors, referenceMap, index),
// Tie-break that keeps a node next to its dominant neighbor's group.
anchor: dominantNeighborIndex(neighbors, referenceMap, index),
};
});
decorated.sort(
(a, b) =>
a.barycenter - b.barycenter || a.anchor - b.anchor || a.index - b.index
);
const sorted = decorated.map((d) => d.node);
const changed = sorted.some((n, idx) => n !== section[idx]);
return { sorted, changed };
@@ -452,6 +482,55 @@ function crossingsAdjacentTo(
return total;
}
function countAllCrossings(
sections: Node[][],
sectionMaps: Map<string, number>[],
depths: number[],
edges: GraphEdge[]
): number {
let total = 0;
for (let i = 0; i < sections.length - 1; i++) {
total += countCrossings(
getEdgeSegmentsBetween(
depths[i],
depths[i + 1],
depths,
edges,
sectionMaps[i],
sectionMaps[i + 1]
)
);
}
return total;
}
// A section is "multi-parent" when at least one of its real nodes draws from
// two or more distinct parents in the previous section. In the energy/power/
// water cards only the source/home layers do this (grid/solar/battery feed
// home + battery_in + grid_return); every later section (floors, areas,
// devices) is a pure single-parent tree level. Pass-throughs are always
// single-parent (one source/target chain) and never make a section multi-parent.
function sectionHasMultipleParents(
section: Node[],
prevDepth: number,
depths: number[]
): boolean {
return section.some((node) => {
if (isPassThroughNode(node)) {
return false;
}
const parentIds = new Set(
getNeighborIds(node, "source", prevDepth, depths).map((n) => n.id)
);
return parentIds.size > 1;
});
}
// The head barycenter sweep (STEP 1) only touches the multi-parent head
// sections (the single-parent tree below is placed deterministically in
// STEP 2), so a single forward+backward pass converges in practice and the loop
// early-exits on the first no-change sweep. The cap is just headroom for unusual
// topologies with several interacting head sections.
const MAX_SORT_ITERATIONS = 4;
export function sortNodesInSections(
@@ -460,13 +539,30 @@ export function sortNodesInSections(
edges: GraphEdge[]
): Record<number, Node[]> {
const sections: Node[][] = depths.map((d) => [...(nodesPerSection[d] || [])]);
// Id→index lookup per section, kept in sync with sections. Rebuilt only when
// a section's order actually changes (inside tryReplace).
// Id→index lookup per section, kept in sync with sections.
const sectionMaps: Map<string, number>[] = sections.map(buildIdIndexMap);
// Replace a section with a candidate ordering only when crossings strictly
// drop on either side. This keeps user-intended ordering intact when
// barycenter would shuffle nodes without improving the layout.
// Classify each section past the root. Multi-parent sections (the
// intentionally-ordered source/home layers) are minimized by barycenter in
// PASS 2; the rest are single-parent tree levels placed deterministically in
// PASS 1. Classification reads only the graph, so it is stable across passes.
const multiParent = depths.map(
(_d, i) =>
i >= 1 && sectionHasMultipleParents(sections[i], depths[i - 1], depths)
);
// Best (fewest-crossing) head ordering seen so far, seeded from the original
// input so the head is provably never worse than the seed.
const snapshot = (): Node[][] => sections.map((s) => s.slice());
let liveCrossings = countAllCrossings(sections, sectionMaps, depths, edges);
let bestCrossings = liveCrossings;
let bestSections = snapshot();
// Replace a multi-parent section with a candidate ordering when crossings on
// its adjacent boundaries do not increase. Accepting equal-crossing
// ("plateau") moves lets the sweep escape local optima; the best snapshot
// (captured only on a strict global decrease) is what seeds the head order,
// so the result is deterministic and never worse than the seed.
const tryReplace = (i: number, candidate: Node[]): boolean => {
const before = crossingsAdjacentTo(i, sections, sectionMaps, depths, edges);
const sectionSnapshot = sections[i];
@@ -474,7 +570,12 @@ export function sortNodesInSections(
sections[i] = candidate;
sectionMaps[i] = buildIdIndexMap(candidate);
const after = crossingsAdjacentTo(i, sections, sectionMaps, depths, edges);
if (after < before) {
if (after <= before) {
liveCrossings += after - before;
if (liveCrossings < bestCrossings) {
bestCrossings = liveCrossings;
bestSections = snapshot();
}
return true;
}
sections[i] = sectionSnapshot;
@@ -482,39 +583,97 @@ export function sortNodesInSections(
return false;
};
for (let iter = 0; iter < MAX_SORT_ITERATIONS; iter++) {
let changed = false;
// STEP 1 — settle the multi-parent head sections (sources → home/battery_in/
// grid_return) by barycenter. These layers have nodes with several parents and
// so no single parent position to inherit; we minimize their crossings by the
// weighted average of neighbour positions, iterating forward then backward
// until the order is stable. The backward sweep is restricted to multi-parent
// neighbours, so the head order never depends on its single-parent children —
// that is what keeps the whole result idempotent, because STEP 2 re-derives
// those children purely from the settled head. The root section (index 0) is
// never reordered.
if (multiParent.some(Boolean)) {
for (let iter = 0; iter < MAX_SORT_ITERATIONS; iter++) {
let changed = false;
for (let i = 1; i < sections.length; i++) {
const prevDepth = depths[i - 1];
const result = sortSectionByBarycenter(
sections[i],
sectionMaps[i - 1],
(node) => getNeighborIds(node, "source", prevDepth, depths)
);
if (result.changed && tryReplace(i, result.sorted)) {
changed = true;
for (let i = 1; i < sections.length; i++) {
if (!multiParent[i]) {
continue;
}
const prevDepth = depths[i - 1];
const result = sortSectionByBarycenter(
sections[i],
sectionMaps[i - 1],
(node) => getNeighborIds(node, "source", prevDepth, depths)
);
if (result.changed && tryReplace(i, result.sorted)) {
changed = true;
}
}
}
for (let i = sections.length - 2; i >= 0; i--) {
const nextDepth = depths[i + 1];
const result = sortSectionByBarycenter(
sections[i],
sectionMaps[i + 1],
(node) => getNeighborIds(node, "target", nextDepth, depths)
);
if (result.changed && tryReplace(i, result.sorted)) {
changed = true;
for (let i = sections.length - 2; i >= 1; i--) {
if (!multiParent[i] || !multiParent[i + 1]) {
continue;
}
const nextDepth = depths[i + 1];
const result = sortSectionByBarycenter(
sections[i],
sectionMaps[i + 1],
(node) => getNeighborIds(node, "target", nextDepth, depths)
);
if (result.changed && tryReplace(i, result.sorted)) {
changed = true;
}
}
}
if (!changed) break;
if (!changed || bestCrossings === 0) break;
}
}
// STEP 2 — deterministic hierarchy placement. Starting from the best head
// ordering, walk left to right and order every single-parent section by the
// position of each node's single parent in the already-final previous section
// (sortSectionByBarycenter with one parent reduces to a stable sort by that
// parent's index). This is the classic layered-tree drawing: each parent's
// children stay contiguous, every single-parent boundary is crossing-free,
// pass-throughs travel along their chain, and the user-configured floor/area
// order is preserved because same-parent siblings keep their seed index.
// It runs unconditionally — grouping a parent's children under it must win
// even on a crossing-neutral plateau (the #52852 fix) — and because it is a
// pure function of the settled head, re-running yields the identical layout.
const finalSections = bestSections.map((s) => s.slice());
const finalMaps = finalSections.map(buildIdIndexMap);
for (let i = 1; i < finalSections.length; i++) {
if (multiParent[i]) {
continue;
}
const prevDepth = depths[i - 1];
const { sorted } = sortSectionByBarycenter(
finalSections[i],
finalMaps[i - 1],
(node) => getNeighborIds(node, "source", prevDepth, depths)
);
finalSections[i] = sorted;
finalMaps[i] = buildIdIndexMap(sorted);
}
// Hierarchy placement makes every single-parent boundary crossing-free, so on
// the energy/water cards (multi-parent only at the head) it can only lower the
// total. Guard the general graph: if regrouping somehow raised crossings (only
// possible for a multi-parent section sitting *below* single-parent ones,
// which the cards never produce), fall back to the gated best head so the
// never-worse-than-seed guarantee always holds. Ties keep the grouped layout.
const finalCrossings = countAllCrossings(
finalSections,
finalMaps,
depths,
edges
);
const chosen = finalCrossings <= bestCrossings ? finalSections : bestSections;
const sortedSections: Record<number, Node[]> = {};
depths.forEach((depth, i) => {
sortedSections[depth] = sections[i];
sortedSections[depth] = chosen[i];
});
return sortedSections;
}
+8 -1
View File
@@ -2503,6 +2503,7 @@
"edit_sidebar": "Edit sidebar",
"edit_subtitle": "Synced on all devices",
"migrate_to_user_data": "This will change the sidebar on all the devices you are logged in to. To create a sidebar per device, you should use a different user for that device.",
"default": "default",
"reset_to_defaults": "Reset to defaults",
"reset_confirmation": "Are you sure you want to reset the sidebar to its default configuration? This will restore the original order and visibility of all panels."
},
@@ -6194,7 +6195,8 @@
"paste_invalid_config": "Pasted script is not editable in the visual editor"
},
"trace": {
"edit_script": "Edit script"
"edit_script": "Edit script",
"loading": "Loading"
}
},
"scene": {
@@ -6342,6 +6344,8 @@
},
"account": {
"download_support_package": "Download support package",
"support_package_generating_preview": "Generating preview",
"support_package_privacy_warning": "This file may contain personal data about your home. Avoid sharing them with unverified or untrusted parties.",
"reset_cloud_data": "Reset cloud data",
"reset_data_confirm_title": "Reset cloud data?",
"reset_data_confirm_text": "This will reset all your cloud settings. This includes your remote connection, Google Assistant, and Amazon Alexa integrations. This action cannot be undone.",
@@ -9218,6 +9222,9 @@
"title": "Delete section",
"text": "This section and all its cards will be deleted."
},
"error_section": {
"title": "Error"
},
"edit_section": {
"header": "Edit section",
"tab_visibility": "[%key:ui::panel::lovelace::editor::edit_view::tab_visibility%]",
+102 -23
View File
@@ -5,8 +5,62 @@
* yarn test:e2e:app
*/
import { test, expect, type Page } from "@playwright/test";
import type { MoreInfoView } from "../../src/dialogs/more-info/const";
import { PANEL_TIMEOUT, QUICK_TIMEOUT, SHELL_TIMEOUT } from "./helpers";
/**
* Each More info view renders one root element inside the dialog, plus one or
* more characteristic descendants that prove the view actually populated rather
* than rendering an empty shell. `text`, when set, asserts the element's text
* instead of just its presence.
*/
const MORE_INFO_VIEW_ELEMENTS: {
view: MoreInfoView;
element: string;
content: { selector: string; text?: string }[];
}[] = [
{
view: "info",
element: "ha-more-info-info",
content: [
{ selector: "more-info-light" },
{ selector: "span.title", text: "Test Light" },
],
},
{
view: "history",
element: "ha-more-info-history-and-logbook",
// The demo loads the history component but not logbook.
content: [{ selector: "ha-more-info-history" }],
},
{
view: "settings",
element: "ha-more-info-settings",
// The scenario mocks config/entity_registry/get, so the real registry
// panel renders instead of the "no unique ID" warning.
content: [{ selector: "entity-registry-settings" }],
},
{
view: "related",
element: "ha-related-items",
// search/related is mocked to return no relations, so the empty list
// renders.
content: [{ selector: "ha-related-items >> ha-list" }],
},
{
view: "add_to",
element: "ha-more-info-add-to",
// Admin users get the default add-to action list.
content: [{ selector: "ha-add-to-action-list" }],
},
{
view: "details",
element: "ha-more-info-details",
// The details view renders the state and attributes cards.
content: [{ selector: "ha-card" }],
},
];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -156,31 +210,56 @@ test.describe("Lovelace dashboard", () => {
// ---------------------------------------------------------------------------
test.describe("Light more-info dialog", () => {
test("opens more-info dialog for a light entity", async ({ page }) => {
// The light-more-info scenario seeds light.test_light synchronously.
await goToPanel(page, "/?scenario=light-more-info#/lovelace");
for (const { view, element, content } of MORE_INFO_VIEW_ELEMENTS) {
test(`opens more-info ${view} view for a light entity`, async ({
page,
}) => {
// The light-more-info scenario seeds light.test_light synchronously.
await goToPanel(page, "/?scenario=light-more-info#/lovelace");
// Fire the standard hass-more-info event from the app root. The HA shell
// listens for this and opens ha-more-info-dialog via its dialog manager.
await page.evaluate(() => {
const el = document.querySelector("ha-test");
el?.dispatchEvent(
new CustomEvent("hass-more-info", {
detail: { entityId: "light.test_light" },
bubbles: true,
composed: true,
})
);
const dialog = page.locator("ha-more-info-dialog");
// Fire the standard hass-more-info event from the app root with an
// explicit view. The HA shell opens ha-more-info-dialog on the requested
// view directly, so the test does not depend on the admin/demo-hidden
// header controls.
//
// The event is one-shot: if it lands before the shell's hass-more-info
// listener is attached it is silently dropped. Re-dispatching is
// idempotent (showDialog just resets the dialog to the requested view),
// so poll the dispatch until the requested view actually renders.
await expect(async () => {
await page.evaluate((v) => {
const el = document.querySelector("ha-test");
el?.dispatchEvent(
new CustomEvent("hass-more-info", {
detail: { entityId: "light.test_light", view: v },
bubbles: true,
composed: true,
})
);
}, view);
await expect(dialog).toBeAttached({ timeout: QUICK_TIMEOUT });
await expect(dialog.locator(element)).toBeAttached({
timeout: QUICK_TIMEOUT,
});
}).toPass({ timeout: SHELL_TIMEOUT });
// Each view should render its own characteristic content, not just an
// empty shell.
for (const { selector, text } of content) {
const locator = dialog.locator(selector).first();
if (text) {
// eslint-disable-next-line no-await-in-loop
await expect(locator).toContainText(text, { timeout: QUICK_TIMEOUT });
} else {
// eslint-disable-next-line no-await-in-loop
await expect(locator).toBeAttached({ timeout: QUICK_TIMEOUT });
}
}
});
const dialog = page.locator("ha-more-info-dialog");
await expect(dialog).toBeAttached({ timeout: SHELL_TIMEOUT });
// Confirm it actually rendered our entity, not a generic empty dialog.
await expect(dialog.locator("span.title")).toContainText("Test Light", {
timeout: QUICK_TIMEOUT,
});
});
}
});
// ---------------------------------------------------------------------------
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
# Develop the e2e test app
# Stop on errors
set -e
cd "$(dirname "$0")/../../../.."
./node_modules/.bin/gulp develop-e2e-test-app
+2
View File
@@ -25,6 +25,7 @@ import { mockLovelace } from "../../../../demo/src/stubs/lovelace";
import { mockMediaPlayer } from "../../../../demo/src/stubs/media_player";
import { mockPersistentNotification } from "../../../../demo/src/stubs/persistent_notification";
import { mockRecorder } from "../../../../demo/src/stubs/recorder";
import { mockSearch } from "../../../../demo/src/stubs/search";
import { mockSensor } from "../../../../demo/src/stubs/sensor";
import { mockSystemLog } from "../../../../demo/src/stubs/system_log";
import { mockTemplate } from "../../../../demo/src/stubs/template";
@@ -100,6 +101,7 @@ export class HaTest extends HomeAssistantAppEl {
mockConfigEntries(hass);
mockIcons(hass);
mockPersistentNotification(hass);
mockSearch(hass);
// Load default entities from the sections config
hass.addEntities(energyEntities());
+31
View File
@@ -1,3 +1,4 @@
import type { ExtEntityRegistryEntry } from "../../../../../src/data/entity/entity_registry";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
export type Scenario = (hass: MockHomeAssistant) => Promise<void> | void;
@@ -56,6 +57,36 @@ const lightMoreInfoScenario: Scenario = async (hass) => {
},
},
]);
// The base entity registry stub only mocks the list/get_entries commands, so
// the more-info settings view falls back to its "no unique ID" warning. Mock
// the single-entry lookup (config/entity_registry/get) so the settings view
// renders the real entity-registry-settings panel.
const registryEntry: ExtEntityRegistryEntry = {
created_at: 0,
modified_at: 0,
id: "test_light",
entity_id: "light.test_light",
unique_id: "test_light_unique_id",
name: null,
icon: null,
platform: "demo",
config_entry_id: null,
config_subentry_id: null,
device_id: null,
area_id: null,
labels: [],
disabled_by: null,
hidden_by: null,
entity_category: null,
has_entity_name: false,
original_name: "Test Light",
options: null,
categories: {},
capabilities: {},
aliases: [],
};
hass.mockWS("config/entity_registry/get", () => registryEntry);
};
// ── Registry ──────────────────────────────────────────────────────────────
@@ -12,6 +12,7 @@ import {
getPassThroughSections,
createPassThroughNode,
computeBarycenter,
dominantNeighborIndex,
sortNodesInSections,
} from "../../../../../src/resources/echarts/components/sankey/sankey-layout";
@@ -756,5 +757,470 @@ describe("Sankey Layout Functions", () => {
});
expectIdentityPreserved(result, input);
});
it("untangles a plateau-trapped subtree to remove an avoidable crossing (#52852)", () => {
// Realistic consumption tree: home → floors → areas → devices, plus two
// devices that attach higher up — one on a floor with no area
// (dev_floor_outside) and one straight on home (dev_home) — which the
// engine threads through with pass-throughs. The seed splits
// floor_outside's subtree: its pass-through child sits *after*
// floor_foundation's area. Pulling it back trades a crossing from the
// (1,2) boundary to the (2,3) boundary — a net-zero "plateau" the old
// strict gate refused, leaving the crossing. The plateau-escape must now
// take that step and let the device section follow, reaching 0 crossings.
const e = {
homeFo: { source: "home", target: "floor_outside", value: 1 },
homeFf: { source: "home", target: "floor_foundation", value: 1 },
foHvac: { source: "floor_outside", target: "area_hvac", value: 1 },
ffParking: {
source: "floor_foundation",
target: "area_parking",
value: 1,
},
hvacDev: { source: "area_hvac", target: "dev_hvac", value: 1 },
parkingDev: { source: "area_parking", target: "dev_parking", value: 1 },
foDev: {
source: "floor_outside",
target: "dev_floor_outside",
value: 1,
},
homeDev: { source: "home", target: "dev_home", value: 1 },
};
const testNodes: Record<string, TestNode> = {
home: {
id: "home",
depth: 0,
value: 4,
inEdges: [],
outEdges: [e.homeFo, e.homeFf, e.homeDev],
},
floor_outside: {
id: "floor_outside",
depth: 1,
value: 2,
inEdges: [e.homeFo],
outEdges: [e.foHvac, e.foDev],
},
floor_foundation: {
id: "floor_foundation",
depth: 1,
value: 1,
inEdges: [e.homeFf],
outEdges: [e.ffParking],
},
area_hvac: {
id: "area_hvac",
depth: 2,
value: 1,
inEdges: [e.foHvac],
outEdges: [e.hvacDev],
},
area_parking: {
id: "area_parking",
depth: 2,
value: 1,
inEdges: [e.ffParking],
outEdges: [e.parkingDev],
},
dev_hvac: {
id: "dev_hvac",
depth: 3,
value: 1,
inEdges: [e.hvacDev],
outEdges: [],
},
dev_parking: {
id: "dev_parking",
depth: 3,
value: 1,
inEdges: [e.parkingDev],
outEdges: [],
},
dev_floor_outside: {
id: "dev_floor_outside",
depth: 3,
value: 1,
inEdges: [e.foDev],
outEdges: [],
},
dev_home: {
id: "dev_home",
depth: 3,
value: 1,
inEdges: [e.homeDev],
outEdges: [],
},
};
const { nodes: graph, edges } = buildGraph(testNodes);
const ptHome1 = createPassThroughNode("home", "dev_home", 1, 1);
const ptFo2 = createPassThroughNode(
"floor_outside",
"dev_floor_outside",
2,
1
);
const ptHome2 = createPassThroughNode("home", "dev_home", 2, 1);
// Seed order from ha-sankey-chart: pass-throughs appended after the real
// children, so floor_outside's subtree is broken across the section.
const input = {
0: [graph.home],
1: [graph.floor_outside, graph.floor_foundation, ptHome1],
2: [graph.area_hvac, graph.area_parking, ptFo2, ptHome2],
3: [
graph.dev_hvac,
graph.dev_parking,
graph.dev_floor_outside,
graph.dev_home,
],
};
const result = sortNodesInSections(input, [0, 1, 2, 3], edges);
// floor_outside's children (area_hvac and its pass-through) are now
// contiguous, ahead of floor_foundation's; the layout is crossing-free.
expect(sectionIds(result)).toEqual({
0: ["home"],
1: ["floor_outside", "floor_foundation", "home-dev_home-1"],
2: [
"area_hvac",
"floor_outside-dev_floor_outside-2",
"area_parking",
"home-dev_home-2",
],
3: ["dev_hvac", "dev_floor_outside", "dev_parking", "dev_home"],
});
expectIdentityPreserved(result, input);
// Re-running on the result must not drift: plateau churn is discarded and
// the best snapshot is returned, so the order is stable (idempotent).
const again = sortNodesInSections(result, [0, 1, 2, 3], edges);
expect(sectionIds(again)).toEqual(sectionIds(result));
});
it("groups single-parent siblings under their parent and keeps configured sibling order", () => {
// Two floors, two areas each, fed to the engine interleaved (not grouped
// by floor). The deterministic hierarchy pass must regroup areas under
// their floor, and within a floor preserve the configured (seed) order:
// a1 before a2, b1 before b2.
const e = {
hFa: { source: "home", target: "floor_a", value: 2 },
hFb: { source: "home", target: "floor_b", value: 2 },
faA1: { source: "floor_a", target: "a1", value: 1 },
faA2: { source: "floor_a", target: "a2", value: 1 },
fbB1: { source: "floor_b", target: "b1", value: 1 },
fbB2: { source: "floor_b", target: "b2", value: 1 },
};
const testNodes: Record<string, TestNode> = {
home: {
id: "home",
depth: 0,
value: 4,
inEdges: [],
outEdges: [e.hFa, e.hFb],
},
floor_a: {
id: "floor_a",
depth: 1,
value: 2,
inEdges: [e.hFa],
outEdges: [e.faA1, e.faA2],
},
floor_b: {
id: "floor_b",
depth: 1,
value: 2,
inEdges: [e.hFb],
outEdges: [e.fbB1, e.fbB2],
},
a1: { id: "a1", depth: 2, value: 1, inEdges: [e.faA1], outEdges: [] },
a2: { id: "a2", depth: 2, value: 1, inEdges: [e.faA2], outEdges: [] },
b1: { id: "b1", depth: 2, value: 1, inEdges: [e.fbB1], outEdges: [] },
b2: { id: "b2", depth: 2, value: 1, inEdges: [e.fbB2], outEdges: [] },
};
const { nodes: graph, edges } = buildGraph(testNodes);
const input = {
0: [graph.home],
1: [graph.floor_a, graph.floor_b],
2: [graph.a1, graph.b1, graph.a2, graph.b2], // interleaved
};
const result = sortNodesInSections(input, [0, 1, 2], edges);
expect(sectionIds(result)).toEqual({
0: ["home"],
1: ["floor_a", "floor_b"],
2: ["a1", "a2", "b1", "b2"],
});
expectIdentityPreserved(result, input);
});
it("orders a single-parent section by parent position, ignoring flow magnitude", () => {
// childB carries a far larger flow than childA, but a single-parent
// section is ordered by parent position (hierarchy), never by value.
const edgeACa = { source: "A", target: "childA", value: 1 };
const edgeBCb = { source: "B", target: "childB", value: 100 };
const testNodes: Record<string, TestNode> = {
A: { id: "A", depth: 0, value: 1, inEdges: [], outEdges: [edgeACa] },
B: { id: "B", depth: 0, value: 100, inEdges: [], outEdges: [edgeBCb] },
childA: {
id: "childA",
depth: 1,
value: 1,
inEdges: [edgeACa],
outEdges: [],
},
childB: {
id: "childB",
depth: 1,
value: 100,
inEdges: [edgeBCb],
outEdges: [],
},
};
const { nodes: graph, edges } = buildGraph(testNodes);
const input = { 0: [graph.A, graph.B], 1: [graph.childB, graph.childA] };
const result = sortNodesInSections(input, [0, 1], edges);
expect(sectionIds(result)).toEqual({
0: ["A", "B"],
1: ["childA", "childB"],
});
expectIdentityPreserved(result, input);
});
it("keeps the single-parent tree hierarchical below a multi-parent source layer", () => {
// grid + solar feed home (a genuine multi-parent section that stays under
// the barycenter sweep); the floor/area tree below is single-parent and
// must regroup by parent regardless of the multi-parent head.
const e = {
gH: { source: "grid", target: "home", value: 2 },
sH: { source: "solar", target: "home", value: 2 },
hFa: { source: "home", target: "floor_a", value: 2 },
hFb: { source: "home", target: "floor_b", value: 2 },
faA: { source: "floor_a", target: "area_a", value: 2 },
fbB: { source: "floor_b", target: "area_b", value: 2 },
};
const testNodes: Record<string, TestNode> = {
grid: { id: "grid", depth: 0, value: 2, inEdges: [], outEdges: [e.gH] },
solar: {
id: "solar",
depth: 0,
value: 2,
inEdges: [],
outEdges: [e.sH],
},
home: {
id: "home",
depth: 1,
value: 4,
inEdges: [e.gH, e.sH],
outEdges: [e.hFa, e.hFb],
},
floor_a: {
id: "floor_a",
depth: 2,
value: 2,
inEdges: [e.hFa],
outEdges: [e.faA],
},
floor_b: {
id: "floor_b",
depth: 2,
value: 2,
inEdges: [e.hFb],
outEdges: [e.fbB],
},
area_a: {
id: "area_a",
depth: 3,
value: 2,
inEdges: [e.faA],
outEdges: [],
},
area_b: {
id: "area_b",
depth: 3,
value: 2,
inEdges: [e.fbB],
outEdges: [],
},
};
const { nodes: graph, edges } = buildGraph(testNodes);
const input = {
0: [graph.grid, graph.solar],
1: [graph.home],
2: [graph.floor_a, graph.floor_b],
3: [graph.area_b, graph.area_a], // reversed; must regroup under floors
};
const result = sortNodesInSections(input, [0, 1, 2, 3], edges);
expect(sectionIds(result)).toEqual({
0: ["grid", "solar"],
1: ["home"],
2: ["floor_a", "floor_b"],
3: ["area_a", "area_b"],
});
expectIdentityPreserved(result, input);
});
it("regroups single-parent children after a multi-parent head is reordered (idempotent)", () => {
// A multi-parent head section [H0,H1,H2] (only H1 draws from two sources)
// gets reordered by the barycenter sweep. Its single-parent children must
// then be regrouped under the *settled* head — H1's children before H2's
// child — and the result must be idempotent. Before the head/tree passes
// were ordered correctly the children stayed grouped against the head's
// SEED order, which both mis-grouped them and broke f(f(x)) === f(x).
const e = {
s0h0: { source: "S0", target: "H0", value: 6 },
s0h1: { source: "S0", target: "H1", value: 7 },
s1h1: { source: "S1", target: "H1", value: 5 },
s2h2: { source: "S2", target: "H2", value: 6 },
h1d0: { source: "H1", target: "D0", value: 7 },
h1d2: { source: "H1", target: "D2", value: 9 },
h2d1: { source: "H2", target: "D1", value: 7 },
};
const testNodes: Record<string, TestNode> = {
S0: {
id: "S0",
depth: 0,
value: 13,
inEdges: [],
outEdges: [e.s0h0, e.s0h1],
},
S1: { id: "S1", depth: 0, value: 5, inEdges: [], outEdges: [e.s1h1] },
S2: { id: "S2", depth: 0, value: 6, inEdges: [], outEdges: [e.s2h2] },
H0: { id: "H0", depth: 1, value: 6, inEdges: [e.s0h0], outEdges: [] },
H1: {
id: "H1",
depth: 1,
value: 12,
inEdges: [e.s1h1, e.s0h1],
outEdges: [e.h1d0, e.h1d2],
},
H2: {
id: "H2",
depth: 1,
value: 6,
inEdges: [e.s2h2],
outEdges: [e.h2d1],
},
D0: { id: "D0", depth: 2, value: 7, inEdges: [e.h1d0], outEdges: [] },
D1: { id: "D1", depth: 2, value: 7, inEdges: [e.h2d1], outEdges: [] },
D2: { id: "D2", depth: 2, value: 9, inEdges: [e.h1d2], outEdges: [] },
};
const { nodes: graph, edges } = buildGraph(testNodes);
const input = {
0: [graph.S0, graph.S1, graph.S2],
1: [graph.H2, graph.H1, graph.H0],
2: [graph.D1, graph.D2, graph.D0],
};
const result = sortNodesInSections(input, [0, 1, 2], edges);
// Head settles to barycenter order [H0,H1,H2]; children regroup under it:
// H1's children (D2,D0) precede H2's child (D1).
expect(sectionIds(result)).toEqual({
0: ["S0", "S1", "S2"],
1: ["H0", "H1", "H2"],
2: ["D2", "D0", "D1"],
});
expectIdentityPreserved(result, input);
// Idempotent: re-feeding the output yields the identical order.
const again = sortNodesInSections(result, [0, 1, 2], edges);
expect(sectionIds(again)).toEqual(sectionIds(result));
});
it("lets single-parent children follow their reordered multi-parent parent to reach 0 crossings", () => {
// root [a,b,c,d]; section 1 [m1,m2] is multi-parent with a clean split
// (c,d -> m1 ; a,b -> m2); section 2 [x<-m1, y<-m2] single-parent. The
// optimum needs BOTH the parent order swapped (m2,m1) AND the children to
// follow (y,x) — placing the tree after the head settles reaches it.
const e = {
am2: { source: "a", target: "m2", value: 1 },
bm2: { source: "b", target: "m2", value: 1 },
cm1: { source: "c", target: "m1", value: 1 },
dm1: { source: "d", target: "m1", value: 1 },
m1x: { source: "m1", target: "x", value: 2 },
m2y: { source: "m2", target: "y", value: 2 },
};
const testNodes: Record<string, TestNode> = {
a: { id: "a", depth: 0, value: 1, inEdges: [], outEdges: [e.am2] },
b: { id: "b", depth: 0, value: 1, inEdges: [], outEdges: [e.bm2] },
c: { id: "c", depth: 0, value: 1, inEdges: [], outEdges: [e.cm1] },
d: { id: "d", depth: 0, value: 1, inEdges: [], outEdges: [e.dm1] },
m1: {
id: "m1",
depth: 1,
value: 2,
inEdges: [e.cm1, e.dm1],
outEdges: [e.m1x],
},
m2: {
id: "m2",
depth: 1,
value: 2,
inEdges: [e.am2, e.bm2],
outEdges: [e.m2y],
},
x: { id: "x", depth: 2, value: 2, inEdges: [e.m1x], outEdges: [] },
y: { id: "y", depth: 2, value: 2, inEdges: [e.m2y], outEdges: [] },
};
const { nodes: graph, edges } = buildGraph(testNodes);
const input = {
0: [graph.a, graph.b, graph.c, graph.d],
1: [graph.m1, graph.m2],
2: [graph.x, graph.y],
};
const result = sortNodesInSections(input, [0, 1, 2], edges);
expect(sectionIds(result)).toEqual({
0: ["a", "b", "c", "d"],
1: ["m2", "m1"],
2: ["y", "x"],
});
expectIdentityPreserved(result, input);
});
});
describe("dominantNeighborIndex", () => {
it("returns the index of the single heaviest neighbor", () => {
const map = new Map([
["light", 0],
["heavy", 3],
]);
const result = dominantNeighborIndex(
[
{ id: "light", weight: 1 },
{ id: "heavy", weight: 5 },
],
map,
9
);
expect(result).toBe(3);
});
it("breaks weight ties by the earliest edge", () => {
const map = new Map([
["a", 2],
["b", 4],
]);
const result = dominantNeighborIndex(
[
{ id: "a", weight: 1 },
{ id: "b", weight: 1 },
],
map,
9
);
expect(result).toBe(2);
});
it("falls back when no neighbor is in the reference section", () => {
const result = dominantNeighborIndex(
[{ id: "missing", weight: 1 }],
new Map(),
7
);
expect(result).toBe(7);
});
});
});
+91 -99
View File
@@ -4187,13 +4187,6 @@ __metadata:
languageName: node
linkType: hard
"@package-json/types@npm:^0.0.12":
version: 0.0.12
resolution: "@package-json/types@npm:0.0.12"
checksum: 10/435d921b3ccc817ebe282c6eaac50282691d3be4dafb461d01c03b89fb89cda14ba7b5e20d01503bc1996ab93f98ae8e71601c3be7119b4ea76193b550523fcd
languageName: node
linkType: hard
"@playwright/test@npm:1.61.0":
version: 1.61.0
resolution: "@playwright/test@npm:1.61.0"
@@ -5719,105 +5712,105 @@ __metadata:
languageName: node
linkType: hard
"@typescript-eslint/eslint-plugin@npm:8.61.1":
version: 8.61.1
resolution: "@typescript-eslint/eslint-plugin@npm:8.61.1"
"@typescript-eslint/eslint-plugin@npm:8.62.0":
version: 8.62.0
resolution: "@typescript-eslint/eslint-plugin@npm:8.62.0"
dependencies:
"@eslint-community/regexpp": "npm:^4.12.2"
"@typescript-eslint/scope-manager": "npm:8.61.1"
"@typescript-eslint/type-utils": "npm:8.61.1"
"@typescript-eslint/utils": "npm:8.61.1"
"@typescript-eslint/visitor-keys": "npm:8.61.1"
"@typescript-eslint/scope-manager": "npm:8.62.0"
"@typescript-eslint/type-utils": "npm:8.62.0"
"@typescript-eslint/utils": "npm:8.62.0"
"@typescript-eslint/visitor-keys": "npm:8.62.0"
ignore: "npm:^7.0.5"
natural-compare: "npm:^1.4.0"
ts-api-utils: "npm:^2.5.0"
peerDependencies:
"@typescript-eslint/parser": ^8.61.1
"@typescript-eslint/parser": ^8.62.0
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/5434b78781f750eb1e2918f960ff3a6a3fae36951591456b1a309695a5c6c027d914038d7c2c71e614611b5c46a3be85b4b004581be0bcfb1be84e741b0e98a8
checksum: 10/a093962c84e49d7524078a97c3ecfdedfaa217a6f68047d3eedb29677425210acfacaa2fe88f447e9662063979f31c8268e4568caca038df09deee9f06124d7f
languageName: node
linkType: hard
"@typescript-eslint/parser@npm:8.61.1":
version: 8.61.1
resolution: "@typescript-eslint/parser@npm:8.61.1"
"@typescript-eslint/parser@npm:8.62.0":
version: 8.62.0
resolution: "@typescript-eslint/parser@npm:8.62.0"
dependencies:
"@typescript-eslint/scope-manager": "npm:8.61.1"
"@typescript-eslint/types": "npm:8.61.1"
"@typescript-eslint/typescript-estree": "npm:8.61.1"
"@typescript-eslint/visitor-keys": "npm:8.61.1"
"@typescript-eslint/scope-manager": "npm:8.62.0"
"@typescript-eslint/types": "npm:8.62.0"
"@typescript-eslint/typescript-estree": "npm:8.62.0"
"@typescript-eslint/visitor-keys": "npm:8.62.0"
debug: "npm:^4.4.3"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/cdaca9bb78bd6cc7210e88b28c42af11b23a47393a97ed37350e64a3846d036ebd178583fd2a54216974a740b3f6932274bdaf72046e6307ef26aee3ebe35cec
checksum: 10/d0abbf12080532f6460af186098fab15f1f3695ee4817f96209ecfb00ef7ec89ec476051bde35a396217c8e37d5e441f3814807eb082e11904a0a1dc4b6d3b14
languageName: node
linkType: hard
"@typescript-eslint/project-service@npm:8.61.1":
version: 8.61.1
resolution: "@typescript-eslint/project-service@npm:8.61.1"
"@typescript-eslint/project-service@npm:8.62.0":
version: 8.62.0
resolution: "@typescript-eslint/project-service@npm:8.62.0"
dependencies:
"@typescript-eslint/tsconfig-utils": "npm:^8.61.1"
"@typescript-eslint/types": "npm:^8.61.1"
"@typescript-eslint/tsconfig-utils": "npm:^8.62.0"
"@typescript-eslint/types": "npm:^8.62.0"
debug: "npm:^4.4.3"
peerDependencies:
typescript: ">=4.8.4 <6.1.0"
checksum: 10/51eb5cbd74748d08512db976bbeabdb9352f44e220621dce3bc96837bc309c7d266df49007be196e57950cf9e12e2c574649cbf14aa2e518734ee55ff7d86f2c
checksum: 10/e296f3aaaf7b4fc56e6410420a98a995f59bf45187445c9ad94d76de557a47071558869414c8ec179dfefce4f65ef8c15fcda7db653ed8fb95ff25b8119f9bb1
languageName: node
linkType: hard
"@typescript-eslint/scope-manager@npm:8.61.1":
version: 8.61.1
resolution: "@typescript-eslint/scope-manager@npm:8.61.1"
"@typescript-eslint/scope-manager@npm:8.62.0":
version: 8.62.0
resolution: "@typescript-eslint/scope-manager@npm:8.62.0"
dependencies:
"@typescript-eslint/types": "npm:8.61.1"
"@typescript-eslint/visitor-keys": "npm:8.61.1"
checksum: 10/69c1d5b403b2e6adbae7e24856628941e912304ac728b6beae959df98092707adf3f60e1d0c9b90badd758d76174e9d44a7eba55608693c976e5cba5cc47593c
"@typescript-eslint/types": "npm:8.62.0"
"@typescript-eslint/visitor-keys": "npm:8.62.0"
checksum: 10/6477062eb056986c9f94b35761c0b67bb9995798ba94c5d2bcb01932e525604715ce62e816468b2c80a8f05daa33b3339ea40646a31f733ea9840cee1dd3e82d
languageName: node
linkType: hard
"@typescript-eslint/tsconfig-utils@npm:8.61.1, @typescript-eslint/tsconfig-utils@npm:^8.61.1":
version: 8.61.1
resolution: "@typescript-eslint/tsconfig-utils@npm:8.61.1"
"@typescript-eslint/tsconfig-utils@npm:8.62.0, @typescript-eslint/tsconfig-utils@npm:^8.62.0":
version: 8.62.0
resolution: "@typescript-eslint/tsconfig-utils@npm:8.62.0"
peerDependencies:
typescript: ">=4.8.4 <6.1.0"
checksum: 10/ee81d01809178d6fd88a478bdf8fb546063c55f01385c6443fe7b93ebe9ba26ecda4f0eb804b2fc0a189dc34a51e89690477fb68cd099cd3952902f376d641c6
checksum: 10/578f486df8eb2d2ec3939afc37102b89521d531d409d76e30a8ac3e9f48a3ae410e19e40c2aba3810f28391925a35ed391204ff786cc230542de82817efafda0
languageName: node
linkType: hard
"@typescript-eslint/type-utils@npm:8.61.1":
version: 8.61.1
resolution: "@typescript-eslint/type-utils@npm:8.61.1"
"@typescript-eslint/type-utils@npm:8.62.0":
version: 8.62.0
resolution: "@typescript-eslint/type-utils@npm:8.62.0"
dependencies:
"@typescript-eslint/types": "npm:8.61.1"
"@typescript-eslint/typescript-estree": "npm:8.61.1"
"@typescript-eslint/utils": "npm:8.61.1"
"@typescript-eslint/types": "npm:8.62.0"
"@typescript-eslint/typescript-estree": "npm:8.62.0"
"@typescript-eslint/utils": "npm:8.62.0"
debug: "npm:^4.4.3"
ts-api-utils: "npm:^2.5.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/01c62227479d94ed3745e3bef5a0c870586d75b6ed550e4beb84b25df23de29558e0bdb6ff291e457977254764766c5d252b75a03d4ad592998269dba69a32a6
checksum: 10/e1627d2bd792cf856c36db4f4e9c89e3111ad9bf81fc7489e957d26d36f89ab00226eee1838ee499947a898e37c1f30338bca4fa05ca437154c1813d54831b7e
languageName: node
linkType: hard
"@typescript-eslint/types@npm:8.61.1, @typescript-eslint/types@npm:^8.56.0, @typescript-eslint/types@npm:^8.61.1":
version: 8.61.1
resolution: "@typescript-eslint/types@npm:8.61.1"
checksum: 10/9aa036b27d1874533bf1b931151adaaebb4380cfd14cc71395ab8d2c00c7420218e42811c2b5a68c9fa54cd048e1ab47085afd8b44cd5d736fb5cb8edd300d64
"@typescript-eslint/types@npm:8.62.0, @typescript-eslint/types@npm:^8.56.0, @typescript-eslint/types@npm:^8.62.0":
version: 8.62.0
resolution: "@typescript-eslint/types@npm:8.62.0"
checksum: 10/459f5834dedbb73fc80c8eb92de693faef0f0f341d3b4d65426dbf43640f98a50104f6e15108808aed2c3c66d518db15a648c72c40831f498d36bfb62be564cb
languageName: node
linkType: hard
"@typescript-eslint/typescript-estree@npm:8.61.1":
version: 8.61.1
resolution: "@typescript-eslint/typescript-estree@npm:8.61.1"
"@typescript-eslint/typescript-estree@npm:8.62.0":
version: 8.62.0
resolution: "@typescript-eslint/typescript-estree@npm:8.62.0"
dependencies:
"@typescript-eslint/project-service": "npm:8.61.1"
"@typescript-eslint/tsconfig-utils": "npm:8.61.1"
"@typescript-eslint/types": "npm:8.61.1"
"@typescript-eslint/visitor-keys": "npm:8.61.1"
"@typescript-eslint/project-service": "npm:8.62.0"
"@typescript-eslint/tsconfig-utils": "npm:8.62.0"
"@typescript-eslint/types": "npm:8.62.0"
"@typescript-eslint/visitor-keys": "npm:8.62.0"
debug: "npm:^4.4.3"
minimatch: "npm:^10.2.2"
semver: "npm:^7.7.3"
@@ -5825,32 +5818,32 @@ __metadata:
ts-api-utils: "npm:^2.5.0"
peerDependencies:
typescript: ">=4.8.4 <6.1.0"
checksum: 10/36b8b68e7fe9bdba0bb24b46a43a29e39f0cd45440ea190644e230d10e96b0bab9289027668910ff976100d68a9b3bc222618bf96b99bae6fd0053eb560a1257
checksum: 10/c3ae8e13671957e4a1c4acfc861b40e1545a9d32fe9d5cc851992186314f5a1dbe780cecc8f16b8448a1350f4c11648572352b5d04b77bb62e8dac3e6f3a2e04
languageName: node
linkType: hard
"@typescript-eslint/utils@npm:8.61.1":
version: 8.61.1
resolution: "@typescript-eslint/utils@npm:8.61.1"
"@typescript-eslint/utils@npm:8.62.0":
version: 8.62.0
resolution: "@typescript-eslint/utils@npm:8.62.0"
dependencies:
"@eslint-community/eslint-utils": "npm:^4.9.1"
"@typescript-eslint/scope-manager": "npm:8.61.1"
"@typescript-eslint/types": "npm:8.61.1"
"@typescript-eslint/typescript-estree": "npm:8.61.1"
"@typescript-eslint/scope-manager": "npm:8.62.0"
"@typescript-eslint/types": "npm:8.62.0"
"@typescript-eslint/typescript-estree": "npm:8.62.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/7c8886801f73fc09ecf585b0e8f33799e4a341d51b00db0467f05853f84b808bb98a35e92eab49f28d5d24a1c915959d834214776f0ff6f0cfa5abb3f2e11496
checksum: 10/95fed9feb823106f09b517637b0cf00e39fdc3537d05023f84710f23d00457898d8f1c68a69115d624a686411136b5cfdd7b84b657d6b51aea410cf2eb7fde7a
languageName: node
linkType: hard
"@typescript-eslint/visitor-keys@npm:8.61.1":
version: 8.61.1
resolution: "@typescript-eslint/visitor-keys@npm:8.61.1"
"@typescript-eslint/visitor-keys@npm:8.62.0":
version: 8.62.0
resolution: "@typescript-eslint/visitor-keys@npm:8.62.0"
dependencies:
"@typescript-eslint/types": "npm:8.61.1"
"@typescript-eslint/types": "npm:8.62.0"
eslint-visitor-keys: "npm:^5.0.0"
checksum: 10/7d99c6ae9e91d32b8cc3662ead0e393c912351b5786ece62e1dc198a6b0e9813bb7eae44772970512e7e424a342c86529d9f3dae7c8ab83ac95b5dc33826647a
checksum: 10/63d66db628befb1d160c02f3fe3b00b7c7ffc47a477335591affde2108e913a72d4a327bdd15d91f5784c3c3624e9b347e9351754390a61ca182bb7e1788d350
languageName: node
linkType: hard
@@ -6240,6 +6233,13 @@ __metadata:
languageName: node
linkType: hard
"@vvo/tzdb@npm:6.198.0":
version: 6.198.0
resolution: "@vvo/tzdb@npm:6.198.0"
checksum: 10/702d25ed7e7a55c4ee3c81e5de79cdb5d11c73bc02e511fa8f93eb497e6ada1b198805469f9e203bef6ea304b914637a6c570f19ade405b6e97d45181a0216a1
languageName: node
linkType: hard
"@webcomponents/scoped-custom-element-registry@npm:0.0.10":
version: 0.0.10
resolution: "@webcomponents/scoped-custom-element-registry@npm:0.0.10"
@@ -8536,11 +8536,10 @@ __metadata:
languageName: node
linkType: hard
"eslint-plugin-import-x@npm:4.16.2":
version: 4.16.2
resolution: "eslint-plugin-import-x@npm:4.16.2"
"eslint-plugin-import-x@npm:4.17.0":
version: 4.17.0
resolution: "eslint-plugin-import-x@npm:4.17.0"
dependencies:
"@package-json/types": "npm:^0.0.12"
"@typescript-eslint/types": "npm:^8.56.0"
comment-parser: "npm:^1.4.1"
debug: "npm:^4.4.1"
@@ -8559,7 +8558,7 @@ __metadata:
optional: true
eslint-import-resolver-node:
optional: true
checksum: 10/b149ca51ffba102535cddbe37f298ab3704181ea877a00b87d50062e03ad31a86317cf4205ac3ebc6d4e7872953215777ee4a3b0390616937f3adc9a86b86a90
checksum: 10/143081e0a2cb418990d5d61c08ad4dd46f4f10dd7664939cc4be8454c2f51cd69134746d2d8b7534786f3a13857d176456bb0c1d1ffc9c168830b4ce93d2c0a8
languageName: node
linkType: hard
@@ -9464,10 +9463,10 @@ __metadata:
languageName: node
linkType: hard
"globals@npm:17.6.0":
version: 17.6.0
resolution: "globals@npm:17.6.0"
checksum: 10/2bf0febf31c942edee6f4eca7e939a9c885f8ecfb767048b1c4dd2a32008d0ab136e6076665d76b44b29c2571bbbc1681371caab05fd8ee0067c7618e841b89d
"globals@npm:17.7.0":
version: 17.7.0
resolution: "globals@npm:17.7.0"
checksum: 10/79304ccc4d2ca167ea15bdb25da346aa34ce3847b18fbd6c3cad182e152505305db3c9722fd5e292c62f6db97a8fa06e0c110a1e7703d7325498e5351d08cab4
languageName: node
linkType: hard
@@ -9511,13 +9510,6 @@ __metadata:
languageName: node
linkType: hard
"google-timezones-json@npm:1.2.0":
version: 1.2.0
resolution: "google-timezones-json@npm:1.2.0"
checksum: 10/c83fdaa3681de7b63704aa5d5c644fecd1e2c46047eb65716fd0a1ef28a778b1bbfd6f521c499247b4d7afdc085c7d8bbdbea56398492d395ef9c8d87a648b11
languageName: node
linkType: hard
"gopd@npm:^1.0.1, gopd@npm:^1.2.0":
version: 1.2.0
resolution: "gopd@npm:1.2.0"
@@ -9774,6 +9766,7 @@ __metadata:
"@types/tar": "npm:7.0.87"
"@vibrant/color": "npm:4.0.4"
"@vitest/coverage-v8": "npm:4.1.9"
"@vvo/tzdb": "npm:6.198.0"
"@webcomponents/scoped-custom-element-registry": "npm:0.0.10"
"@webcomponents/webcomponentsjs": "npm:2.8.0"
babel-loader: "npm:10.1.1"
@@ -9796,7 +9789,7 @@ __metadata:
eslint: "npm:10.5.0"
eslint-config-prettier: "npm:10.1.8"
eslint-import-resolver-webpack: "npm:0.13.11"
eslint-plugin-import-x: "npm:4.16.2"
eslint-plugin-import-x: "npm:4.17.0"
eslint-plugin-lit: "npm:2.3.1"
eslint-plugin-lit-a11y: "npm:5.1.1"
eslint-plugin-unused-imports: "npm:4.4.1"
@@ -9806,8 +9799,7 @@ __metadata:
fuse.js: "npm:7.4.2"
generate-license-file: "npm:4.2.1"
glob: "npm:13.0.6"
globals: "npm:17.6.0"
google-timezones-json: "npm:1.2.0"
globals: "npm:17.7.0"
gulp: "npm:5.0.1"
gulp-brotli: "npm:3.0.0"
gulp-json-transform: "npm:0.5.0"
@@ -9857,7 +9849,7 @@ __metadata:
tinykeys: "patch:tinykeys@npm%3A4.0.0#~/.yarn/patches/tinykeys-npm-4.0.0-a6ca3fd771.patch"
ts-lit-plugin: "npm:2.0.2"
typescript: "npm:6.0.3"
typescript-eslint: "npm:8.61.1"
typescript-eslint: "npm:8.62.0"
vite-tsconfig-paths: "npm:6.1.1"
vitest: "npm:4.1.9"
webpack-stats-plugin: "npm:1.1.3"
@@ -14845,18 +14837,18 @@ __metadata:
languageName: node
linkType: hard
"typescript-eslint@npm:8.61.1":
version: 8.61.1
resolution: "typescript-eslint@npm:8.61.1"
"typescript-eslint@npm:8.62.0":
version: 8.62.0
resolution: "typescript-eslint@npm:8.62.0"
dependencies:
"@typescript-eslint/eslint-plugin": "npm:8.61.1"
"@typescript-eslint/parser": "npm:8.61.1"
"@typescript-eslint/typescript-estree": "npm:8.61.1"
"@typescript-eslint/utils": "npm:8.61.1"
"@typescript-eslint/eslint-plugin": "npm:8.62.0"
"@typescript-eslint/parser": "npm:8.62.0"
"@typescript-eslint/typescript-estree": "npm:8.62.0"
"@typescript-eslint/utils": "npm:8.62.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/33a798da178f8942a5fb188a991a2eaa9047d7ca95178c67df2565531379b2a587c14ff836716a8b11ed3328c34af5e3b3ea985fa4d2a521a1bb605c2f0e0aa4
checksum: 10/c75b16115a3e6f7704f71a9fe14d8cf52129db7e0578755f10a344a0e22474cc0b5383822ef0344cd886b98300af4cce19a306ccda391b2e1eed6c07088f3019
languageName: node
linkType: hard