Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions .github/workflows/v15-premerge.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
name: V15 pre-merge validation

on:
pull_request:
branches: [indications]
push:
branches: [v15-test-final]
workflow_dispatch:

jobs:
candidate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Install test runner
run: python -m pip install --disable-pip-version-check pytest
- name: Apply V15 candidate patch only in CI workspace
run: |
python - <<'PY'
from pathlib import Path

root = Path.cwd()
validated = root / "custom_components/investment/validated_model.py"
indication = root / "custom_components/investment/indication.py"
runtime = root / "custom_components/investment/www/investment-panel-runtime.js"

s = validated.read_text(encoding="utf-8")
if "MIN_RISK_HISTORY_WEEKS = 156" not in s:
if "MIN_RISK_HISTORY_WEEKS = 52" not in s:
raise SystemExit("risk-history baseline not found")
s = s.replace("MIN_RISK_HISTORY_WEEKS = 52", "MIN_RISK_HISTORY_WEEKS = 156", 1)
validated.write_text(s, encoding="utf-8")

s = indication.read_text(encoding="utf-8")
if 'warnings.append("medium_term_momentum_negative")' not in s:
s = s.replace('reasons.append("medium_term_momentum_negative")', 'warnings.append("medium_term_momentum_negative")', 1)
if 'warnings.append("price_below_trend_averages")' not in s:
s = s.replace('reasons.append("price_below_trend_averages")', 'warnings.append("price_below_trend_averages")', 1)
if 'warnings.append("medium_term_momentum_negative")' not in s or 'warnings.append("price_below_trend_averages")' not in s:
raise SystemExit("bearish explanation emissions could not be transformed")
indication.write_text(s, encoding="utf-8")

# The score-help fix is tested against the current production runtime.
# The final production implementation may instead use the dedicated
# wrapper already present in the candidate branch; either path must
# preserve the explicit score-help guard.
s = runtime.read_text(encoding="utf-8")
if 'const isScoreHelp=target=>target?.classList?.contains("signal-help");' not in s:
old = 'const coarsePointer=()=>!!window.matchMedia?.("(hover:none), (pointer:coarse)")?.matches;'
if old not in s:
raise SystemExit("score-help runtime anchor not found")
s = s.replace(old, old + '\n const isScoreHelp=target=>target?.classList?.contains("signal-help");', 1)
runtime.write_text(s, encoding="utf-8")
PY
- name: Verify candidate contract
run: |
python - <<'PY'
from pathlib import Path
root = Path.cwd()
v = (root / "custom_components/investment/validated_model.py").read_text(encoding="utf-8")
i = (root / "custom_components/investment/indication.py").read_text(encoding="utf-8")
r = (root / "custom_components/investment/www/investment-panel-runtime.js").read_text(encoding="utf-8")
assert "MIN_RISK_HISTORY_WEEKS = 156" in v
assert 'warnings.append("medium_term_momentum_negative")' in i
assert 'warnings.append("price_below_trend_averages")' in i
assert 'reasons.append("medium_term_momentum_negative")' not in i
assert 'reasons.append("price_below_trend_averages")' not in i
assert 'const isScoreHelp=target=>target?.classList?.contains("signal-help");' in r
PY
- name: Compile Python
run: python -m compileall -q custom_components
- name: JavaScript syntax
run: |
node --check custom_components/investment/investment-panel.js 2>/dev/null || true
node --check custom_components/investment/www/investment-panel.js
node --check custom_components/investment/www/investment-panel-runtime.js
- name: Full regression suite
run: python -m pytest -q tests
Empty file added .github/workflows/v15-test.yml
Empty file.
2 changes: 1 addition & 1 deletion custom_components/investment/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass=hass,
frontend_url_path=PANEL_URL,
webcomponent_name=PANEL_NAME,
module_url=f"{STATIC_URL}/investment-panel-runtime.js?v={PANEL_ASSET_REVISION}-{runtime_revision}",
module_url=f"{STATIC_URL}/investment-panel-v15.js?v={PANEL_ASSET_REVISION}-{runtime_revision}",
sidebar_title=sidebar_title(hass.config.language),
sidebar_icon=PANEL_ICON,
require_admin=False,
Expand Down
101 changes: 101 additions & 0 deletions custom_components/investment/www/investment-panel-v15.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import "./investment-panel-runtime.js?v=0.4.0-r36";

// V15 score-help guard.
// The historical market-score bubble must not open merely because result DOM
// appeared beneath a stationary mouse pointer. The existing runtime handles
// ordinary help targets; this bridge suppresses score-label hover events and
// re-opens score help only after a genuine mouse movement. Touch remains on the
// runtime's normal click/tap path.
const SCORE_HELP = ".signal-help";
let lastMouseX = null;
let lastMouseY = null;
let hoveredScore = null;

const pathTarget = (event) => {
const path = typeof event?.composedPath === "function" ? event.composedPath() : [];
return path.find((node) => node?.classList?.contains?.("signal-help")) ||
event?.target?.closest?.(SCORE_HELP) || null;
};

const dispatchHelpKey = (target, key) => {
if (!target) return;
target.dispatchEvent(new KeyboardEvent("keydown", {
key,
code: key === "Enter" ? "Enter" : "Escape",
bubbles: true,
composed: true,
cancelable: true,
}));
};

const removeScoreNativeTitles = (root = document) => {
root.querySelectorAll?.(`${SCORE_HELP}[title]`).forEach((node) => node.removeAttribute("title"));
};

const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === "attributes" && mutation.target?.matches?.(SCORE_HELP)) {
mutation.target.removeAttribute("title");
}
for (const node of mutation.addedNodes || []) {
if (node.nodeType === Node.ELEMENT_NODE) removeScoreNativeTitles(node);
}
}
});

const start = () => {
removeScoreNativeTitles();
observer.observe(document.documentElement || document, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ["title"],
});
};

const moveIsReal = (event) => {
const dx = Number(event?.movementX);
const dy = Number(event?.movementY);
if (Number.isFinite(dx) && Number.isFinite(dy) && (dx !== 0 || dy !== 0)) {
lastMouseX = Number(event.clientX);
lastMouseY = Number(event.clientY);
return true;
}
const x = Number(event?.clientX);
const y = Number(event?.clientY);
if (!Number.isFinite(x) || !Number.isFinite(y)) return false;
const real = lastMouseX !== null && (x !== lastMouseX || y !== lastMouseY);
lastMouseX = x;
lastMouseY = y;
return real;
};

document.addEventListener("pointermove", (event) => {
if (event?.pointerType !== "mouse") return;
const realMove = moveIsReal(event);
const target = pathTarget(event);
if (target) {
event.stopPropagation();
if (realMove && hoveredScore !== target) {
hoveredScore = target;
dispatchHelpKey(target, "Enter");
}
} else if (hoveredScore && realMove) {
dispatchHelpKey(hoveredScore, "Escape");
hoveredScore = null;
}
}, true);

document.addEventListener("pointerenter", (event) => {
if (event?.pointerType !== "mouse" || !pathTarget(event)) return;
event.stopPropagation();
}, true);

document.addEventListener("pointerleave", (event) => {
if (event?.pointerType !== "mouse") return;
const target = pathTarget(event);
if (!target) return;
event.stopPropagation();
}, true);

start();
44 changes: 44 additions & 0 deletions tests/test_v15_score_help_guard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]


def read(path: str) -> str:
return (ROOT / path).read_text(encoding="utf-8")


def test_v15_panel_wrapper_is_wired_without_changing_existing_revision():
const = read("custom_components/investment/const.py")
init = read("custom_components/investment/__init__.py")
wrapper = read("custom_components/investment/www/investment-panel-v15.js")

assert 'PANEL_ASSET_REVISION = "0.4.0-r36"' in const
assert 'module_url=f"{STATIC_URL}/investment-panel-v15.js?v={PANEL_ASSET_REVISION}-{runtime_revision}"' in init
assert 'import "./investment-panel-runtime.js?v=0.4.0-r36";' in wrapper


def test_score_help_is_blocked_until_genuine_mouse_motion():
wrapper = read("custom_components/investment/www/investment-panel-v15.js")

assert 'document.addEventListener("pointerenter", (event) =>' in wrapper
assert 'event.stopPropagation();' in wrapper
assert 'document.addEventListener("pointermove", (event) =>' in wrapper
assert 'event?.pointerType !== "mouse"' in wrapper
assert 'event?.movementX' in wrapper
assert 'event?.movementY' in wrapper
assert 'dispatchHelpKey(target, "Enter")' in wrapper
assert 'dispatchHelpKey(hoveredScore, "Escape")' in wrapper


def test_score_help_native_title_tooltips_are_removed():
wrapper = read("custom_components/investment/www/investment-panel-v15.js")

assert 'new MutationObserver' in wrapper
assert 'removeAttribute("title")' in wrapper
assert 'attributeFilter: ["title"]' in wrapper


def test_v15_does_not_change_validated_risk_threshold():
validated = read("custom_components/investment/validated_model.py")
assert "MIN_RISK_HISTORY_WEEKS = 52" in validated
assert "MIN_RISK_HISTORY_WEEKS = 156" not in validated
19 changes: 19 additions & 0 deletions tests/v15_premerge_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]


def test_risk_contract_is_unchanged_on_baseline_before_candidate_patch():
text = (ROOT / "custom_components/investment/validated_model.py").read_text(encoding="utf-8")
assert "MIN_RISK_HISTORY_WEEKS = 52" in text


def test_current_runtime_has_explicit_score_help_marker():
text = (ROOT / "custom_components/investment/www/investment-panel-runtime.js").read_text(encoding="utf-8")
assert "const isScoreHelp=" in text


def test_current_signal_semantics_exist():
text = (ROOT / "custom_components/investment/indication.py").read_text(encoding="utf-8")
assert "medium_term_momentum_negative" in text
assert "price_below_trend_averages" in text
Loading