From 2db196fb5bb8e186c6c2683122ceaa62e4040467 Mon Sep 17 00:00:00 2001 From: Chris Chreece <68458228+Chreece@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:00:29 +0200 Subject: [PATCH 01/19] Refine model presentation safeguards Refine model presentation safeguards --- custom_components/investment/__init__.py | 2 +- .../investment/www/investment-panel-v15.js | 101 ++++++++++++++++++ tests/test_v15_score_help_guard.py | 44 ++++++++ 3 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 custom_components/investment/www/investment-panel-v15.js create mode 100644 tests/test_v15_score_help_guard.py 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 From a32b303f574a72e261f834c7c583c530cf245e8d Mon Sep 17 00:00:00 2001 From: Chris Chreece <68458228+Chreece@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:17:22 +0200 Subject: [PATCH 02/19] Add isolated V15 test workflow --- .github/workflows/v15-test.yml | 96 ++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 .github/workflows/v15-test.yml diff --git a/.github/workflows/v15-test.yml b/.github/workflows/v15-test.yml new file mode 100644 index 0000000..962e2a6 --- /dev/null +++ b/.github/workflows/v15-test.yml @@ -0,0 +1,96 @@ +name: V15 isolated test + +on: + push: + branches: [v15-test] + workflow_dispatch: + +jobs: + test-v15: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Install pytest + run: python -m pip install --disable-pip-version-check pytest + - name: Apply V15 candidate changes + shell: python + run: | + from pathlib import Path + + indication = Path('custom_components/investment/indication.py') + runtime = Path('custom_components/investment/www/investment-panel-runtime.js') + tests = Path('tests/test_v14g7_desktop_hover_portal.py') + + s = indication.read_text(encoding='utf-8') + a = s.replace('reasons.append("medium_term_momentum_negative")', 'warnings.append("medium_term_momentum_negative")', 1) + if a == s: + assert 'warnings.append("medium_term_momentum_negative")' in s + s = a.replace('reasons.append("price_below_trend_averages")', 'warnings.append("price_below_trend_averages")', 1) + if s == a: + assert 'warnings.append("price_below_trend_averages")' in a + indication.write_text(s, encoding='utf-8') + + s = runtime.read_text(encoding='utf-8') + old_hover = 'const isMouseHover=e=>e?.pointerType==="mouse"&&trueHoverMouse()&&!coarsePointer();' + new_hover = 'const isMouseHover=e=>e?.pointerType==="mouse"&&trueHoverMouse()&&!coarsePointer()&&!e?.target?.closest?.(".signal-help");' + if old_hover in s: + s = s.replace(old_hover, new_hover, 1) + elif new_hover not in s: + raise SystemExit('runtime: isMouseHover anchor missing') + + old_click = 'if(touch)toggleHelp(target,{touch:true,source:"tap"});else closeHelp(null);return;' + new_click = 'if(touch||target.classList.contains("signal-help"))toggleHelp(target,{touch,source:touch?"tap":"click"});else closeHelp(null);return;' + if old_click in s: + s = s.replace(old_click, new_click, 1) + elif new_click not in s: + raise SystemExit('runtime: click handler anchor missing') + + needle = 'const modal=root.querySelector(".indication-modal");' + title_strip = needle + '\n modal?.querySelectorAll(".signal-help[title]").forEach(el=>el.removeAttribute("title"));' + if 'signal-help[title]' not in s: + if needle not in s: + raise SystemExit('runtime: modal anchor missing') + s = s.replace(needle, title_strip, 1) + runtime.write_text(s, encoding='utf-8') + + # Existing V14g7 tests encoded the old generic-hover contract. + # Replace them with the V15 score-specific explicit-action contract. + t = tests.read_text(encoding='utf-8') + t = t.replace( + 'arm = \'target.addEventListener("pointerenter",e=>{if(isMouseHover(e))target.setAttribute("data-help-hover-ready","1");});\'', + 'arm = \'target.addEventListener("pointerenter",e=>{if(isScoreHelp(target))return;if(isMouseHover(e))target.setAttribute("data-help-hover-ready","1");});\'', + ) + t = t.replace( + 'move = \'target.addEventListener("pointermove",e=>{if(!isMouseHover(e)||target.getAttribute("data-help-hover-ready")!=="1")return;\'', + 'move = \'target.addEventListener("pointermove",e=>{if(isScoreHelp(target))return;if(!isMouseHover(e)||target.getAttribute("data-help-hover-ready")!=="1")return;\'', + ) + t = t.replace( + 'assert \'if(touch)toggleHelp(target,{touch:true,source:"tap"});else closeHelp(null);return;\' in runtime', + 'assert \'if(touch||target.classList.contains("signal-help"))toggleHelp(target,{touch,source:touch?"tap":"click"});else closeHelp(null);return;\' in runtime', + ) + tests.write_text(t, encoding='utf-8') + + Path('tests/test_v15_score_help_and_semantics.py').write_text('''from pathlib import Path\n\nROOT = Path(__file__).resolve().parents[1]\n\ndef read(name):\n return (ROOT / name).read_text(encoding="utf-8")\n\ndef test_score_help_is_never_hover_activated():\n s = read("custom_components/investment/www/investment-panel-runtime.js")\n assert 'const isMouseHover=e=>e?.pointerType==="mouse"&&trueHoverMouse()&&!coarsePointer()&&!e?.target?.closest?.(".signal-help");' in s\n assert 'if(touch||target.classList.contains("signal-help"))toggleHelp(target,{touch,source:touch?"tap":"click"});else closeHelp(null);return;' in s\n assert 'signal-help[title]' in s\n\ndef test_bearish_signals_are_warnings_not_positive_reasons():\n s = read("custom_components/investment/indication.py")\n assert 'warnings.append("medium_term_momentum_negative")' in s\n assert 'warnings.append("price_below_trend_averages")' in s\n assert 'reasons.append("medium_term_momentum_negative")' not in s\n assert 'reasons.append("price_below_trend_averages")' not in s\n''', encoding='utf-8') + + print('V15 candidate changes applied') + - name: Full regression + run: | + python -m pytest -q tests + python -m compileall -q custom_components tests + node --check custom_components/investment/www/investment-panel.js + node --check custom_components/investment/www/investment-panel-runtime.js + git diff --check + - name: Commit tested candidate + shell: bash + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add custom_components/investment/indication.py custom_components/investment/www/investment-panel-runtime.js tests/test_v14g7_desktop_hover_portal.py tests/test_v15_score_help_and_semantics.py .github/workflows/v15-test.yml + git commit -m "Harden indication presentation" || true + git push From 7608a20888c0c1fef44a848fd263418525c178d5 Mon Sep 17 00:00:00 2001 From: Chris Chreece <68458228+Chreece@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:17:55 +0200 Subject: [PATCH 03/19] Add isolated V15 test workflow --- .github/workflows/v15-test.yml | 95 ++++++++-------------------------- 1 file changed, 21 insertions(+), 74 deletions(-) diff --git a/.github/workflows/v15-test.yml b/.github/workflows/v15-test.yml index 962e2a6..3ba143d 100644 --- a/.github/workflows/v15-test.yml +++ b/.github/workflows/v15-test.yml @@ -1,96 +1,43 @@ name: V15 isolated test - on: push: branches: [v15-test] - workflow_dispatch: - jobs: test-v15: runs-on: ubuntu-latest - permissions: - contents: write steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.13" - - name: Install pytest - run: python -m pip install --disable-pip-version-check pytest - - name: Apply V15 candidate changes + - run: python -m pip install --disable-pip-version-check pytest + - name: Apply candidate changes in CI workspace shell: python run: | from pathlib import Path - - indication = Path('custom_components/investment/indication.py') - runtime = Path('custom_components/investment/www/investment-panel-runtime.js') - tests = Path('tests/test_v14g7_desktop_hover_portal.py') - - s = indication.read_text(encoding='utf-8') - a = s.replace('reasons.append("medium_term_momentum_negative")', 'warnings.append("medium_term_momentum_negative")', 1) - if a == s: - assert 'warnings.append("medium_term_momentum_negative")' in s - s = a.replace('reasons.append("price_below_trend_averages")', 'warnings.append("price_below_trend_averages")', 1) - if s == a: - assert 'warnings.append("price_below_trend_averages")' in a - indication.write_text(s, encoding='utf-8') - - s = runtime.read_text(encoding='utf-8') - old_hover = 'const isMouseHover=e=>e?.pointerType==="mouse"&&trueHoverMouse()&&!coarsePointer();' - new_hover = 'const isMouseHover=e=>e?.pointerType==="mouse"&&trueHoverMouse()&&!coarsePointer()&&!e?.target?.closest?.(".signal-help");' - if old_hover in s: - s = s.replace(old_hover, new_hover, 1) - elif new_hover not in s: - raise SystemExit('runtime: isMouseHover anchor missing') - - old_click = 'if(touch)toggleHelp(target,{touch:true,source:"tap"});else closeHelp(null);return;' - new_click = 'if(touch||target.classList.contains("signal-help"))toggleHelp(target,{touch,source:touch?"tap":"click"});else closeHelp(null);return;' - if old_click in s: - s = s.replace(old_click, new_click, 1) - elif new_click not in s: - raise SystemExit('runtime: click handler anchor missing') - - needle = 'const modal=root.querySelector(".indication-modal");' - title_strip = needle + '\n modal?.querySelectorAll(".signal-help[title]").forEach(el=>el.removeAttribute("title"));' + indication=Path('custom_components/investment/indication.py') + runtime=Path('custom_components/investment/www/investment-panel-runtime.js') + s=indication.read_text(encoding='utf-8') + old='reasons.append("medium_term_momentum_negative")' + if old in s: s=s.replace(old,'warnings.append("medium_term_momentum_negative")',1) + old='reasons.append("price_below_trend_averages")' + if old in s: s=s.replace(old,'warnings.append("price_below_trend_averages")',1) + indication.write_text(s,encoding='utf-8') + s=runtime.read_text(encoding='utf-8') + old='const isMouseHover=e=>e?.pointerType==="mouse"&&trueHoverMouse()&&!coarsePointer();' + new='const isMouseHover=e=>e?.pointerType==="mouse"&&trueHoverMouse()&&!coarsePointer()&&!e?.target?.closest?.(".signal-help");' + if old in s: s=s.replace(old,new,1) + old='if(touch)toggleHelp(target,{touch:true,source:"tap"});else closeHelp(null);return;' + new='if(touch||target.classList.contains("signal-help"))toggleHelp(target,{touch,source:touch?"tap":"click"});else closeHelp(null);return;' + if old in s: s=s.replace(old,new,1) if 'signal-help[title]' not in s: - if needle not in s: - raise SystemExit('runtime: modal anchor missing') - s = s.replace(needle, title_strip, 1) - runtime.write_text(s, encoding='utf-8') - - # Existing V14g7 tests encoded the old generic-hover contract. - # Replace them with the V15 score-specific explicit-action contract. - t = tests.read_text(encoding='utf-8') - t = t.replace( - 'arm = \'target.addEventListener("pointerenter",e=>{if(isMouseHover(e))target.setAttribute("data-help-hover-ready","1");});\'', - 'arm = \'target.addEventListener("pointerenter",e=>{if(isScoreHelp(target))return;if(isMouseHover(e))target.setAttribute("data-help-hover-ready","1");});\'', - ) - t = t.replace( - 'move = \'target.addEventListener("pointermove",e=>{if(!isMouseHover(e)||target.getAttribute("data-help-hover-ready")!=="1")return;\'', - 'move = \'target.addEventListener("pointermove",e=>{if(isScoreHelp(target))return;if(!isMouseHover(e)||target.getAttribute("data-help-hover-ready")!=="1")return;\'', - ) - t = t.replace( - 'assert \'if(touch)toggleHelp(target,{touch:true,source:"tap"});else closeHelp(null);return;\' in runtime', - 'assert \'if(touch||target.classList.contains("signal-help"))toggleHelp(target,{touch,source:touch?"tap":"click"});else closeHelp(null);return;\' in runtime', - ) - tests.write_text(t, encoding='utf-8') - - Path('tests/test_v15_score_help_and_semantics.py').write_text('''from pathlib import Path\n\nROOT = Path(__file__).resolve().parents[1]\n\ndef read(name):\n return (ROOT / name).read_text(encoding="utf-8")\n\ndef test_score_help_is_never_hover_activated():\n s = read("custom_components/investment/www/investment-panel-runtime.js")\n assert 'const isMouseHover=e=>e?.pointerType==="mouse"&&trueHoverMouse()&&!coarsePointer()&&!e?.target?.closest?.(".signal-help");' in s\n assert 'if(touch||target.classList.contains("signal-help"))toggleHelp(target,{touch,source:touch?"tap":"click"});else closeHelp(null);return;' in s\n assert 'signal-help[title]' in s\n\ndef test_bearish_signals_are_warnings_not_positive_reasons():\n s = read("custom_components/investment/indication.py")\n assert 'warnings.append("medium_term_momentum_negative")' in s\n assert 'warnings.append("price_below_trend_averages")' in s\n assert 'reasons.append("medium_term_momentum_negative")' not in s\n assert 'reasons.append("price_below_trend_averages")' not in s\n''', encoding='utf-8') - - print('V15 candidate changes applied') - - name: Full regression + needle='const modal=root.querySelector(".indication-modal");' + if needle in s: s=s.replace(needle,needle+'\n modal?.querySelectorAll(".signal-help[title]").forEach(el=>el.removeAttribute("title"));',1) + runtime.write_text(s,encoding='utf-8') + - name: Full regression and syntax run: | python -m pytest -q tests python -m compileall -q custom_components tests node --check custom_components/investment/www/investment-panel.js node --check custom_components/investment/www/investment-panel-runtime.js git diff --check - - name: Commit tested candidate - shell: bash - run: | - set -euo pipefail - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add custom_components/investment/indication.py custom_components/investment/www/investment-panel-runtime.js tests/test_v14g7_desktop_hover_portal.py tests/test_v15_score_help_and_semantics.py .github/workflows/v15-test.yml - git commit -m "Harden indication presentation" || true - git push From 143286c1d49bb782193457f5aecc38c326557bb4 Mon Sep 17 00:00:00 2001 From: Chris Chreece <68458228+Chreece@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:18:02 +0200 Subject: [PATCH 04/19] temporary --- .github/workflows/v15-test.yml | 44 +--------------------------------- 1 file changed, 1 insertion(+), 43 deletions(-) diff --git a/.github/workflows/v15-test.yml b/.github/workflows/v15-test.yml index 3ba143d..b16853f 100644 --- a/.github/workflows/v15-test.yml +++ b/.github/workflows/v15-test.yml @@ -1,43 +1 @@ -name: V15 isolated test -on: - push: - branches: [v15-test] -jobs: - test-v15: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.13" - - run: python -m pip install --disable-pip-version-check pytest - - name: Apply candidate changes in CI workspace - shell: python - run: | - from pathlib import Path - indication=Path('custom_components/investment/indication.py') - runtime=Path('custom_components/investment/www/investment-panel-runtime.js') - s=indication.read_text(encoding='utf-8') - old='reasons.append("medium_term_momentum_negative")' - if old in s: s=s.replace(old,'warnings.append("medium_term_momentum_negative")',1) - old='reasons.append("price_below_trend_averages")' - if old in s: s=s.replace(old,'warnings.append("price_below_trend_averages")',1) - indication.write_text(s,encoding='utf-8') - s=runtime.read_text(encoding='utf-8') - old='const isMouseHover=e=>e?.pointerType==="mouse"&&trueHoverMouse()&&!coarsePointer();' - new='const isMouseHover=e=>e?.pointerType==="mouse"&&trueHoverMouse()&&!coarsePointer()&&!e?.target?.closest?.(".signal-help");' - if old in s: s=s.replace(old,new,1) - old='if(touch)toggleHelp(target,{touch:true,source:"tap"});else closeHelp(null);return;' - new='if(touch||target.classList.contains("signal-help"))toggleHelp(target,{touch,source:touch?"tap":"click"});else closeHelp(null);return;' - if old in s: s=s.replace(old,new,1) - if 'signal-help[title]' not in s: - needle='const modal=root.querySelector(".indication-modal");' - if needle in s: s=s.replace(needle,needle+'\n modal?.querySelectorAll(".signal-help[title]").forEach(el=>el.removeAttribute("title"));',1) - runtime.write_text(s,encoding='utf-8') - - name: Full regression and syntax - run: | - python -m pytest -q tests - python -m compileall -q custom_components tests - node --check custom_components/investment/www/investment-panel.js - node --check custom_components/investment/www/investment-panel-runtime.js - git diff --check +name: placeholder From 2bd1104aaf045376aa59b62bf02d93c01db25994 Mon Sep 17 00:00:00 2001 From: Chris Chreece <68458228+Chreece@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:18:15 +0200 Subject: [PATCH 05/19] Add V15 test workflow --- .github/workflows/v15-test.yml | 48 +++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/.github/workflows/v15-test.yml b/.github/workflows/v15-test.yml index b16853f..6d4bd9a 100644 --- a/.github/workflows/v15-test.yml +++ b/.github/workflows/v15-test.yml @@ -1 +1,47 @@ -name: placeholder +name: V15 isolated test +on: + push: + branches: [v15-test] +jobs: + test-v15: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - run: python -m pip install --disable-pip-version-check pytest + - name: Apply candidate changes in CI workspace + shell: python + run: | + from pathlib import Path + indication=Path('custom_components/investment/indication.py') + runtime=Path('custom_components/investment/www/investment-panel-runtime.js') + s=indication.read_text(encoding='utf-8') + a=s.replace('reasons.append("medium_term_momentum_negative")','warnings.append("medium_term_momentum_negative")',1) + if a==s: assert 'warnings.append("medium_term_momentum_negative")' in s + s=a.replace('reasons.append("price_below_trend_averages")','warnings.append("price_below_trend_averages")',1) + if s==a: assert 'warnings.append("price_below_trend_averages")' in a + indication.write_text(s,encoding='utf-8') + s=runtime.read_text(encoding='utf-8') + old='const isMouseHover=e=>e?.pointerType==="mouse"&&trueHoverMouse()&&!coarsePointer();' + new='const isMouseHover=e=>e?.pointerType==="mouse"&&trueHoverMouse()&&!coarsePointer()&&!e?.target?.closest?.(".signal-help");' + if old in s: s=s.replace(old,new,1) + elif new not in s: raise SystemExit('runtime hover anchor missing') + old='if(touch)toggleHelp(target,{touch:true,source:"tap"});else closeHelp(null);return;' + new='if(touch||target.classList.contains("signal-help"))toggleHelp(target,{touch,source:touch?"tap":"click"});else closeHelp(null);return;' + if old in s: s=s.replace(old,new,1) + elif new not in s: raise SystemExit('runtime click anchor missing') + needle='const modal=root.querySelector(".indication-modal");' + if 'signal-help[title]' not in s: + if needle not in s: raise SystemExit('runtime modal anchor missing') + s=s.replace(needle,needle+'\n modal?.querySelectorAll(".signal-help[title]").forEach(el=>el.removeAttribute("title"));',1) + runtime.write_text(s,encoding='utf-8') + Path('tests/test_v15_score_help_and_semantics.py').write_text('''from pathlib import Path\nROOT=Path(__file__).resolve().parents[1]\ndef r(p): return (ROOT/p).read_text(encoding="utf-8")\ndef test_score_help_explicit_action_only():\n s=r("custom_components/investment/www/investment-panel-runtime.js")\n assert 'const isMouseHover=e=>e?.pointerType==="mouse"&&trueHoverMouse()&&!coarsePointer()&&!e?.target?.closest?.(".signal-help");' in s\n assert 'if(touch||target.classList.contains("signal-help"))toggleHelp(target,{touch,source:touch?"tap":"click"});else closeHelp(null);return;' in s\n assert 'signal-help[title]' in s\ndef test_bearish_codes_are_warnings():\n s=r("custom_components/investment/indication.py")\n assert 'warnings.append("medium_term_momentum_negative")' in s\n assert 'warnings.append("price_below_trend_averages")' in s\n assert 'reasons.append("medium_term_momentum_negative")' not in s\n assert 'reasons.append("price_below_trend_averages")' not in s\n''',encoding='utf-8') + - name: Full regression + run: | + python -m pytest -q tests + python -m compileall -q custom_components tests + node --check custom_components/investment/www/investment-panel.js + node --check custom_components/investment/www/investment-panel-runtime.js + git diff --check From 8be104e0492e1dd57b56e148c3fa2a6ba86f0349 Mon Sep 17 00:00:00 2001 From: Chris Chreece <68458228+Chreece@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:18:23 +0200 Subject: [PATCH 06/19] temporary test file --- .github/workflows/v15-test.yml | 48 +--------------------------------- 1 file changed, 1 insertion(+), 47 deletions(-) diff --git a/.github/workflows/v15-test.yml b/.github/workflows/v15-test.yml index 6d4bd9a..9152975 100644 --- a/.github/workflows/v15-test.yml +++ b/.github/workflows/v15-test.yml @@ -1,47 +1 @@ -name: V15 isolated test -on: - push: - branches: [v15-test] -jobs: - test-v15: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.13" - - run: python -m pip install --disable-pip-version-check pytest - - name: Apply candidate changes in CI workspace - shell: python - run: | - from pathlib import Path - indication=Path('custom_components/investment/indication.py') - runtime=Path('custom_components/investment/www/investment-panel-runtime.js') - s=indication.read_text(encoding='utf-8') - a=s.replace('reasons.append("medium_term_momentum_negative")','warnings.append("medium_term_momentum_negative")',1) - if a==s: assert 'warnings.append("medium_term_momentum_negative")' in s - s=a.replace('reasons.append("price_below_trend_averages")','warnings.append("price_below_trend_averages")',1) - if s==a: assert 'warnings.append("price_below_trend_averages")' in a - indication.write_text(s,encoding='utf-8') - s=runtime.read_text(encoding='utf-8') - old='const isMouseHover=e=>e?.pointerType==="mouse"&&trueHoverMouse()&&!coarsePointer();' - new='const isMouseHover=e=>e?.pointerType==="mouse"&&trueHoverMouse()&&!coarsePointer()&&!e?.target?.closest?.(".signal-help");' - if old in s: s=s.replace(old,new,1) - elif new not in s: raise SystemExit('runtime hover anchor missing') - old='if(touch)toggleHelp(target,{touch:true,source:"tap"});else closeHelp(null);return;' - new='if(touch||target.classList.contains("signal-help"))toggleHelp(target,{touch,source:touch?"tap":"click"});else closeHelp(null);return;' - if old in s: s=s.replace(old,new,1) - elif new not in s: raise SystemExit('runtime click anchor missing') - needle='const modal=root.querySelector(".indication-modal");' - if 'signal-help[title]' not in s: - if needle not in s: raise SystemExit('runtime modal anchor missing') - s=s.replace(needle,needle+'\n modal?.querySelectorAll(".signal-help[title]").forEach(el=>el.removeAttribute("title"));',1) - runtime.write_text(s,encoding='utf-8') - Path('tests/test_v15_score_help_and_semantics.py').write_text('''from pathlib import Path\nROOT=Path(__file__).resolve().parents[1]\ndef r(p): return (ROOT/p).read_text(encoding="utf-8")\ndef test_score_help_explicit_action_only():\n s=r("custom_components/investment/www/investment-panel-runtime.js")\n assert 'const isMouseHover=e=>e?.pointerType==="mouse"&&trueHoverMouse()&&!coarsePointer()&&!e?.target?.closest?.(".signal-help");' in s\n assert 'if(touch||target.classList.contains("signal-help"))toggleHelp(target,{touch,source:touch?"tap":"click"});else closeHelp(null);return;' in s\n assert 'signal-help[title]' in s\ndef test_bearish_codes_are_warnings():\n s=r("custom_components/investment/indication.py")\n assert 'warnings.append("medium_term_momentum_negative")' in s\n assert 'warnings.append("price_below_trend_averages")' in s\n assert 'reasons.append("medium_term_momentum_negative")' not in s\n assert 'reasons.append("price_below_trend_averages")' not in s\n''',encoding='utf-8') - - name: Full regression - run: | - python -m pytest -q tests - python -m compileall -q custom_components tests - node --check custom_components/investment/www/investment-panel.js - node --check custom_components/investment/www/investment-panel-runtime.js - git diff --check +name: temporary From 4dc974efa5f12407b31b0da399d00d195295750c Mon Sep 17 00:00:00 2001 From: Chris Chreece <68458228+Chreece@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:18:29 +0200 Subject: [PATCH 07/19] temporary From 434ec6b42eb1d05d17d37fd17a68fd1d106d4f0d Mon Sep 17 00:00:00 2001 From: Chris Chreece <68458228+Chreece@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:18:36 +0200 Subject: [PATCH 08/19] placeholder --- .github/workflows/v15-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/v15-test.yml b/.github/workflows/v15-test.yml index 9152975..b16853f 100644 --- a/.github/workflows/v15-test.yml +++ b/.github/workflows/v15-test.yml @@ -1 +1 @@ -name: temporary +name: placeholder From e825138f10f3080b5847348198f26fa856b9c778 Mon Sep 17 00:00:00 2001 From: Chris Chreece <68458228+Chreece@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:18:43 +0200 Subject: [PATCH 09/19] cleanup --- .github/workflows/v15-test.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/v15-test.yml b/.github/workflows/v15-test.yml index b16853f..e69de29 100644 --- a/.github/workflows/v15-test.yml +++ b/.github/workflows/v15-test.yml @@ -1 +0,0 @@ -name: placeholder From f2c41e1921ee9ebc8732336f49ade73a84824b8a Mon Sep 17 00:00:00 2001 From: Chris Chreece <68458228+Chreece@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:18:49 +0200 Subject: [PATCH 10/19] cleanup placeholder --- .github/workflows/v15-test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/v15-test.yml b/.github/workflows/v15-test.yml index e69de29..8b13789 100644 --- a/.github/workflows/v15-test.yml +++ b/.github/workflows/v15-test.yml @@ -0,0 +1 @@ + From d32905e7c50c1889b2cded991a42cc2abeb32115 Mon Sep 17 00:00:00 2001 From: Chris Chreece <68458228+Chreece@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:18:56 +0200 Subject: [PATCH 11/19] placeholder cleanup --- .github/workflows/v15-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/v15-test.yml b/.github/workflows/v15-test.yml index 8b13789..b16853f 100644 --- a/.github/workflows/v15-test.yml +++ b/.github/workflows/v15-test.yml @@ -1 +1 @@ - +name: placeholder From 7ba91de1240012f2d34765a89516ecdb261b9b95 Mon Sep 17 00:00:00 2001 From: Chris Chreece <68458228+Chreece@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:19:03 +0200 Subject: [PATCH 12/19] temp --- .github/workflows/v15-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/v15-test.yml b/.github/workflows/v15-test.yml index b16853f..3296fc2 100644 --- a/.github/workflows/v15-test.yml +++ b/.github/workflows/v15-test.yml @@ -1 +1 @@ -name: placeholder +name: isolate From 8141c7c889c6af9fe18366f05a859b6575a83a6c Mon Sep 17 00:00:00 2001 From: Chris Chreece <68458228+Chreece@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:19:11 +0200 Subject: [PATCH 13/19] temp From 2bb43ab97879c69d8310a9ea702fb0cd180a5244 Mon Sep 17 00:00:00 2001 From: Chris Chreece <68458228+Chreece@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:19:17 +0200 Subject: [PATCH 14/19] temp From 16ced3b9fb5355b53e057417541e1aa0f58e6202 Mon Sep 17 00:00:00 2001 From: Chris Chreece <68458228+Chreece@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:19:30 +0200 Subject: [PATCH 15/19] cleanup --- .github/workflows/v15-test.yml | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/.github/workflows/v15-test.yml b/.github/workflows/v15-test.yml index 3296fc2..c886f16 100644 --- a/.github/workflows/v15-test.yml +++ b/.github/workflows/v15-test.yml @@ -1 +1,20 @@ -name: isolate +name: Validate + +on: + push: + pull_request: + +jobs: + python-tests: + 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: Compile integration Python + run: python -m compileall -q custom_components/investment + - name: Run pure Python regression tests + run: python -m pytest -q tests From 1dc51552805157a83fd876514153bc126391e4a4 Mon Sep 17 00:00:00 2001 From: Chris Chreece <68458228+Chreece@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:19:44 +0200 Subject: [PATCH 16/19] Remove temporary test workflow --- .github/workflows/v15-test.yml | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/.github/workflows/v15-test.yml b/.github/workflows/v15-test.yml index c886f16..e69de29 100644 --- a/.github/workflows/v15-test.yml +++ b/.github/workflows/v15-test.yml @@ -1,20 +0,0 @@ -name: Validate - -on: - push: - pull_request: - -jobs: - python-tests: - 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: Compile integration Python - run: python -m compileall -q custom_components/investment - - name: Run pure Python regression tests - run: python -m pytest -q tests From 2d3933afdfe2139791994d88854c351c685466c5 Mon Sep 17 00:00:00 2001 From: Chris Chreece <68458228+Chreece@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:29:11 +0200 Subject: [PATCH 17/19] Add V15 pre-merge baseline contracts --- tests/v15_premerge_contract.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 tests/v15_premerge_contract.py 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 From c8fca0b68a289738769b5fc1169e99a9ef2f8525 Mon Sep 17 00:00:00 2001 From: Chris Chreece <68458228+Chreece@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:29:43 +0200 Subject: [PATCH 18/19] Add isolated V15 pre-merge validation --- .github/workflows/v15-premerge.yml | 104 +++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 .github/workflows/v15-premerge.yml diff --git a/.github/workflows/v15-premerge.yml b/.github/workflows/v15-premerge.yml new file mode 100644 index 0000000..e1ce531 --- /dev/null +++ b/.github/workflows/v15-premerge.yml @@ -0,0 +1,104 @@ +name: V15 pre-merge validation + +on: + 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 + import re + + 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: + old = 'reasons.append("medium_term_momentum_negative")' + if old not in s: + raise SystemExit("negative momentum emission not found") + s = s.replace(old, 'warnings.append("medium_term_momentum_negative")', 1) + if 'warnings.append("price_below_trend_averages")' not in s: + old = 'reasons.append("price_below_trend_averages")' + if old not in s: + raise SystemExit("below-trend emission not found") + s = s.replace(old, 'warnings.append("price_below_trend_averages")', 1) + indication.write_text(s, encoding="utf-8") + + s = runtime.read_text(encoding="utf-8") + marker = 'const isScoreHelp=target=>target?.classList?.contains("signal-help");' + if marker not in s: + old = '''const coarsePointer=()=>!!window.matchMedia?.("(hover:none), (pointer:coarse)")?.matches; + const trueHoverMouse=()=>!!window.matchMedia?.("(hover:hover) and (pointer:fine)")?.matches; + const isMouseHover=e=>e?.pointerType==="mouse"&&trueHoverMouse()&&!coarsePointer(); + modal.querySelectorAll(".help-label").forEach(target=>{target.classList.remove("help-open","help-touch");target.removeAttribute("data-help-source");target.removeAttribute("data-help-hover-ready");target.setAttribute("aria-expanded","false");target.addEventListener("pointerenter",e=>{if(isMouseHover(e))target.setAttribute("data-help-hover-ready","1");});target.addEventListener("pointermove",e=>{if(!isMouseHover(e)||target.getAttribute("data-help-hover-ready")!=="1")return;if(target.getAttribute("data-help-source")!=="hover")openHelp(target,{touch:false,source:"hover"});});target.addEventListener("pointerleave",e=>{target.removeAttribute("data-help-hover-ready");if(isMouseHover(e)&&target.getAttribute("data-help-source")==="hover")closeHelp(null);});});''' + new = '''const coarsePointer=()=>!!window.matchMedia?.("(hover:none), (pointer:coarse)")?.matches; + const trueHoverMouse=()=>!!window.matchMedia?.("(hover:hover) and (pointer:fine)")?.matches; + const isMouseHover=e=>e?.pointerType==="mouse"&&trueHoverMouse()&&!coarsePointer(); + const isScoreHelp=target=>target?.classList?.contains("signal-help"); + modal.querySelectorAll(".help-label").forEach(target=>{target.classList.remove("help-open","help-touch");target.removeAttribute("data-help-source");target.removeAttribute("data-help-hover-ready");target.setAttribute("aria-expanded","false");target.addEventListener("pointerenter",e=>{if(isScoreHelp(target))return;if(isMouseHover(e))target.setAttribute("data-help-hover-ready","1");});target.addEventListener("pointermove",e=>{if(isScoreHelp(target))return;if(!isMouseHover(e)||target.getAttribute("data-help-hover-ready")!=="1")return;if(target.getAttribute("data-help-source")!=="hover")openHelp(target,{touch:false,source:"hover"});});target.addEventListener("pointerleave",e=>{target.removeAttribute("data-help-hover-ready");if(isScoreHelp(target))return;if(isMouseHover(e)&&target.getAttribute("data-help-source")==="hover")closeHelp(null);});});''' + if old not in s: + raise SystemExit("score-help runtime block not found") + s = s.replace(old, new, 1) + + old_click = 'const touch=lastPointerType==="touch"||lastPointerType==="pen"||coarsePointer();if(touch)toggleHelp(target,{touch:true,source:"tap"});else closeHelp(null);return;}closeHelp(null);' + new_click = 'const touch=lastPointerType==="touch"||lastPointerType==="pen"||coarsePointer();const explicitScore=isScoreHelp(target);if(touch||explicitScore)toggleHelp(target,{touch,source:touch?"tap":"click"});else closeHelp(null);return;}closeHelp(null);' + if old_click not in s and 'explicitScore=isScoreHelp(target)' not in s: + raise SystemExit("score-help click handler not found") + if old_click in s: + s = s.replace(old_click, new_click, 1) + + if "signal-help[title]" not in s: + needle = 'const modal=root.querySelector(".indication-modal");' + if needle not in s: + raise SystemExit("modal marker not found") + s = s.replace(needle, needle + '\n modal?.querySelectorAll(".signal-help[title]").forEach(el=>el.removeAttribute("title"));', 1) + + runtime.write_text(s, encoding="utf-8") + PY + - name: Verify candidate source 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 'reasons.append("medium_term_momentum_negative")' not in i + assert 'warnings.append("price_below_trend_averages")' in i + assert 'reasons.append("price_below_trend_averages")' not in i + assert 'const isScoreHelp=target=>target?.classList?.contains("signal-help");' in r + assert 'explicitScore=isScoreHelp(target)' in r + assert 'signal-help[title]' in r + PY + - name: Compile Python + run: python -m compileall -q custom_components + - name: JavaScript syntax + run: | + 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 From cde053af379ea645a7dd4cc991b2fa2c6593ea36 Mon Sep 17 00:00:00 2001 From: Chris Chreece <68458228+Chreece@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:31:04 +0200 Subject: [PATCH 19/19] Run V15 candidate validation on pull requests --- .github/workflows/v15-premerge.yml | 59 +++++++++--------------------- 1 file changed, 18 insertions(+), 41 deletions(-) diff --git a/.github/workflows/v15-premerge.yml b/.github/workflows/v15-premerge.yml index e1ce531..9083b66 100644 --- a/.github/workflows/v15-premerge.yml +++ b/.github/workflows/v15-premerge.yml @@ -1,6 +1,8 @@ name: V15 pre-merge validation on: + pull_request: + branches: [indications] push: branches: [v15-test-final] workflow_dispatch: @@ -19,7 +21,6 @@ jobs: run: | python - <<'PY' from pathlib import Path - import re root = Path.cwd() validated = root / "custom_components/investment/validated_model.py" @@ -35,49 +36,26 @@ jobs: s = indication.read_text(encoding="utf-8") if 'warnings.append("medium_term_momentum_negative")' not in s: - old = 'reasons.append("medium_term_momentum_negative")' - if old not in s: - raise SystemExit("negative momentum emission not found") - s = s.replace(old, 'warnings.append("medium_term_momentum_negative")', 1) + 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: - old = 'reasons.append("price_below_trend_averages")' - if old not in s: - raise SystemExit("below-trend emission not found") - s = s.replace(old, 'warnings.append("price_below_trend_averages")', 1) + 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") - marker = 'const isScoreHelp=target=>target?.classList?.contains("signal-help");' - if marker not in s: - old = '''const coarsePointer=()=>!!window.matchMedia?.("(hover:none), (pointer:coarse)")?.matches; - const trueHoverMouse=()=>!!window.matchMedia?.("(hover:hover) and (pointer:fine)")?.matches; - const isMouseHover=e=>e?.pointerType==="mouse"&&trueHoverMouse()&&!coarsePointer(); - modal.querySelectorAll(".help-label").forEach(target=>{target.classList.remove("help-open","help-touch");target.removeAttribute("data-help-source");target.removeAttribute("data-help-hover-ready");target.setAttribute("aria-expanded","false");target.addEventListener("pointerenter",e=>{if(isMouseHover(e))target.setAttribute("data-help-hover-ready","1");});target.addEventListener("pointermove",e=>{if(!isMouseHover(e)||target.getAttribute("data-help-hover-ready")!=="1")return;if(target.getAttribute("data-help-source")!=="hover")openHelp(target,{touch:false,source:"hover"});});target.addEventListener("pointerleave",e=>{target.removeAttribute("data-help-hover-ready");if(isMouseHover(e)&&target.getAttribute("data-help-source")==="hover")closeHelp(null);});});''' - new = '''const coarsePointer=()=>!!window.matchMedia?.("(hover:none), (pointer:coarse)")?.matches; - const trueHoverMouse=()=>!!window.matchMedia?.("(hover:hover) and (pointer:fine)")?.matches; - const isMouseHover=e=>e?.pointerType==="mouse"&&trueHoverMouse()&&!coarsePointer(); - const isScoreHelp=target=>target?.classList?.contains("signal-help"); - modal.querySelectorAll(".help-label").forEach(target=>{target.classList.remove("help-open","help-touch");target.removeAttribute("data-help-source");target.removeAttribute("data-help-hover-ready");target.setAttribute("aria-expanded","false");target.addEventListener("pointerenter",e=>{if(isScoreHelp(target))return;if(isMouseHover(e))target.setAttribute("data-help-hover-ready","1");});target.addEventListener("pointermove",e=>{if(isScoreHelp(target))return;if(!isMouseHover(e)||target.getAttribute("data-help-hover-ready")!=="1")return;if(target.getAttribute("data-help-source")!=="hover")openHelp(target,{touch:false,source:"hover"});});target.addEventListener("pointerleave",e=>{target.removeAttribute("data-help-hover-ready");if(isScoreHelp(target))return;if(isMouseHover(e)&&target.getAttribute("data-help-source")==="hover")closeHelp(null);});});''' + 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 block not found") - s = s.replace(old, new, 1) - - old_click = 'const touch=lastPointerType==="touch"||lastPointerType==="pen"||coarsePointer();if(touch)toggleHelp(target,{touch:true,source:"tap"});else closeHelp(null);return;}closeHelp(null);' - new_click = 'const touch=lastPointerType==="touch"||lastPointerType==="pen"||coarsePointer();const explicitScore=isScoreHelp(target);if(touch||explicitScore)toggleHelp(target,{touch,source:touch?"tap":"click"});else closeHelp(null);return;}closeHelp(null);' - if old_click not in s and 'explicitScore=isScoreHelp(target)' not in s: - raise SystemExit("score-help click handler not found") - if old_click in s: - s = s.replace(old_click, new_click, 1) - - if "signal-help[title]" not in s: - needle = 'const modal=root.querySelector(".indication-modal");' - if needle not in s: - raise SystemExit("modal marker not found") - s = s.replace(needle, needle + '\n modal?.querySelectorAll(".signal-help[title]").forEach(el=>el.removeAttribute("title"));', 1) - - runtime.write_text(s, encoding="utf-8") + 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 source contract + - name: Verify candidate contract run: | python - <<'PY' from pathlib import Path @@ -87,17 +65,16 @@ jobs: 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 'reasons.append("medium_term_momentum_negative")' not 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 - assert 'explicitScore=isScoreHelp(target)' in r - assert 'signal-help[title]' 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