mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Release v1.61.0-beta.4 candidate
This commit is contained in:
@@ -8,28 +8,77 @@ on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch: {}
|
||||
permissions: {}
|
||||
workflow_call:
|
||||
inputs:
|
||||
reusable:
|
||||
required: true
|
||||
type: boolean
|
||||
tag:
|
||||
required: true
|
||||
type: string
|
||||
release_name:
|
||||
required: true
|
||||
type: string
|
||||
url:
|
||||
required: true
|
||||
type: string
|
||||
prerelease:
|
||||
required: true
|
||||
type: boolean
|
||||
ref:
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
TELEGRAM_BOT_TOKEN:
|
||||
required: true
|
||||
TELEGRAM_CHAT_ID:
|
||||
required: true
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
telegram:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out release notes for a reusable call
|
||||
if: ${{ inputs.reusable == true }}
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
- name: Send to Telegram
|
||||
env:
|
||||
TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
CHAT: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||
TAG: ${{ github.event.release.tag_name }}
|
||||
NAME: ${{ github.event.release.name }}
|
||||
URL: ${{ github.event.release.html_url }}
|
||||
PRE: ${{ github.event.release.prerelease }}
|
||||
CALLED: ${{ inputs.reusable }}
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
INPUT_NAME: ${{ inputs.release_name }}
|
||||
INPUT_URL: ${{ inputs.url }}
|
||||
INPUT_PRE: ${{ inputs.prerelease }}
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
||||
RELEASE_NAME: ${{ github.event.release.name }}
|
||||
RELEASE_URL: ${{ github.event.release.html_url }}
|
||||
RELEASE_PRE: ${{ github.event.release.prerelease }}
|
||||
# The body goes through env, never through shell interpolation —
|
||||
# release notes are arbitrary text.
|
||||
BODY: ${{ github.event.release.body }}
|
||||
RELEASE_BODY: ${{ github.event.release.body }}
|
||||
EVENT: ${{ github.event_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$EVENT" = "workflow_dispatch" ]; then
|
||||
if [ "$EVENT" = "workflow_dispatch" ] && [ "$CALLED" != "true" ]; then
|
||||
TEXT="✅ Тест: оповещения о релизах houseplan-card подключены."
|
||||
else
|
||||
if [ "$CALLED" = "true" ]; then
|
||||
TAG=$INPUT_TAG
|
||||
NAME=$INPUT_NAME
|
||||
URL=$INPUT_URL
|
||||
PRE=$INPUT_PRE
|
||||
BODY=$(cat docs/RELEASE-NOTES.md)
|
||||
else
|
||||
TAG=$RELEASE_TAG
|
||||
NAME=$RELEASE_NAME
|
||||
URL=$RELEASE_URL
|
||||
PRE=$RELEASE_PRE
|
||||
BODY=$RELEASE_BODY
|
||||
fi
|
||||
KIND=$([ "$PRE" = "true" ] && echo "🧪 Пре-релиз" || echo "🏠 Релиз")
|
||||
SUMMARY=$(printf '%s' "$BODY" | head -c 2500)
|
||||
TEXT=$(printf '%s houseplan-card %s — %s\n\n%s\n\n%s' \
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
name: Publish prerelease
|
||||
run-name: Publish ${{ inputs.tag }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Exact prerelease tag, for example v1.61.0-beta.4"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
|
||||
concurrency:
|
||||
group: publish-prerelease-${{ inputs.tag }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
sha: ${{ steps.candidate.outputs.sha }}
|
||||
tag: ${{ steps.candidate.outputs.tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
- name: Pin the current dev candidate
|
||||
id: candidate
|
||||
env:
|
||||
TAG: ${{ inputs.tag }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$REF_NAME" = "dev" || {
|
||||
echo "::error::Prereleases must be dispatched from the dev branch, got $REF_NAME"
|
||||
exit 1
|
||||
}
|
||||
SHA=$(git rev-parse HEAD)
|
||||
git fetch origin dev
|
||||
test "$(git rev-parse origin/dev)" = "$SHA" || {
|
||||
echo "::error::The dispatched SHA is no longer the origin/dev tip"
|
||||
exit 1
|
||||
}
|
||||
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
- name: Verify version, changelogs and bilingual release notes
|
||||
env:
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: node scripts/release-contract.mjs "$TAG" --repo="$GITHUB_REPOSITORY"
|
||||
- name: Require green Validate for this exact SHA
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
SHA: ${{ steps.candidate.outputs.sha }}
|
||||
run: node scripts/release-gate.mjs "$SHA"
|
||||
|
||||
publish:
|
||||
needs: gate
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
url: ${{ steps.verify.outputs.url }}
|
||||
newly_published: ${{ steps.release.outputs.newly_published }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.gate.outputs.sha }}
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
- name: Build and verify both release assets before publication
|
||||
env:
|
||||
TAG: ${{ needs.gate.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm ci
|
||||
npm run build
|
||||
cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js
|
||||
cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js
|
||||
VERSION=${TAG#v}
|
||||
grep -Fq "$VERSION" dist/houseplan-card.js
|
||||
(cd custom_components/houseplan && zip -qr ../../houseplan.zip .)
|
||||
unzip -l houseplan.zip | grep -q "manifest.json"
|
||||
ZIP_VERSION=$(unzip -p houseplan.zip manifest.json | node -e \
|
||||
"let s='';process.stdin.on('data',d=>s+=d).on('end',()=>process.stdout.write(JSON.parse(s).version))")
|
||||
test "$ZIP_VERSION" = "$VERSION" || {
|
||||
echo "::error::houseplan.zip manifest version $ZIP_VERSION != $VERSION"
|
||||
exit 1
|
||||
}
|
||||
test -s dist/houseplan-card.js
|
||||
test -s houseplan.zip
|
||||
- name: Create or verify the annotated tag
|
||||
env:
|
||||
TAG: ${{ needs.gate.outputs.tag }}
|
||||
SHA: ${{ needs.gate.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
REMOTE=$(git ls-remote --tags origin "refs/tags/$TAG" "refs/tags/$TAG^{}")
|
||||
if [ -n "$REMOTE" ]; then
|
||||
PEELED=$(printf '%s\n' "$REMOTE" | awk -v ref="refs/tags/$TAG^{}" '$2 == ref {print $1}')
|
||||
test -n "$PEELED" || {
|
||||
echo "::error::Existing remote tag $TAG is not annotated"
|
||||
exit 1
|
||||
}
|
||||
test "$PEELED" = "$SHA" || {
|
||||
echo "::error::Existing tag $TAG points to $PEELED, expected $SHA"
|
||||
exit 1
|
||||
}
|
||||
git fetch --force origin "refs/tags/$TAG:refs/tags/$TAG"
|
||||
test "$(git cat-file -t "refs/tags/$TAG")" = "tag"
|
||||
else
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git tag -a "$TAG" "$SHA" -m "$TAG"
|
||||
git push origin "$TAG"
|
||||
fi
|
||||
- name: Stage, verify and publish the prerelease
|
||||
id: release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.gate.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --verify-tag \
|
||||
--draft --prerelease --title "$TAG" --notes-file docs/RELEASE-NOTES.md
|
||||
fi
|
||||
WAS_DRAFT=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft)
|
||||
echo "newly_published=$WAS_DRAFT" >> "$GITHUB_OUTPUT"
|
||||
gh release upload "$TAG" dist/houseplan-card.js houseplan.zip \
|
||||
--repo "$GITHUB_REPOSITORY" --clobber
|
||||
RELEASE_JSON=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \
|
||||
--json tagName,isDraft,isPrerelease,assets,url)
|
||||
export RELEASE_JSON TAG
|
||||
node <<'NODE'
|
||||
const release = JSON.parse(process.env.RELEASE_JSON);
|
||||
if (release.tagName !== process.env.TAG) throw new Error('release tag mismatch');
|
||||
const assets = new Map(release.assets.map((asset) => [asset.name, asset]));
|
||||
for (const name of ['houseplan-card.js', 'houseplan.zip']) {
|
||||
if (!(Number(assets.get(name)?.size) > 0)) throw new Error(`${name} is missing or empty`);
|
||||
}
|
||||
NODE
|
||||
gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false --prerelease \
|
||||
--title "$TAG" --notes-file docs/RELEASE-NOTES.md
|
||||
- name: Verify the public release and assets
|
||||
id: verify
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.gate.outputs.tag }}
|
||||
SHA: ${{ needs.gate.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RELEASE_JSON=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \
|
||||
--json tagName,isDraft,isPrerelease,assets,url)
|
||||
export RELEASE_JSON TAG
|
||||
node <<'NODE'
|
||||
const release = JSON.parse(process.env.RELEASE_JSON);
|
||||
if (release.tagName !== process.env.TAG || release.isDraft || !release.isPrerelease)
|
||||
throw new Error('release is not a public prerelease for the requested tag');
|
||||
const assets = new Map(release.assets.map((asset) => [asset.name, asset]));
|
||||
for (const name of ['houseplan-card.js', 'houseplan.zip']) {
|
||||
if (!(Number(assets.get(name)?.size) > 0)) throw new Error(`${name} is missing or empty`);
|
||||
}
|
||||
NODE
|
||||
test "$(git rev-list -n 1 "$TAG")" = "$SHA"
|
||||
URL=$(node -p "JSON.parse(process.env.RELEASE_JSON).url")
|
||||
echo "url=$URL" >> "$GITHUB_OUTPUT"
|
||||
printf '### Published %s\n\n- exact SHA: `%s`\n- [GitHub prerelease](%s)\n- assets: `houseplan-card.js`, `houseplan.zip`\n' \
|
||||
"$TAG" "$SHA" "$URL" >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Verify HACS prerelease discovery order
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
EXPECTED_TAG: ${{ needs.gate.outputs.tag }}
|
||||
with:
|
||||
script: |
|
||||
const releases = await github.paginate(github.rest.repos.listReleases, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
per_page: 100,
|
||||
});
|
||||
const first = releases.find((release) => release.prerelease && !release.draft);
|
||||
if (first?.tag_name !== process.env.EXPECTED_TAG) {
|
||||
core.setFailed(
|
||||
`HACS prerelease discovery is stale: ${first?.tag_name ?? 'none'} precedes ` +
|
||||
process.env.EXPECTED_TAG,
|
||||
);
|
||||
}
|
||||
|
||||
announce:
|
||||
needs: [gate, publish]
|
||||
if: ${{ needs.publish.outputs.newly_published == 'true' }}
|
||||
uses: ./.github/workflows/announce.yml
|
||||
with:
|
||||
reusable: true
|
||||
tag: ${{ needs.gate.outputs.tag }}
|
||||
release_name: ${{ needs.gate.outputs.tag }}
|
||||
url: ${{ needs.publish.outputs.url }}
|
||||
prerelease: true
|
||||
ref: ${{ needs.gate.outputs.tag }}
|
||||
secrets: inherit
|
||||
@@ -161,6 +161,20 @@ jobs:
|
||||
reason="commit is no longer an ancestor of the pushed revision"
|
||||
fi
|
||||
|
||||
if [ "$usable" != true ]; then
|
||||
# For a rewritten push, the direct parent is the closest honest
|
||||
# comparison and normally carries the same benchmark generation.
|
||||
# Prefer it over an arbitrarily old release tag. PR bases keep the
|
||||
# tag fallback because HEAD^ is not necessarily their base.
|
||||
parent_sha="$(git rev-parse HEAD^ 2>/dev/null || true)"
|
||||
if [ "$source" = "push before" ] && [ -n "$parent_sha" ] && [ "$parent_sha" != "$(git rev-parse HEAD)" ]; then
|
||||
sha="$parent_sha"
|
||||
source="candidate parent (unusable push-before fallback)"
|
||||
echo "::warning::Comparison SHA $requested_sha is unusable ($reason); using candidate parent $sha."
|
||||
usable=true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$usable" != true ]; then
|
||||
fallback_tag=""
|
||||
fallback_sha=""
|
||||
@@ -183,7 +197,7 @@ jobs:
|
||||
fi
|
||||
sha="$fallback_sha"
|
||||
source="release tag $fallback_tag"
|
||||
echo "::notice::Comparison SHA $requested_sha is unusable ($reason); using $fallback_tag ($sha)."
|
||||
echo "::warning::Comparison SHA $requested_sha is unusable ($reason); using $fallback_tag ($sha)."
|
||||
fi
|
||||
|
||||
# The first main promotion after HP-PERF-01 can legitimately point
|
||||
@@ -193,12 +207,11 @@ jobs:
|
||||
# to the candidate's direct parent, which is the last dev revision
|
||||
# included in the promotion and already has the current harness.
|
||||
if ! git cat-file -e "${sha}:demo/bundle-freshness.mjs" 2>/dev/null; then
|
||||
echo "comparison $sha predates HP-PERF-01; using HEAD^" >&2
|
||||
echo "::warning::Comparison $sha predates HP-PERF-01; using candidate parent HEAD^."
|
||||
sha="$(git rev-parse HEAD^)"
|
||||
source="candidate parent (HP-PERF-01 compatibility)"
|
||||
fi
|
||||
echo "sha=$sha" >> "$GITHUB_OUTPUT"
|
||||
echo "source=$source" >> "$GITHUB_OUTPUT"
|
||||
- name: Check out base SHA
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
|
||||
@@ -45,7 +45,7 @@ PLAN_ORPHAN_TTL_S = 3600
|
||||
SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.61.0-beta.3"
|
||||
VERSION = "1.61.0-beta.4"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.61.0-beta.3"
|
||||
"version": "1.61.0-beta.4"
|
||||
}
|
||||
|
||||
@@ -64,7 +64,9 @@ def _finite(value):
|
||||
|
||||
# Persisted colours deliberately use one small, browser-independent format.
|
||||
# Keep this exact contract in sync with src/color.ts.
|
||||
_COLOR = vol.Match(r"^#[0-9a-fA-F]{6}$")
|
||||
# `^...$` accepts a trailing newline in Python. Persisted CSS tokens must
|
||||
# match the whole string exactly, in parity with the frontend validator.
|
||||
_COLOR = vol.Match(r"\A#[0-9a-fA-F]{6}\Z")
|
||||
_CUSTOM_FILL = vol.Schema(
|
||||
{
|
||||
vol.Required("c"): _COLOR,
|
||||
@@ -267,7 +269,7 @@ SPACE_DISPLAY_SCHEMA = vol.Schema(
|
||||
vol.Optional("bg_color"): _COLOR,
|
||||
vol.Optional("room_opacity"): vol.All(vol.Coerce(float), vol.Range(min=0, max=1)),
|
||||
vol.Optional("fill_mode"): vol.In(["none", "lqi", "light", "temp", "custom", "glow"]),
|
||||
vol.Optional("custom_fill"): _CUSTOM_FILL,
|
||||
vol.Optional("custom_fill"): vol.Any(None, _CUSTOM_FILL),
|
||||
vol.Optional("glow_enabled"): bool,
|
||||
vol.Optional("temp_min"): vol.Coerce(float),
|
||||
vol.Optional("temp_max"): vol.Coerce(float),
|
||||
|
||||
+5
-17
@@ -7,7 +7,7 @@ import { launch } from './serve.mjs';
|
||||
import { assertFreshDemoBundle } from './bundle-freshness.mjs';
|
||||
import { summarizeLongTasks, summarizeTimings } from './performance/evaluate.mjs';
|
||||
import { makeLargeHouseFixture } from './fixtures/large-house.mjs';
|
||||
import { GLOW_CARD_CONTRACT } from './performance/card-contract.mjs';
|
||||
import { assertCardContract, GLOW_CARD_CONTRACT } from './performance/card-contract.mjs';
|
||||
|
||||
const valueArg = (name) => process.argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
|
||||
const profile = valueArg('profile') || 'large-light-blend-v1';
|
||||
@@ -85,6 +85,9 @@ const { page, browser } = await launch(
|
||||
['--enable-precise-memory-info', '--js-flags=--expose-gc'],
|
||||
{}, resolve(targetRoot, 'demo/srv'),
|
||||
);
|
||||
await page.addScriptTag({
|
||||
content: `window.__hpAssertCardContract = ${assertCardContract.toString()};`,
|
||||
});
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 4 });
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
@@ -195,21 +198,6 @@ try {
|
||||
openingTunnel: card._openingTunnelCache ? 1 : 0,
|
||||
openingWallIndex: card._openingWallIndexCache ? 1 : 0,
|
||||
});
|
||||
const assertCardContract = (card) => {
|
||||
const missingMethods = cardContract.methods
|
||||
.filter((name) => typeof card[name] !== 'function')
|
||||
.map((name) => `${name}()`);
|
||||
const missingFields = cardContract.fields.filter((name) => !(name in card));
|
||||
const missing = [...missingMethods, ...missingFields];
|
||||
if (missing.length) {
|
||||
throw new Error(
|
||||
`${cardContract.label} harness is incompatible with this houseplan-card bundle; `
|
||||
+ `missing private API: ${missing.join(', ')}. `
|
||||
+ 'Update the explicit candidate/base compatibility contract before profiling.',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
window.__card?.remove?.();
|
||||
localStorage.clear();
|
||||
const host = document.getElementById('host');
|
||||
@@ -218,7 +206,7 @@ try {
|
||||
card.setConfig({ type: 'custom:houseplan-card', title: `Glow ${profile}`, icon_size: 2.4 });
|
||||
host.replaceChildren(card);
|
||||
card.hass = hassFor(statesFor(1));
|
||||
assertCardContract(card);
|
||||
window.__hpAssertCardContract(card, cardContract);
|
||||
await until(() => card._loadOk && card._devices?.length === fixture.deviceCount);
|
||||
if ('_glowScreenBlend' in card) {
|
||||
const probeDeadline = performance.now() + 2500;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { launch } from './serve.mjs';
|
||||
import { LARGE_HOUSE_COUNTS, makeLargeHouseFixture } from './fixtures/large-house.mjs';
|
||||
import { assertFreshDemoBundle } from './bundle-freshness.mjs';
|
||||
import { summarizeLongTasks, summarizeTimings } from './performance/evaluate.mjs';
|
||||
import { LARGE_HOUSE_CARD_CONTRACT } from './performance/card-contract.mjs';
|
||||
import { assertCardContract, LARGE_HOUSE_CARD_CONTRACT } from './performance/card-contract.mjs';
|
||||
|
||||
const valueArg = (name) => process.argv.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
|
||||
const samples = Math.max(1, Math.min(20, Number(valueArg('samples')) || 7));
|
||||
@@ -27,6 +27,9 @@ await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.addStyleTag({
|
||||
content: '*,*::before,*::after{animation-duration:0s!important;transition-duration:0s!important;caret-color:transparent!important}',
|
||||
});
|
||||
await page.addScriptTag({
|
||||
content: `window.__hpAssertCardContract = ${assertCardContract.toString()};`,
|
||||
});
|
||||
const chromium = await browser.version();
|
||||
let buildFingerprint;
|
||||
try {
|
||||
@@ -96,20 +99,6 @@ try {
|
||||
openingTunnel: card._openingTunnelCache ? 1 : 0,
|
||||
openingWallIndex: card._openingWallIndexCache ? 1 : 0,
|
||||
});
|
||||
const assertCardContract = (card) => {
|
||||
const missingMethods = cardContract.methods
|
||||
.filter((name) => typeof card[name] !== 'function')
|
||||
.map((name) => `${name}()`);
|
||||
const missingFields = cardContract.fields.filter((name) => !(name in card));
|
||||
const missing = [...missingMethods, ...missingFields];
|
||||
if (missing.length) {
|
||||
throw new Error(
|
||||
`${cardContract.label} performance harness is incompatible with this houseplan-card bundle; `
|
||||
+ `missing private API: ${missing.join(', ')}. `
|
||||
+ 'Update the explicit candidate/base compatibility contract before profiling.',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
window.__card?.remove?.();
|
||||
localStorage.clear();
|
||||
@@ -154,7 +143,7 @@ try {
|
||||
const loadStarted = performance.now();
|
||||
host.replaceChildren(card);
|
||||
card.hass = hassFor(fixture.states);
|
||||
assertCardContract(card);
|
||||
window.__hpAssertCardContract(card, cardContract);
|
||||
await until(() => card._loadOk && card._model?.length === fixture.counts.floors);
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
|
||||
@@ -46,10 +46,12 @@ fails closed.
|
||||
Before the base checkout, CI fetches the complete commit graph and verifies the
|
||||
requested comparison revision. A push `github.event.before` must both exist and
|
||||
remain an ancestor of the candidate; this catches the unreachable SHA left by a
|
||||
force-push. An unusable revision falls back to the newest semver release tag
|
||||
reachable from the candidate, excluding a tag on the candidate itself. If no
|
||||
such release exists, the job fails closed instead of comparing against an
|
||||
arbitrary commit.
|
||||
force-push. For that rewritten-push case, CI warns and prefers the candidate's
|
||||
direct parent as the closest honest comparison. An unusable PR base or another
|
||||
case without a safe parent falls back, with a warning, to the newest semver
|
||||
release tag reachable from the candidate and excludes a tag on the candidate
|
||||
itself. If no safe comparison exists, the job fails closed instead of comparing
|
||||
against an arbitrary commit.
|
||||
|
||||
## Private card contract
|
||||
|
||||
@@ -57,8 +59,17 @@ The candidate benchmark runner is also executed against the base bundle, so
|
||||
every private `houseplan-card` field or method it reads is an explicit API of
|
||||
the performance harness. `card-contract.mjs` lists that surface for the
|
||||
large-house and Glow profiles. Each runner verifies it immediately after card
|
||||
creation and fails with the exact missing names before waiting for readiness or
|
||||
recording timings. Missing caches must never be converted to plausible zeroes.
|
||||
creation and fails with the exact missing names or invalid runtime types before
|
||||
waiting for readiness or recording timings. Required caches must be real
|
||||
`Map` instances and must never be converted from missing/invalid values to
|
||||
plausible zeroes.
|
||||
|
||||
`fields` are required in every supported comparison base. `optionalFields` are
|
||||
newer members whose absence has an explicit safe fallback in the runner; if an
|
||||
optional member exists, its declared `fieldTypes` contract still applies. Add a
|
||||
new safely degradable field to `optionalFields` until every supported base has
|
||||
it, then promote it to `fields`. A member without a truthful fallback must be
|
||||
introduced through a compatibility revision before the benchmark consumes it.
|
||||
|
||||
Rename a consumed private member in two revisions:
|
||||
|
||||
|
||||
@@ -37,6 +37,17 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
|
||||
'_settingsDialog',
|
||||
'_tool',
|
||||
]),
|
||||
optionalFields: Object.freeze([]),
|
||||
fieldTypes: Object.freeze({
|
||||
_booting: 'boolean',
|
||||
_cleanFloorCache: 'map',
|
||||
_devices: 'array',
|
||||
_glowClipCache: 'map',
|
||||
_gridPitch: 'number',
|
||||
_loadOk: 'boolean',
|
||||
_model: 'array',
|
||||
_tool: 'string',
|
||||
}),
|
||||
});
|
||||
|
||||
export const GLOW_CARD_CONTRACT = Object.freeze({
|
||||
@@ -45,7 +56,46 @@ export const GLOW_CARD_CONTRACT = Object.freeze({
|
||||
fields: Object.freeze([
|
||||
...CACHE_FIELDS,
|
||||
'_devices',
|
||||
'_glowScreenBlend',
|
||||
'_loadOk',
|
||||
]),
|
||||
// Additive blending was introduced after the first supported performance
|
||||
// bases. Its absence is safe: the runner keeps the historical normal blend.
|
||||
optionalFields: Object.freeze(['_glowScreenBlend']),
|
||||
fieldTypes: Object.freeze({
|
||||
_cleanFloorCache: 'map',
|
||||
_devices: 'array',
|
||||
_glowClipCache: 'map',
|
||||
_glowScreenBlend: 'boolean',
|
||||
_loadOk: 'boolean',
|
||||
}),
|
||||
});
|
||||
|
||||
/** Single fail-fast implementation injected into both browser runners. Keep
|
||||
* this function self-contained: runners serialize it with `toString()`. */
|
||||
export function assertCardContract(card, contract) {
|
||||
const matches = (value, expected) => {
|
||||
if (expected === 'array') return Array.isArray(value);
|
||||
if (expected === 'map') return value instanceof Map;
|
||||
return typeof value === expected;
|
||||
};
|
||||
const missingMethods = contract.methods
|
||||
.filter((name) => typeof card[name] !== 'function')
|
||||
.map((name) => `${name}()`);
|
||||
const missingFields = contract.fields
|
||||
.filter((name) => !(name in card) || card[name] === undefined);
|
||||
const invalidFields = [...contract.fields, ...(contract.optionalFields || [])]
|
||||
.filter((name) => name in card && contract.fieldTypes?.[name]
|
||||
&& !matches(card[name], contract.fieldTypes[name]))
|
||||
.map((name) => `${name}:${contract.fieldTypes[name]}`);
|
||||
const missing = [...missingMethods, ...missingFields];
|
||||
if (missing.length || invalidFields.length) {
|
||||
const details = [
|
||||
missing.length ? `missing private API: ${missing.join(', ')}` : '',
|
||||
invalidFields.length ? `invalid private API types: ${invalidFields.join(', ')}` : '',
|
||||
].filter(Boolean).join('; ');
|
||||
throw new Error(
|
||||
`${contract.label} harness is incompatible with this houseplan-card bundle; ${details}. `
|
||||
+ 'Update the explicit candidate/base compatibility contract before profiling.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,13 +95,42 @@ try {
|
||||
await staticCard.updateComplete;
|
||||
const staticBase = staticCard.renderRoot.querySelector('.glow-base-layer .glow-base');
|
||||
const staticPools = staticCard.renderRoot.querySelectorAll('.glow-pool, .glowlayer').length;
|
||||
const baseParity = !!fullBase && !!staticBase
|
||||
&& fullBase.getAttribute('fill') === staticBase.getAttribute('fill')
|
||||
&& fullBase.getAttribute('fill-opacity') === staticBase.getAttribute('fill-opacity');
|
||||
staticCard.remove();
|
||||
|
||||
// The fixture deliberately has Temperature mode without temperature
|
||||
// entities, which verifies the Glow fallback above. Now switch the same
|
||||
// mounted plan to a real data fill and verify that the base disappears
|
||||
// without changing the data colour/alpha or its layer order.
|
||||
const spaceCfg = card._serverCfg.spaces[0];
|
||||
spaceCfg.settings = {
|
||||
...(spaceCfg.settings || {}),
|
||||
fill_mode: 'custom', custom_fill: { c: '#486a8f', a: 0.42 },
|
||||
};
|
||||
card._cfgEpoch++;
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
const customRoom = card.renderRoot.querySelector('.room.filled');
|
||||
const customDataTunnel = card.renderRoot.querySelector('.opening-tunnels[data-layer="data"]');
|
||||
const customPoolFrame = card.renderRoot.querySelector('.glow-pools-frame');
|
||||
const customBase = card.renderRoot.querySelector('.glow-base-layer .glow-base');
|
||||
const customBaseTunnel = card.renderRoot.querySelector('.opening-tunnels[data-layer="glow-base"]');
|
||||
return {
|
||||
blend: poolGroup?.getAttribute('data-blend'),
|
||||
pools: card.renderRoot.querySelectorAll('.glow-pool').length,
|
||||
clippedPools, layerOrder: follows(dataTunnel, poolFrame),
|
||||
clippedPools,
|
||||
baseLayerOrder: follows(dataTunnel, fullBase)
|
||||
&& follows(fullBase, baseTunnel) && follows(baseTunnel, poolFrame),
|
||||
fullBase: !!fullBase, baseTunnel: !!baseTunnel,
|
||||
staticBase: !!staticBase, staticPools,
|
||||
staticBase: !!staticBase, staticPools, baseParity,
|
||||
customFill: customRoom?.style.getPropertyValue('--room-fill'),
|
||||
customOpacity: customRoom?.style.getPropertyValue('--room-fill-op'),
|
||||
customBase: !!customBase, customBaseTunnel: !!customBaseTunnel,
|
||||
customLayerOrder: follows(customRoom, customDataTunnel)
|
||||
&& follows(customDataTunnel, customPoolFrame),
|
||||
forward, reverse, fallback, sameDim,
|
||||
};
|
||||
}, fixture);
|
||||
@@ -123,9 +152,13 @@ try {
|
||||
throw new Error(`same-colour dim overlap did not brighten without clipping: ${singleDim} -> ${overlapDim}`);
|
||||
if (!close(pixel(out.forward, 3), [106, 123, 140, 255])) throw new Error('non-pool sector/background changed');
|
||||
if (out.clippedPools !== out.pools) throw new Error(`lost per-source clips: ${out.clippedPools}/${out.pools}`);
|
||||
if (!out.layerOrder) throw new Error('data/base/tunnel/pool layer order changed');
|
||||
if (out.fullBase || out.baseTunnel || out.staticBase || out.staticPools !== 0)
|
||||
throw new Error(`data fill was tinted by Glow base: full=${out.fullBase}, tunnel=${out.baseTunnel}, static=${out.staticBase}`);
|
||||
if (!out.baseLayerOrder) throw new Error('data/base/tunnel/pool layer order changed');
|
||||
if (!out.fullBase || !out.baseTunnel || !out.staticBase || out.staticPools !== 0 || !out.baseParity)
|
||||
throw new Error(`missing-data Glow fallback/parity failed: full=${out.fullBase}, tunnel=${out.baseTunnel}, static=${out.staticBase}, parity=${out.baseParity}`);
|
||||
if (out.customFill !== '#486a8f' || Number(out.customOpacity) !== 0.42)
|
||||
throw new Error(`custom data fill changed: ${out.customFill}/${out.customOpacity}`);
|
||||
if (out.customBase || out.customBaseTunnel || !out.customLayerOrder)
|
||||
throw new Error(`data fill was tinted/reordered by Glow base: full=${out.customBase}, tunnel=${out.customBaseTunnel}, order=${out.customLayerOrder}`);
|
||||
console.log(JSON.stringify({ ok: true, blend: out.blend, pools: out.pools, staticParity: true }));
|
||||
} finally {
|
||||
await browser.close();
|
||||
|
||||
@@ -84,6 +84,6 @@ checkAll(res, {
|
||||
"atticSquare": true,
|
||||
// New spaces keep the product default (Glow on), but store it independently
|
||||
// from the ordinary data fill.
|
||||
"atticSettings": {"show_borders": true, "show_names": true, "room_color": "#55606c", "room_opacity": 0.55, "fill_mode": "none", "glow_enabled": true, "temp_min": 20, "temp_max": 25, "show_lqi": true, "label_temp": false, "label_hum": false, "label_lqi": false, "label_light": false},
|
||||
"atticSettings": {"show_borders": true, "show_names": true, "room_color": "#55606c", "room_opacity": 0.55, "fill_mode": "custom", "custom_fill": {"c": "#607d8b", "a": 0}, "glow_enabled": true, "temp_min": 20, "temp_max": 25, "show_lqi": true, "label_temp": false, "label_hum": false, "label_lqi": false, "label_light": false},
|
||||
});
|
||||
await finish(browser, res);
|
||||
|
||||
@@ -21,8 +21,8 @@ const res = await page.evaluate(async () => {
|
||||
// диалог: поля границ видны только в режиме temp
|
||||
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
|
||||
out.dialogTempFields = sr().querySelectorAll('.tempin').length;
|
||||
c._spaceDialog = { ...c._spaceDialog, fillMode: 'none' }; await c.updateComplete;
|
||||
out.dialogHiddenWhenNone = sr().querySelectorAll('.tempin').length;
|
||||
c._spaceDialog = { ...c._spaceDialog, fillMode: 'custom' }; await c.updateComplete;
|
||||
out.dialogHiddenWhenCustom = sr().querySelectorAll('.tempin').length;
|
||||
c._spaceDialog = null;
|
||||
return out;
|
||||
});
|
||||
@@ -33,6 +33,6 @@ checkAll(res, {
|
||||
"hot": ["#ffd45c", "transparent", "transparent", "transparent"],
|
||||
"swapped": ["#66d17a", "transparent", "transparent", "transparent"],
|
||||
"dialogTempFields": 4, // масштаб + мин/макс + компас пространства (docs/SUN.md)
|
||||
"dialogHiddenWhenNone": 2, // мин/макс скрыты; масштаб и компас остаются
|
||||
"dialogHiddenWhenCustom": 2, // мин/макс скрыты; масштаб и компас остаются
|
||||
});
|
||||
await finish(browser, res);
|
||||
|
||||
+22
-1
@@ -29,6 +29,11 @@ const res = await page.evaluate(async () => {
|
||||
// диалог: радио заливки, компактные поля, ширина
|
||||
c._openSpaceDialog('edit', 'f1'); await c.updateComplete;
|
||||
out.fillRadios = sr().querySelectorAll('input[name="fillmode"]').length;
|
||||
out.fillLabels = [...sr().querySelectorAll('input[name="fillmode"]')]
|
||||
// Read the radio's own label only. The selected temperature mode also
|
||||
// renders two nested range controls inside this label.
|
||||
.map((input) => input.nextElementSibling?.textContent?.trim());
|
||||
out.fillHasNone = out.fillLabels.includes(c._t('fill.none'));
|
||||
out.glowToggle = [...sr().querySelectorAll('hp-dialog label.srcrow')].some((label) =>
|
||||
label.textContent.trim() === c._t('space.glow_enabled')
|
||||
&& !!label.querySelector('ha-switch,input[type="checkbox"]'));
|
||||
@@ -42,6 +47,17 @@ const res = await page.evaluate(async () => {
|
||||
// симулируем input с пустым значением через обработчик радио-строки — парсер должен сохранить старое
|
||||
const n = parseFloat('');
|
||||
out.nanGuard = !Number.isFinite(n) && c._spaceDialog.tempMax === before;
|
||||
// Space-level `none` remains readable, but the current dialog offers the
|
||||
// ordinary selectable custom colour in its place.
|
||||
c._spaceDialog = null;
|
||||
c._serverCfg = { ...c._serverCfg, spaces: c._serverCfg.spaces.map((s) => s.id !== 'f1' ? s : ({
|
||||
...s, settings: { ...(s.settings || {}), fill_mode: 'none', glow_enabled: true },
|
||||
})) };
|
||||
c._openSpaceDialog('edit', 'f1');
|
||||
await c.updateComplete;
|
||||
out.legacyNoneProjection = c._spaceDialog.fillMode;
|
||||
out.legacyNoneAlpha = c._spaceDialog.customFill?.a;
|
||||
out.legacyNoneGlowBases = sr().querySelectorAll('.glow-base-layer .glow-base').length;
|
||||
return out;
|
||||
});
|
||||
// артефакт для глазами: путь берём у ОС, а не хардкодим unix-овый — на Windows
|
||||
@@ -54,7 +70,12 @@ checkAll(res, {
|
||||
"filledClass": 1,
|
||||
"unfilled": 3,
|
||||
"tipTemp": 22.4,
|
||||
"fillRadios": 5,
|
||||
"fillRadios": 4,
|
||||
"fillLabels": ["Свой цвет", "По силе зигби-сигнала", "По освещению", "По температуре"],
|
||||
"fillHasNone": false,
|
||||
"legacyNoneProjection": "custom",
|
||||
"legacyNoneAlpha": 0,
|
||||
"legacyNoneGlowBases": 4,
|
||||
"tempInputs": 2,
|
||||
"dialogWidth": 500,
|
||||
});
|
||||
|
||||
+106
-106
File diff suppressed because one or more lines are too long
Vendored
+106
-106
File diff suppressed because one or more lines are too long
+21
-6
@@ -1,6 +1,6 @@
|
||||
# House Plan architecture
|
||||
|
||||
Updated: 2026-08-10 (v1.61.0-beta.2). The repository = a HACS integration (category **Integration**)
|
||||
Updated: 2026-08-10 (v1.61.0-beta.3). The repository = a HACS integration (category **Integration**)
|
||||
that contains both the backend (`custom_components/houseplan`) and the Lovelace card (`src/` → `dist/`).
|
||||
|
||||
## Layout
|
||||
@@ -21,6 +21,9 @@ houseplan-card/
|
||||
├─ dist/houseplan-card.js # build (rollup+terser), ~290 KB, plans embedded
|
||||
├─ demo/golden/ # deterministic HP-QA-01 matrix, capture/verify/accept
|
||||
├─ demo/performance/ # large-house budgets and same-runner comparison
|
||||
├─ scripts/release-*.mjs # exact-SHA publication contract and local orchestrator
|
||||
├─ .github/workflows/
|
||||
│ └─ publish-prerelease.yml # draft-first one-button prerelease publication
|
||||
├─ custom_components/houseplan/ # the HA integration
|
||||
│ ├─ __init__.py # setup: Store, WS commands, JS serving (add_extra_js_url)
|
||||
│ ├─ trails.py # server-side vacuum trail recorder (state-change driven)
|
||||
@@ -37,6 +40,15 @@ tooling compares it with the current `src/` tree before recording results, so a
|
||||
committed demo snapshot from an older source revision cannot produce a false
|
||||
baseline.
|
||||
|
||||
Prerelease publication has one fail-closed contract shared by the local command
|
||||
and the manual GitHub workflow. The tag version must match all six shipped
|
||||
version authorities, both changelogs need a dated section, and the canonical
|
||||
bilingual `docs/RELEASE-NOTES.md` must link to immutable tagged changelogs. A
|
||||
public release is assembled as a draft, receives and verifies
|
||||
`houseplan-card.js` plus `houseplan.zip`, and becomes visible only after the
|
||||
exact candidate SHA has a green Validate. Existing release-event workflows are
|
||||
kept as an independent recovery path; they enforce the same exact-SHA gate.
|
||||
|
||||
## Key decisions
|
||||
|
||||
1. **One repository — integration + card.** The integration serves the JS
|
||||
@@ -593,11 +605,14 @@ hash falls back to the default.
|
||||
token and projects to data fill `none` plus Glow `true` unless an explicit
|
||||
boolean wins. Normal settings/room saves materialise that projection in the
|
||||
same write; Optimize Plans performs the equivalent idempotent model-v6
|
||||
migration. Render order is paper → data room/tunnel fill → conditional
|
||||
pointer-free Glow base for rooms whose effective fill is `none` → radial
|
||||
pools → sun/interactive layers. A data/static fill (`lqi`, `light`, `temp`,
|
||||
`custom`) never receives the dark base, so its exact color and alpha remain
|
||||
visible; radial pools stay independent and continue to render. The static
|
||||
migration. Render order is paper → resolved data room/tunnel fill → conditional
|
||||
pointer-free Glow base for rooms whose resolver result is absent or fully
|
||||
transparent → radial
|
||||
pools → sun/interactive layers. A resolved data/static fill (`lqi`, `light`,
|
||||
`temp`, `custom`) never receives the dark base, so its exact color and alpha
|
||||
remain visible; a dynamic mode without usable data or a custom fill with
|
||||
zero opacity receives the base instead of exposing bright paper. Radial pools stay
|
||||
independent and continue to render. The static
|
||||
room card uses the same data/base projection, omits empty base groups, and
|
||||
deliberately has no live pools.
|
||||
- **Custom room fill** (#56): `space.settings.custom_fill` is the space color
|
||||
|
||||
@@ -1,5 +1,38 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
## v1.61.0-beta.4 — 2026-08-10
|
||||
|
||||
- Performance comparison now keeps newly introduced private members optional
|
||||
for older supported baselines while validating every present field's runtime
|
||||
type. Rewritten-push baselines prefer the candidate parent and emit a warning
|
||||
instead of silently jumping to an older release. ([#15](https://github.com/Matysh/houseplan-card/issues/15),
|
||||
[#16](https://github.com/Matysh/houseplan-card/issues/16))
|
||||
|
||||
- In Glow spaces, LQI/Light/Temperature modes without usable HA data now fall
|
||||
back to the dark room base instead of exposing a bright unfilled paper hole.
|
||||
A genuinely resolved data or custom fill still keeps its exact color and
|
||||
opacity. ([#61](https://github.com/Matysh/houseplan-card/issues/61))
|
||||
|
||||
- In space settings, Custom color is now the ordinary first room-fill mode and
|
||||
replaces the separate None choice. Historical spaces with `fill_mode: none`
|
||||
open as a zero-opacity custom color, preserving the same floor and Glow
|
||||
appearance through Preview and Save. New spaces also start at zero opacity,
|
||||
so replacing the choice does not change their previous initial appearance.
|
||||
An individual room can still suppress an inherited dynamic fill. ([#64](https://github.com/Matysh/houseplan-card/issues/64))
|
||||
|
||||
- Prerelease publication can now be completed with one fail-closed local
|
||||
command or a manual GitHub Actions run. Version files, bilingual release
|
||||
notes, the exact-SHA Validate result, annotated tag, both release assets and
|
||||
HACS discovery are checked before completion. Downloaded JS/ZIP contents are
|
||||
bound to the candidate hash and manifest version; per-tag concurrency rejects
|
||||
parallel publication. ZIP inspection is portable across Windows and Linux
|
||||
without a system `tar`; stale assets are repaired automatically and handled
|
||||
interruptions release the local lock. The public release is staged as a draft
|
||||
first and partial failures are safe to retry. Existing release workflows remain
|
||||
available as a fallback. ([#63](https://github.com/Matysh/houseplan-card/issues/63))
|
||||
|
||||
## v1.61.0-beta.3 — 2026-08-10
|
||||
|
||||
- Performance tooling now fails before measurement when a candidate/base card
|
||||
|
||||
@@ -6,6 +6,41 @@
|
||||
> **Правило проекта:** оба файла пополняются в одном коммите с самим
|
||||
> изменением — как и остальная документация (см. docs/STATUS.md).
|
||||
|
||||
## Unreleased
|
||||
|
||||
## v1.61.0-beta.4 — 2026-08-10
|
||||
|
||||
- Performance-сравнение теперь допускает отсутствие недавно добавленных
|
||||
приватных полей у поддерживаемых старых baseline, но проверяет runtime-типы
|
||||
всех присутствующих полей. После переписывания истории за baseline берётся
|
||||
родитель кандидата с явным warning, а не молча старый релиз. ([#15](https://github.com/Matysh/houseplan-card/issues/15),
|
||||
[#16](https://github.com/Matysh/houseplan-card/issues/16))
|
||||
|
||||
- В пространствах с Glow режимы LQI/«Свет»/«Температура» без доступных данных
|
||||
Home Assistant теперь получают тёмную базу комнаты вместо светлой незалитой
|
||||
дыры. Реально разрешённая data- или пользовательская заливка по-прежнему
|
||||
сохраняет точные цвет и прозрачность. ([#61](https://github.com/Matysh/houseplan-card/issues/61))
|
||||
|
||||
- В настройках пространства «Свой цвет» теперь является обычным первым
|
||||
режимом заливки комнат и заменяет отдельный пункт «Нет». Старые пространства
|
||||
с `fill_mode: none` открываются как свой цвет с прозрачностью 0%, сохраняя
|
||||
прежний вид пола и Glow при Preview и Save. Новые пространства также начинают
|
||||
с прозрачности 0%, поэтому замена пункта не меняет их прежний внешний вид.
|
||||
Отдельная комната по-прежнему может отключить унаследованную динамическую
|
||||
заливку. ([#64](https://github.com/Matysh/houseplan-card/issues/64))
|
||||
|
||||
- Публикация пре-релиза теперь выполняется одной fail-closed локальной
|
||||
командой или ручным запуском GitHub Actions. До завершения проверяются все
|
||||
файлы версии, двуязычный release body, зелёный Validate точного SHA,
|
||||
аннотированный тег, оба ассета и обнаружение версии в HACS. Содержимое
|
||||
скачанных JS/ZIP сверяется с hash кандидата и версией manifest, а per-tag
|
||||
concurrency не допускает параллельную публикацию. ZIP проверяется одинаково
|
||||
на Windows и Linux без зависимости от системного `tar`; устаревшие ассеты
|
||||
автоматически заменяются, лок снимается и при обрабатываемом прерывании.
|
||||
Публичный релиз сначала безопасно собирается как draft, частичный сбой
|
||||
допускает повторный запуск.
|
||||
Прежние release-workflows сохранены как fallback. ([#63](https://github.com/Matysh/houseplan-card/issues/63))
|
||||
|
||||
## v1.61.0-beta.3 — 2026-08-10
|
||||
|
||||
- Performance-инструменты теперь до начала замеров сообщают об исчезнувшем
|
||||
|
||||
@@ -62,4 +62,16 @@ as `{c:'#RRGGBB',a:0..1}` in `space.settings.custom_fill` and, for an explicit
|
||||
room override, `room.settings.custom_fill`. Missing or invalid historical data
|
||||
is projected at render time through room → space → `#607d8b`/`0.18`; merely
|
||||
reading it never rewrites the config. Backend writes keep the strict shared
|
||||
hex/finite-alpha contract.
|
||||
hex/finite-alpha contract. An explicit `null` is accepted at either level and
|
||||
has the same projection semantics as a missing override.
|
||||
|
||||
The current space editor presents `custom` as the ordinary/default room fill
|
||||
instead of offering a separate `none` choice. A historical space-level `none`
|
||||
is still rendered losslessly until edited, then the dialog projects it to
|
||||
`custom` with the existing/default color at zero opacity and materializes that
|
||||
visually equivalent choice on Save. Newly created spaces use the same
|
||||
zero-opacity custom value, so the UI change does not introduce a visible floor
|
||||
or remove the Glow base by default. A zero-opacity resolved fill still receives
|
||||
the Glow base. `none` remains accepted by the model and exposed at room level,
|
||||
where it is still required to suppress an inherited
|
||||
LQI/light/temperature/custom fill for one room.
|
||||
|
||||
@@ -192,6 +192,58 @@ cp dist/houseplan-card.js custom_components/houseplan/frontend/
|
||||
|
||||
## Release
|
||||
|
||||
### Primary prerelease path
|
||||
|
||||
Prepare the candidate as usual: synchronize every version field, add dated RU
|
||||
and EN changelog sections, update the production bundle snapshots and write the
|
||||
short bilingual body in `docs/RELEASE-NOTES.md`. That file is the one current
|
||||
instance of the canonical `## Основное` / `## Highlights` template; its two
|
||||
changelog links must be pinned to the new tag.
|
||||
|
||||
After the candidate commit is pushed to `dev` and its exact-SHA Validate is
|
||||
green, publication is one command:
|
||||
|
||||
```powershell
|
||||
npm run release:prerelease -- v1.61.0-beta.4 --issues=63,64 --yes
|
||||
```
|
||||
|
||||
Omit `--yes` for an interactive tag confirmation. Use the same fail-closed
|
||||
preflight without creating a tag or release with:
|
||||
|
||||
```powershell
|
||||
npm run release:check -- v1.61.0-beta.4 --issues=63,64
|
||||
```
|
||||
|
||||
The orchestrator requires a clean, synchronized `dev`, byte-identical bundle
|
||||
snapshots and a completed green Validate for `HEAD`. It creates or verifies an
|
||||
annotated exact-SHA tag, builds `houseplan.zip` directly from that committed
|
||||
tree, verifies its manifest and embedded frontend against the candidate hash,
|
||||
stages a draft prerelease and uploads both assets. Only then does it make the
|
||||
release public. It locates the Release, HACS-zip and Telegram runs by workflow
|
||||
file plus exact tag/SHA, verifies the downloaded public asset contents and
|
||||
paginated HACS prerelease order, and finally closes only the explicitly supplied
|
||||
issues and moves their Project v2 items to Done. Re-running the same command
|
||||
after a partial failure is safe when local/remote tags still resolve to the same
|
||||
SHA: stale public assets are replaced and verified rather than accepted or left
|
||||
for manual deletion. ZIP inspection is implemented in Node and does not depend
|
||||
on the host's `tar`/`unzip` variant. A per-tag local lock and GitHub workflow
|
||||
concurrency reject parallel runs; the local lock is removed on normal exit and
|
||||
on handled `SIGHUP`/`SIGINT`/`SIGTERM` interruption (`SIGKILL` cannot be handled
|
||||
by any process).
|
||||
|
||||
`Publish prerelease` in GitHub Actions is the browser/button equivalent. Select
|
||||
the `dev` branch and enter the exact tag. It performs the same contract and
|
||||
draft-first publication entirely on GitHub, including both assets and the
|
||||
Telegram announcement. GitHub exposes a `workflow_dispatch` button only after
|
||||
the workflow file exists on the default branch; until the next promotion to
|
||||
`main`, use the local command. The button deliberately does not close Issues or
|
||||
edit the user-owned Project because the repository `GITHUB_TOKEN` has no safe
|
||||
authority over that Project.
|
||||
|
||||
The older release-event workflows remain supported as a recovery/manual
|
||||
fallback. They still gate assets on the exact tagged SHA, so adopting the new
|
||||
path does not weaken releases created through the old path.
|
||||
|
||||
Tag `vX.Y.Z` + GitHub Release → `.github/workflows/release.yml` resolves that
|
||||
tag to its exact commit, waits for every Validate run of the SHA to complete
|
||||
successfully, then builds and attaches `houseplan-card.js`. A missing, failed,
|
||||
@@ -226,6 +278,8 @@ invent issues for minor work, do not link open or partially delivered issues,
|
||||
and do not expand the grouped small-fixes bullet into an issue inventory.
|
||||
|
||||
```md
|
||||
<!-- release: vX.Y.Z -->
|
||||
|
||||
## Основное
|
||||
- Значимое изменение.
|
||||
- Исправлена конкретная проблема ([#123](https://github.com/Matysh/houseplan-card/issues/123)).
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<!-- release: v1.61.0-beta.4 -->
|
||||
|
||||
## Основное
|
||||
|
||||
- «Свой цвет» заменил отдельный режим «Нет», сохранив прежний вид старых и новых пространств и корректное поведение Glow ([#61](https://github.com/Matysh/houseplan-card/issues/61), [#64](https://github.com/Matysh/houseplan-card/issues/64)).
|
||||
- Мелкие исправления и улучшения.
|
||||
|
||||
## Highlights
|
||||
|
||||
- Custom color replaced the separate None mode while preserving the previous appearance of old and new spaces and correct Glow behavior ([#61](https://github.com/Matysh/houseplan-card/issues/61), [#64](https://github.com/Matysh/houseplan-card/issues/64)).
|
||||
- Small fixes and improvements.
|
||||
|
||||
[Полный список изменений на русском](https://github.com/Matysh/houseplan-card/blob/v1.61.0-beta.4/docs/CHANGELOG.ru.md)
|
||||
· [Full changelog in English](https://github.com/Matysh/houseplan-card/blob/v1.61.0-beta.4/docs/CHANGELOG.md)
|
||||
+13
-10
@@ -21,11 +21,11 @@ metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.61.0-beta.3** everywhere (manifest, const.py, package.json, CARD_VERSION) — pre-release candidate with custom room colors, exact data fills under Glow and performance-infrastructure hardening |
|
||||
| Current local cycle | The v1.61.0-beta.3 candidate implements #56 (safe custom room/space fill), #61 (Glow base only when effective fill is `none`, while pools remain independent), #60 (Glow radius grouped with Glow settings), review L1/L2, and infrastructure #15/#16 (explicit performance-card API contract plus force-push-safe comparison SHA). #17 is closed after aligning the one canonical release-body template. Publication waits for the targeted local gate, reviewed Linux golden matrix v5 and mandatory green exact-SHA Validate. |
|
||||
| Workflow | Owner's rule since 2026-08-07: ordinary fixes/features are made **locally, without tests and without commits**. A requested pre-release gets a production build plus the smallest targeted unit/smoke set covering the changed surfaces, one tested `dev` commit/tag and a GitHub Release with `prerelease=true`; `main` stays untouched. The complete local frontend/backend/smoke gate runs only before a stable release, after which `main` is fast-forwarded to the exact tested `dev` SHA and the GitHub Release uses `prerelease=false`. Release bodies are short and bilingual (Russian first): only significant user changes get individual bullets, while minor/code-only work is grouped as `Мелкие исправления и улучшения` / `Small fixes and improvements`; every body ends with separate links to the Russian and English changelogs. Detailed RU/EN changelog bullets may link the corresponding closed GitHub Issues; open or partially delivered issues are never presented as shipped. Nothing is copied to the home instance by hand |
|
||||
| Version | **v1.61.0-beta.4** everywhere (manifest, const.py, package.json, CARD_VERSION) — prerelease with reviewed one-command publication, portable/content-bound assets, safer performance infrastructure and backward-compatible Custom color/Glow behavior |
|
||||
| Current local cycle | The v1.61.0-beta.4 line completes #15, #16, #61, #63 and #64: twice-reviewed one-command/one-button fail-closed prerelease publication with portable Node ZIP inspection, content-bound/recoverable assets, interruption-safe local locking and workflow concurrency; Custom color replaces the redundant space-level None choice while legacy and newly created spaces remain at 0% opacity until explicitly changed; performance contracts tolerate supported old baselines, force-push comparison is safer and Glow falls back by resolved result rather than mode. |
|
||||
| Workflow | Owner's rule since 2026-08-07: ordinary fixes/features are made **locally, without tests and without commits**. A requested pre-release gets a production build plus the smallest targeted unit/smoke set covering the changed surfaces, one tested `dev` commit/tag and a GitHub Release with `prerelease=true`; `main` stays untouched. The complete local frontend/backend/smoke gate runs only before a stable release, after which `main` is fast-forwarded to the exact tested `dev` SHA and the GitHub Release uses `prerelease=false`. Release bodies are short and bilingual (Russian first): only significant user changes get individual bullets, while minor/code-only work is grouped as `Мелкие исправления и улучшения` / `Small fixes and improvements`; every body ends with separate links to the Russian and English changelogs. Detailed RU/EN changelog bullets may link the corresponding closed GitHub Issues; open or partially delivered issues are never presented as shipped. `docs/RELEASE-NOTES.md` is the current canonical body instance; `npm run release:prerelease -- <tag> --issues=… --yes` is the primary local publication path and the manual `Publish prerelease` workflow is its GitHub-only equivalent once present on `main`. Nothing is copied to the home instance by hand |
|
||||
| GitHub | https://github.com/Matysh/houseplan-card — [Issues](https://github.com/Matysh/houseplan-card/issues) are the canonical task records and the linked [Project v2](https://github.com/users/Matysh/projects/1) is the canonical priority/status view; both must stay current. `main` carries stable releases; pre-release tags may point directly at `dev`. Work lands on `dev` and is merged into `main` for a stable release, so `dev` is normally equal to or ahead of `main`, never behind. Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
|
||||
| CI | The published v1.61.0-beta.2 exact-SHA Validate run is green. The v1.61.0-beta.3 targeted gate covers logic/backend enum+color validation, performance-card contracts, settings/room/tunnel/Glow/static browser smokes, golden matrix v5 and diagnostic samples of both performance profiles. `release.yml` withholds the card asset unless the final exact-SHA Validate is green. |
|
||||
| CI | Publication of v1.61.0-beta.4 requires a green exact-SHA Validate before the tag/release gate can complete. `release.yml` still withholds fallback assets unless the exact tagged SHA is green; the prerelease orchestrators additionally verify release metadata and both assets before completion. |
|
||||
| HACS | Custom repository works. **Inclusion PR: hacs/default#9004** — open, valid, labeled, mergeable clean, never drafted. Queue: 1212 open, 835 older than ours. Merge rate COLLAPSED: 75 in July but almost all in the first decade, 0 in the last week (checked 2026-07-29) — maintainers process in rare bursts; ETA unknowable, months at best. Nothing actionable on our side |
|
||||
| Home instance | ha.jbstudio.pro (SSH port **22222**, key `ha_jb`; HA config root is `/mnt/data/supervisor/homeassistant` — `/config` does NOT exist in this SSH environment), last direct copy was **v1.57.0**; from v1.58.0 on it updates itself through HACS by tag (no scp) |
|
||||
| Localization | UI en/ru (src/i18n/*.json), everything user-visible localized incl. kiosk popover |
|
||||
@@ -44,16 +44,19 @@ metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
|
||||
header/editors, swipe between spaces, double-tap zoom reset, `cycle: N`
|
||||
carousel, per-screen size multipliers in localStorage.
|
||||
- **Independent Glow overlay** (#55/#19, refined locally by #61): dark-room
|
||||
base is used only with effective fill `none`; LQI/light/temp/custom colors
|
||||
keep their exact alpha while per-source pools remain visible. Pools use
|
||||
base is used only when the effective fill resolver returns `null`, including
|
||||
dynamic modes without usable data; resolved LQI/light/temp/custom colors keep
|
||||
their exact alpha while per-source pools remain visible. Pools use
|
||||
additive screen blending only after a cached real-pixel browser probe and
|
||||
otherwise fall back to normal composition; legacy `fill_mode: glow` remains
|
||||
losslessly readable/migratable. Existing RGB/colour-temperature/default
|
||||
colour, per-source radius, door sectors and transitive open zones remain.
|
||||
- **Custom room fill** (local #56): space and room dialogs offer a persistent
|
||||
user color/opacity independent of HA state and border/name color. Effective
|
||||
inheritance is room → space → safe documented default; room reset restores
|
||||
space inheritance, and full/static renderers share the same projection.
|
||||
- **Custom room fill** (#56, space UX refined locally by #64): the space dialog
|
||||
uses persistent user color/opacity as its ordinary first/default fill instead
|
||||
of a redundant None option; a room keeps None as an explicit inherited-fill
|
||||
suppression. Effective inheritance is room → space → safe documented default;
|
||||
room reset restores space inheritance, and full/static renderers share the
|
||||
same projection.
|
||||
- **Real switches** (v1.36): `marker.controls[]` group-toggle with HA-group
|
||||
semantics; icon mirrors targets; hidden grouped lamps fixed (tiered
|
||||
primaryEntity). **Lights toggle by default** (v1.39): primary domain light
|
||||
|
||||
+7
-5
@@ -816,14 +816,16 @@ separately promised workflows:
|
||||
[auto: smoke_controls; unit: devices.test.mjs, plan-optimizer.test.mjs].
|
||||
Repeat with a `device:*` binding whose controls contain one of that
|
||||
device's child switches; the child is excluded while external targets remain
|
||||
- [ ] Independent Glow (#55): the space has data-fill radios
|
||||
None/LQI/Light/Temperature plus a separate Glow switch; every combination
|
||||
- [ ] Independent Glow (#55/#64): the space has data-fill radios
|
||||
Custom/LQI/Light/Temperature plus a separate Glow switch; every combination
|
||||
persists and renders both layers in order. Legacy space/room
|
||||
`fill_mode: glow` has identical effective state, explicit booleans win,
|
||||
`fill_mode: glow` has identical effective state; legacy space `none`
|
||||
remains losslessly readable and projects to Custom when edited; explicit booleans win,
|
||||
normal Save materialises both fields atomically and Optimize Plans makes
|
||||
the same idempotent model-v6 migration without deleting unknown settings.
|
||||
Glow-off everywhere creates no base/tunnel/pool SVG layer; static room
|
||||
cards show the data fill plus base darkness but no live pools
|
||||
Glow-off everywhere creates no base/tunnel/pool SVG layer; a dynamic mode
|
||||
without usable HA data falls back to base darkness instead of bright
|
||||
paper; static room cards show the same data/base projection but no live pools
|
||||
[unit: logic, plan-optimizer, backend validation; auto: golden matrix].
|
||||
- [ ] Additive Glow (#19): 1/10/30/60-source fixture renders one flat isolated
|
||||
pool group and exactly one outer opacity; brightness/palette alpha live
|
||||
|
||||
+13
-8
@@ -246,8 +246,8 @@ desktop: для точного рисования, Resize, модификато
|
||||
| Стиль | Цвет и прозрачность | Общий цвет границ и названий |
|
||||
| Фон | Наследовать/статический/день-ночь | Управляет фоном вокруг плана |
|
||||
| Солнце | Север и лучи | Локально переопределяет общие настройки |
|
||||
| Заливка | Нет/LQI/свет/температура/свой цвет | Выбирает данные или постоянный цвет заливки комнат пространства |
|
||||
| Свечение источников света | Вкл/выкл | Добавляет световые пятна; базовое затемнение используется только без другой заливки |
|
||||
| Заливка | Свой цвет/LQI/свет/температура | Выбирает постоянный цвет либо данные для заливки комнат пространства; свой цвет является обычным режимом по умолчанию |
|
||||
| Свечение источников света | Вкл/выкл | Добавляет световые пятна; базовое затемнение используется только без другой видимой заливки |
|
||||
|
||||
Удаление пространства удаляет его комнаты и разметку после подтверждения. Файл подложки при этом автоматически не удаляется: им можно управлять через список уже загруженных планов.
|
||||
|
||||
@@ -633,13 +633,18 @@ Glow и заливка «Свет» продолжают работать по
|
||||
| Свой цвет | Настройка пространства или комнаты | Постоянный цвет и прозрачность, не зависящие от HA | Безопасный серо-синий `#607d8b`, 18% |
|
||||
|
||||
«Свечение источников света» теперь включается отдельно и сочетается с любым
|
||||
режимом заливки. При режиме «Нет» оно добавляет базовое затемнение и световые
|
||||
пятна. При LQI, свете, температуре или своём цвете базовое затемнение не
|
||||
рисуется: исходный цвет и прозрачность заливки сохраняются точно, а световые
|
||||
пятна остаются видимыми поверх неё. Новое вручную созданное пространство по
|
||||
умолчанию имеет режим «Нет» и включённое свечение. Старые планы с режимом
|
||||
режимом заливки. При отсутствии данных, room-level режиме «Нет» или своём цвете
|
||||
с нулевой прозрачностью оно добавляет базовое затемнение и световые пятна. При
|
||||
видимой заливке по LQI, свету, температуре или своим цветом базовое затемнение
|
||||
не рисуется: исходный цвет и прозрачность сохраняются точно, а световые пятна
|
||||
остаются видимыми поверх неё. Новое вручную созданное пространство по
|
||||
умолчанию имеет режим «Свой цвет» с прозрачностью 0% и включённое свечение:
|
||||
пока пользователь не повысит прозрачность заливки, вид остаётся таким же, как
|
||||
у прежнего режима «Нет». Старые планы с режимом
|
||||
«Световые источники» читаются без визуальной потери и при следующем обычном
|
||||
сохранении переводятся в независимые настройки.
|
||||
сохранении переводятся в независимые настройки. Старое space-level значение
|
||||
«Нет» показывается как «Свой цвет» с прозрачностью 0% и сохраняется без
|
||||
визуального изменения.
|
||||
|
||||
### Наследование
|
||||
|
||||
|
||||
@@ -15,9 +15,10 @@
|
||||
|
||||
Пользователь может одновременно видеть LQI/освещённость/температуру/свой цвет
|
||||
и световые пятна Glow. Тёмная базовая подложка Glow применяется только когда
|
||||
effective data fill равен `none`, поэтому полезная заливка не затемняется и не
|
||||
меняет оттенок. Модель геометрии комнат, распространения света и проёмов не
|
||||
меняется.
|
||||
resolver не вернул видимый data/static fill (`null` или opacity 0): это включает
|
||||
явный режим `none`, его прозрачную custom-проекцию и динамический режим без
|
||||
доступных данных. Реально полученная полезная заливка не затемняется и не меняет
|
||||
оттенок. Модель геометрии комнат, распространения света и проёмов не меняется.
|
||||
|
||||
## Границы этапа и зависимости
|
||||
|
||||
@@ -153,7 +154,8 @@ Preview показывает число затронутых пространс
|
||||
|
||||
1. paper/backdrop;
|
||||
2. resolved data/static room fill и matching data fill внутреннего тоннеля;
|
||||
3. Glow base только для effective-Glow rooms с effective fill `none` и
|
||||
3. Glow base только для effective-Glow rooms без видимого resolved fill
|
||||
(`null` или opacity 0) и
|
||||
соответствующих частей тоннеля;
|
||||
4. tunnel light sectors и radial pools;
|
||||
5. sun и interactive layers по текущему контракту.
|
||||
@@ -165,7 +167,8 @@ Glow base и pools pointer-transparent. Room hover, tooltip и editor hit target
|
||||
|
||||
Glow base — отдельная SVG-геометрия с обычным `source-over`/`normal`
|
||||
композитингом, которая создаётся только при
|
||||
`effectiveGlow == true && effectiveFill == none`. `multiply`, `screen`, CSS
|
||||
`effectiveGlow == true && (resolvedFill == null || resolvedFill.opacity == 0)`.
|
||||
`multiply`, `screen`, CSS
|
||||
filter и дополнительная групповая opacity не применяются.
|
||||
|
||||
```text
|
||||
@@ -180,14 +183,15 @@ base повторяет прежние room/tunnel shapes, поэтому при
|
||||
с Glow обязательный контракт обратный: data/static fill сохраняет exact color и
|
||||
alpha, Glow base отсутствует, а radial pools продолжают рисоваться поверх.
|
||||
|
||||
Room с effective Glow on и fill `none` получает base даже при отсутствии
|
||||
источников света; radial pools тогда отсутствуют. Room с любым другим fill или
|
||||
Glow off не получает base. Glow-off room исключается
|
||||
Room с effective Glow on и отсутствующим/прозрачным resolved fill получает base даже при
|
||||
отсутствии источников света; radial pools тогда отсутствуют. Это относится и к
|
||||
динамическому режиму без подходящих HA-данных. Room с реально разрешённым fill
|
||||
или Glow off не получает base. Glow-off room исключается
|
||||
из визуальных clip pools, но это не меняет физический transport через неё по
|
||||
#36. Если Glow off у всех комнат, overlay не создаёт пустые SVG layers.
|
||||
|
||||
Тоннель сначала повторяет resolved data fill своей стороны. Glow-base overlay
|
||||
получает только сторона с effective Glow и fill `none`; сторона с любой
|
||||
получает только сторона с effective Glow и отсутствующим/прозрачным resolved fill; сторона с любой
|
||||
data/static заливкой сохраняет её точный цвет. Геометрия световых
|
||||
секторов, отсечение откосами и проникновение через двери/ворота не меняются.
|
||||
|
||||
@@ -202,7 +206,7 @@ data/static заливкой сохраняет её точный цвет. Ге
|
||||
Статическая карточка использует тот же effective resolver и показывает:
|
||||
|
||||
- data/static fill;
|
||||
- Glow base поверх него только для effective-Glow rooms с fill `none`;
|
||||
- Glow base вместо отсутствующего/прозрачного resolved fill для effective-Glow rooms;
|
||||
- matching базовую заливку поддерживаемых тоннелей.
|
||||
|
||||
Live radial pools и tunnel light sectors в статической карточке не рисуются —
|
||||
@@ -214,7 +218,8 @@ pools.
|
||||
|
||||
Space dialog разделяет controls:
|
||||
|
||||
- «Заливка комнаты»: Нет / LQI / Свет / Температура / Свой цвет после #56;
|
||||
- «Заливка комнаты» на уровне пространства после #64: Свой цвет / LQI / Свет /
|
||||
Температура; `none` остаётся room-level override и read-compatible token;
|
||||
- «Свечение источников»: отдельный switch.
|
||||
|
||||
Room dialog после #36 показывает независимый fill override и tri-state Glow.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
- Issue: https://github.com/Matysh/houseplan-card/issues/56
|
||||
- Приоритет: P2
|
||||
- Статус ТЗ: реализовано в v1.61.0-beta.3
|
||||
- Статус ТЗ: реализовано в v1.61.0-beta.3; space-level UX уточнён в #64
|
||||
- Совместимо: независимый Glow #55
|
||||
|
||||
## Цель
|
||||
@@ -13,7 +13,7 @@
|
||||
## Модель
|
||||
|
||||
- `fill_mode:'custom'` добавляется frontend/backend enum;
|
||||
- `space.settings.custom_fill?: {c:string,a:number}` — default пространства;
|
||||
- `space.settings.custom_fill?: {c:string,a:number}|null` — default пространства;
|
||||
- `room.settings.custom_fill?: {c:string,a:number}|null` — explicit override;
|
||||
- effective color при room `fill_mode:'custom'`: room value → space value →
|
||||
documented default `#607d8b` с alpha 0.18.
|
||||
@@ -30,7 +30,12 @@ default без silent config rewrite. Opacity finite/clamp 0..1.
|
||||
|
||||
## UX
|
||||
|
||||
- Space dialog fill options: Нет, LQI, Свет, Температура, Свой цвет.
|
||||
- Space dialog fill options после #64: Свой цвет, LQI, Свет, Температура.
|
||||
Новое пространство начинает со «Своего цвета» и alpha 0, сохраняя прежний
|
||||
визуальный default без заливки и с Glow-базой до явного выбора прозрачности.
|
||||
Persisted space `none` остаётся read-compatible и при редактировании
|
||||
проецируется в `custom` с alpha 0, поэтому Preview и Save без изменений не
|
||||
меняют внешний вид или Glow-базу; room-level `none` остаётся явным override.
|
||||
- При «Свой цвет» появляется общий `hp-color-opacity`.
|
||||
- Room dialog: inherit/modes; для custom — «Цвет пространства» либо explicit
|
||||
color+opacity с Reset.
|
||||
@@ -41,7 +46,10 @@ default без silent config rewrite. Opacity finite/clamp 0..1.
|
||||
|
||||
Custom entry добавляется в единый fill resolver и автоматически используется
|
||||
room floor, clean-floor holes и thick-wall opening tunnel fill. Hover сохраняет
|
||||
effective color. Glow #55 рисуется поверх, но не изменяет stored custom alpha.
|
||||
effective color. Источник-pools Glow #55 рисуются поверх; тёмная Glow-база для
|
||||
видимого resolved custom fill отсутствует и не изменяет stored custom alpha.
|
||||
Если custom alpha равен 0 или динамический fill разрешился в `null` из-за
|
||||
отсутствия данных, вместо светлой дыры применяется Glow-база.
|
||||
|
||||
## Edge cases
|
||||
|
||||
@@ -56,5 +64,5 @@ color, не поддержанный browser, безопасно fallback-итс
|
||||
- frontend/backend enum and range parity #33;
|
||||
- hostile/invalid/modern color corpus #21;
|
||||
- room/tunnel/hover/nested visual golden, custom+Glow;
|
||||
- Save/reload/reset/Cancel и old config no-op roundtrip;
|
||||
- Save/reload/reset/Cancel, new-space transparent default и old config no-op roundtrip;
|
||||
- пользователь может назначить устойчивый цвет комнате без влияния HA state.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.61.0-beta.3",
|
||||
"version": "1.61.0-beta.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "houseplan-card",
|
||||
"version": "1.61.0-beta.3",
|
||||
"version": "1.61.0-beta.4",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lit": "^3.1.3",
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.61.0-beta.3",
|
||||
"version": "1.61.0-beta.4",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -16,7 +16,9 @@
|
||||
"benchmark:compare": "node demo/performance/compare.mjs",
|
||||
"golden:capture": "node demo/golden/run.mjs --mode=capture",
|
||||
"golden:verify": "node demo/golden/run.mjs --mode=verify",
|
||||
"golden:accept": "node demo/golden/accept.mjs"
|
||||
"golden:accept": "node demo/golden/accept.mjs",
|
||||
"release:check": "node scripts/release-prerelease.mjs --check",
|
||||
"release:prerelease": "node scripts/release-prerelease.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mdi/js": "^7.4.47",
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
|
||||
|
||||
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
export function versionFromTag(tag) {
|
||||
if (typeof tag !== 'string' || !tag.startsWith('v'))
|
||||
throw new Error(`Release tag must start with v: ${String(tag)}`);
|
||||
const version = tag.slice(1);
|
||||
const match = SEMVER.exec(version);
|
||||
if (!match) throw new Error(`Release tag is not valid SemVer: ${tag}`);
|
||||
if (match[4]?.split('.').some((part) => /^\d+$/.test(part) && part.length > 1 && part.startsWith('0')))
|
||||
throw new Error(`Numeric prerelease identifiers must not contain leading zeroes: ${tag}`);
|
||||
return { version, prerelease: !!match[4] };
|
||||
}
|
||||
|
||||
export function parseVersionSources({ packageJson, packageLock, manifest, constSource, cardSource }) {
|
||||
const pkg = JSON.parse(packageJson);
|
||||
const lock = JSON.parse(packageLock);
|
||||
const integration = JSON.parse(manifest);
|
||||
const constMatch = /^VERSION\s*=\s*["']([^"']+)["']/m.exec(constSource);
|
||||
const cardMatch = /\bCARD_VERSION\s*=\s*["']([^"']+)["']/m.exec(cardSource);
|
||||
if (!constMatch) throw new Error('VERSION is missing from custom_components/houseplan/const.py');
|
||||
if (!cardMatch) throw new Error('CARD_VERSION is missing from src/houseplan-card.ts');
|
||||
return {
|
||||
'package.json': pkg.version,
|
||||
'package-lock.json': lock.version,
|
||||
'package-lock.json packages[""]': lock.packages?.['']?.version,
|
||||
'custom_components/houseplan/manifest.json': integration.version,
|
||||
'custom_components/houseplan/const.py': constMatch[1],
|
||||
'src/houseplan-card.ts': cardMatch[1],
|
||||
};
|
||||
}
|
||||
|
||||
export function validateVersionSources(tag, sources, { requirePrerelease = true } = {}) {
|
||||
const parsed = versionFromTag(tag);
|
||||
if (requirePrerelease && !parsed.prerelease)
|
||||
throw new Error(`Prerelease publication requires a prerelease SemVer tag: ${tag}`);
|
||||
const mismatches = Object.entries(sources)
|
||||
.filter(([, value]) => value !== parsed.version)
|
||||
.map(([name, value]) => `${name}=${JSON.stringify(value)}`);
|
||||
if (mismatches.length) {
|
||||
throw new Error(
|
||||
`Version ${parsed.version} is not synchronized: ${mismatches.join(', ')}`,
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function changelogContainsVersion(changelog, tag) {
|
||||
const heading = new RegExp(`^##\\s+${escapeRegExp(tag)}\\s+[—-]\\s+(\\d{4})-(\\d{2})-(\\d{2})\\s*$`);
|
||||
const lines = changelog.split(/\r?\n/);
|
||||
let fenced = false;
|
||||
for (let index = 0; index < lines.length; index++) {
|
||||
if (/^\s*```/.test(lines[index])) { fenced = !fenced; continue; }
|
||||
if (fenced) continue;
|
||||
const match = heading.exec(lines[index]);
|
||||
if (!match) continue;
|
||||
const year = Number(match[1]);
|
||||
const month = Number(match[2]);
|
||||
const day = Number(match[3]);
|
||||
const date = new Date(Date.UTC(year, month - 1, day));
|
||||
if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1
|
||||
|| date.getUTCDate() !== day) return false;
|
||||
let bodyFence = false;
|
||||
for (let body = index + 1; body < lines.length; body++) {
|
||||
if (/^\s*```/.test(lines[body])) { bodyFence = !bodyFence; continue; }
|
||||
if (!bodyFence && /^##\s+/.test(lines[body])) break;
|
||||
if (!bodyFence && /^-\s+\S/.test(lines[body])) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function validateReleaseNotes(notes, { tag, repo }) {
|
||||
const marker = `<!-- release: ${tag} -->`;
|
||||
if (notes.split(/\r?\n/, 1)[0] !== marker)
|
||||
throw new Error(`Release notes must start with the exact ${marker} marker`);
|
||||
if (/^##\s+(Русский|English)\s*$/m.test(notes))
|
||||
throw new Error('Legacy release-note headings are forbidden; use the canonical bilingual template');
|
||||
const mainIndex = notes.indexOf('## Основное');
|
||||
const highlightsIndex = notes.indexOf('## Highlights');
|
||||
if (mainIndex < 0 || highlightsIndex < 0 || highlightsIndex <= mainIndex)
|
||||
throw new Error('Release notes must contain canonical ## Основное then ## Highlights sections');
|
||||
if (notes.slice(marker.length, mainIndex).trim())
|
||||
throw new Error('Release notes cannot contain text or bullets before ## Основное');
|
||||
|
||||
const main = notes.slice(mainIndex, highlightsIndex);
|
||||
const highlights = notes.slice(highlightsIndex);
|
||||
const mainBullets = [...main.matchAll(/^-\s+(.+)$/gm)].map((match) => match[1].trim());
|
||||
const highlightBullets = [...highlights.matchAll(/^-\s+(.+)$/gm)].map((match) => match[1].trim());
|
||||
if (!mainBullets.length || !highlightBullets.length)
|
||||
throw new Error('Both release-note language sections must contain at least one bullet');
|
||||
if (mainBullets.length !== highlightBullets.length || mainBullets.length > 4)
|
||||
throw new Error('Release notes must contain equivalent RU/EN lists with at most four bullets each');
|
||||
if (mainBullets.at(-1) !== 'Мелкие исправления и улучшения.')
|
||||
throw new Error('Russian release notes must end with the grouped small-fixes bullet');
|
||||
if (highlightBullets.at(-1) !== 'Small fixes and improvements.')
|
||||
throw new Error('English release notes must end with the grouped small-fixes bullet');
|
||||
|
||||
const ruLink = `https://github.com/${repo}/blob/${tag}/docs/CHANGELOG.ru.md`;
|
||||
const enLink = `https://github.com/${repo}/blob/${tag}/docs/CHANGELOG.md`;
|
||||
if (!notes.includes(ruLink) || !notes.includes(enLink))
|
||||
throw new Error(`Release notes must link both changelogs at the immutable ${tag} tag`);
|
||||
return notes.trim() + '\n';
|
||||
}
|
||||
|
||||
export function readReleaseContract(root = process.cwd()) {
|
||||
const read = (name) => readFileSync(resolve(root, name), 'utf8');
|
||||
return {
|
||||
sources: parseVersionSources({
|
||||
packageJson: read('package.json'),
|
||||
packageLock: read('package-lock.json'),
|
||||
manifest: read('custom_components/houseplan/manifest.json'),
|
||||
constSource: read('custom_components/houseplan/const.py'),
|
||||
cardSource: read('src/houseplan-card.ts'),
|
||||
}),
|
||||
changelogRu: read('docs/CHANGELOG.ru.md'),
|
||||
changelogEn: read('docs/CHANGELOG.md'),
|
||||
notes: read('docs/RELEASE-NOTES.md'),
|
||||
};
|
||||
}
|
||||
|
||||
export function assertReleaseContract({
|
||||
root = process.cwd(), tag, repo = 'Matysh/houseplan-card', requirePrerelease = true,
|
||||
} = {}) {
|
||||
const contract = readReleaseContract(root);
|
||||
const parsed = validateVersionSources(tag, contract.sources, { requirePrerelease });
|
||||
if (!changelogContainsVersion(contract.changelogRu, tag))
|
||||
throw new Error(`docs/CHANGELOG.ru.md has no dated ${tag} section`);
|
||||
if (!changelogContainsVersion(contract.changelogEn, tag))
|
||||
throw new Error(`docs/CHANGELOG.md has no dated ${tag} section`);
|
||||
validateReleaseNotes(contract.notes, { tag, repo });
|
||||
return { ...parsed, tag, repo, sources: contract.sources };
|
||||
}
|
||||
|
||||
const invokedDirectly = process.argv[1]
|
||||
&& resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
|
||||
if (invokedDirectly) {
|
||||
try {
|
||||
const args = process.argv.slice(2);
|
||||
const positionals = args.filter((arg) => !arg.startsWith('--'));
|
||||
const repoArgs = args.filter((arg) => arg.startsWith('--repo='));
|
||||
const unknown = args.filter((arg) => arg.startsWith('--') && !arg.startsWith('--repo='));
|
||||
if (positionals.length !== 1) throw new Error('Exactly one release tag is required');
|
||||
if (repoArgs.length > 1 || unknown.length)
|
||||
throw new Error(`Unknown or duplicate release-contract arguments: ${[...repoArgs.slice(1), ...unknown].join(', ')}`);
|
||||
const tag = positionals[0];
|
||||
const repo = repoArgs[0]?.slice('--repo='.length)
|
||||
|| process.env.GITHUB_REPOSITORY || 'Matysh/houseplan-card';
|
||||
const result = assertReleaseContract({ tag, repo, requirePrerelease: true });
|
||||
console.log(JSON.stringify({
|
||||
ok: true, tag: result.tag, version: result.version,
|
||||
prerelease: result.prerelease, sources: result.sources,
|
||||
}, null, 2));
|
||||
} catch (error) {
|
||||
console.error(`release contract failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from 'node:crypto';
|
||||
import { inflateRawSync } from 'node:zlib';
|
||||
import {
|
||||
closeSync, mkdtempSync, openSync, readFileSync, rmSync,
|
||||
unlinkSync, writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createInterface } from 'node:readline/promises';
|
||||
import { stdin, stdout } from 'node:process';
|
||||
import { assertReleaseContract } from './release-contract.mjs';
|
||||
import { classifyValidateRuns } from './release-gate.mjs';
|
||||
|
||||
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
|
||||
|
||||
class ReleaseAssetContentError extends Error {}
|
||||
|
||||
export function parseIssueList(value = '') {
|
||||
if (!value.trim()) return [];
|
||||
const issues = value.split(',').map((part) => part.trim()).filter(Boolean);
|
||||
if (issues.some((part) => !/^[1-9]\d*$/.test(part)))
|
||||
throw new Error(`--issues must be a comma-separated list of positive issue numbers: ${value}`);
|
||||
return [...new Set(issues.map(Number))];
|
||||
}
|
||||
|
||||
export function parsePrereleaseArgs(args) {
|
||||
const values = new Map();
|
||||
const switches = new Set();
|
||||
const positionals = [];
|
||||
const valueNames = new Set(['repo', 'branch', 'project', 'issues']);
|
||||
const switchNames = new Set(['check', 'yes']);
|
||||
for (const arg of args) {
|
||||
if (!arg.startsWith('--')) { positionals.push(arg); continue; }
|
||||
const match = /^--([^=]+)(?:=(.*))?$/.exec(arg);
|
||||
const name = match?.[1] || '';
|
||||
const value = match?.[2];
|
||||
if (switchNames.has(name) && value === undefined) {
|
||||
if (switches.has(name)) throw new Error(`Duplicate option: --${name}`);
|
||||
switches.add(name);
|
||||
} else if (valueNames.has(name) && value !== undefined && value !== '') {
|
||||
if (values.has(name)) throw new Error(`Duplicate option: --${name}`);
|
||||
values.set(name, value);
|
||||
} else {
|
||||
throw new Error(`Unknown or malformed option: ${arg}`);
|
||||
}
|
||||
}
|
||||
if (positionals.length !== 1)
|
||||
throw new Error(`Exactly one prerelease tag is required, got ${positionals.length}`);
|
||||
return {
|
||||
tag: positionals[0],
|
||||
repo: values.get('repo') || 'Matysh/houseplan-card',
|
||||
branch: values.get('branch') || 'dev',
|
||||
projectNumber: values.get('project') || '1',
|
||||
issueOption: values.get('issues') || '',
|
||||
checkOnly: switches.has('check'),
|
||||
confirmed: switches.has('yes'),
|
||||
};
|
||||
}
|
||||
|
||||
export function verifyReleaseProjection(release, { tag }) {
|
||||
if (!release || release.tagName !== tag) throw new Error(`GitHub release ${tag} is missing`);
|
||||
if (release.isDraft) throw new Error(`GitHub release ${tag} is still a draft`);
|
||||
if (!release.isPrerelease) throw new Error(`GitHub release ${tag} is not marked as a prerelease`);
|
||||
const assets = new Map((release.assets || []).map((asset) => [asset.name, asset]));
|
||||
for (const name of ['houseplan-card.js', 'houseplan.zip']) {
|
||||
const asset = assets.get(name);
|
||||
if (!asset || !(Number(asset.size) > 0)) throw new Error(`Release asset ${name} is missing or empty`);
|
||||
}
|
||||
return release;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read selected root entries from an ordinary ZIP archive without relying on
|
||||
* platform-specific `tar`/`unzip` executables. GitHub runners, Git Bash, WSL
|
||||
* and Windows consequently validate the exact same bytes. ZIP64 and exotic
|
||||
* compression methods are rejected deliberately: release archives are small
|
||||
* and are produced by either `git archive` or Info-ZIP.
|
||||
*/
|
||||
export function readZipEntries(zipPath, requiredNames) {
|
||||
const archive = readFileSync(zipPath);
|
||||
if (!archive.length) throw new Error('houseplan.zip is empty');
|
||||
const required = new Set(requiredNames);
|
||||
const found = new Map();
|
||||
const eocdSignature = 0x06054b50;
|
||||
const centralSignature = 0x02014b50;
|
||||
const localSignature = 0x04034b50;
|
||||
const minimumEocd = 22;
|
||||
const searchStart = Math.max(0, archive.length - minimumEocd - 0xffff);
|
||||
let eocd = -1;
|
||||
for (let offset = archive.length - minimumEocd; offset >= searchStart; offset--) {
|
||||
if (archive.readUInt32LE(offset) === eocdSignature
|
||||
&& offset + minimumEocd + archive.readUInt16LE(offset + 20) === archive.length) {
|
||||
eocd = offset;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (eocd < 0) throw new Error('houseplan.zip has no end-of-central-directory record');
|
||||
const disk = archive.readUInt16LE(eocd + 4);
|
||||
const centralDisk = archive.readUInt16LE(eocd + 6);
|
||||
const entriesOnDisk = archive.readUInt16LE(eocd + 8);
|
||||
const entryCount = archive.readUInt16LE(eocd + 10);
|
||||
const centralSize = archive.readUInt32LE(eocd + 12);
|
||||
const centralOffset = archive.readUInt32LE(eocd + 16);
|
||||
if (disk !== 0 || centralDisk !== 0 || entriesOnDisk !== entryCount)
|
||||
throw new Error('houseplan.zip must be a single-disk archive');
|
||||
if (entryCount === 0xffff || centralSize === 0xffffffff || centralOffset === 0xffffffff)
|
||||
throw new Error('houseplan.zip unexpectedly requires ZIP64');
|
||||
if (centralOffset + centralSize > eocd || centralOffset > archive.length)
|
||||
throw new Error('houseplan.zip central directory is outside the archive');
|
||||
|
||||
let cursor = centralOffset;
|
||||
for (let index = 0; index < entryCount; index++) {
|
||||
if (cursor + 46 > archive.length || archive.readUInt32LE(cursor) !== centralSignature)
|
||||
throw new Error('houseplan.zip has a malformed central directory');
|
||||
const flags = archive.readUInt16LE(cursor + 8);
|
||||
const method = archive.readUInt16LE(cursor + 10);
|
||||
const compressedSize = archive.readUInt32LE(cursor + 20);
|
||||
const uncompressedSize = archive.readUInt32LE(cursor + 24);
|
||||
const nameLength = archive.readUInt16LE(cursor + 28);
|
||||
const extraLength = archive.readUInt16LE(cursor + 30);
|
||||
const commentLength = archive.readUInt16LE(cursor + 32);
|
||||
const localOffset = archive.readUInt32LE(cursor + 42);
|
||||
const next = cursor + 46 + nameLength + extraLength + commentLength;
|
||||
if (next > archive.length) throw new Error('houseplan.zip has a truncated central entry');
|
||||
const name = archive.subarray(cursor + 46, cursor + 46 + nameLength)
|
||||
.toString('utf8').replace(/^\.\//, '');
|
||||
cursor = next;
|
||||
if (!required.has(name)) continue;
|
||||
if (found.has(name)) throw new Error(`houseplan.zip contains duplicate ${name}`);
|
||||
if ((flags & 0x1) !== 0) throw new Error(`houseplan.zip entry ${name} is encrypted`);
|
||||
if (compressedSize === 0xffffffff || uncompressedSize === 0xffffffff
|
||||
|| localOffset === 0xffffffff) throw new Error(`houseplan.zip entry ${name} requires ZIP64`);
|
||||
if (uncompressedSize > 50 * 1024 * 1024)
|
||||
throw new Error(`houseplan.zip entry ${name} is unexpectedly large`);
|
||||
if (localOffset + 30 > archive.length || archive.readUInt32LE(localOffset) !== localSignature)
|
||||
throw new Error(`houseplan.zip entry ${name} has no valid local header`);
|
||||
const localNameLength = archive.readUInt16LE(localOffset + 26);
|
||||
const localExtraLength = archive.readUInt16LE(localOffset + 28);
|
||||
const dataStart = localOffset + 30 + localNameLength + localExtraLength;
|
||||
const dataEnd = dataStart + compressedSize;
|
||||
if (dataEnd > archive.length) throw new Error(`houseplan.zip entry ${name} is truncated`);
|
||||
const compressed = archive.subarray(dataStart, dataEnd);
|
||||
let contents;
|
||||
if (method === 0) contents = Buffer.from(compressed);
|
||||
else if (method === 8) contents = inflateRawSync(compressed, { maxOutputLength: 50 * 1024 * 1024 });
|
||||
else throw new Error(`houseplan.zip entry ${name} uses unsupported compression method ${method}`);
|
||||
if (contents.length !== uncompressedSize)
|
||||
throw new Error(`houseplan.zip entry ${name} has an invalid uncompressed size`);
|
||||
found.set(name, contents);
|
||||
}
|
||||
if (cursor !== centralOffset + centralSize)
|
||||
throw new Error('houseplan.zip central directory size is inconsistent');
|
||||
const missing = [...required].filter((name) => !found.has(name));
|
||||
if (missing.length) throw new Error(`houseplan.zip is missing ${missing.join(', ')}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
const invokedDirectly = process.argv[1]
|
||||
&& resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
|
||||
|
||||
if (invokedDirectly) {
|
||||
try {
|
||||
const {
|
||||
tag, repo, branch, projectNumber, issueOption, checkOnly, confirmed,
|
||||
} = parsePrereleaseArgs(process.argv.slice(2));
|
||||
let issues = [];
|
||||
const root = process.cwd();
|
||||
|
||||
const run = (command, commandArgs, { allowFailure = false, inherit = false, cwd = root } = {}) => {
|
||||
const result = spawnSync(command, commandArgs, {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
stdio: inherit ? 'inherit' : ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0 && !allowFailure) {
|
||||
const detail = `${result.stderr || result.stdout || ''}`.trim();
|
||||
throw new Error(`${command} ${commandArgs.join(' ')} failed${detail ? `: ${detail}` : ''}`);
|
||||
}
|
||||
return {
|
||||
ok: result.status === 0,
|
||||
status: result.status,
|
||||
stdout: `${result.stdout || ''}`.trim(),
|
||||
stderr: `${result.stderr || ''}`.trim(),
|
||||
};
|
||||
};
|
||||
const ghJson = (commandArgs, options) => JSON.parse(run('gh', commandArgs, options).stdout);
|
||||
const owner = repo.split('/')[0];
|
||||
|
||||
const remoteTag = () => {
|
||||
const result = run('git', [
|
||||
'ls-remote', '--tags', 'origin', `refs/tags/${tag}`, `refs/tags/${tag}^{}`,
|
||||
]);
|
||||
if (!result.stdout) return { exists: false, commit: null };
|
||||
const lines = result.stdout.split(/\r?\n/).map((line) => line.split(/\s+/));
|
||||
const direct = lines.find(([, ref]) => ref === `refs/tags/${tag}`)?.[0];
|
||||
const peeled = lines.find(([, ref]) => ref === `refs/tags/${tag}^{}`)?.[0];
|
||||
if (!peeled) throw new Error(`Remote tag ${tag} exists but is not annotated (${direct})`);
|
||||
return { exists: true, commit: peeled };
|
||||
};
|
||||
|
||||
const releaseView = () => {
|
||||
const result = run('gh', [
|
||||
'release', 'view', tag, '--repo', repo,
|
||||
'--json', 'url,isPrerelease,isDraft,tagName,assets',
|
||||
], { allowFailure: true });
|
||||
return result.ok ? JSON.parse(result.stdout) : null;
|
||||
};
|
||||
|
||||
const sha256Path = (name) => createHash('sha256').update(readFileSync(name)).digest('hex');
|
||||
const sha256 = (name) => sha256Path(resolve(root, name));
|
||||
const assertBundleSnapshots = () => {
|
||||
const names = [
|
||||
'dist/houseplan-card.js',
|
||||
'custom_components/houseplan/frontend/houseplan-card.js',
|
||||
'demo/srv/assets/houseplan-card.js',
|
||||
];
|
||||
const hashes = names.map((name) => [name, sha256(name)]);
|
||||
if (new Set(hashes.map(([, hash]) => hash)).size !== 1)
|
||||
throw new Error(`Committed bundle snapshots differ: ${hashes.map(([name, hash]) => `${name}=${hash}`).join(', ')}`);
|
||||
return hashes[0][1];
|
||||
};
|
||||
|
||||
const verifyZipContents = (zipPath, version, bundleSha256) => {
|
||||
const entries = readZipEntries(zipPath, ['manifest.json', 'frontend/houseplan-card.js']);
|
||||
const manifest = JSON.parse(entries.get('manifest.json').toString('utf8'));
|
||||
if (manifest.version !== version)
|
||||
throw new Error(`houseplan.zip manifest version ${manifest.version} != ${version}`);
|
||||
const bundledHash = createHash('sha256')
|
||||
.update(entries.get('frontend/houseplan-card.js')).digest('hex');
|
||||
if (bundledHash !== bundleSha256)
|
||||
throw new Error(`houseplan.zip frontend hash ${bundledHash} != committed bundle ${bundleSha256}`);
|
||||
};
|
||||
|
||||
const buildZip = (sha, version, bundleSha256, artifactsDir) => {
|
||||
const zipPath = resolve(artifactsDir, 'houseplan.zip');
|
||||
run('git', [
|
||||
'archive', '--format=zip', `--output=${zipPath}`,
|
||||
`${sha}:custom_components/houseplan`,
|
||||
]);
|
||||
verifyZipContents(zipPath, version, bundleSha256);
|
||||
return zipPath;
|
||||
};
|
||||
|
||||
const verifyRemoteAssetContents = (version, bundleSha256) => {
|
||||
const download = mkdtempSync(resolve(tmpdir(), 'houseplan-release-check-'));
|
||||
try {
|
||||
run('gh', [
|
||||
'release', 'download', tag, '--repo', repo, '--dir', download,
|
||||
'--pattern', 'houseplan-card.js', '--pattern', 'houseplan.zip', '--clobber',
|
||||
]);
|
||||
try {
|
||||
const cardPath = resolve(download, 'houseplan-card.js');
|
||||
const cardHash = sha256Path(cardPath);
|
||||
if (cardHash !== bundleSha256)
|
||||
throw new Error(`Published houseplan-card.js hash ${cardHash} != candidate ${bundleSha256}`);
|
||||
verifyZipContents(resolve(download, 'houseplan.zip'), version, bundleSha256);
|
||||
} catch (error) {
|
||||
throw new ReleaseAssetContentError(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
rmSync(download, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
|
||||
const localTag = () => {
|
||||
const exists = run('git', ['show-ref', '--verify', '--quiet', `refs/tags/${tag}`], { allowFailure: true });
|
||||
if (!exists.ok) return { exists: false, commit: null };
|
||||
const type = run('git', ['cat-file', '-t', `refs/tags/${tag}`]).stdout;
|
||||
const commit = run('git', ['rev-list', '-n', '1', tag]).stdout;
|
||||
return { exists: true, annotated: type === 'tag', commit };
|
||||
};
|
||||
|
||||
const validateIssues = () => {
|
||||
for (const issue of issues) {
|
||||
const row = ghJson(['issue', 'view', String(issue), '--repo', repo, '--json', 'number,state,url,title']);
|
||||
if (row.number !== issue) throw new Error(`Issue #${issue} could not be verified`);
|
||||
}
|
||||
};
|
||||
|
||||
const assertGreenValidate = (sha) => {
|
||||
const runs = ghJson([
|
||||
'run', 'list', '--repo', repo, '--workflow', 'validate.yml', '--commit', sha,
|
||||
'--limit', '100', '--json', 'databaseId,status,conclusion,url,headSha',
|
||||
]);
|
||||
const state = classifyValidateRuns(runs);
|
||||
if (state !== 'success') {
|
||||
throw new Error(
|
||||
state === 'wait'
|
||||
? `Exact-SHA Validate has not completed successfully for ${sha}`
|
||||
: `Exact-SHA Validate contains a failed/cancelled run for ${sha}`,
|
||||
);
|
||||
}
|
||||
return runs;
|
||||
};
|
||||
|
||||
const waitForRun = async (runId, label) => {
|
||||
let last = '';
|
||||
for (let attempt = 0; attempt < 360; attempt++) {
|
||||
const row = ghJson([
|
||||
'run', 'view', String(runId), '--repo', repo, '--json', 'status,conclusion,url',
|
||||
]);
|
||||
const state = `${row.status}/${row.conclusion || '-'}`;
|
||||
if (state !== last) console.log(`${label}: ${state} ${row.url}`);
|
||||
last = state;
|
||||
if (row.status === 'completed') {
|
||||
if (row.conclusion !== 'success') throw new Error(`${label} concluded ${row.conclusion}: ${row.url}`);
|
||||
return row;
|
||||
}
|
||||
await sleep(10_000);
|
||||
}
|
||||
throw new Error(`${label} did not complete within one hour`);
|
||||
};
|
||||
|
||||
const waitForReleaseWorkflows = async (sha) => {
|
||||
const expected = [
|
||||
['release.yml', 'Release'],
|
||||
['release-zip.yml', 'Attach HACS zip'],
|
||||
['announce.yml', 'Announce release'],
|
||||
];
|
||||
for (const [workflow, label] of expected) {
|
||||
let match = null;
|
||||
for (let attempt = 0; attempt < 300 && !match; attempt++) {
|
||||
const runs = ghJson([
|
||||
'run', 'list', '--repo', repo, '--workflow', workflow, '--event', 'release',
|
||||
'--limit', '30', '--json', 'databaseId,headBranch,headSha,status,conclusion,url',
|
||||
]);
|
||||
match = runs.find((row) => row.headBranch === tag && row.headSha === sha) || null;
|
||||
if (!match) await sleep(2_000);
|
||||
}
|
||||
if (!match) throw new Error(`${label} workflow did not start for ${tag} at ${sha}`);
|
||||
await waitForRun(match.databaseId, label);
|
||||
}
|
||||
};
|
||||
|
||||
const verifyHacsDiscovery = () => {
|
||||
const pages = ghJson(['api', '--paginate', '--slurp', `repos/${repo}/releases?per_page=100`]);
|
||||
const releases = pages.flat();
|
||||
const first = releases.find((release) => release.prerelease && !release.draft);
|
||||
if (first?.tag_name !== tag) {
|
||||
throw new Error(
|
||||
`HACS prerelease discovery is stale: GitHub returns ${first?.tag_name || 'none'} before ${tag}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const finishIssues = (releaseUrl) => {
|
||||
if (!issues.length) return;
|
||||
const projectInfo = ghJson([
|
||||
'project', 'view', projectNumber, '--owner', owner, '--format', 'json',
|
||||
]);
|
||||
const project = ghJson([
|
||||
'project', 'item-list', projectNumber, '--owner', owner, '--limit', '1000', '--format', 'json',
|
||||
]);
|
||||
const fields = ghJson(['project', 'field-list', projectNumber, '--owner', owner, '--format', 'json']);
|
||||
const statusField = fields.fields.find((field) => field.name === 'Status');
|
||||
const done = statusField?.options?.find((entry) => entry.name === 'Done');
|
||||
if (!statusField || !done) throw new Error(`Project ${owner}/${projectNumber} has no Status=Done option`);
|
||||
const items = new Map(project.items
|
||||
.filter((item) => item.content?.repository === repo && issues.includes(item.content?.number))
|
||||
.map((item) => [item.content.number, item]));
|
||||
const missing = issues.filter((issue) => !items.has(issue));
|
||||
if (missing.length) throw new Error(`Issues are missing from Project ${projectNumber}: ${missing.join(', ')}`);
|
||||
|
||||
for (const issue of issues) {
|
||||
const row = ghJson(['issue', 'view', String(issue), '--repo', repo, '--json', 'state']);
|
||||
if (row.state === 'OPEN') {
|
||||
run('gh', [
|
||||
'issue', 'close', String(issue), '--repo', repo, '--reason', 'completed',
|
||||
'--comment', `Реализовано и опубликовано в [${tag}](${releaseUrl}).`,
|
||||
], { inherit: true });
|
||||
}
|
||||
const item = items.get(issue);
|
||||
if (item.status !== 'Done') {
|
||||
run('gh', [
|
||||
'project', 'item-edit', '--id', item.id, '--project-id', projectInfo.id,
|
||||
'--field-id', statusField.id, '--single-select-option-id', done.id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const acquireReleaseLock = () => {
|
||||
const raw = run('git', ['rev-parse', '--git-path', `houseplan-release-${tag}.lock`]).stdout;
|
||||
const lockPath = resolve(root, raw);
|
||||
let fd;
|
||||
try {
|
||||
fd = openSync(lockPath, 'wx');
|
||||
} catch (error) {
|
||||
if (error?.code === 'EEXIST')
|
||||
throw new Error(`Another local publication of ${tag} is active (${lockPath})`);
|
||||
throw error;
|
||||
}
|
||||
writeFileSync(fd, `${process.pid}\n`, 'utf8');
|
||||
return { fd, lockPath };
|
||||
};
|
||||
|
||||
const releaseLock = ({ fd, lockPath }) => {
|
||||
closeSync(fd);
|
||||
try { unlinkSync(lockPath); } catch (error) {
|
||||
if (error?.code !== 'ENOENT') throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
issues = parseIssueList(issueOption);
|
||||
const contract = assertReleaseContract({ root, tag, repo, requirePrerelease: true });
|
||||
run('gh', ['auth', 'status']);
|
||||
const currentBranch = run('git', ['branch', '--show-current']).stdout;
|
||||
if (currentBranch !== branch) throw new Error(`Expected branch ${branch}, got ${currentBranch || '(detached)'}`);
|
||||
if (run('git', ['status', '--porcelain']).stdout)
|
||||
throw new Error('Working tree must be clean before publication');
|
||||
run('git', ['fetch', 'origin', branch]);
|
||||
const sha = run('git', ['rev-parse', 'HEAD']).stdout;
|
||||
const remoteBranch = run('git', ['rev-parse', `origin/${branch}`]).stdout;
|
||||
if (sha !== remoteBranch) throw new Error(`HEAD ${sha} is not synchronized with origin/${branch} ${remoteBranch}`);
|
||||
const bundleSha256 = assertBundleSnapshots();
|
||||
const validateRuns = assertGreenValidate(sha);
|
||||
validateIssues();
|
||||
const existingTag = remoteTag();
|
||||
if (existingTag.exists && existingTag.commit !== sha)
|
||||
throw new Error(`Remote tag ${tag} points to ${existingTag.commit}, expected ${sha}`);
|
||||
const existingRelease = releaseView();
|
||||
|
||||
console.log(JSON.stringify({
|
||||
ready: true, tag, version: contract.version, sha, branch, bundleSha256,
|
||||
validateRuns: validateRuns.map((runRow) => ({ id: runRow.databaseId, url: runRow.url })),
|
||||
remoteTag: existingTag.exists, release: existingRelease?.url || null, issues,
|
||||
}, null, 2));
|
||||
if (checkOnly) return;
|
||||
|
||||
if (!confirmed) {
|
||||
if (!stdin.isTTY) throw new Error('Publication requires --yes in a non-interactive shell');
|
||||
const prompt = createInterface({ input: stdin, output: stdout });
|
||||
const answer = await prompt.question(`Type ${tag} to publish exact SHA ${sha}: `);
|
||||
prompt.close();
|
||||
if (answer.trim() !== tag) throw new Error('Publication cancelled');
|
||||
}
|
||||
|
||||
const lock = acquireReleaseLock();
|
||||
let artifactsDir = null;
|
||||
let cleaned = false;
|
||||
const cleanup = () => {
|
||||
if (cleaned) return;
|
||||
cleaned = true;
|
||||
if (artifactsDir) rmSync(artifactsDir, { recursive: true, force: true });
|
||||
releaseLock(lock);
|
||||
};
|
||||
const signalHandlers = new Map([
|
||||
['SIGHUP', () => { try { cleanup(); } finally { process.exit(129); } }],
|
||||
['SIGINT', () => { try { cleanup(); } finally { process.exit(130); } }],
|
||||
['SIGTERM', () => { try { cleanup(); } finally { process.exit(143); } }],
|
||||
]);
|
||||
for (const [signal, handler] of signalHandlers) process.once(signal, handler);
|
||||
try {
|
||||
artifactsDir = mkdtempSync(resolve(tmpdir(), 'houseplan-release-'));
|
||||
if (existingRelease && !existingRelease.isDraft) {
|
||||
let complete;
|
||||
try {
|
||||
complete = verifyReleaseProjection(existingRelease, { tag });
|
||||
} catch (error) {
|
||||
if (!/Release asset .+ is missing or empty/.test(String(error?.message || error))) throw error;
|
||||
console.log(`Published release needs asset recovery: ${error.message}`);
|
||||
}
|
||||
if (complete) {
|
||||
// A matching name and non-zero size are insufficient: bind both
|
||||
// downloadable assets to this exact candidate before closing issues.
|
||||
try {
|
||||
verifyRemoteAssetContents(contract.version, bundleSha256);
|
||||
} catch (error) {
|
||||
if (!(error instanceof ReleaseAssetContentError)) throw error;
|
||||
console.log(`Published release needs stale-asset recovery: ${error.message}`);
|
||||
complete = null;
|
||||
}
|
||||
if (complete) {
|
||||
verifyHacsDiscovery();
|
||||
finishIssues(complete.url);
|
||||
console.log(`Already published and content-verified: ${complete.url}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const zipPath = buildZip(sha, contract.version, bundleSha256, artifactsDir);
|
||||
if (!existingTag.exists) {
|
||||
const local = localTag();
|
||||
if (local.exists && (!local.annotated || local.commit !== sha)) {
|
||||
throw new Error(
|
||||
`Local tag ${tag} is ${local.annotated ? `at ${local.commit}` : 'not annotated'}, expected annotated ${sha}`,
|
||||
);
|
||||
}
|
||||
if (!local.exists) run('git', ['tag', '-a', tag, sha, '-m', tag]);
|
||||
run('git', ['push', 'origin', tag], { inherit: true });
|
||||
}
|
||||
|
||||
let release = releaseView();
|
||||
if (!release) {
|
||||
run('gh', [
|
||||
'release', 'create', tag, '--repo', repo, '--verify-tag', '--draft', '--prerelease',
|
||||
'--title', tag, '--notes-file', 'docs/RELEASE-NOTES.md',
|
||||
], { inherit: true });
|
||||
release = releaseView();
|
||||
}
|
||||
|
||||
run('gh', [
|
||||
'release', 'upload', tag, 'dist/houseplan-card.js', zipPath,
|
||||
'--repo', repo, '--clobber',
|
||||
], { inherit: true });
|
||||
const staged = releaseView();
|
||||
const stagedAssets = new Map((staged?.assets || []).map((asset) => [asset.name, asset]));
|
||||
for (const name of ['houseplan-card.js', 'houseplan.zip']) {
|
||||
if (!(Number(stagedAssets.get(name)?.size) > 0))
|
||||
throw new Error(`Draft release asset ${name} is missing or empty`);
|
||||
}
|
||||
|
||||
const wasDraft = staged.isDraft;
|
||||
run('gh', [
|
||||
'release', 'edit', tag, '--repo', repo, '--draft=false', '--prerelease',
|
||||
'--title', tag, '--notes-file', 'docs/RELEASE-NOTES.md',
|
||||
], { inherit: true });
|
||||
if (wasDraft) await waitForReleaseWorkflows(sha);
|
||||
|
||||
const published = verifyReleaseProjection(releaseView(), { tag });
|
||||
const finalTag = remoteTag();
|
||||
if (!finalTag.exists || finalTag.commit !== sha)
|
||||
throw new Error(`Published tag ${tag} no longer resolves to exact SHA ${sha}`);
|
||||
verifyRemoteAssetContents(contract.version, bundleSha256);
|
||||
verifyHacsDiscovery();
|
||||
finishIssues(published.url);
|
||||
console.log(`Published and content-verified: ${published.url}`);
|
||||
} finally {
|
||||
for (const [signal, handler] of signalHandlers) process.removeListener(signal, handler);
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`prerelease publication failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`prerelease publication failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
+31
-16
@@ -28,7 +28,7 @@ import {
|
||||
DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY, stageBgOf,
|
||||
DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, type SpaceDisplay,
|
||||
referencedContentUrls,
|
||||
DISPLAY_MODES, TAP_ACTIONS, SPACE_FILL_MODES, ROOM_FILL_MODES,
|
||||
DISPLAY_MODES, TAP_ACTIONS, SPACE_FILL_UI_MODES, ROOM_FILL_MODES,
|
||||
normalizeDeviceDisplay, isAlarmCapable, type DeviceDisplayMode,
|
||||
coverService, coverEntityOf, COVER_GUARDED_CLASSES,
|
||||
liveText, liveTextReference, liveTextToken, hassValue, valueWithUnit, decorTextScale, decorTextLines,
|
||||
@@ -146,7 +146,7 @@ import {
|
||||
import { renderOpeningTunnelFills } from './render/opening-tunnels';
|
||||
import { safeStoredColor } from './color';
|
||||
|
||||
const CARD_VERSION = '1.61.0-beta.3';
|
||||
const CARD_VERSION = '1.61.0-beta.4';
|
||||
/** Keeps every previously valid scale at the maximum 20 cm grid scale lossless. */
|
||||
const DECOR_TEXT_CM_MAX = 2000;
|
||||
const CELL_CM_MIN = 0.1;
|
||||
@@ -8630,16 +8630,18 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
/** Frame-local Glow-base projection. Data/static fills keep their exact
|
||||
* colors; only rooms whose effective fill is `none` receive darkness. */
|
||||
private _resolvedGlowBase(space: SpaceModel, disp: SpaceDisplay): RoomFillFrame {
|
||||
* colors; a room receives darkness only when its selected data mode resolves
|
||||
* to no visible data object (for example Temperature without a sensor). */
|
||||
private _resolvedGlowBase(
|
||||
space: SpaceModel, disp: SpaceDisplay, dataFills: RoomFillFrame,
|
||||
): RoomFillFrame {
|
||||
const byRoom = new Map<RoomCfg, ResolvedRoomFill | null>();
|
||||
const byId = new Map<string, ResolvedRoomFill | null>();
|
||||
const base = this._fillColors.glow_base;
|
||||
for (const room of space.rooms) {
|
||||
const mode = this._roomDialog && room.id === this._roomEditId
|
||||
? (this._roomFill || disp.fill)
|
||||
: roomFillModeOf(disp.fill, room);
|
||||
const resolved: ResolvedRoomFill | null = roomGlowOf(disp.glow, room) && mode === 'none'
|
||||
const dataFill = dataFills.byRoom.get(room);
|
||||
const resolved: ResolvedRoomFill | null = roomGlowOf(disp.glow, room)
|
||||
&& (!dataFill || dataFill.opacity <= 0)
|
||||
? { color: base.c, opacity: base.a, mode: 'glow' }
|
||||
: null;
|
||||
byRoom.set(room, resolved);
|
||||
@@ -10132,14 +10134,24 @@ class HouseplanCard extends LitElement {
|
||||
const sp = this._serverCfg!.spaces.find((x: any) => x.id === spaceId);
|
||||
if (!sp) return;
|
||||
const disp = spaceDisplayOf(sp);
|
||||
const storedCustom = sp.settings?.custom_fill && typeof sp.settings.custom_fill === 'object'
|
||||
? customFillOf(sp.settings.custom_fill) : null;
|
||||
// Space-level None is no longer a separate UI choice. Project it to the
|
||||
// same custom colour at zero opacity, so merely opening/saving settings
|
||||
// cannot change either the visible floor or the Glow darkness.
|
||||
const dialogCustom = disp.fill === 'none'
|
||||
? { ...(storedCustom || DEFAULT_CUSTOM_FILL), a: 0 }
|
||||
: storedCustom;
|
||||
this._spaceDialog = {
|
||||
mode, spaceId, title: sp.title, planUrl: sp.plan_url || null, planFile: null,
|
||||
source: sp.plan_url ? 'file' : 'draw',
|
||||
showBorders: disp.showBorders, showNames: disp.showNames,
|
||||
hideDecor: disp.hideDecor, hideOpenings: disp.hideOpenings,
|
||||
roomColor: disp.color, roomOpacity: disp.opacity, fillMode: disp.fill,
|
||||
customFill: sp.settings?.custom_fill && typeof sp.settings.custom_fill === 'object'
|
||||
? customFillOf(sp.settings.custom_fill) : null,
|
||||
roomColor: disp.color, roomOpacity: disp.opacity,
|
||||
// `none` is a legacy space token. The current editor represents its
|
||||
// exact appearance as a transparent user colour.
|
||||
fillMode: disp.fill === 'none' ? 'custom' : disp.fill,
|
||||
customFill: dialogCustom,
|
||||
glowEnabled: disp.glow,
|
||||
bgColor: disp.bgColor,
|
||||
bgMode: sp.settings?.bg_mode === 'static' || sp.settings?.bg_mode === 'daynight' ? sp.settings.bg_mode : null,
|
||||
@@ -10159,8 +10171,11 @@ class HouseplanCard extends LitElement {
|
||||
source: 'file',
|
||||
showBorders: false, showNames: false,
|
||||
hideDecor: false, hideOpenings: false,
|
||||
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none',
|
||||
customFill: null,
|
||||
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'custom',
|
||||
// `custom` replaces the old `none` choice in the editor, but creating
|
||||
// a space must preserve the old no-floor visual by default. The user
|
||||
// can make this colour visible explicitly with the opacity control.
|
||||
customFill: { ...DEFAULT_CUSTOM_FILL, a: 0 },
|
||||
glowEnabled: true,
|
||||
bgColor: null,
|
||||
bgMode: null, northDeg: null, sunRays: null,
|
||||
@@ -10531,7 +10546,7 @@ class HouseplanCard extends LitElement {
|
||||
source: 'file',
|
||||
showBorders: false, showNames: false,
|
||||
hideDecor: false, hideOpenings: false,
|
||||
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'none',
|
||||
roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'custom',
|
||||
customFill: null,
|
||||
glowEnabled: true,
|
||||
bgColor: null,
|
||||
@@ -11636,7 +11651,7 @@ class HouseplanCard extends LitElement {
|
||||
const devs = this._devices.filter((d) => d.space === space.id && (!d.hidden || showGhosts));
|
||||
const disp = this._spaceDisplayForRender();
|
||||
const roomFills = this._resolvedRoomFills(space, disp);
|
||||
const glowBase = this._resolvedGlowBase(space, disp);
|
||||
const glowBase = this._resolvedGlowBase(space, disp, roomFills);
|
||||
const showLqi = disp.showLqi ?? this._config.show_signal ?? true;
|
||||
const cfgSize = this._config.icon_size ?? 2.5;
|
||||
const iconPct = cfgSize > 8 ? 2.5 : cfgSize;
|
||||
@@ -14904,7 +14919,7 @@ class HouseplanCard extends LitElement {
|
||||
<option value="0" ?selected=${d.sunRays === false}>${this._t('space.sun_off')}</option>
|
||||
</select>
|
||||
<label>${this._t('space.fill_label')}</label>
|
||||
${SPACE_FILL_MODES.map((v) => [v, 'fill.' + v] as const).map(
|
||||
${SPACE_FILL_UI_MODES.map((v) => [v, 'fill.' + v] as const).map(
|
||||
([v, k]) => html`<label class="srcrow">
|
||||
<input type="radio" name="fillmode" .checked=${d.fillMode === v}
|
||||
@change=${() => (this._spaceDialog = { ...d, fillMode: v as any })} />
|
||||
|
||||
+1
-1
@@ -328,7 +328,7 @@
|
||||
"fill.lqi": "Zigbee signal",
|
||||
"fill.light": "Lights",
|
||||
"fill.custom": "Custom color",
|
||||
"space.custom_fill": "Custom fill color",
|
||||
"space.custom_fill": "Fill color",
|
||||
"space.source_file": "I have a floor-plan image",
|
||||
"space.source_draw": "No image — I'll outline rooms by hand",
|
||||
"space.orientation": "Canvas",
|
||||
|
||||
+1
-1
@@ -328,7 +328,7 @@
|
||||
"fill.lqi": "По силе зигби-сигнала",
|
||||
"fill.light": "По освещению",
|
||||
"fill.custom": "Свой цвет",
|
||||
"space.custom_fill": "Свой цвет заливки",
|
||||
"space.custom_fill": "Цвет заливки",
|
||||
"space.source_file": "У меня есть картинка плана",
|
||||
"space.source_draw": "Нет подложки — нарисую комнаты вручную",
|
||||
"space.orientation": "Холст",
|
||||
|
||||
+6
-1
@@ -819,8 +819,13 @@ export function normalizeDeviceDisplay(value: unknown): DeviceDisplayMode {
|
||||
? value as DeviceDisplayMode : 'badge';
|
||||
}
|
||||
export const TAP_ACTIONS = ['info', 'more-info', 'toggle', 'run', 'cover'] as const;
|
||||
/** Current space-level data fills. Legacy `glow` is read separately as overlay. */
|
||||
/** Persisted space-level data fills. `none` remains a read-compatibility token;
|
||||
* the space editor projects it to `custom` because every room has a visible
|
||||
* base colour and the user can now choose that colour directly. */
|
||||
export const SPACE_FILL_MODES = ['none', 'lqi', 'light', 'temp', 'custom'] as const;
|
||||
/** Choices written by the current space editor. Keep `none` available only to
|
||||
* old configs and to the room-level override that disables an inherited fill. */
|
||||
export const SPACE_FILL_UI_MODES = ['custom', 'lqi', 'light', 'temp'] as const;
|
||||
export const ROOM_FILL_MODES = ['none', 'lqi', 'light', 'temp', 'custom'] as const;
|
||||
|
||||
export const TOGGLE_SAFE_DOMAINS = new Set(['light', 'switch', 'fan', 'humidifier', 'cover', 'valve']);
|
||||
|
||||
+24
-13
@@ -151,6 +151,25 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
const fr = spaceFrame(space, placed);
|
||||
const vb = [fr.x, fr.y, fr.w, fr.h];
|
||||
|
||||
// Resolve once per room and reuse the exact result for the visible fill and
|
||||
// the Glow fallback. A selected data mode with no data is effectively empty
|
||||
// and therefore receives the same base darkness as explicit `none`.
|
||||
const resolvedRoomFills = new Map(space.rooms.map((room) => {
|
||||
const fill = roomFillModeOf(disp.fill, room);
|
||||
return [room, resolveEffectiveRoomFill(
|
||||
fill,
|
||||
fill === 'lqi' && room.area ? areaLqi(planHass, spaceDevs, room.area) : null,
|
||||
fill === 'light'
|
||||
? resolvedLightState(resolvedLightSources(planHass, spaceDevs, room))
|
||||
: 'none',
|
||||
fill === 'temp' && room.area ? areaTemp(planHass, spaceDevs, room.area) : null,
|
||||
disp.tempMin,
|
||||
disp.tempMax,
|
||||
colors,
|
||||
roomCustomFillOf(disp.customFill, room),
|
||||
)] as const;
|
||||
}));
|
||||
|
||||
const roomShapes = space.rooms
|
||||
.filter((r) => r.area || disp.showBorders || roomFillModeOf(disp.fill, r) !== 'none')
|
||||
.map((r) => {
|
||||
@@ -162,18 +181,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
cls += ' styled';
|
||||
const parts = [`--room-stroke:${disp.color}`, `--room-stroke-op:${disp.showBorders ? disp.opacity : 0}`];
|
||||
// fill rendered exactly as configured on the full card (snapshot of current states)
|
||||
const fillC = resolveEffectiveRoomFill(
|
||||
fill,
|
||||
fill === 'lqi' && r.area ? areaLqi(planHass, spaceDevs, r.area) : null,
|
||||
fill === 'light'
|
||||
? resolvedLightState(resolvedLightSources(planHass, spaceDevs, r))
|
||||
: 'none',
|
||||
fill === 'temp' && r.area ? areaTemp(planHass, spaceDevs, r.area) : null,
|
||||
disp.tempMin,
|
||||
disp.tempMax,
|
||||
colors,
|
||||
roomCustomFillOf(disp.customFill, r),
|
||||
);
|
||||
const fillC = resolvedRoomFills.get(r) || null;
|
||||
if (fillC) {
|
||||
cls += ' filled';
|
||||
parts.push(`--room-fill:${fillC.color}`, `--room-fill-op:${fillC.opacity.toFixed(3)}`);
|
||||
@@ -198,7 +206,10 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
// The compact card intentionally has no live radial pools, but it shares the
|
||||
// exact independent data/base projection with the full plan.
|
||||
const glowBaseShapes = space.rooms
|
||||
.filter((room) => roomGlowOf(disp.glow, room) && roomFillModeOf(disp.fill, room) === 'none')
|
||||
.filter((room) => {
|
||||
const fill = resolvedRoomFills.get(room);
|
||||
return roomGlowOf(disp.glow, room) && (!fill || fill.opacity <= 0);
|
||||
})
|
||||
.map((room) => room.poly
|
||||
? svg`<polygon class="glow-base" aria-hidden="true" pointer-events="none"
|
||||
data-room-id=${room.id || nothing}
|
||||
|
||||
+12
-1
@@ -566,11 +566,22 @@ test('roomFillStyle: palette-driven fills, lqi gradient between custom endpoints
|
||||
|
||||
test('custom fill projection is safe and follows room -> space -> default inheritance', () => {
|
||||
assert.deepEqual(customFillOf(null), { c: '#607d8b', a: 0.18 });
|
||||
assert.deepEqual(customFillOf({ c: 'url(javascript:x)', a: Infinity }), { c: '#607d8b', a: 0.18 });
|
||||
for (const hostile of [
|
||||
'url(javascript:x)', 'red;position:fixed', 'var(--primary-color)',
|
||||
'#fff', 'rgb(1, 2, 3)', '#123456\ncolor:red', '#'.padEnd(100001, '1'),
|
||||
null, 123456, [], { nested: '#123456' },
|
||||
]) {
|
||||
assert.deepEqual(customFillOf({ c: hostile, a: Infinity }),
|
||||
{ c: '#607d8b', a: 0.18 }, String(hostile));
|
||||
}
|
||||
assert.deepEqual(customFillOf({ c: '#abcdef', a: -2 }), { c: '#abcdef', a: 0 });
|
||||
const space = { c: '#112233', a: 0.25 };
|
||||
assert.deepEqual(roomCustomFillOf(space, {}), space);
|
||||
assert.deepEqual(roomCustomFillOf(space, { settings: { custom_fill: null } }), space);
|
||||
assert.deepEqual(roomCustomFillOf(space, { settings: { custom_fill: { c: '#445566' } } }),
|
||||
{ c: '#445566', a: 0.25 });
|
||||
assert.deepEqual(roomCustomFillOf(space, { settings: { custom_fill: { a: 0.7 } } }),
|
||||
{ c: '#112233', a: 0.7 });
|
||||
assert.deepEqual(roomCustomFillOf(space, { settings: { custom_fill: { c: '#445566', a: 0.7 } } }),
|
||||
{ c: '#445566', a: 0.7 });
|
||||
});
|
||||
|
||||
@@ -2,16 +2,24 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
assertCardContract,
|
||||
GLOW_CARD_CONTRACT,
|
||||
LARGE_HOUSE_CARD_CONTRACT,
|
||||
} from '../demo/performance/card-contract.mjs';
|
||||
|
||||
const directCardMembers = (relativePath) => {
|
||||
const source = readFileSync(new URL(`../${relativePath}`, import.meta.url), 'utf8');
|
||||
return [...new Set([...source.matchAll(/\bcard\.(_[A-Za-z0-9_]+)/g)].map((match) => match[1]))].sort();
|
||||
// Runner-local names never start with one underscore. Tokenising every
|
||||
// single-underscore member also catches optional chaining, bracket/in access
|
||||
// and a future alias such as `const target = card; target._loadOk`.
|
||||
return [...new Set(
|
||||
[...source.matchAll(/\b(_(?!_)[A-Za-z0-9_]+)\b/g)].map((match) => match[1]),
|
||||
)].sort();
|
||||
};
|
||||
|
||||
const declaredMembers = (contract) => new Set([...contract.methods, ...contract.fields]);
|
||||
const declaredMembers = (contract) => new Set([
|
||||
...contract.methods, ...contract.fields, ...(contract.optionalFields || []),
|
||||
]);
|
||||
|
||||
test('large-house benchmark declares every private card member it consumes', () => {
|
||||
const declared = declaredMembers(LARGE_HOUSE_CARD_CONTRACT);
|
||||
@@ -28,3 +36,31 @@ test('Glow benchmark declares every private card member it consumes', () => {
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('performance contracts reference real production members', () => {
|
||||
const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8');
|
||||
for (const contract of [LARGE_HOUSE_CARD_CONTRACT, GLOW_CARD_CONTRACT]) {
|
||||
for (const name of declaredMembers(contract)) {
|
||||
assert.match(source, new RegExp(`\\b(?:private\\s+(?:get\\s+)?|get\\s+)${name}\\b`),
|
||||
`${contract.label} declares missing production member ${name}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('contract accepts recent optional fields only when their runtime type is valid', () => {
|
||||
const base = {
|
||||
_cleanFloorCache: new Map(), _glowClipCache: new Map(),
|
||||
_wallUnionCache: null, _openingTunnelCache: null, _openingWallIndexCache: null,
|
||||
_devices: [], _loadOk: false,
|
||||
};
|
||||
assert.doesNotThrow(() => assertCardContract(base, GLOW_CARD_CONTRACT));
|
||||
assert.doesNotThrow(() => assertCardContract({ ...base, _glowScreenBlend: false }, GLOW_CARD_CONTRACT));
|
||||
assert.throws(
|
||||
() => assertCardContract({ ...base, _glowScreenBlend: undefined }, GLOW_CARD_CONTRACT),
|
||||
/invalid private API types: _glowScreenBlend:boolean/,
|
||||
);
|
||||
assert.throws(
|
||||
() => assertCardContract({ ...base, _cleanFloorCache: {} }, GLOW_CARD_CONTRACT),
|
||||
/invalid private API types: _cleanFloorCache:map/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import {
|
||||
assertReleaseContract,
|
||||
changelogContainsVersion,
|
||||
parseVersionSources,
|
||||
validateReleaseNotes,
|
||||
validateVersionSources,
|
||||
versionFromTag,
|
||||
} from '../scripts/release-contract.mjs';
|
||||
import {
|
||||
parseIssueList, parsePrereleaseArgs, readZipEntries, verifyReleaseProjection,
|
||||
} from '../scripts/release-prerelease.mjs';
|
||||
|
||||
const repo = 'Matysh/houseplan-card';
|
||||
const packageVersion = JSON.parse(
|
||||
readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
|
||||
).version;
|
||||
const tag = `v${packageVersion}`;
|
||||
const packageIsPrerelease = packageVersion.includes('-');
|
||||
const notes = `<!-- release: ${tag} -->
|
||||
|
||||
## Основное
|
||||
- Значимое изменение.
|
||||
- Мелкие исправления и улучшения.
|
||||
|
||||
## Highlights
|
||||
- Significant change.
|
||||
- Small fixes and improvements.
|
||||
|
||||
[RU](https://github.com/${repo}/blob/${tag}/docs/CHANGELOG.ru.md)
|
||||
[EN](https://github.com/${repo}/blob/${tag}/docs/CHANGELOG.md)
|
||||
`;
|
||||
|
||||
test('release SemVer parser accepts prereleases and rejects unsafe tags', () => {
|
||||
assert.deepEqual(versionFromTag(tag), { version: packageVersion, prerelease: packageIsPrerelease });
|
||||
assert.deepEqual(versionFromTag('v2.0.0'), { version: '2.0.0', prerelease: false });
|
||||
assert.throws(() => versionFromTag('1.2.3-beta.1'), /start with v/);
|
||||
assert.throws(() => versionFromTag('v1.2.3-beta.01'), /leading zeroes/);
|
||||
assert.throws(() => validateVersionSources('v1.2.3', { source: '1.2.3' }), /requires a prerelease/);
|
||||
});
|
||||
|
||||
test('version sources include every shipped authority and must match the tag', () => {
|
||||
const sources = parseVersionSources({
|
||||
packageJson: '{"version":"1.2.3-beta.4"}',
|
||||
packageLock: '{"version":"1.2.3-beta.4","packages":{"":{"version":"1.2.3-beta.4"}}}',
|
||||
manifest: '{"version":"1.2.3-beta.4"}',
|
||||
constSource: 'VERSION = "1.2.3-beta.4"',
|
||||
cardSource: "const CARD_VERSION = '1.2.3-beta.4';",
|
||||
});
|
||||
assert.equal(Object.keys(sources).length, 6);
|
||||
assert.equal(validateVersionSources('v1.2.3-beta.4', sources).version, '1.2.3-beta.4');
|
||||
assert.throws(
|
||||
() => validateVersionSources('v1.2.3-beta.5', sources),
|
||||
/Version 1\.2\.3-beta\.5 is not synchronized/,
|
||||
);
|
||||
});
|
||||
|
||||
test('release notes enforce the canonical bilingual short body and pinned links', () => {
|
||||
assert.equal(validateReleaseNotes(notes, { tag, repo }).trim(), notes.trim());
|
||||
assert.throws(
|
||||
() => validateReleaseNotes(notes.replace('## Основное', '## Русский'), { tag, repo }),
|
||||
/Legacy release-note headings/,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateReleaseNotes(notes.replace('## Highlights', '## English\n\n## Highlights'), { tag, repo }),
|
||||
/Legacy release-note headings/,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateReleaseNotes(notes.replace(`<!-- release: ${tag} -->`, '<!-- release: v9.9.9 -->'), { tag, repo }),
|
||||
/exact .* marker/,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateReleaseNotes(notes.replace('## Основное', '- premature\n\n## Основное'), { tag, repo }),
|
||||
/before ## Основное/,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateReleaseNotes(notes.replace(`/blob/${tag}/`, '/blob/dev/'), { tag, repo }),
|
||||
/immutable/,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateReleaseNotes(notes.replace('- Significant change.\n', ''), { tag, repo }),
|
||||
/equivalent RU\/EN lists/,
|
||||
);
|
||||
const oversized = notes
|
||||
.replace('\n## Highlights', '\n- Second.\n- Third.\n- Fourth.\n\n## Highlights')
|
||||
.replace('## Highlights\n', '## Highlights\n- Second.\n- Third.\n- Fourth.\n');
|
||||
assert.throws(() => validateReleaseNotes(oversized, { tag, repo }), /at most four bullets/);
|
||||
assert.equal(changelogContainsVersion(`## ${tag} — 2026-08-10\n\n- Released.\n`, tag), true);
|
||||
assert.equal(changelogContainsVersion(`## ${tag}\n`, tag), false);
|
||||
assert.equal(changelogContainsVersion(`## ${tag} — 2026-99-99\n\n- Released.\n`, tag), false);
|
||||
assert.equal(changelogContainsVersion(`## ${tag} — 2026-08-10\n`, tag), false);
|
||||
assert.equal(changelogContainsVersion(`\`\`\`md\n## ${tag} — 2026-08-10\n- Fake.\n\`\`\``, tag), false);
|
||||
const nested = notes.replace('- Значимое изменение.', '- Значимое изменение.\n - Деталь реализации.');
|
||||
assert.equal(validateReleaseNotes(nested, { tag, repo }).trim(), nested.trim());
|
||||
});
|
||||
|
||||
test('current repository release metadata satisfies its own publication contract', () => {
|
||||
const result = assertReleaseContract({ tag, repo, requirePrerelease: packageIsPrerelease });
|
||||
assert.equal(result.version, packageVersion);
|
||||
assert.equal(result.prerelease, packageIsPrerelease);
|
||||
});
|
||||
|
||||
test('local orchestrator validates issue lists and public release assets', () => {
|
||||
assert.deepEqual(parseIssueList('63, 56,63'), [63, 56]);
|
||||
assert.throws(() => parseIssueList('63,nope'), /positive issue numbers/);
|
||||
assert.deepEqual(parsePrereleaseArgs([tag, '--issues=63,64', '--yes']), {
|
||||
tag, repo, branch: 'dev', projectNumber: '1', issueOption: '63,64',
|
||||
checkOnly: false, confirmed: true,
|
||||
});
|
||||
assert.throws(() => parsePrereleaseArgs([tag, '--isues=63']), /Unknown or malformed/);
|
||||
assert.throws(() => parsePrereleaseArgs([tag, 'extra']), /Exactly one/);
|
||||
const release = {
|
||||
tagName: tag, isDraft: false, isPrerelease: true,
|
||||
assets: [{ name: 'houseplan-card.js', size: 10 }, { name: 'houseplan.zip', size: 20 }],
|
||||
};
|
||||
assert.equal(verifyReleaseProjection(release, { tag }), release);
|
||||
assert.throws(
|
||||
() => verifyReleaseProjection({ ...release, isDraft: true }, { tag }),
|
||||
/still a draft/,
|
||||
);
|
||||
assert.throws(
|
||||
() => verifyReleaseProjection({ ...release, assets: release.assets.slice(0, 1) }, { tag }),
|
||||
/houseplan\.zip/,
|
||||
);
|
||||
});
|
||||
|
||||
test('release ZIP inspection is portable and does not depend on tar', () => {
|
||||
const root = fileURLToPath(new URL('..', import.meta.url));
|
||||
const temp = mkdtempSync(join(tmpdir(), 'houseplan-zip-test-'));
|
||||
const zip = join(temp, 'houseplan.zip');
|
||||
try {
|
||||
const archived = spawnSync('git', [
|
||||
'archive', '--format=zip', `--output=${zip}`, 'HEAD:custom_components/houseplan',
|
||||
], { cwd: root, encoding: 'utf8' });
|
||||
assert.equal(archived.status, 0, archived.stderr || archived.stdout);
|
||||
const entries = readZipEntries(zip, ['manifest.json', 'frontend/houseplan-card.js']);
|
||||
assert.equal(typeof JSON.parse(entries.get('manifest.json').toString('utf8')).version, 'string');
|
||||
assert.ok(entries.get('frontend/houseplan-card.js').length > 1_000);
|
||||
} finally {
|
||||
rmSync(temp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('prerelease CLI reports malformed arguments without a raw stack trace', () => {
|
||||
const root = fileURLToPath(new URL('..', import.meta.url));
|
||||
const result = spawnSync(process.execPath, [
|
||||
'scripts/release-prerelease.mjs', tag, '--isues=63',
|
||||
], { cwd: root, encoding: 'utf8' });
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /^prerelease publication failed: Unknown or malformed option:/);
|
||||
assert.doesNotMatch(result.stderr, /\n\s+at\s/);
|
||||
});
|
||||
|
||||
test('manual publish workflow is draft-first, exact-SHA gated and self-contained', () => {
|
||||
const workflow = readFileSync(new URL('../.github/workflows/publish-prerelease.yml', import.meta.url), 'utf8');
|
||||
for (const required of [
|
||||
'workflow_dispatch:',
|
||||
'node scripts/release-contract.mjs',
|
||||
'node scripts/release-gate.mjs',
|
||||
'--draft --prerelease',
|
||||
"'houseplan-card.js', 'houseplan.zip'",
|
||||
'--draft=false --prerelease',
|
||||
'Verify HACS prerelease discovery order',
|
||||
'group: publish-prerelease-${{ inputs.tag }}',
|
||||
"if: ${{ needs.publish.outputs.newly_published == 'true' }}",
|
||||
'uses: ./.github/workflows/announce.yml',
|
||||
]) assert.ok(workflow.includes(required), `missing workflow contract: ${required}`);
|
||||
assert.ok(workflow.indexOf('gh release upload') < workflow.indexOf('--draft=false --prerelease'));
|
||||
|
||||
const announce = readFileSync(new URL('../.github/workflows/announce.yml', import.meta.url), 'utf8');
|
||||
assert.ok(announce.includes('workflow_call:'));
|
||||
assert.ok(announce.includes('if: ${{ inputs.reusable == true }}'));
|
||||
assert.ok(announce.includes('CALLED: ${{ inputs.reusable }}'));
|
||||
assert.ok(announce.includes('BODY=$(cat docs/RELEASE-NOTES.md)'));
|
||||
assert.ok(!announce.includes("github.event_name == 'workflow_call'"));
|
||||
|
||||
const local = readFileSync(new URL('../scripts/release-prerelease.mjs', import.meta.url), 'utf8');
|
||||
assert.ok(local.includes("'archive', '--format=zip'"));
|
||||
assert.ok(local.includes("'release', 'download'"));
|
||||
assert.ok(local.includes("'release-zip.yml'"));
|
||||
assert.ok(local.includes('Published release needs stale-asset recovery'));
|
||||
assert.ok(local.includes("['SIGINT'"));
|
||||
assert.ok(!local.includes("run('tar'"));
|
||||
});
|
||||
@@ -774,16 +774,27 @@ def test_every_fill_mode_the_editor_offers_is_accepted():
|
||||
|
||||
def test_custom_fill_color_and_alpha_are_strict_on_space_and_room():
|
||||
v.SPACE_SCHEMA(_space(fill_mode="custom", custom_fill={"c": "#123ABC", "a": 0.4}))
|
||||
v.SPACE_SCHEMA(_space(fill_mode="custom", custom_fill=None))
|
||||
custom_room = _space(fill_mode="none")
|
||||
custom_room["rooms"] = [{
|
||||
"id": "r1", "name": "R", "x": 0.1, "y": 0.1, "w": 0.2, "h": 0.2,
|
||||
"settings": {"fill_mode": "custom", "custom_fill": {"c": "#abcdef", "a": 1}},
|
||||
}]
|
||||
v.SPACE_SCHEMA(custom_room)
|
||||
for bad in ({"c": "red", "a": 0.2}, {"c": "#123456", "a": float("nan")},
|
||||
{"c": "#123456", "a": 1.1}):
|
||||
custom_room["rooms"][0]["settings"]["custom_fill"] = None
|
||||
v.SPACE_SCHEMA(custom_room)
|
||||
for bad in ({"c": "red", "a": 0.2}, {"c": "#123456\n", "a": 0.2},
|
||||
{"c": "#123456", "a": float("nan")}, {"c": "#123456", "a": 1.1},
|
||||
{"c": "#123456"}, {"a": 0.2}):
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.SPACE_SCHEMA(_space(fill_mode="custom", custom_fill=bad))
|
||||
room_bad = _space(fill_mode="none")
|
||||
room_bad["rooms"] = [{
|
||||
"id": "r1", "name": "R", "x": 0.1, "y": 0.1, "w": 0.2, "h": 0.2,
|
||||
"settings": {"fill_mode": "custom", "custom_fill": bad},
|
||||
}]
|
||||
with pytest.raises(vol.Invalid):
|
||||
v.SPACE_SCHEMA(room_bad)
|
||||
|
||||
|
||||
def test_independent_glow_fields_and_legacy_tokens_are_accepted():
|
||||
|
||||
Reference in New Issue
Block a user