diff --git a/.github/workflows/v15-premerge.yml b/.github/workflows/v15-premerge.yml new file mode 100644 index 0000000..9083b66 --- /dev/null +++ b/.github/workflows/v15-premerge.yml @@ -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 diff --git a/.github/workflows/v15-test.yml b/.github/workflows/v15-test.yml new file mode 100644 index 0000000..e69de29 diff --git a/custom_components/investment/__init__.py b/custom_components/investment/__init__.py index 93cd370..4cf64a9 100755 --- a/custom_components/investment/__init__.py +++ b/custom_components/investment/__init__.py @@ -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, diff --git a/custom_components/investment/www/investment-panel-v15.js b/custom_components/investment/www/investment-panel-v15.js new file mode 100644 index 0000000..3661a0c --- /dev/null +++ b/custom_components/investment/www/investment-panel-v15.js @@ -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(); diff --git a/tests/test_v15_score_help_guard.py b/tests/test_v15_score_help_guard.py new file mode 100644 index 0000000..3c9a359 --- /dev/null +++ b/tests/test_v15_score_help_guard.py @@ -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 diff --git a/tests/v15_premerge_contract.py b/tests/v15_premerge_contract.py new file mode 100644 index 0000000..1ce9c41 --- /dev/null +++ b/tests/v15_premerge_contract.py @@ -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