Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cf3843af3 | ||
|
|
9e93ca0db4 | ||
|
|
35691e08eb | ||
|
|
7d46cb96d3 | ||
|
|
5e1545efad |
@@ -1230,7 +1230,24 @@ def _thermal_cost_getter(metric: str, window: str | None) -> Callable[[Session],
|
||||
if metric == "water_tax":
|
||||
return result["breakdown"]["hot_water_tax"]
|
||||
if metric == "hot_water_total":
|
||||
return result["breakdown"]["hot_water_heating"] + result["breakdown"]["hot_water"]
|
||||
# Keep the existing component entities variable-only, while making
|
||||
# the two consumer-facing totals partition the all-in summary.
|
||||
# Standing costs are assigned by their contractual commodity:
|
||||
# hot-water network belongs to hot water; the remaining named
|
||||
# standing fees belong to heating.
|
||||
return (
|
||||
result["breakdown"]["hot_water_heating"]
|
||||
+ result["breakdown"]["hot_water"]
|
||||
+ result["breakdown"]["hot_water_tax"]
|
||||
+ result["fixed_breakdown"]["hot_water_network"]
|
||||
)
|
||||
if metric == "heating":
|
||||
return result["breakdown"]["heating"] + sum(
|
||||
(result["fixed_breakdown"][key] for key in (
|
||||
"heating_network", "metering", "delivery_set", "other"
|
||||
)),
|
||||
0,
|
||||
)
|
||||
return result["breakdown"][metric]
|
||||
return _getter
|
||||
|
||||
|
||||
@@ -1222,7 +1222,17 @@ Assistant Energy Water 所要求的 `water`;保留 `m³` 与 `total_increasing
|
||||
|
||||
### M8-R15 — HA Discovery identity/topic repair
|
||||
|
||||
修复 HA 将多值 `device.identifiers` 任一匹配合并的风险:Source、Meter、Modbus 与成本 epoch 均使用单一完整 namespaced identifier,内部 MQTT identity 独立保存。thermal 的既有点号 identity/key 保持兼容,node/object topic segment 单独规范化;仅对 v1.6.1 实际发布过的 dot topic 做可重试、成功后抑制的精确 retained cleanup。新增默认关闭的 `Thermal Hot Water Total/Today`,金额为 `hot_water_heating + hot_water`,不含 `hot_water_tax`。
|
||||
修复 HA 将多值 `device.identifiers` 任一匹配合并的风险:Source、Meter、Modbus 与成本 epoch 均使用单一完整 namespaced identifier,内部 MQTT identity 独立保存。thermal 的既有点号 identity/key 保持兼容,node/object topic segment 单独规范化;仅对 v1.6.1 实际发布过的 dot topic 做可重试、成功后抑制的精确 retained cleanup。新增默认关闭的 `Thermal Hot Water Total/Today`;其后由 R17 明确为 hot-water 的 all-in 分配值。
|
||||
|
||||
### M8-R17 — Thermal HA standing-cost allocation
|
||||
|
||||
保持全部 thermal HA entity key、unique_id 与名称不变,只调整 `Thermal Heating Total/Today` 与
|
||||
`Thermal Hot Water Total/Today` 的数值语义。Heating 为 `heating` variable 加上
|
||||
`heating_network`、`metering`、`delivery_set`、`other` 四项 fixed breakdown;Hot Water 为
|
||||
`hot_water_heating + hot_water + hot_water_tax` 加上 `hot_water_network`。因此两者之和精确等于
|
||||
`Thermal All-in Total/Today`,每项 standing 只分配一次。component 实体(hot-water heating、water、
|
||||
water tax)继续只显示 variable,`Fixed` 继续显示全部 standing;provider 只读取 `meter_cost.summarize`
|
||||
在 01:05 结算后的结果,不改 15 分钟 ledger、API/schema 或数据库。
|
||||
|
||||
M8 交付后的 Meter lifecycle 修复链记录在本地 `review-notes/M8-meter-lifecycle-repair-plan.md`。
|
||||
它不新增 ORM / **数据库** schema 或 Alembic revision,不做启动自动修复、一次性数据脚本或历史删除;R08 虽然
|
||||
|
||||
@@ -77,7 +77,13 @@ docker compose -f docker-compose.yml run --rm migration
|
||||
|
||||
成本页的 15 分钟 ledger 分开显示 heating 与 hot-water 三项 variable breakdown;fixed 费只在合同级 summary 按本地自然日计提一次,all-in = variable + fixed。用显式 recompute 来验证测试时间窗时,应手算并核对 Decimal 金额,保留原有 electricity 合同和数字不变。
|
||||
|
||||
在 Config 的 HA Expose 中只开启需要的 source、Meter 与 thermal entities。核对 unit、state class、availability、today reset 和换表后 identity;关闭 toggle 后应用会清理 retained discovery。不要把 source secret、设备 identity 或合同金额放进 HA entity 名称、日志或截图。
|
||||
在 Config 的 HA Expose 中只开启需要的 source、Meter 与 thermal entities。`Thermal Heating Total/Today`
|
||||
显示 heating variable 加上 heating network、metering、delivery set 与 other 固定费;`Thermal Hot Water
|
||||
Total/Today` 显示 hot-water heating、water、water tax variable 加上 hot-water network 固定费。两条合计
|
||||
恰好等于 `Thermal All-in Total/Today`;component entities 仍仅显示各自 variable,`Fixed` 仍显示全部
|
||||
standing,避免重复计费。数值仍来自在 01:05 后结算的 summary。核对 unit、state class、availability、today
|
||||
reset 和换表后 identity;关闭 toggle 后应用会清理 retained discovery。不要把 source secret、设备 identity
|
||||
或合同金额放进 HA entity 名称、日志或截图。
|
||||
|
||||
## 安全回滚
|
||||
|
||||
|
||||
Generated
+99
-221
@@ -22,13 +22,14 @@
|
||||
"react-dom": "^18.3.1",
|
||||
"react-feather": "^2.0.10",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-router-dom": "^6.30.4",
|
||||
"react-router-dom": "^7.18.3",
|
||||
"recharts": "^3.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@testing-library/dom": "10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^14.3.1",
|
||||
"@testing-library/react": "16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^18.3.31",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
@@ -1390,9 +1391,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@redocly/openapi-core": {
|
||||
"version": "1.34.15",
|
||||
"resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.15.tgz",
|
||||
"integrity": "sha512-HAwCnNyKcs5XGQqms+9t7OdAPM/5TDstmhF+0i7tdCFato2QKuYIlyWETwkXd8c5zbltr1oB+6y9NTeQLr2d6Q==",
|
||||
"version": "1.34.19",
|
||||
"resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.19.tgz",
|
||||
"integrity": "sha512-o/0VgsBXgwcY1lyeqcVtSGdTQAPnVggo0fbFVPlxl5XVDKUcVH0OLRqt3CbkwByT5FU305E0iE0O7MzThjDblw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1401,7 +1402,7 @@
|
||||
"colorette": "1.4.0",
|
||||
"https-proxy-agent": "7.0.6",
|
||||
"js-levenshtein": "1.1.6",
|
||||
"js-yaml": "4.1.1",
|
||||
"js-yaml": "4.3.1",
|
||||
"minimatch": "5.1.9",
|
||||
"pluralize": "8.0.0",
|
||||
"yaml-ast-parser": "0.0.43"
|
||||
@@ -1412,9 +1413,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@redocly/openapi-core/node_modules/brace-expansion": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
|
||||
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1422,10 +1423,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@redocly/openapi-core/node_modules/js-yaml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
|
||||
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodeca"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
@@ -1483,15 +1494,6 @@
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.23.3",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
|
||||
"integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@@ -1591,9 +1593,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1608,9 +1607,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1625,9 +1621,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1642,9 +1635,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1659,9 +1649,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1676,9 +1663,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1693,9 +1677,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1710,9 +1691,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1727,9 +1705,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1744,9 +1719,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1761,9 +1733,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1778,9 +1747,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1795,9 +1761,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1932,7 +1895,6 @@
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
@@ -1975,52 +1937,31 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@testing-library/react": {
|
||||
"version": "14.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.3.1.tgz",
|
||||
"integrity": "sha512-H99XjUhWQw0lTgyMN05W3xQG1Nh4lq574D8keFf1dDoNTJgp66VbJozRaczoF+wsiaPJNt/TcnfpLGufGxSrZQ==",
|
||||
"version": "16.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz",
|
||||
"integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@testing-library/dom": "^9.0.0",
|
||||
"@types/react-dom": "^18.0.0"
|
||||
"@babel/runtime": "^7.12.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0"
|
||||
"@testing-library/dom": "^10.0.0",
|
||||
"@types/react": "^18.0.0 || ^19.0.0",
|
||||
"@types/react-dom": "^18.0.0 || ^19.0.0",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/react/node_modules/@testing-library/dom": {
|
||||
"version": "9.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz",
|
||||
"integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/aria-query": "^5.0.1",
|
||||
"aria-query": "5.1.3",
|
||||
"chalk": "^4.1.0",
|
||||
"dom-accessibility-api": "^0.5.9",
|
||||
"lz-string": "^1.5.0",
|
||||
"pretty-format": "^27.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/react/node_modules/aria-query": {
|
||||
"version": "5.1.3",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz",
|
||||
"integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"deep-equal": "^2.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/user-event": {
|
||||
@@ -2441,16 +2382,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
||||
@@ -2979,9 +2920,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
|
||||
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3188,6 +3129,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -3451,39 +3405,6 @@
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deep-equal": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz",
|
||||
"integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"array-buffer-byte-length": "^1.0.0",
|
||||
"call-bind": "^1.0.5",
|
||||
"es-get-iterator": "^1.1.3",
|
||||
"get-intrinsic": "^1.2.2",
|
||||
"is-arguments": "^1.1.1",
|
||||
"is-array-buffer": "^3.0.2",
|
||||
"is-date-object": "^1.0.5",
|
||||
"is-regex": "^1.1.4",
|
||||
"is-shared-array-buffer": "^1.0.2",
|
||||
"isarray": "^2.0.5",
|
||||
"object-is": "^1.1.5",
|
||||
"object-keys": "^1.1.1",
|
||||
"object.assign": "^4.1.4",
|
||||
"regexp.prototype.flags": "^1.5.1",
|
||||
"side-channel": "^1.0.4",
|
||||
"which-boxed-primitive": "^1.0.2",
|
||||
"which-collection": "^1.0.1",
|
||||
"which-typed-array": "^1.1.13"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
@@ -3687,27 +3608,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-get-iterator": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz",
|
||||
"integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind": "^1.0.2",
|
||||
"get-intrinsic": "^1.1.3",
|
||||
"has-symbols": "^1.0.3",
|
||||
"is-arguments": "^1.1.1",
|
||||
"is-map": "^2.0.2",
|
||||
"is-set": "^2.0.2",
|
||||
"is-string": "^1.0.7",
|
||||
"isarray": "^2.0.5",
|
||||
"stop-iteration-iterator": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/es-iterator-helpers": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.3.tgz",
|
||||
@@ -4650,23 +4550,6 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-arguments": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
|
||||
"integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"has-tostringtag": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-array-buffer": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
||||
@@ -5121,9 +5004,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
|
||||
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
|
||||
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -5414,9 +5297,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -5490,23 +5373,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/object-is": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
|
||||
"integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind": "^1.0.7",
|
||||
"define-properties": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/object-keys": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
|
||||
@@ -5842,9 +5708,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"version": "8.5.26",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -5862,7 +5728,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"nanoid": "^3.3.17",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -6092,35 +5958,41 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "6.30.4",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz",
|
||||
"integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==",
|
||||
"version": "7.18.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz",
|
||||
"integrity": "sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.3"
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8"
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "6.30.4",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz",
|
||||
"integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==",
|
||||
"version": "7.18.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.3.tgz",
|
||||
"integrity": "sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.3",
|
||||
"react-router": "6.30.4"
|
||||
"react-router": "7.18.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/react-style-singleton": {
|
||||
@@ -6447,6 +6319,12 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/set-function-length": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||
@@ -7080,9 +6958,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.27.2",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz",
|
||||
"integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==",
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
|
||||
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
@@ -27,13 +27,14 @@
|
||||
"react-dom": "^18.3.1",
|
||||
"react-feather": "^2.0.10",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-router-dom": "^6.30.4",
|
||||
"react-router-dom": "^7.18.3",
|
||||
"recharts": "^3.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@testing-library/dom": "10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^14.3.1",
|
||||
"@testing-library/react": "16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^18.3.31",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
|
||||
@@ -462,12 +462,17 @@ describe('CostView', () => {
|
||||
|
||||
it('paginates the complete thermal ledger and resets offset when its range or scope changes', async () => {
|
||||
const user = userEvent.setup()
|
||||
const lastRow = { ...THERMAL_ROW, period_start: '2026-06-22T12:00:00Z', quantity: '501' }
|
||||
const firstPageRows = Array.from({ length: 500 }, (_, index) => {
|
||||
const periodStart = new Date(Date.UTC(2026, 5, 22, 10, index * 15))
|
||||
const periodEnd = new Date(Date.UTC(2026, 5, 22, 10, (index + 1) * 15))
|
||||
return { ...THERMAL_ROW, period_start: periodStart.toISOString(), period_end: periodEnd.toISOString() }
|
||||
})
|
||||
const lastRow = { ...THERMAL_ROW, period_start: '2026-06-27T15:00:00Z', period_end: '2026-06-27T15:15:00Z', quantity: '501' }
|
||||
mockGet.mockImplementation((path: string, options?: { params?: { query?: { offset?: number } } }) => {
|
||||
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [ACTIVE_HEATING_METER, ACTIVE_HOT_WATER_METER], total: 2 } })
|
||||
if (path === '/api/energy/meter-costs') {
|
||||
const offset = options?.params?.query?.offset ?? 0
|
||||
return Promise.resolve({ data: offset === 0 ? { items: Array.from({ length: 500 }, () => THERMAL_ROW), total: 501 } : { items: [lastRow], total: 501 } })
|
||||
return Promise.resolve({ data: offset === 0 ? { items: firstPageRows, total: 501 } : { items: [lastRow], total: 501 } })
|
||||
}
|
||||
if (path === '/api/energy/meter-costs/summary') return Promise.resolve({ data: THERMAL_SUMMARY })
|
||||
if (path === '/api/energy/costs/summary') return Promise.resolve({ data: SUMMARY })
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import { screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { renderWithProviders } from '../test-utils'
|
||||
import { MeterManager } from './MeterManager'
|
||||
@@ -1197,20 +1197,24 @@ describe('MeterManager — lifecycle modal submissions', () => {
|
||||
|
||||
let input: HTMLInputElement
|
||||
let submit: HTMLElement
|
||||
let modal: HTMLElement
|
||||
if (entry === 'close') {
|
||||
await user.click(await screen.findByRole('button', { name: 'Close meter' }))
|
||||
input = screen.getByTestId(`close-meter-modal-${ACTIVE_METER.id}`).querySelector('input[type="datetime-local"]')!
|
||||
modal = screen.getByTestId(`close-meter-modal-${ACTIVE_METER.id}`)
|
||||
input = modal.querySelector('input[type="datetime-local"]')!
|
||||
submit = screen.getAllByRole('button', { name: 'Close meter' })[1]
|
||||
} else if (entry === 'unbind') {
|
||||
await user.click(await screen.findByRole('button', { name: 'Unbind' }))
|
||||
input = screen.getByTestId('unbind-modal-pending-source').querySelector('input[type="datetime-local"]')!
|
||||
modal = screen.getByTestId('unbind-modal-pending-source')
|
||||
input = modal.querySelector('input[type="datetime-local"]')!
|
||||
submit = screen.getAllByRole('button', { name: 'Unbind' })[1]
|
||||
} else {
|
||||
await user.click(await screen.findByRole('button', { name: entry === 'direct bind' ? 'Bind source' : entry === 'same-meter transfer' ? 'Transfer source' : 'Recover binding' }))
|
||||
await chooseChannel(user)
|
||||
input = entry === 'direct bind'
|
||||
? screen.getByTestId(`direct-bind-modal-${ACTIVE_METER.id}`).querySelector('input[type="datetime-local"]')!
|
||||
: screen.getByTestId('transfer-effective-at')
|
||||
modal = entry === 'direct bind'
|
||||
? screen.getByTestId(`direct-bind-modal-${ACTIVE_METER.id}`)
|
||||
: screen.getByTestId('transfer-modal-pending-source')
|
||||
input = entry === 'direct bind' ? modal.querySelector('input[type="datetime-local"]')! : screen.getByTestId('transfer-effective-at')
|
||||
const submitButtons = screen.getAllByRole('button', { name: entry === 'direct bind' ? 'Bind source' : 'Transfer binding' })
|
||||
submit = submitButtons[submitButtons.length - 1]
|
||||
}
|
||||
@@ -1224,6 +1228,7 @@ describe('MeterManager — lifecycle modal submissions', () => {
|
||||
await user.type(input, '{Enter}')
|
||||
expect(entry === 'unbind' ? mockPatch : mockPost).toHaveBeenCalledTimes(1)
|
||||
release()
|
||||
await waitForElementToBeRemoved(modal)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { act, render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { MantineProvider } from '@mantine/core'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
@@ -199,7 +199,7 @@ describe('HomePage', () => {
|
||||
altitude: null,
|
||||
}
|
||||
expect(capturedOnSelectLocation).toBeDefined()
|
||||
capturedOnSelectLocation!(record)
|
||||
act(() => capturedOnSelectLocation!(record))
|
||||
|
||||
// EditLocationModal should appear
|
||||
await waitFor(() => screen.getByTestId('edit-location-modal'))
|
||||
@@ -216,7 +216,7 @@ describe('HomePage', () => {
|
||||
longitude: 116.41,
|
||||
}
|
||||
expect(capturedOnSelectPoo).toBeDefined()
|
||||
capturedOnSelectPoo!(record)
|
||||
act(() => capturedOnSelectPoo!(record))
|
||||
|
||||
await waitFor(() => screen.getByTestId('edit-poo-modal'))
|
||||
expect(screen.getByTestId('edit-poo-modal')).toBeTruthy()
|
||||
@@ -232,7 +232,7 @@ describe('HomePage', () => {
|
||||
longitude: 116.4,
|
||||
altitude: null,
|
||||
}
|
||||
capturedOnSelectLocation!(record)
|
||||
act(() => capturedOnSelectLocation!(record))
|
||||
await waitFor(() => screen.getByTestId('edit-location-modal'))
|
||||
|
||||
fireEvent.click(screen.getByTestId('edit-location-cancel'))
|
||||
@@ -248,7 +248,7 @@ describe('HomePage', () => {
|
||||
latitude: 39.91,
|
||||
longitude: 116.41,
|
||||
}
|
||||
capturedOnSelectPoo!(record)
|
||||
act(() => capturedOnSelectPoo!(record))
|
||||
await waitFor(() => screen.getByTestId('edit-poo-modal'))
|
||||
|
||||
fireEvent.click(screen.getByTestId('edit-poo-cancel'))
|
||||
|
||||
@@ -7,6 +7,28 @@
|
||||
* - ResizeObserver (Mantine uses it for responsive components)
|
||||
*/
|
||||
import '@testing-library/jest-dom'
|
||||
// Import RTL here so its automatic cleanup hook is registered before the
|
||||
// warning assertion below; later test imports reuse the same module instance.
|
||||
import { cleanup } from '@testing-library/react'
|
||||
import { afterEach, beforeEach } from 'vitest'
|
||||
import { assertNoReactTestWarnings, createReactWarningGuard } from './test-warning-guard'
|
||||
|
||||
const originalConsoleError = console.error
|
||||
const reactTestWarnings: unknown[][] = []
|
||||
|
||||
console.error = createReactWarningGuard(originalConsoleError, reactTestWarnings)
|
||||
|
||||
beforeEach(() => {
|
||||
reactTestWarnings.length = 0
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Run teardown before checking the guard. RTL's automatic cleanup is also
|
||||
// registered, but cleanup is idempotent and this ordering keeps a guard
|
||||
// failure from preventing a later test from starting with stale portals.
|
||||
cleanup()
|
||||
assertNoReactTestWarnings(reactTestWarnings)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// window.matchMedia polyfill (jsdom does not implement this)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { screen } from '@testing-library/react'
|
||||
import { useMantineEnv } from '@mantine/core'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { renderWithProviders } from './test-utils'
|
||||
|
||||
function MantineEnvironmentProbe() {
|
||||
return <output>{useMantineEnv()}</output>
|
||||
}
|
||||
|
||||
describe('renderWithProviders', () => {
|
||||
it('uses Mantine test environment', () => {
|
||||
renderWithProviders(<MantineEnvironmentProbe />)
|
||||
|
||||
expect(screen.getByText('test')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -51,7 +51,7 @@ export function renderWithProviders(ui: ReactNode, options: RenderOptions = {})
|
||||
|
||||
function Wrapper() {
|
||||
return (
|
||||
<MantineProvider>
|
||||
<MantineProvider env="test">
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={entries}>
|
||||
<Routes>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
assertNoReactTestWarnings,
|
||||
createReactWarningGuard,
|
||||
formatReactTestWarnings,
|
||||
isReactTestWarning,
|
||||
} from './test-warning-guard'
|
||||
|
||||
describe('React test warning guard', () => {
|
||||
it('recognizes React act and duplicate-key warnings only', () => {
|
||||
expect(isReactTestWarning(['Warning: An update to Example inside a test was not wrapped in act(...).'])).toBe(true)
|
||||
expect(isReactTestWarning(['Warning: Each child in a list should have a unique "key" prop.'])).toBe(true)
|
||||
expect(isReactTestWarning(['Warning: Encountered two children with the same key, `duplicate`.'])).toBe(true)
|
||||
expect(isReactTestWarning(['network request failed'])).toBe(false)
|
||||
})
|
||||
|
||||
it('collects target warnings for one compact diagnostic and forwards unrelated errors', () => {
|
||||
const originalConsoleError = vi.fn()
|
||||
const warnings: unknown[][] = []
|
||||
const guardedConsoleError = createReactWarningGuard(originalConsoleError, warnings)
|
||||
|
||||
guardedConsoleError('Warning: An update to Example inside a test was not wrapped in act(...)')
|
||||
guardedConsoleError('Warning: An update to Other inside a test was not wrapped in act(...)')
|
||||
guardedConsoleError('Warning: Encountered two children with the same key, `duplicate`.')
|
||||
guardedConsoleError('network request failed', { status: 500 })
|
||||
|
||||
expect(originalConsoleError).toHaveBeenCalledTimes(1)
|
||||
expect(originalConsoleError).toHaveBeenCalledWith('network request failed', { status: 500 })
|
||||
expect(formatReactTestWarnings(warnings)).toBe(
|
||||
'React act warning: 2, React duplicate-key warning: 1 (Example, Other)',
|
||||
)
|
||||
expect(() => assertNoReactTestWarnings(warnings)).toThrow(
|
||||
'React test warning guard: React act warning: 2, React duplicate-key warning: 1 (Example, Other).',
|
||||
)
|
||||
})
|
||||
|
||||
it('passes only when no target warnings were captured', () => {
|
||||
expect(() => assertNoReactTestWarnings([])).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Fail tests that emit React warnings which otherwise only reach console.error.
|
||||
*
|
||||
* The guard intentionally forwards every unrelated console error unchanged.
|
||||
* It collects only the two warnings that make React test results unreliable and
|
||||
* reports one compact error per test instead of flooding CI logs with stacks.
|
||||
*/
|
||||
export function isReactTestWarning(args: unknown[]): boolean {
|
||||
const message = args.map((arg) => String(arg)).join(' ')
|
||||
return (
|
||||
message.includes('not wrapped in act(...)') ||
|
||||
message.includes('Each child in a list should have a unique "key"') ||
|
||||
message.includes('Encountered two children with the same key')
|
||||
)
|
||||
}
|
||||
|
||||
export function createReactWarningGuard(
|
||||
originalConsoleError: (...args: unknown[]) => void,
|
||||
warnings: unknown[][],
|
||||
): (...args: unknown[]) => void {
|
||||
return (...args: unknown[]) => {
|
||||
if (isReactTestWarning(args)) {
|
||||
warnings.push(args)
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
}
|
||||
}
|
||||
|
||||
export function formatReactTestWarnings(warnings: unknown[][]): string {
|
||||
const kinds = new Map<string, number>()
|
||||
for (const args of warnings) {
|
||||
const kind = args.map((arg) => String(arg)).join(' ').includes('not wrapped in act(...)')
|
||||
? 'React act warning'
|
||||
: 'React duplicate-key warning'
|
||||
kinds.set(kind, (kinds.get(kind) ?? 0) + 1)
|
||||
}
|
||||
const summaries = [...kinds.entries()].map(([kind, count]) => `${kind}: ${count}`).join(', ')
|
||||
const components = [...new Set(warnings
|
||||
.map((args) => {
|
||||
const message = String(args[0])
|
||||
return message.includes('An update to %s inside a test') ? String(args[1]) : message.match(/An update to (.+?) inside a test/)?.[1]
|
||||
})
|
||||
.filter((component): component is string => Boolean(component)))].slice(0, 3)
|
||||
return components.length > 0 ? `${summaries} (${components.join(', ')})` : summaries
|
||||
}
|
||||
|
||||
export function assertNoReactTestWarnings(warnings: unknown[][]): void {
|
||||
if (warnings.length > 0) {
|
||||
throw new Error(
|
||||
`React test warning guard: ${formatReactTestWarnings(warnings)}. ` +
|
||||
'Await the observable update, close/removal, or query settlement that caused it.',
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,13 @@ export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
// Keep test output identical in interactive, agent, and CI environments.
|
||||
// Vitest otherwise selects its minimal agent reporter when CODEX_CI is set,
|
||||
// which hides console output from passing tests.
|
||||
reporters: ['default'],
|
||||
// Mantine-heavy UI tests allocate substantial jsdom resources. Keep the
|
||||
// worker pool bounded so concurrent CI jobs remain reliable.
|
||||
maxWorkers: 2,
|
||||
setupFiles: ['./src/test-setup.ts'],
|
||||
env: {
|
||||
// Lock the test timezone to UTC so that date-formatting assertions
|
||||
|
||||
@@ -3002,6 +3002,11 @@ def test_m8_thermal_today_summary_ends_at_frozen_now(energy_db) -> None:
|
||||
"heating": Decimal("0"), "hot_water_heating": Decimal("1.25"),
|
||||
"hot_water": Decimal("2.75"), "hot_water_tax": Decimal("9.99"),
|
||||
},
|
||||
"fixed_breakdown": {
|
||||
"heating_network": Decimal("0"), "metering": Decimal("0"),
|
||||
"delivery_set": Decimal("0"), "hot_water_network": Decimal("0"),
|
||||
"other": Decimal("0"),
|
||||
},
|
||||
}
|
||||
|
||||
def summarize_spy(_session: Session, start: datetime, end: datetime, *, now: datetime) -> dict:
|
||||
@@ -3023,5 +3028,53 @@ def test_m8_thermal_today_summary_ends_at_frozen_now(energy_db) -> None:
|
||||
assert entity.value_getter(session) == Decimal("0")
|
||||
hot_water_total = next(item.entity for item in build_catalog(session)
|
||||
if item.entity.key.endswith(".hot_water_total_today"))
|
||||
assert hot_water_total.value_getter(session) == Decimal("4.00")
|
||||
assert hot_water_total.value_getter(session) == Decimal("13.99")
|
||||
assert captured["end"] == now
|
||||
|
||||
|
||||
@pytest.mark.parametrize("suffix", ("total", "today"))
|
||||
def test_m8_thermal_totals_allocate_standing_costs_and_match_all_in(energy_db, suffix: str) -> None:
|
||||
"""Heating and hot water each receive their assigned costs, without overlap."""
|
||||
from app.integrations.expose import build_catalog
|
||||
from app.services import timezone as tz
|
||||
|
||||
now = datetime(2026, 1, 15, 10, tzinfo=timezone.utc)
|
||||
summary = {
|
||||
"period_count": 2,
|
||||
"fixed_cost": Decimal("35"),
|
||||
"total_cost": Decimal("54"),
|
||||
"breakdown": {
|
||||
"heating": Decimal("10"), "hot_water_heating": Decimal("2"),
|
||||
"hot_water": Decimal("3"), "hot_water_tax": Decimal("4"),
|
||||
},
|
||||
"fixed_breakdown": {
|
||||
"heating_network": Decimal("5"), "metering": Decimal("6"),
|
||||
"delivery_set": Decimal("7"), "hot_water_network": Decimal("9"),
|
||||
"other": Decimal("8"),
|
||||
},
|
||||
}
|
||||
|
||||
with Session(energy_db) as session:
|
||||
_make_thermal_source_and_meter(session, "heating", now)
|
||||
_make_thermal_source_and_meter(session, "hot_water", now)
|
||||
session.commit()
|
||||
with (
|
||||
patch("app.integrations.expose._utc_now", return_value=now),
|
||||
patch.object(tz, "local_tz", return_value=ZoneInfo("Europe/Amsterdam")),
|
||||
patch.object(tz, "local_now", return_value=now.astimezone(ZoneInfo("Europe/Amsterdam"))),
|
||||
patch("app.services.meter_cost.summarize", return_value=summary),
|
||||
):
|
||||
entities = {item.entity.key.rsplit(".", 1)[-1]: item.entity
|
||||
for item in build_catalog(session) if item.entity.key.startswith("thermal_cost.")}
|
||||
heating = entities[f"heating_{suffix}"].value_getter(session)
|
||||
hot_water = entities[f"hot_water_total_{suffix}"].value_getter(session)
|
||||
|
||||
assert heating == Decimal("36") # 10 + heating_network + metering + delivery_set + other
|
||||
assert hot_water == Decimal("18") # 2 + 3 + 4 + hot_water_network
|
||||
assert entities[f"fixed_{suffix}"].value_getter(session) == Decimal("35")
|
||||
assert entities[f"all_in_{suffix}"].value_getter(session) == Decimal("54")
|
||||
assert heating + hot_water == entities[f"all_in_{suffix}"].value_getter(session)
|
||||
# Component entities deliberately remain variable-only.
|
||||
assert entities[f"hot_water_heating_{suffix}"].value_getter(session) == Decimal("2")
|
||||
assert entities[f"water_{suffix}"].value_getter(session) == Decimal("3")
|
||||
assert entities[f"water_tax_{suffix}"].value_getter(session) == Decimal("4")
|
||||
|
||||
Reference in New Issue
Block a user