Skip to content

Commit 48d9f85

Browse files
authored
test: regression tests for sky-compass N-wrap arc clamp (#89) (#95)
* test: regression tests for sky-compass N-wrap arc clamp (#89) I confirmed v2.0.2's clampActiveArcToFov already handles the wrap-through-north envelope with interior sunrise/sunset azimuths correctly (the @an0Nym0us63 follow-up on #89 reads as a stale-client issue, not a defect). These two tests lock the contract in place so future refactors can't silently regress it. Also includes a deterministic dist/ rebuild (56 bytes) — develop's committed bundle was slightly out of sync with the source. Satisfies the tests.yml dist-up-to-date gate. * feat: add static-FOV underlay and developer test harness Sky compass now renders a dimmed dashed wedge of the configured windowAzi ± fov_left/right envelope beneath the active sun arc, so the developer can see both at once. Skipped when the active arc already covers the full envelope. Adds a committed harness/ workspace (lit + a mock HomeAssistant layer driven by SunCalc) for reproducing card renders without HA — including a share-state URL feature for handing off broken states. CI now sanity-builds the harness and lint/format/ typecheck cover harness/ alongside src/ and tests/.
1 parent c3ae231 commit 48d9f85

31 files changed

Lines changed: 4029 additions & 25 deletions

.github/workflows/tests.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ jobs:
3737
- name: Build
3838
run: npm run build
3939

40+
- name: Build harness
41+
# Sanity-build the developer test harness so the mock layer stays in
42+
# sync with src/. Output (harness/dist/) is .gitignored — this step
43+
# only verifies the build succeeds.
44+
run: npm run build:harness
45+
4046
- name: Verify dist/ is up to date
4147
run: |
4248
if [ -n "$(git status --porcelain dist/)" ]; then

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# build artifacts
22
node_modules/
33
*.tsbuildinfo
4+
harness/dist/
45

56
# editor / OS
67
.vscode/*

dist/adaptive-cover-pro-card.js

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -86,23 +86,27 @@ function e(e,t,s,o){var i,n=arguments.length,r=n<3?t:null===o?o=Object.getOwnPro
8686
${this.showLegend?this._renderLegend(e,o):K}
8787
${this.showStats?this._renderStats(e,o):K}
8888
</div>
89-
`}_renderEntryLayers(e,t,s=0,o=[]){const i=Be(e.sun.window_azimuth),n=Be(i-e.sun.fov_left),r=Be(i+e.sun.fov_right),a=this._readActiveAzimuth(e.d.entities.start_sensor),l=this._readActiveAzimuth(e.d.entities.end_sensor),c=null!==a&&null!==l;let d,h;if(c)({wedgeStart:d,wedgeEnd:h}=function(e,t,s,o,i){const n=((s-o)%360+360)%360,r=o+i,a=((t-n)%360+360)%360,l=e=>e<=r?e:e-r<360-e?r:0,c=l(((e-n)%360+360)%360),d=l(a);return c===d?{wedgeStart:n,wedgeEnd:((n+r)%360+360)%360}:{wedgeStart:((n+Math.min(c,d))%360+360)%360,wedgeEnd:((n+Math.max(c,d))%360+360)%360}}(Be(a),Be(l),i,e.sun.fov_left,e.sun.fov_right));else{const t=function(e,t,s,o,i){if(void 0===i)return null;const n=Be(t-s),r=s+o,a=e.filter(e=>((e.azimuth-n)%360+360)%360<=r&&e.elevation>i);return 0===a.length?null:{wedgeStart:a[0].azimuth,wedgeEnd:a[a.length-1].azimuth}}(o,i,e.sun.fov_left,e.sun.fov_right,e.sun.min_elevation);d=t?t.wedgeStart:n,h=t?t.wedgeEnd:r}const p=je(i,ft,s),{outer:u,inner:_}=(m=e.sun.min_elevation,g=e.sun.max_elevation,v=ft,void 0!==m&&void 0!==g&&m>g?{outer:v,inner:0}:{outer:void 0!==m?v*He(m):v,inner:void 0!==g?v*He(g):0});var m,g,v;const f="cover_awning"===e.coverType?e.coverPos/100:1-e.coverPos/100,y=null!==e.coverPos?ft*f:null,b=null!==y?Math.min(y,u):null,$=e.sun.blind_spot_range?[Be((w=i)-(x=e.sun.blind_spot_range)[1]),Be(w-x[0])]:null;var w,x;const k=$?qe($[0],$[1],ft,0,s):null,C=qe(d,h,u,_,s),S=null!==b&&b>_?qe(d,h,b,_,s):"",A=t?`${e.d.entry_title}: `:"",E=void 0!==e.sun.min_elevation||void 0!==e.sun.max_elevation?De("compass.elev_suffix",this.hass,{min:ut(e.sun.min_elevation??0),max:ut(e.sun.max_elevation??90)}):"",z=c?`${A}${De("compass.active_sun_arc",this.hass,{from:ut(d),to:ut(h),elev:E})}`:`${A}${De("compass.fov_arc",this.hass,{left:ut(e.sun.fov_left),right:ut(e.sun.fov_right),elev:E})}`,P=`${A}${De("compass.window_normal_tooltip",this.hass,{bearing:ut(i)})}`,O=null!==e.coverPos?"cover_awning"===e.coverType?`${A}${De("compass.cover_extended",this.hass,{pct:e.coverPos})}`:`${A}${De("compass.cover_closed_tooltip",this.hass,{pct:e.coverPos})}`:"",T=$?`${A}${De("compass.blind_spot",this.hass,{from:ut($[0]),to:ut($[1])})}`:"",M=t||e.isOverride,I=M?`fill: ${e.color}; stroke: ${e.color};`:"",F=M?`fill: ${e.color}; stroke: ${e.color};`:"",N=M?`fill: ${e.color}; stroke: ${e.color};`:"",R=M?`stroke: ${e.color};`:"",D=M?`fill: ${e.color};`:"",L=this.showCoverFill&&""!==S,j=this.showBlindSpot&&!!k,H=this.showWindowArrow,q=`M 0 0 L ${p.x} ${p.y}`,U="display: none;";return W`<g class="entry-overlay">
90-
<g data-tooltip=${z}>
91-
<title>${z}</title>
92-
<path class="fov" style=${I} d=${C}></path>
93-
</g>
94-
<g class="arrow-group" data-tooltip=${P} style=${H?"":U}>
95-
<title>${P}</title>
96-
<path class="window" style=${R} d=${q}></path>
97-
<circle class="window-base" style=${D} cx="0" cy="0" r="4"></circle>
98-
</g>
99-
<g class="cover-group" data-tooltip=${O} style=${L?"":U}>
89+
`}_renderEntryLayers(e,t,s=0,o=[]){const i=Be(e.sun.window_azimuth),n=Be(i-e.sun.fov_left),r=Be(i+e.sun.fov_right),a=this._readActiveAzimuth(e.d.entities.start_sensor),l=this._readActiveAzimuth(e.d.entities.end_sensor),c=null!==a&&null!==l;let d,h;if(c)({wedgeStart:d,wedgeEnd:h}=function(e,t,s,o,i){const n=((s-o)%360+360)%360,r=o+i,a=((t-n)%360+360)%360,l=e=>e<=r?e:e-r<360-e?r:0,c=l(((e-n)%360+360)%360),d=l(a);return c===d?{wedgeStart:n,wedgeEnd:((n+r)%360+360)%360}:{wedgeStart:((n+Math.min(c,d))%360+360)%360,wedgeEnd:((n+Math.max(c,d))%360+360)%360}}(Be(a),Be(l),i,e.sun.fov_left,e.sun.fov_right));else{const t=function(e,t,s,o,i){if(void 0===i)return null;const n=Be(t-s),r=s+o,a=e.filter(e=>((e.azimuth-n)%360+360)%360<=r&&e.elevation>i);return 0===a.length?null:{wedgeStart:a[0].azimuth,wedgeEnd:a[a.length-1].azimuth}}(o,i,e.sun.fov_left,e.sun.fov_right,e.sun.min_elevation);d=t?t.wedgeStart:n,h=t?t.wedgeEnd:r}const p=je(i,ft,s),{outer:u,inner:_}=(m=e.sun.min_elevation,g=e.sun.max_elevation,v=ft,void 0!==m&&void 0!==g&&m>g?{outer:v,inner:0}:{outer:void 0!==m?v*He(m):v,inner:void 0!==g?v*He(g):0});var m,g,v;const f="cover_awning"===e.coverType?e.coverPos/100:1-e.coverPos/100,y=null!==e.coverPos?ft*f:null,b=null!==y?Math.min(y,u):null,$=e.sun.blind_spot_range?[Be((w=i)-(x=e.sun.blind_spot_range)[1]),Be(w-x[0])]:null;var w,x;const k=$?qe($[0],$[1],ft,0,s):null,C=qe(d,h,u,_,s),S=c&&(d!==n||h!==r),A=S?qe(n,r,u,_,s):"",E=null!==b&&b>_?qe(d,h,b,_,s):"",z=t?`${e.d.entry_title}: `:"",P=void 0!==e.sun.min_elevation||void 0!==e.sun.max_elevation?De("compass.elev_suffix",this.hass,{min:ut(e.sun.min_elevation??0),max:ut(e.sun.max_elevation??90)}):"",O=c?`${z}${De("compass.active_sun_arc",this.hass,{from:ut(d),to:ut(h),elev:P})}`:`${z}${De("compass.fov_arc",this.hass,{left:ut(e.sun.fov_left),right:ut(e.sun.fov_right),elev:P})}`,T=`${z}${De("compass.window_normal_tooltip",this.hass,{bearing:ut(i)})}`,M=null!==e.coverPos?"cover_awning"===e.coverType?`${z}${De("compass.cover_extended",this.hass,{pct:e.coverPos})}`:`${z}${De("compass.cover_closed_tooltip",this.hass,{pct:e.coverPos})}`:"",I=$?`${z}${De("compass.blind_spot",this.hass,{from:ut($[0]),to:ut($[1])})}`:"",F=t||e.isOverride,N=F?`fill: ${e.color}; stroke: ${e.color};`:"",R=F?`fill: ${e.color}; stroke: ${e.color};`:"",D=F?`fill: ${e.color}; stroke: ${e.color};`:"",L=F?`stroke: ${e.color};`:"",j=F?`fill: ${e.color};`:"",H=this.showCoverFill&&""!==E,q=this.showBlindSpot&&!!k,U=this.showWindowArrow,B=`M 0 0 L ${p.x} ${p.y}`,V="display: none;",G=`${z}${De("compass.fov_arc",this.hass,{left:ut(e.sun.fov_left),right:ut(e.sun.fov_right),elev:P})}`;return W`<g class="entry-overlay">
90+
${S?W`<g data-tooltip=${G}>
91+
<title>${G}</title>
92+
<path class="fov fov-static" style=${N} d=${A}></path>
93+
</g>`:K}
94+
<g data-tooltip=${O}>
10095
<title>${O}</title>
101-
<path class="cover-fill" style=${F} d=${S}></path>
96+
<path class="fov" style=${N} d=${C}></path>
10297
</g>
103-
<g class="blind-group" data-tooltip=${T} style=${j?"":U}>
98+
<g class="arrow-group" data-tooltip=${T} style=${U?"":V}>
10499
<title>${T}</title>
105-
<path class="blind-spot" style=${N} d=${k??""}></path>
100+
<path class="window" style=${L} d=${B}></path>
101+
<circle class="window-base" style=${j} cx="0" cy="0" r="4"></circle>
102+
</g>
103+
<g class="cover-group" data-tooltip=${M} style=${H?"":V}>
104+
<title>${M}</title>
105+
<path class="cover-fill" style=${R} d=${E}></path>
106+
</g>
107+
<g class="blind-group" data-tooltip=${I} style=${q?"":V}>
108+
<title>${I}</title>
109+
<path class="blind-spot" style=${D} d=${k??""}></path>
106110
</g>
107111
</g>`}_renderLegend(e,t){return t?B`
108112
<div class="legend">
@@ -232,6 +236,14 @@ function e(e,t,s,o){var i,n=arguments.length,r=n<3?t:null===o?o=Object.getOwnPro
232236
stroke-opacity: 0.7;
233237
transition: all 0.3s ease;
234238
}
239+
/* Static FOV envelope shown dim beneath the active sun arc — lets the
240+
reader see the configured ±fov_left/right span at the same time as
241+
today's reachable sub-arc. */
242+
.fov.fov-static {
243+
fill-opacity: 0.07;
244+
stroke-opacity: 0.25;
245+
stroke-dasharray: 4 3;
246+
}
235247
.cover-fill {
236248
fill: var(--primary-color);
237249
fill-opacity: 0.3;

eslint.config.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import tsPlugin from '@typescript-eslint/eslint-plugin';
22

33
export default [
4-
{ ignores: ['dist/**', 'node_modules/**'] },
4+
{ ignores: ['dist/**', 'node_modules/**', 'harness/dist/**'] },
55
...tsPlugin.configs['flat/recommended'],
66
{
7-
files: ['src/**/*.ts', 'tests/**/*.ts'],
7+
files: ['src/**/*.ts', 'tests/**/*.ts', 'harness/**/*.ts'],
88
rules: {
99
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
1010
'@typescript-eslint/no-explicit-any': 'warn',

harness/harness.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/**
2+
* Harness entry point.
3+
*
4+
* 1. Defines minimal stubs for `ha-card`, `ha-icon`, `ha-svg-icon`, `ha-form`
5+
* so the cards' Lit templates can render outside Home Assistant.
6+
* 2. Imports the cards from src/, which self-register their custom elements.
7+
* 3. Imports the harness app shell.
8+
*
9+
* Order matters: define the polyfills BEFORE importing the cards so the
10+
* cards see them at module-eval time.
11+
*/
12+
13+
import * as mdi from '@mdi/js';
14+
15+
// Build "mdi:foo" → SVG-path lookup from @mdi/js exports.
16+
const ICON_PATHS: Record<string, string> = {};
17+
for (const [k, v] of Object.entries(mdi)) {
18+
if (k.startsWith('mdi') && typeof v === 'string') {
19+
const slug = k
20+
.replace(/^mdi/, '')
21+
.replace(/([A-Z])/g, '-$1')
22+
.toLowerCase()
23+
.replace(/^-/, '');
24+
ICON_PATHS[`mdi:${slug}`] = v;
25+
}
26+
}
27+
28+
class HaCard extends HTMLElement {
29+
connectedCallback(): void {
30+
this.style.display = 'block';
31+
}
32+
}
33+
34+
class HaIcon extends HTMLElement {
35+
static observedAttributes = ['icon'];
36+
37+
attributeChangedCallback(): void {
38+
this._render();
39+
}
40+
41+
set icon(v: string) {
42+
this.setAttribute('icon', v);
43+
}
44+
get icon(): string {
45+
return this.getAttribute('icon') ?? '';
46+
}
47+
48+
private _render(): void {
49+
const name = this.getAttribute('icon') ?? '';
50+
const path = ICON_PATHS[name];
51+
if (!path) {
52+
this.innerHTML = '';
53+
return;
54+
}
55+
this.innerHTML = `<svg viewBox="0 0 24 24"><path d="${path}"></path></svg>`;
56+
}
57+
}
58+
59+
class HaSvgIcon extends HTMLElement {
60+
static observedAttributes = ['path'];
61+
62+
attributeChangedCallback(): void {
63+
this._render();
64+
}
65+
66+
set path(v: string) {
67+
this.setAttribute('path', v);
68+
}
69+
get path(): string {
70+
return this.getAttribute('path') ?? '';
71+
}
72+
73+
private _render(): void {
74+
const path = this.getAttribute('path') ?? '';
75+
if (!path) {
76+
this.innerHTML = '';
77+
return;
78+
}
79+
this.innerHTML = `<svg viewBox="0 0 24 24"><path d="${path}"></path></svg>`;
80+
}
81+
}
82+
83+
class HaFormStub extends HTMLElement {
84+
// Editor cards aren't loaded in v1; this exists only so any incidental render
85+
// doesn't blow up.
86+
}
87+
88+
if (!customElements.get('ha-card')) customElements.define('ha-card', HaCard);
89+
if (!customElements.get('ha-icon')) customElements.define('ha-icon', HaIcon);
90+
if (!customElements.get('ha-svg-icon')) customElements.define('ha-svg-icon', HaSvgIcon);
91+
if (!customElements.get('ha-form')) customElements.define('ha-form', HaFormStub);
92+
93+
// Cards self-register via @customElement decorator on import.
94+
import '../src/adaptive-cover-pro-card';
95+
import '../src/adaptive-cover-pro-sky-compass-card';
96+
import '../src/adaptive-cover-pro-tile-card';
97+
98+
// Harness app shell.
99+
import './src/harness-app';

harness/index.html

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1" />
6+
<title>Adaptive Cover Pro — Card Test Harness</title>
7+
<link rel="stylesheet" href="./styles.css" />
8+
</head>
9+
<body>
10+
<acp-harness-app></acp-harness-app>
11+
<script type="module" src="./dist/harness.js"></script>
12+
</body>
13+
</html>

harness/rollup.config.mjs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import resolve from '@rollup/plugin-node-resolve';
2+
import commonjs from '@rollup/plugin-commonjs';
3+
import typescript from '@rollup/plugin-typescript';
4+
import serve from 'rollup-plugin-serve';
5+
import livereload from 'rollup-plugin-livereload';
6+
7+
const watch = process.env.ROLLUP_WATCH === 'true';
8+
const port = parseInt(process.env.PORT ?? '8080', 10);
9+
10+
export default {
11+
input: 'harness/harness.ts',
12+
output: {
13+
file: 'harness/dist/harness.js',
14+
format: 'es',
15+
sourcemap: true,
16+
},
17+
moduleContext: (id) => (id.includes('@formatjs/intl-utils') ? 'window' : undefined),
18+
plugins: [
19+
resolve({ browser: true }),
20+
commonjs(),
21+
typescript({ tsconfig: './tsconfig.json', sourceMap: true, inlineSources: true }),
22+
watch && serve({ contentBase: 'harness', port, host: '0.0.0.0' }),
23+
watch && livereload({ watch: 'harness/dist' }),
24+
].filter(Boolean),
25+
};

0 commit comments

Comments
 (0)