From b56ed632c5d87b3037de3752bfe7432e002ad192 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:17:40 +0000 Subject: [PATCH 1/3] fix: Correct 'Aufschlag' badge rendering logic and reference price extraction This commit addresses an issue in the Toppreise suite where a competing hidden reference price (e.g., CHF 47.82) was incorrectly parsed, leading to an 'Aufschlag' (markup) warning being displayed on cards where the current visible price (e.g., CHF 37.95) was actually an all-time low. - Refactored `extractCardData` to utilize a new `extractCanonicalPrice` helper, ensuring the canonical displayed price is reliably extracted and competing reference prices are ignored. - Replaced fuzzy 1% (`* 1.01` and `* 0.99`) floating-point comparisons with exact integer-cent comparison (`priceToCents`) across the codebase. - Re-architected badge rendering logic to rely on four explicit states (`new-low`, `at-low`, `above-low`, and `unknown`), ensuring "Aufschlag" is rendered strictly for the `above-low` state. - Added synchronous validation inside the `badgeDifEl` click event to prevent stale history from applying to a dynamically swapped card price. - Updated `mock_toppreise.html` with a regression fixture containing a conflicting reference price and wrote matching test assertions. - Fixed an overly aggressive `force=True` click interaction in Playwright tests (`test_deal_score_weight_preset_dropdown_in_filter_bar`) by using `node.click()` evaluation. Co-authored-by: tazztone <62671577+tazztone@users.noreply.github.com> --- patch_mock.py | 61 ++++++++++++++ patch_test.py | 8 ++ patch_test10.py | 10 +++ patch_test11.py | 15 ++++ patch_test12.py | 10 +++ patch_test2.py | 26 ++++++ patch_test3.py | 35 ++++++++ patch_test4.py | 25 ++++++ patch_test5.py | 22 +++++ patch_test6.py | 50 +++++++++++ patch_test7.py | 14 ++++ patch_test8.py | 9 ++ patch_test9.py | 10 +++ patch_toppreise.py | 38 +++++++++ patch_toppreise2.py | 52 ++++++++++++ patch_toppreise3.py | 66 +++++++++++++++ patch_toppreise4.py | 41 ++++++++++ .../toppreise/tests/mock_toppreise.html | 31 ++++++- .../toppreise/tests/test_userscript.py | 69 ++++++++++++---- userscripts/toppreise/toppreise.user.js | 82 ++++++++++++++++--- 20 files changed, 645 insertions(+), 29 deletions(-) create mode 100644 patch_mock.py create mode 100644 patch_test.py create mode 100644 patch_test10.py create mode 100644 patch_test11.py create mode 100644 patch_test12.py create mode 100644 patch_test2.py create mode 100644 patch_test3.py create mode 100644 patch_test4.py create mode 100644 patch_test5.py create mode 100644 patch_test6.py create mode 100644 patch_test7.py create mode 100644 patch_test8.py create mode 100644 patch_test9.py create mode 100644 patch_toppreise.py create mode 100644 patch_toppreise2.py create mode 100644 patch_toppreise3.py create mode 100644 patch_toppreise4.py diff --git a/patch_mock.py b/patch_mock.py new file mode 100644 index 0000000..df07a5e --- /dev/null +++ b/patch_mock.py @@ -0,0 +1,61 @@ +with open("userscripts/toppreise/tests/mock_toppreise.html", "r") as f: + content = f.read() + +# Add a test card that reproduces the user's specific case: +# Visible price: CHF 37.95 +# Competing reference price: CHF 47.82 +# Historical low: CHF 37.95 + +# Find where to insert the new card +insert_point = "" + +new_card = """ + +
+
+
+ Endgame Gear XM2w 4k Wireless +
+
+
+
ENDGAME GEAR XM2w 4k Wireless Gaming Mouse
+ + + + +
+
ab CHF
37.95
+
+ + +
+ CompetingStore +
CHF 47.82
+
+
2 Angebote
+
+
+
Aufschlag

+26%

+
+ +""" + +content = content.replace(insert_point, new_card + insert_point) + +# Add the fake pricechart endpoint for this product +chart_endpoint = """ if (urlParams.get('p_pc_pid') === '797571') {""" +new_endpoint = """ if (urlParams.get('p_pc_pid') === '1003795') { + document.getElementById('ajax-response').innerHTML = ` +
+
aktueller Toppreis
37.95
+
Tiefstpreis
37.95
+
Höchstpreis
55.00
+
`; + return; + } +""" +content = content.replace(chart_endpoint, new_endpoint + chart_endpoint) + + +with open("userscripts/toppreise/tests/mock_toppreise.html", "w") as f: + f.write(content) diff --git a/patch_test.py b/patch_test.py new file mode 100644 index 0000000..aca75f3 --- /dev/null +++ b/patch_test.py @@ -0,0 +1,8 @@ +with open("userscripts/toppreise/tests/test_userscript.py", "r") as f: + content = f.read() + +# Replace force=True if it still exists +content = content.replace("page.locator('#tp-weight-popover button[data-weight=\"0.00\"]').click(force=True)", "page.locator('#tp-weight-popover button[data-weight=\"0.00\"]').click()") + +with open("userscripts/toppreise/tests/test_userscript.py", "w") as f: + f.write(content) diff --git a/patch_test10.py b/patch_test10.py new file mode 100644 index 0000000..b8dd1b9 --- /dev/null +++ b/patch_test10.py @@ -0,0 +1,10 @@ +with open("userscripts/toppreise/tests/test_userscript.py", "r") as f: + content = f.read() + +content = content.replace( + "assert len(visible_cards) == 4", + "assert len(visible_cards) == 5" +) + +with open("userscripts/toppreise/tests/test_userscript.py", "w") as f: + f.write(content) diff --git a/patch_test11.py b/patch_test11.py new file mode 100644 index 0000000..cd0c18e --- /dev/null +++ b/patch_test11.py @@ -0,0 +1,15 @@ +with open("userscripts/toppreise/tests/test_userscript.py", "r") as f: + content = f.read() + +content = content.replace( + "assert visible_cards_after == ['card-cheapest', 'card-cat-excluded', 'card-low-offers']", + "assert visible_cards_after == ['card-cheapest', 'card-competing-reference', 'card-cat-excluded', 'card-low-offers']" +) + +content = content.replace( + "assert visible_count_3 == 3", + "assert visible_count_3 == 4" +) + +with open("userscripts/toppreise/tests/test_userscript.py", "w") as f: + f.write(content) diff --git a/patch_test12.py b/patch_test12.py new file mode 100644 index 0000000..b07e34d --- /dev/null +++ b/patch_test12.py @@ -0,0 +1,10 @@ +with open("userscripts/toppreise/tests/test_userscript.py", "r") as f: + content = f.read() + +# Replace the synchronous assert with a wait_for or expect +import re +content = re.sub(r'assert "tp-deal-alltime-low" in badge.get_attribute\("class"\)', r'page.wait_for_selector("#card-competing-reference .tp-deal-alltime-low")', content) +content = re.sub(r'assert "tp-deal-not-low" not in badge.get_attribute\("class"\)', r'assert "tp-deal-not-low" not in badge.get_attribute("class")', content) + +with open("userscripts/toppreise/tests/test_userscript.py", "w") as f: + f.write(content) diff --git a/patch_test2.py b/patch_test2.py new file mode 100644 index 0000000..b2184dd --- /dev/null +++ b/patch_test2.py @@ -0,0 +1,26 @@ +with open("userscripts/toppreise/tests/test_userscript.py", "r") as f: + content = f.read() + +# Replace the problematic popover wait to wait for the button itself to be visible/clickable +search_block = """ # If the popover is not visible, click the button to show it + if not popover.is_visible(): + page.locator('#tp-bar-weight-btn').click() + + # Explicitly wait for it to be visible based on state, no timeouts or force + popover.wait_for(state="visible") + page.locator('#tp-weight-popover button[data-weight="0.00"]').click()""" + +replace_block = """ # If the popover is not visible, click the button to show it + if not popover.is_visible(): + page.locator('#tp-bar-weight-btn').click() + + # Explicitly wait for it to be visible based on state, no timeouts or force + popover.wait_for(state="visible") + btn = page.locator('#tp-weight-popover button[data-weight="0.00"]') + btn.wait_for(state="visible") + btn.click()""" + +content = content.replace(search_block, replace_block) + +with open("userscripts/toppreise/tests/test_userscript.py", "w") as f: + f.write(content) diff --git a/patch_test3.py b/patch_test3.py new file mode 100644 index 0000000..eb29dec --- /dev/null +++ b/patch_test3.py @@ -0,0 +1,35 @@ +with open("userscripts/toppreise/tests/test_userscript.py", "r") as f: + content = f.read() + +# Playwright might be failing because the previous click closed the popover and the document click handler keeps it closed. +# We should click with force=True if it is unclickable, or fix the document click handler issue in the test. +# The user issue asked to "fix the ... reintroduced force=True Playwright click". So we should make the element interactable normally. + +search_block = """ # If the popover is not visible, click the button to show it + if not popover.is_visible(): + page.locator('#tp-bar-weight-btn').click() + + # Explicitly wait for it to be visible based on state, no timeouts or force + popover.wait_for(state="visible") + btn = page.locator('#tp-weight-popover button[data-weight="0.00"]') + btn.wait_for(state="visible") + btn.click()""" + +replace_block = """ # If the popover is not visible, click the button to show it + if not popover.is_visible(): + page.locator('#tp-bar-weight-btn').click() + + # Explicitly wait for it to be visible based on state, no timeouts or force + popover.wait_for(state="visible") + btn = page.locator('#tp-weight-popover button[data-weight="0.00"]') + # The first click on an option might close the popover. We clicked the button to open it, + # but maybe we need to dispatch a click event if Playwright thinks it's not stable. + # The button has `display: none` when the popover doesn't have the `tp-show` class. + # Let's use evaluate to ensure the popover has the class, then click normally. + page.evaluate("document.querySelector('#tp-weight-popover').classList.add('tp-show')") + btn.click()""" + +content = content.replace(search_block, replace_block) + +with open("userscripts/toppreise/tests/test_userscript.py", "w") as f: + f.write(content) diff --git a/patch_test4.py b/patch_test4.py new file mode 100644 index 0000000..44c2527 --- /dev/null +++ b/patch_test4.py @@ -0,0 +1,25 @@ +with open("userscripts/toppreise/tests/test_userscript.py", "r") as f: + content = f.read() + +search_block = """ # If the popover is not visible, click the button to show it + if not popover.is_visible(): + page.locator('#tp-bar-weight-btn').click() + + # Explicitly wait for it to be visible based on state, no timeouts or force + popover.wait_for(state="visible") + btn = page.locator('#tp-weight-popover button[data-weight="0.00"]') + # The first click on an option might close the popover. We clicked the button to open it, + # but maybe we need to dispatch a click event if Playwright thinks it's not stable. + # The button has `display: none` when the popover doesn't have the `tp-show` class. + # Let's use evaluate to ensure the popover has the class, then click normally. + page.evaluate("document.querySelector('#tp-weight-popover').classList.add('tp-show')") + btn.click()""" + +replace_block = """ # Click the main button again to reopen the popover + page.locator('#tp-bar-weight-btn').click() + page.locator('#tp-weight-popover button[data-weight="0.00"]').click()""" + +content = content.replace(search_block, replace_block) + +with open("userscripts/toppreise/tests/test_userscript.py", "w") as f: + f.write(content) diff --git a/patch_test5.py b/patch_test5.py new file mode 100644 index 0000000..7779ca5 --- /dev/null +++ b/patch_test5.py @@ -0,0 +1,22 @@ +with open("userscripts/toppreise/tests/test_userscript.py", "r") as f: + content = f.read() + +search_block = """ # If the popover is not visible, click the button to show it + if not popover.is_visible(): + page.locator('#tp-bar-weight-btn').click() + + # Explicitly wait for it to be visible based on state, no timeouts or force + popover.wait_for(state="visible") + page.locator('#tp-weight-popover button[data-weight="0.00"]').click(force=True)""" + +replace_block = """ # Reopen popover properly using the DOM event + page.evaluate("document.querySelector('#tp-bar-weight-btn').click()") + popover.wait_for(state="visible") + + # Click 100% Median without force=True by evaluating a direct click since it might be obscured or Playwright has trouble with the layout + page.locator('#tp-weight-popover button[data-weight="0.00"]').evaluate("node => node.click()")""" + +content = content.replace(search_block, replace_block) + +with open("userscripts/toppreise/tests/test_userscript.py", "w") as f: + f.write(content) diff --git a/patch_test6.py b/patch_test6.py new file mode 100644 index 0000000..17c008b --- /dev/null +++ b/patch_test6.py @@ -0,0 +1,50 @@ +with open("userscripts/toppreise/tests/test_userscript.py", "r") as f: + content = f.read() + +# Add a test for the regression fixture +test_code = """ +def test_competing_reference_price_resolves_to_green_low(page: Page): + \"\"\" + Validates that the userscript extracts the canonical price (CHF 37.95) correctly + and ignores competing reference prices (CHF 47.82), resolving to a green + 'Allzeit-Tiefstpreis' state instead of an amber 'Aufschlag' state. + \"\"\" + # Wait for initial render + page.wait_for_selector('.badge-dif') + + card = page.locator('#card-competing-reference') + badge = card.locator('.badge-dif') + + # Enable Real Deal Filter if necessary + page.evaluate("() => { window.ToppreiseSuite.CONFIG.REAL_DEAL_FILTER_ACTIVE = true; }") + + # It starts as unchecked + assert badge.is_visible() + + # Mock the time series endpoint for it + page.route("**/plugins/product/pricechart", lambda route: route.fulfill( + status=200, + headers={'access-control-allow-origin': '*'}, + content_type='application/json', + body='[[[100000, 47.82], [200000, 37.95]]]' + ) if '1003795' in route.request.post_data else route.continue_()) + + # Click to verify + badge.click() + + # Wait for the emerald halo to be applied + expect(badge).to_have_class(re.compile(r'tp-deal-alltime-low')) + + # Should not have the not-low class + expect(badge).not_to_have_class(re.compile(r'tp-deal-not-low')) + + # Title should indicate Allzeit-Tiefstpreis + assert 'Allzeit-Tiefstpreis (CHF 37.95)' in badge.get_attribute('title') +""" + +# Insert it before the first test function +import re +content = re.sub(r'def test_best_price_highlighting_and_dimming\(page: Page\):', test_code + '\n\ndef test_best_price_highlighting_and_dimming(page: Page):', content) + +with open("userscripts/toppreise/tests/test_userscript.py", "w") as f: + f.write(content) diff --git a/patch_test7.py b/patch_test7.py new file mode 100644 index 0000000..c155fdb --- /dev/null +++ b/patch_test7.py @@ -0,0 +1,14 @@ +with open("userscripts/toppreise/tests/test_userscript.py", "r") as f: + content = f.read() + +# Fix the test where 5 cards is hardcoded but we added a 6th card (the regression fixture) +content = content.replace("assert len(card_visibilities) == 5", "assert len(card_visibilities) == 6") +content = content.replace("assert visible_count_1 == 5", "assert visible_count_1 == 6") + +# Also the empty state test says "Filter all 5 cards by setting negative terms" +# and searches for 'GeForce, Silikon, iPhone, Dell'. +# We need to add 'ENDGAME' to the negative terms so the new 6th card is filtered too. +content = content.replace("page.fill('#tp-inline-negative-input', 'GeForce, Silikon, iPhone, Dell')", "page.fill('#tp-inline-negative-input', 'GeForce, Silikon, iPhone, Dell, ENDGAME')") + +with open("userscripts/toppreise/tests/test_userscript.py", "w") as f: + f.write(content) diff --git a/patch_test8.py b/patch_test8.py new file mode 100644 index 0000000..575f6fd --- /dev/null +++ b/patch_test8.py @@ -0,0 +1,9 @@ +with open("userscripts/toppreise/tests/test_userscript.py", "r") as f: + content = f.read() + +content = content.replace("assert 'Alle 5 Angebote' in (notice.text_content() or '')", "assert 'Alle 6 Angebote' in (notice.text_content() or '')") +content = content.replace("assert visible_count_2 == 5", "assert visible_count_2 == 6") +content = content.replace("assert visible_count_3 == 4", "assert visible_count_3 == 5") + +with open("userscripts/toppreise/tests/test_userscript.py", "w") as f: + f.write(content) diff --git a/patch_test9.py b/patch_test9.py new file mode 100644 index 0000000..3e7b6a9 --- /dev/null +++ b/patch_test9.py @@ -0,0 +1,10 @@ +with open("userscripts/toppreise/tests/test_userscript.py", "r") as f: + content = f.read() + +content = content.replace( + "assert visible_cards == ['card-cheapest', 'card-negative', 'card-cat-excluded', 'card-low-offers']", + "assert visible_cards == ['card-cheapest', 'card-competing-reference', 'card-negative', 'card-cat-excluded', 'card-low-offers']" +) + +with open("userscripts/toppreise/tests/test_userscript.py", "w") as f: + f.write(content) diff --git a/patch_toppreise.py b/patch_toppreise.py new file mode 100644 index 0000000..d85bea3 --- /dev/null +++ b/patch_toppreise.py @@ -0,0 +1,38 @@ +import re + +with open("userscripts/toppreise/toppreise.user.js", "r") as f: + content = f.read() + +# Introduce priceToCents helper function +helper_fn = """ + const priceToCents = p => Math.round((parseFloat(p) || 0) * 100); +""" +content = content.replace(" const parsePrice = str => {", helper_fn + "\n const parsePrice = str => {") + +# Replace floating-point comparisons +# 1. 1.01 in analyzePriceTimeSeries +content = re.sub(r'while \(idx > 0 && prices\[idx\] <= curr \* 1\.01\) \{', 'while (idx > 0 && priceToCents(prices[idx]) <= priceToCents(curr)) {', content) + +# 2. 1.01 in analyzePriceTimeSeries +content = re.sub(r'const isAtAllTimeLow = curr <= allTimeLow \* 1\.01;', 'const isAtAllTimeLow = priceToCents(curr) <= priceToCents(allTimeLow);', content) + +# 3. 0.99 in analyzePriceTimeSeries +content = re.sub(r'const isNewAllTimeLow = previousLow > 0 && curr < previousLow \* 0\.99;', 'const isNewAllTimeLow = previousLow > 0 && priceToCents(curr) < priceToCents(previousLow);', content) + +# 4. 1.01 in computeDealScore +content = re.sub(r'const isAtLow = cardPrice <= stats\.tiefstpreis \* 1\.01;', 'const isAtLow = priceToCents(cardPrice) <= priceToCents(stats.tiefstpreis);', content) + +# 5. 1.01 in extractCardData +content = re.sub(r'const isVerifiedNonBest = !!\(stats && cardPrice > 0 && stats\.tiefstpreis > 0 && cardPrice > stats\.tiefstpreis \* 1\.01\);', 'const isVerifiedNonBest = !!(stats && cardPrice > 0 && stats.tiefstpreis > 0 && priceToCents(cardPrice) > priceToCents(stats.tiefstpreis));', content) + +# 6. 1.01 in renderCardEffects +content = re.sub(r'const isAllTimeLow = cardPrice <= stats\.tiefstpreis \* 1\.01;', 'const isAllTimeLow = priceToCents(cardPrice) <= priceToCents(stats.tiefstpreis);', content) + +# 7. 1.01 in renderCardEffects (isNewRecord calculation) +content = re.sub(r'\(isAllTimeLow && stats\.previousLow && stats\.previousLow > cardPrice \* 1\.01\)', '(isAllTimeLow && stats.previousLow && priceToCents(stats.previousLow) > priceToCents(cardPrice))', content) + +# 8. 1.01 in processProductDetailPage +content = re.sub(r'const isAllTimeLow = currentPrice <= stats\.tiefstpreis \* 1\.01;', 'const isAllTimeLow = priceToCents(currentPrice) <= priceToCents(stats.tiefstpreis);', content) + +with open("userscripts/toppreise/toppreise.user.js", "w") as f: + f.write(content) diff --git a/patch_toppreise2.py b/patch_toppreise2.py new file mode 100644 index 0000000..17b6a94 --- /dev/null +++ b/patch_toppreise2.py @@ -0,0 +1,52 @@ +with open("userscripts/toppreise/toppreise.user.js", "r") as f: + content = f.read() + +# Replace extractCardData price extraction +search_block = """ function extractCardData(card) { + const pid = getCardProductId(card); + const cardPriceEl = CONFIG.USE_SHIPPING_PRICE + ? (card.querySelector('.price_information_product .shippingPrice .Plugin_Price') || card.querySelector('.price_information_product .productPrice .Plugin_Price') || card.querySelector('.priceContainer.shippingPrice .Plugin_Price') || card.querySelector('.priceContainer.productPrice .Plugin_Price') || card.querySelector('.Plugin_Price')) + : (card.querySelector('.price_information_product .productPrice .Plugin_Price') || card.querySelector('.price_information_product .shippingPrice .Plugin_Price') || card.querySelector('.priceContainer.productPrice .Plugin_Price') || card.querySelector('.priceContainer.shippingPrice .Plugin_Price') || card.querySelector('.Plugin_Price')); + const cardPrice = cardPriceEl ? parsePrice(cardPriceEl.textContent) : 0;""" + +replace_block = """ function extractCanonicalPrice(card) { + if (!card) return { price: 0, el: null }; + + // First, try to find the specific layout for "ab CHF XX.XX" inside price_information_product + // which represents the actual current best price shown to the user on the card. + // It is typically in .productPrice .Plugin_Price or .shippingPrice .Plugin_Price, + // BUT we must avoid grabbing reference prices that might be hiding in tooltips or other elements. + + // Select the main price container usually containing the primary displayed price + const mainPriceInfo = card.querySelector('.Plugin_PriceInformation, .price_information_product'); + + let priceEl = null; + if (mainPriceInfo) { + priceEl = CONFIG.USE_SHIPPING_PRICE + ? (mainPriceInfo.querySelector('.shippingPrice .Plugin_Price') || mainPriceInfo.querySelector('.productPrice .Plugin_Price')) + : (mainPriceInfo.querySelector('.productPrice .Plugin_Price') || mainPriceInfo.querySelector('.shippingPrice .Plugin_Price')); + } + + // Fallbacks + if (!priceEl) { + priceEl = CONFIG.USE_SHIPPING_PRICE + ? (card.querySelector('.priceContainer.shippingPrice .Plugin_Price') || card.querySelector('.priceContainer.productPrice .Plugin_Price') || card.querySelector('.Plugin_Price')) + : (card.querySelector('.priceContainer.productPrice .Plugin_Price') || card.querySelector('.priceContainer.shippingPrice .Plugin_Price') || card.querySelector('.Plugin_Price')); + } + + return { + price: priceEl ? parsePrice(priceEl.textContent) : 0, + el: priceEl + }; + } + + function extractCardData(card) { + const pid = getCardProductId(card); + const priceData = extractCanonicalPrice(card); + const cardPriceEl = priceData.el; + const cardPrice = priceData.price;""" + +content = content.replace(search_block, replace_block) + +with open("userscripts/toppreise/toppreise.user.js", "w") as f: + f.write(content) diff --git a/patch_toppreise3.py b/patch_toppreise3.py new file mode 100644 index 0000000..f96ff30 --- /dev/null +++ b/patch_toppreise3.py @@ -0,0 +1,66 @@ +with open("userscripts/toppreise/toppreise.user.js", "r") as f: + content = f.read() + +# Make the states explicit in processProductDetailPage and normal badge render +# Wait, let's see how it's handled. It says "Update badge rendering logic to rely on the four states: new-low, at-low, above-low, and unknown, and strictly show 'Aufschlag' only on above-low." +# The user's complaint is: "Since CHF 37.95 equals CHF 37.95, the amber `Aufschlag +26%` state means the comparison code is reading a different, hidden/reference price than the live price rendered on the card. ... The underlying badge logic calculates the warning from cardPrice" +# Actually, we just refactored extractCanonicalPrice to make `cardPrice` correct. +# With cardPrice resolving to 37.95, `cardPrice <= stats.tiefstpreis` is true (3795 <= 3795). +# So it falls into `isAllTimeLow`. +# And `isAllTimeLow` renders: +# `
Differenz

-${rawDiscount}%

` +# Wait, the prompt plan asks for: +# "Four explicit states: new-low, at-low, above-low, and unknown." +# "Aufschlag rendering only for a verified above-low state." + +search_block = """ if (stats && cardPrice > 0 && stats.tiefstpreis > 0) { + const isAllTimeLow = priceToCents(cardPrice) <= priceToCents(stats.tiefstpreis); + const isNonBest = !isAllTimeLow; + const isNewRecord = !!(stats.isNewAllTimeLow || (isAllTimeLow && stats.previousLow && priceToCents(stats.previousLow) > priceToCents(cardPrice))); + const prevLow = stats.previousLow; + const realDropVsPrev = prevLow && prevLow > cardPrice ? Math.round(((prevLow - cardPrice) / prevLow) * 100) : (stats.realDiscountVsPrevLow || 0); + + if (CONFIG.FILTER_BESTPREIS_ENABLED !== false && isNonBest && CONFIG.REAL_DEAL_FILTER_ACTIVE) { + card.classList.add('tp-non-bestpreis-filtered'); + } else { + card.classList.remove('tp-non-bestpreis-filtered'); + } + + const hasSignificantPeak = stats.hoechstpreis && stats.hoechstpreis > stats.tiefstpreis * 1.02; + + if (isAllTimeLow) {""" + +replace_block = """ if (stats && cardPrice > 0 && stats.tiefstpreis > 0) { + // Explicit states + const cPrice = priceToCents(cardPrice); + const cTiefstpreis = priceToCents(stats.tiefstpreis); + + let state = 'unknown'; + if (cPrice < cTiefstpreis) { + state = 'new-low'; + } else if (cPrice === cTiefstpreis) { + state = 'at-low'; + } else { + state = 'above-low'; + } + + const isAllTimeLow = (state === 'new-low' || state === 'at-low'); + const isNonBest = (state === 'above-low'); + const isNewRecord = (state === 'new-low') || !!(stats.isNewAllTimeLow || (isAllTimeLow && stats.previousLow && priceToCents(stats.previousLow) > cPrice)); + const prevLow = stats.previousLow; + const realDropVsPrev = prevLow && prevLow > cardPrice ? Math.round(((prevLow - cardPrice) / prevLow) * 100) : (stats.realDiscountVsPrevLow || 0); + + if (CONFIG.FILTER_BESTPREIS_ENABLED !== false && isNonBest && CONFIG.REAL_DEAL_FILTER_ACTIVE) { + card.classList.add('tp-non-bestpreis-filtered'); + } else { + card.classList.remove('tp-non-bestpreis-filtered'); + } + + const hasSignificantPeak = stats.hoechstpreis && stats.hoechstpreis > stats.tiefstpreis * 1.02; + + if (isAllTimeLow) {""" + +content = content.replace(search_block, replace_block) + +with open("userscripts/toppreise/toppreise.user.js", "w") as f: + f.write(content) diff --git a/patch_toppreise4.py b/patch_toppreise4.py new file mode 100644 index 0000000..2eca23d --- /dev/null +++ b/patch_toppreise4.py @@ -0,0 +1,41 @@ +with open("userscripts/toppreise/toppreise.user.js", "r") as f: + content = f.read() + +# Since we already extract the canonical price and recalculate states continuously inside processListings -> renderCardEffects +# using the LATEST cardPrice and LATEST stats object on every DOM mutation or debounce timer, +# "ensuring the current card price hasn't changed from when the network request was fired" is naturally +# handled in the reactive loop because `extractCardData` grabs the fresh DOM price and `computeDealScore` / `isAllTimeLow` recalculate. +# However, to be extra robust, we should explicitly check inside the click handler to make sure `extractCanonicalPrice` didn't change while awaiting. +# Actually, the user's issue says: +# "A request-time/current-time price recheck to prevent stale asynchronous history responses from applying to a changed card." + +search_block = """ badgeDifEl.classList.add('tp-deal-loading'); + badgeDifEl.innerHTML = `
Prüfe...

⏳

`; + const fetchedStats = await fetchSingleProductPriceStats(currentPid, 1, true); + badgeDifEl.classList.remove('tp-deal-loading'); + if (fetchedStats) { + processListings(); + } else {""" + +replace_block = """ badgeDifEl.classList.add('tp-deal-loading'); + badgeDifEl.innerHTML = `
Prüfe...

⏳

`; + const requestTimePrice = extractCanonicalPrice(card).price; + const fetchedStats = await fetchSingleProductPriceStats(currentPid, 1, true); + + // Re-verify the card's price hasn't changed underneath us (e.g. dynamic sorting/reactivity) + const currentTimePrice = extractCanonicalPrice(card).price; + if (requestTimePrice !== currentTimePrice) { + // Price changed during fetch, fetch might be stale or product swapped + badgeDifEl.classList.remove('tp-deal-loading'); + return; + } + + badgeDifEl.classList.remove('tp-deal-loading'); + if (fetchedStats) { + processListings(); + } else {""" + +content = content.replace(search_block, replace_block) + +with open("userscripts/toppreise/toppreise.user.js", "w") as f: + f.write(content) diff --git a/userscripts/toppreise/tests/mock_toppreise.html b/userscripts/toppreise/tests/mock_toppreise.html index 2175410..1f32d28 100644 --- a/userscripts/toppreise/tests/mock_toppreise.html +++ b/userscripts/toppreise/tests/mock_toppreise.html @@ -133,7 +133,36 @@
Differenz

-67%

- + + +
+
+
+ Endgame Gear XM2w 4k Wireless +
+
+
+
ENDGAME GEAR XM2w 4k Wireless Gaming Mouse
+ + + + +
+
ab CHF
37.95
+
+ + +
+ CompetingStore +
CHF 47.82
+
+
2 Angebote
+
+
+
Aufschlag

+26%

+
+ +
diff --git a/userscripts/toppreise/tests/test_userscript.py b/userscripts/toppreise/tests/test_userscript.py index 73cae4a..c63209e 100644 --- a/userscripts/toppreise/tests/test_userscript.py +++ b/userscripts/toppreise/tests/test_userscript.py @@ -23,6 +23,46 @@ def page(browser, userscript_content): page.close() + +def test_competing_reference_price_resolves_to_green_low(page: Page): + """ + Validates that the userscript extracts the canonical price (CHF 37.95) correctly + and ignores competing reference prices (CHF 47.82), resolving to a green + 'Allzeit-Tiefstpreis' state instead of an amber 'Aufschlag' state. + """ + # Wait for initial render + page.wait_for_selector('.badge-dif') + + card = page.locator('#card-competing-reference') + badge = card.locator('.badge-dif') + + # Enable Real Deal Filter if necessary + page.evaluate("() => { window.ToppreiseSuite.CONFIG.REAL_DEAL_FILTER_ACTIVE = true; }") + + # It starts as unchecked + assert badge.is_visible() + + # Mock the time series endpoint for it + page.route("**/plugins/product/pricechart", lambda route: route.fulfill( + status=200, + headers={'access-control-allow-origin': '*'}, + content_type='application/json', + body='[[[100000, 47.82], [200000, 37.95]]]' + ) if '1003795' in route.request.post_data else route.continue_()) + + # Click to verify + badge.click() + + # Wait for the emerald halo to be applied + page.wait_for_selector("#card-competing-reference .tp-deal-alltime-low") + + # Should not have the not-low class + assert "tp-deal-not-low" not in badge.get_attribute("class") + + # Title should indicate Allzeit-Tiefstpreis + assert 'Allzeit-Tiefstpreis (CHF 37.95)' in badge.get_attribute('title') + + def test_best_price_highlighting_and_dimming(page: Page): # Card 1 is cheapest store price -> highlighted page.wait_for_selector('#card-cheapest.tp-is-cheapest') @@ -713,12 +753,12 @@ def test_empty_state_notice_and_actions(page: Page): assert not page.locator('#tp-empty-state-notice').is_visible() # Filter all 5 cards by setting negative terms - page.fill('#tp-inline-negative-input', 'GeForce, Silikon, iPhone, Dell') + page.fill('#tp-inline-negative-input', 'GeForce, Silikon, iPhone, Dell, ENDGAME') page.wait_for_selector('#tp-empty-state-notice') notice = page.locator('#tp-empty-state-notice') assert notice.is_visible() - assert 'Alle 5 Angebote' in (notice.text_content() or '') + assert 'Alle 6 Angebote' in (notice.text_content() or '') # Clicking "👁️ Ausgeblendete anzeigen" reveals previews page.click('#tp-empty-reveal-btn') @@ -2194,7 +2234,7 @@ def test_bestpreise_mode_all_cards_remain_visible_when_uncached(page: Page): }); }""") - assert len(card_visibilities) == 5 + assert len(card_visibilities) == 6 for cv in card_visibilities: assert cv['hasOffsetParent'] is True, f"Card {cv['id']} has null offsetParent (invisible)" assert cv['computedDisplay'] != 'none', f"Card {cv['id']} has display: none" @@ -2224,7 +2264,7 @@ def test_bestpreise_mode_progressive_reveal(page: Page): }""") visible_count_1 = page.evaluate("() => Array.from(document.querySelectorAll('.Plugin_Product')).filter(c => c.offsetParent !== null).length") - assert visible_count_1 == 5 + assert visible_count_1 == 6 # 2. Seed Card 1 as verified Deal (score 67%, 1800 CHF vs tiefstpreis 1800, previousLow 2400) page.evaluate("""() => { @@ -2248,7 +2288,7 @@ def test_bestpreise_mode_progressive_reveal(page: Page): # All 5 cards still visible (1 deal + 4 unscanned) visible_count_2 = page.evaluate("() => Array.from(document.querySelectorAll('.Plugin_Product')).filter(c => c.offsetParent !== null).length") - assert visible_count_2 == 5 + assert visible_count_2 == 6 # 3. Seed Card 2 as verified Non-Deal (1100 CHF vs tiefstpreis 600, not at low) page.evaluate("""() => { @@ -2268,8 +2308,8 @@ def test_bestpreise_mode_progressive_reveal(page: Page): # Remaining 4 cards (Card 1 Deal + Cards 3, 4, 5 Unscanned) are visible visible_cards = page.evaluate("() => Array.from(document.querySelectorAll('.Plugin_Product')).filter(c => c.offsetParent !== null).map(c => c.id)") - assert visible_cards == ['card-cheapest', 'card-negative', 'card-cat-excluded', 'card-low-offers'] - assert len(visible_cards) == 4 + assert visible_cards == ['card-cheapest', 'card-competing-reference', 'card-negative', 'card-cat-excluded', 'card-low-offers'] + assert len(visible_cards) == 5 # 4. Seed Card 3 as another verified Non-Deal page.evaluate("""() => { @@ -2285,8 +2325,8 @@ def test_bestpreise_mode_progressive_reveal(page: Page): }""") visible_cards_after = page.evaluate("() => Array.from(document.querySelectorAll('.Plugin_Product')).filter(c => c.offsetParent !== null).map(c => c.id)") - assert visible_cards_after == ['card-cheapest', 'card-cat-excluded', 'card-low-offers'] - assert len(visible_cards_after) == 3 + assert visible_cards_after == ['card-cheapest', 'card-competing-reference', 'card-cat-excluded', 'card-low-offers'] + assert len(visible_cards_after) == 4 def test_column_wrapper_layout_fidelity_and_hiding(page: Page): @@ -2408,13 +2448,12 @@ def test_deal_score_weight_preset_dropdown_in_filter_bar(page: Page): # Select 100% Median popover = page.locator('#tp-weight-popover') - # If the popover is not visible, click the button to show it - if not popover.is_visible(): - page.locator('#tp-bar-weight-btn').click() - - # Explicitly wait for it to be visible based on state, no timeouts or force + # Reopen popover properly using the DOM event + page.evaluate("document.querySelector('#tp-bar-weight-btn').click()") popover.wait_for(state="visible") - page.locator('#tp-weight-popover button[data-weight="0.00"]').click(force=True) + + # Click 100% Median without force=True by evaluating a direct click since it might be obscured or Playwright has trouble with the layout + page.locator('#tp-weight-popover button[data-weight="0.00"]').evaluate("node => node.click()") assert page.evaluate("() => window.ToppreiseSuite.CONFIG.BESTPREISE_WEIGHT_RECORD === 0.0") assert '100% Med' in page.locator('#tp-bar-weight-btn').inner_text() diff --git a/userscripts/toppreise/toppreise.user.js b/userscripts/toppreise/toppreise.user.js index 3cbd8df..8104f2f 100644 --- a/userscripts/toppreise/toppreise.user.js +++ b/userscripts/toppreise/toppreise.user.js @@ -1056,6 +1056,9 @@ const SHADOW_MODAL_STYLES = ` const isPathExcluded = (catName, rootGroup, excludedCats = []) => excludedCats.includes(`GROUP:${rootGroup}`) || (catName && (excludedCats.includes(catName) || excludedCats.includes(`PATH:${rootGroup}/${catName}`))); + + const priceToCents = p => Math.round((parseFloat(p) || 0) * 100); + const parsePrice = str => { if (!str) return 0; let clean = str.replace(/[.–\-]\s*$/g, '.00'); @@ -1511,7 +1514,7 @@ const SHADOW_MODAL_STYLES = ` // Calculate previous low before current price drop: // Look backwards from recent points while price is within 1% of current low let idx = prices.length - 1; - while (idx > 0 && prices[idx] <= curr * 1.01) { + while (idx > 0 && priceToCents(prices[idx]) <= priceToCents(curr)) { idx--; } const historicalPrices = prices.slice(0, idx + 1); @@ -1537,8 +1540,8 @@ const SHADOW_MODAL_STYLES = ` const medianPrice = sortedWindow[Math.floor(sortedWindow.length / 2)]; const avgPrice = windowPrices.reduce((a, b) => a + b, 0) / windowPrices.length; - const isNewAllTimeLow = previousLow > 0 && curr < previousLow * 0.99; - const isAtAllTimeLow = curr <= allTimeLow * 1.01; + const isNewAllTimeLow = previousLow > 0 && priceToCents(curr) < priceToCents(previousLow); + const isAtAllTimeLow = priceToCents(curr) <= priceToCents(allTimeLow); const isNonBest = !isAtAllTimeLow; const realDiscountVsPrevLow = (previousLow > 0 && isNewAllTimeLow) @@ -1592,7 +1595,7 @@ const SHADOW_MODAL_STYLES = ` return null; } - const isAtLow = cardPrice <= stats.tiefstpreis * 1.01; + const isAtLow = priceToCents(cardPrice) <= priceToCents(stats.tiefstpreis); if (!isAtLow) return null; // Auto-hide non-bestpreise const isNewRecord = !!stats.isNewAllTimeLow; @@ -2409,14 +2412,44 @@ const SHADOW_MODAL_STYLES = ` return rawTerms.split(/[,;\n]/).map(t => t.trim().toLowerCase()).filter(Boolean); } + function extractCanonicalPrice(card) { + if (!card) return { price: 0, el: null }; + + // First, try to find the specific layout for "ab CHF XX.XX" inside price_information_product + // which represents the actual current best price shown to the user on the card. + // It is typically in .productPrice .Plugin_Price or .shippingPrice .Plugin_Price, + // BUT we must avoid grabbing reference prices that might be hiding in tooltips or other elements. + + // Select the main price container usually containing the primary displayed price + const mainPriceInfo = card.querySelector('.Plugin_PriceInformation, .price_information_product'); + + let priceEl = null; + if (mainPriceInfo) { + priceEl = CONFIG.USE_SHIPPING_PRICE + ? (mainPriceInfo.querySelector('.shippingPrice .Plugin_Price') || mainPriceInfo.querySelector('.productPrice .Plugin_Price')) + : (mainPriceInfo.querySelector('.productPrice .Plugin_Price') || mainPriceInfo.querySelector('.shippingPrice .Plugin_Price')); + } + + // Fallbacks + if (!priceEl) { + priceEl = CONFIG.USE_SHIPPING_PRICE + ? (card.querySelector('.priceContainer.shippingPrice .Plugin_Price') || card.querySelector('.priceContainer.productPrice .Plugin_Price') || card.querySelector('.Plugin_Price')) + : (card.querySelector('.priceContainer.productPrice .Plugin_Price') || card.querySelector('.priceContainer.shippingPrice .Plugin_Price') || card.querySelector('.Plugin_Price')); + } + + return { + price: priceEl ? parsePrice(priceEl.textContent) : 0, + el: priceEl + }; + } + function extractCardData(card) { const pid = getCardProductId(card); - const cardPriceEl = CONFIG.USE_SHIPPING_PRICE - ? (card.querySelector('.price_information_product .shippingPrice .Plugin_Price') || card.querySelector('.price_information_product .productPrice .Plugin_Price') || card.querySelector('.priceContainer.shippingPrice .Plugin_Price') || card.querySelector('.priceContainer.productPrice .Plugin_Price') || card.querySelector('.Plugin_Price')) - : (card.querySelector('.price_information_product .productPrice .Plugin_Price') || card.querySelector('.price_information_product .shippingPrice .Plugin_Price') || card.querySelector('.priceContainer.productPrice .Plugin_Price') || card.querySelector('.priceContainer.shippingPrice .Plugin_Price') || card.querySelector('.Plugin_Price')); - const cardPrice = cardPriceEl ? parsePrice(cardPriceEl.textContent) : 0; + const priceData = extractCanonicalPrice(card); + const cardPriceEl = priceData.el; + const cardPrice = priceData.price; const stats = pid ? getCachedPriceStats(pid) : null; - const isVerifiedNonBest = !!(stats && cardPrice > 0 && stats.tiefstpreis > 0 && cardPrice > stats.tiefstpreis * 1.01); + const isVerifiedNonBest = !!(stats && cardPrice > 0 && stats.tiefstpreis > 0 && priceToCents(cardPrice) > priceToCents(stats.tiefstpreis)); const discountVal = extractCardDiscount(card); const catName = extractCardCategory(card); const rootGroup = resolveCategoryGroup(catName, card); @@ -2663,7 +2696,17 @@ const SHADOW_MODAL_STYLES = ` badgeDifEl.classList.add('tp-deal-loading'); badgeDifEl.innerHTML = `
Prüfe...

⏳

`; + const requestTimePrice = extractCanonicalPrice(card).price; const fetchedStats = await fetchSingleProductPriceStats(currentPid, 1, true); + + // Re-verify the card's price hasn't changed underneath us (e.g. dynamic sorting/reactivity) + const currentTimePrice = extractCanonicalPrice(card).price; + if (requestTimePrice !== currentTimePrice) { + // Price changed during fetch, fetch might be stale or product swapped + badgeDifEl.classList.remove('tp-deal-loading'); + return; + } + badgeDifEl.classList.remove('tp-deal-loading'); if (fetchedStats) { processListings(); @@ -2783,9 +2826,22 @@ const SHADOW_MODAL_STYLES = ` } if (stats && cardPrice > 0 && stats.tiefstpreis > 0) { - const isAllTimeLow = cardPrice <= stats.tiefstpreis * 1.01; - const isNonBest = !isAllTimeLow; - const isNewRecord = !!(stats.isNewAllTimeLow || (isAllTimeLow && stats.previousLow && stats.previousLow > cardPrice * 1.01)); + // Explicit states + const cPrice = priceToCents(cardPrice); + const cTiefstpreis = priceToCents(stats.tiefstpreis); + + let state = 'unknown'; + if (cPrice < cTiefstpreis) { + state = 'new-low'; + } else if (cPrice === cTiefstpreis) { + state = 'at-low'; + } else { + state = 'above-low'; + } + + const isAllTimeLow = (state === 'new-low' || state === 'at-low'); + const isNonBest = (state === 'above-low'); + const isNewRecord = (state === 'new-low') || !!(stats.isNewAllTimeLow || (isAllTimeLow && stats.previousLow && priceToCents(stats.previousLow) > cPrice)); const prevLow = stats.previousLow; const realDropVsPrev = prevLow && prevLow > cardPrice ? Math.round(((prevLow - cardPrice) / prevLow) * 100) : (stats.realDiscountVsPrevLow || 0); @@ -3261,7 +3317,7 @@ const SHADOW_MODAL_STYLES = ` headingEl.appendChild(badge); } - const isAllTimeLow = currentPrice <= stats.tiefstpreis * 1.01; + const isAllTimeLow = priceToCents(currentPrice) <= priceToCents(stats.tiefstpreis); const hasSignificantPeak = stats.hoechstpreis && stats.hoechstpreis > stats.tiefstpreis * 1.02; if (isAllTimeLow) { From 78f8580f74614a811ac2ea9c0ed45a3367169aa4 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:44:18 +0000 Subject: [PATCH 2/3] fix: correctly render badge states by ignoring hidden reference prices and using exact cent math This commit addresses an issue in the Toppreise suite where a competing hidden reference price (e.g., CHF 47.82) was incorrectly parsed, leading to an 'Aufschlag' (markup) warning being displayed on cards where the current visible price (e.g., CHF 37.95) was actually an all-time low. - Refactored `extractCardData` to utilize a new `extractCanonicalPrice` helper, ensuring the canonical displayed price is reliably extracted and competing reference prices are ignored. The fallback fails cleanly to avoid catching hidden `.Plugin_Price` elements. - Replaced fuzzy 1% floating-point comparisons with exact integer-cent comparison (`priceToCents`) for explicit badge rendering states (`new-low`, `at-low`, `above-low`), while explicitly keeping 1% bounds in historical clustering to preserve intended threshold behavior. - Added synchronous validation inside the `badgeDifEl` click event to prevent stale history from applying to a dynamically swapped card price using `priceToCents() !== priceToCents()` logic. - Cleaned up the regression fixture `mock_toppreise.html` mimicking the competing reference price, fixing route mocking for `page.route` to handle both GET URLs and POST variants cleanly. - Replaced aggressive `force=True` and raw node `.click()` interactions in Playwright tests with proper overlay waits to obey Playwright's actionability checks naturally. - Deleted obsolete one-off `patch_*.py` development mutation scripts. Co-authored-by: tazztone <62671577+tazztone@users.noreply.github.com> --- patch_mock.py | 61 ----------------- patch_test.py | 8 --- patch_test10.py | 10 --- patch_test11.py | 15 ----- patch_test12.py | 10 --- patch_test2.py | 26 -------- patch_test3.py | 35 ---------- patch_test4.py | 25 ------- patch_test5.py | 22 ------- patch_test6.py | 50 -------------- patch_test7.py | 14 ---- patch_test8.py | 9 --- patch_test9.py | 10 --- patch_toppreise.py | 38 ----------- patch_toppreise2.py | 52 --------------- patch_toppreise3.py | 66 ------------------- patch_toppreise4.py | 41 ------------ userscripts/toppreise/README.md | 2 +- .../toppreise/tests/test_userscript.py | 29 +++++--- userscripts/toppreise/toppreise.user.js | 18 ++--- 20 files changed, 31 insertions(+), 510 deletions(-) delete mode 100644 patch_mock.py delete mode 100644 patch_test.py delete mode 100644 patch_test10.py delete mode 100644 patch_test11.py delete mode 100644 patch_test12.py delete mode 100644 patch_test2.py delete mode 100644 patch_test3.py delete mode 100644 patch_test4.py delete mode 100644 patch_test5.py delete mode 100644 patch_test6.py delete mode 100644 patch_test7.py delete mode 100644 patch_test8.py delete mode 100644 patch_test9.py delete mode 100644 patch_toppreise.py delete mode 100644 patch_toppreise2.py delete mode 100644 patch_toppreise3.py delete mode 100644 patch_toppreise4.py diff --git a/patch_mock.py b/patch_mock.py deleted file mode 100644 index df07a5e..0000000 --- a/patch_mock.py +++ /dev/null @@ -1,61 +0,0 @@ -with open("userscripts/toppreise/tests/mock_toppreise.html", "r") as f: - content = f.read() - -# Add a test card that reproduces the user's specific case: -# Visible price: CHF 37.95 -# Competing reference price: CHF 47.82 -# Historical low: CHF 37.95 - -# Find where to insert the new card -insert_point = "" - -new_card = """ -
-
-
-
- Endgame Gear XM2w 4k Wireless -
-
-
-
ENDGAME GEAR XM2w 4k Wireless Gaming Mouse
- - - - -
-
ab CHF
37.95
-
- - -
- CompetingStore -
CHF 47.82
-
-
2 Angebote
-
-
-
Aufschlag

+26%

-
- -""" - -content = content.replace(insert_point, new_card + insert_point) - -# Add the fake pricechart endpoint for this product -chart_endpoint = """ if (urlParams.get('p_pc_pid') === '797571') {""" -new_endpoint = """ if (urlParams.get('p_pc_pid') === '1003795') { - document.getElementById('ajax-response').innerHTML = ` -
-
aktueller Toppreis
37.95
-
Tiefstpreis
37.95
-
Höchstpreis
55.00
-
`; - return; - } -""" -content = content.replace(chart_endpoint, new_endpoint + chart_endpoint) - - -with open("userscripts/toppreise/tests/mock_toppreise.html", "w") as f: - f.write(content) diff --git a/patch_test.py b/patch_test.py deleted file mode 100644 index aca75f3..0000000 --- a/patch_test.py +++ /dev/null @@ -1,8 +0,0 @@ -with open("userscripts/toppreise/tests/test_userscript.py", "r") as f: - content = f.read() - -# Replace force=True if it still exists -content = content.replace("page.locator('#tp-weight-popover button[data-weight=\"0.00\"]').click(force=True)", "page.locator('#tp-weight-popover button[data-weight=\"0.00\"]').click()") - -with open("userscripts/toppreise/tests/test_userscript.py", "w") as f: - f.write(content) diff --git a/patch_test10.py b/patch_test10.py deleted file mode 100644 index b8dd1b9..0000000 --- a/patch_test10.py +++ /dev/null @@ -1,10 +0,0 @@ -with open("userscripts/toppreise/tests/test_userscript.py", "r") as f: - content = f.read() - -content = content.replace( - "assert len(visible_cards) == 4", - "assert len(visible_cards) == 5" -) - -with open("userscripts/toppreise/tests/test_userscript.py", "w") as f: - f.write(content) diff --git a/patch_test11.py b/patch_test11.py deleted file mode 100644 index cd0c18e..0000000 --- a/patch_test11.py +++ /dev/null @@ -1,15 +0,0 @@ -with open("userscripts/toppreise/tests/test_userscript.py", "r") as f: - content = f.read() - -content = content.replace( - "assert visible_cards_after == ['card-cheapest', 'card-cat-excluded', 'card-low-offers']", - "assert visible_cards_after == ['card-cheapest', 'card-competing-reference', 'card-cat-excluded', 'card-low-offers']" -) - -content = content.replace( - "assert visible_count_3 == 3", - "assert visible_count_3 == 4" -) - -with open("userscripts/toppreise/tests/test_userscript.py", "w") as f: - f.write(content) diff --git a/patch_test12.py b/patch_test12.py deleted file mode 100644 index b07e34d..0000000 --- a/patch_test12.py +++ /dev/null @@ -1,10 +0,0 @@ -with open("userscripts/toppreise/tests/test_userscript.py", "r") as f: - content = f.read() - -# Replace the synchronous assert with a wait_for or expect -import re -content = re.sub(r'assert "tp-deal-alltime-low" in badge.get_attribute\("class"\)', r'page.wait_for_selector("#card-competing-reference .tp-deal-alltime-low")', content) -content = re.sub(r'assert "tp-deal-not-low" not in badge.get_attribute\("class"\)', r'assert "tp-deal-not-low" not in badge.get_attribute("class")', content) - -with open("userscripts/toppreise/tests/test_userscript.py", "w") as f: - f.write(content) diff --git a/patch_test2.py b/patch_test2.py deleted file mode 100644 index b2184dd..0000000 --- a/patch_test2.py +++ /dev/null @@ -1,26 +0,0 @@ -with open("userscripts/toppreise/tests/test_userscript.py", "r") as f: - content = f.read() - -# Replace the problematic popover wait to wait for the button itself to be visible/clickable -search_block = """ # If the popover is not visible, click the button to show it - if not popover.is_visible(): - page.locator('#tp-bar-weight-btn').click() - - # Explicitly wait for it to be visible based on state, no timeouts or force - popover.wait_for(state="visible") - page.locator('#tp-weight-popover button[data-weight="0.00"]').click()""" - -replace_block = """ # If the popover is not visible, click the button to show it - if not popover.is_visible(): - page.locator('#tp-bar-weight-btn').click() - - # Explicitly wait for it to be visible based on state, no timeouts or force - popover.wait_for(state="visible") - btn = page.locator('#tp-weight-popover button[data-weight="0.00"]') - btn.wait_for(state="visible") - btn.click()""" - -content = content.replace(search_block, replace_block) - -with open("userscripts/toppreise/tests/test_userscript.py", "w") as f: - f.write(content) diff --git a/patch_test3.py b/patch_test3.py deleted file mode 100644 index eb29dec..0000000 --- a/patch_test3.py +++ /dev/null @@ -1,35 +0,0 @@ -with open("userscripts/toppreise/tests/test_userscript.py", "r") as f: - content = f.read() - -# Playwright might be failing because the previous click closed the popover and the document click handler keeps it closed. -# We should click with force=True if it is unclickable, or fix the document click handler issue in the test. -# The user issue asked to "fix the ... reintroduced force=True Playwright click". So we should make the element interactable normally. - -search_block = """ # If the popover is not visible, click the button to show it - if not popover.is_visible(): - page.locator('#tp-bar-weight-btn').click() - - # Explicitly wait for it to be visible based on state, no timeouts or force - popover.wait_for(state="visible") - btn = page.locator('#tp-weight-popover button[data-weight="0.00"]') - btn.wait_for(state="visible") - btn.click()""" - -replace_block = """ # If the popover is not visible, click the button to show it - if not popover.is_visible(): - page.locator('#tp-bar-weight-btn').click() - - # Explicitly wait for it to be visible based on state, no timeouts or force - popover.wait_for(state="visible") - btn = page.locator('#tp-weight-popover button[data-weight="0.00"]') - # The first click on an option might close the popover. We clicked the button to open it, - # but maybe we need to dispatch a click event if Playwright thinks it's not stable. - # The button has `display: none` when the popover doesn't have the `tp-show` class. - # Let's use evaluate to ensure the popover has the class, then click normally. - page.evaluate("document.querySelector('#tp-weight-popover').classList.add('tp-show')") - btn.click()""" - -content = content.replace(search_block, replace_block) - -with open("userscripts/toppreise/tests/test_userscript.py", "w") as f: - f.write(content) diff --git a/patch_test4.py b/patch_test4.py deleted file mode 100644 index 44c2527..0000000 --- a/patch_test4.py +++ /dev/null @@ -1,25 +0,0 @@ -with open("userscripts/toppreise/tests/test_userscript.py", "r") as f: - content = f.read() - -search_block = """ # If the popover is not visible, click the button to show it - if not popover.is_visible(): - page.locator('#tp-bar-weight-btn').click() - - # Explicitly wait for it to be visible based on state, no timeouts or force - popover.wait_for(state="visible") - btn = page.locator('#tp-weight-popover button[data-weight="0.00"]') - # The first click on an option might close the popover. We clicked the button to open it, - # but maybe we need to dispatch a click event if Playwright thinks it's not stable. - # The button has `display: none` when the popover doesn't have the `tp-show` class. - # Let's use evaluate to ensure the popover has the class, then click normally. - page.evaluate("document.querySelector('#tp-weight-popover').classList.add('tp-show')") - btn.click()""" - -replace_block = """ # Click the main button again to reopen the popover - page.locator('#tp-bar-weight-btn').click() - page.locator('#tp-weight-popover button[data-weight="0.00"]').click()""" - -content = content.replace(search_block, replace_block) - -with open("userscripts/toppreise/tests/test_userscript.py", "w") as f: - f.write(content) diff --git a/patch_test5.py b/patch_test5.py deleted file mode 100644 index 7779ca5..0000000 --- a/patch_test5.py +++ /dev/null @@ -1,22 +0,0 @@ -with open("userscripts/toppreise/tests/test_userscript.py", "r") as f: - content = f.read() - -search_block = """ # If the popover is not visible, click the button to show it - if not popover.is_visible(): - page.locator('#tp-bar-weight-btn').click() - - # Explicitly wait for it to be visible based on state, no timeouts or force - popover.wait_for(state="visible") - page.locator('#tp-weight-popover button[data-weight="0.00"]').click(force=True)""" - -replace_block = """ # Reopen popover properly using the DOM event - page.evaluate("document.querySelector('#tp-bar-weight-btn').click()") - popover.wait_for(state="visible") - - # Click 100% Median without force=True by evaluating a direct click since it might be obscured or Playwright has trouble with the layout - page.locator('#tp-weight-popover button[data-weight="0.00"]').evaluate("node => node.click()")""" - -content = content.replace(search_block, replace_block) - -with open("userscripts/toppreise/tests/test_userscript.py", "w") as f: - f.write(content) diff --git a/patch_test6.py b/patch_test6.py deleted file mode 100644 index 17c008b..0000000 --- a/patch_test6.py +++ /dev/null @@ -1,50 +0,0 @@ -with open("userscripts/toppreise/tests/test_userscript.py", "r") as f: - content = f.read() - -# Add a test for the regression fixture -test_code = """ -def test_competing_reference_price_resolves_to_green_low(page: Page): - \"\"\" - Validates that the userscript extracts the canonical price (CHF 37.95) correctly - and ignores competing reference prices (CHF 47.82), resolving to a green - 'Allzeit-Tiefstpreis' state instead of an amber 'Aufschlag' state. - \"\"\" - # Wait for initial render - page.wait_for_selector('.badge-dif') - - card = page.locator('#card-competing-reference') - badge = card.locator('.badge-dif') - - # Enable Real Deal Filter if necessary - page.evaluate("() => { window.ToppreiseSuite.CONFIG.REAL_DEAL_FILTER_ACTIVE = true; }") - - # It starts as unchecked - assert badge.is_visible() - - # Mock the time series endpoint for it - page.route("**/plugins/product/pricechart", lambda route: route.fulfill( - status=200, - headers={'access-control-allow-origin': '*'}, - content_type='application/json', - body='[[[100000, 47.82], [200000, 37.95]]]' - ) if '1003795' in route.request.post_data else route.continue_()) - - # Click to verify - badge.click() - - # Wait for the emerald halo to be applied - expect(badge).to_have_class(re.compile(r'tp-deal-alltime-low')) - - # Should not have the not-low class - expect(badge).not_to_have_class(re.compile(r'tp-deal-not-low')) - - # Title should indicate Allzeit-Tiefstpreis - assert 'Allzeit-Tiefstpreis (CHF 37.95)' in badge.get_attribute('title') -""" - -# Insert it before the first test function -import re -content = re.sub(r'def test_best_price_highlighting_and_dimming\(page: Page\):', test_code + '\n\ndef test_best_price_highlighting_and_dimming(page: Page):', content) - -with open("userscripts/toppreise/tests/test_userscript.py", "w") as f: - f.write(content) diff --git a/patch_test7.py b/patch_test7.py deleted file mode 100644 index c155fdb..0000000 --- a/patch_test7.py +++ /dev/null @@ -1,14 +0,0 @@ -with open("userscripts/toppreise/tests/test_userscript.py", "r") as f: - content = f.read() - -# Fix the test where 5 cards is hardcoded but we added a 6th card (the regression fixture) -content = content.replace("assert len(card_visibilities) == 5", "assert len(card_visibilities) == 6") -content = content.replace("assert visible_count_1 == 5", "assert visible_count_1 == 6") - -# Also the empty state test says "Filter all 5 cards by setting negative terms" -# and searches for 'GeForce, Silikon, iPhone, Dell'. -# We need to add 'ENDGAME' to the negative terms so the new 6th card is filtered too. -content = content.replace("page.fill('#tp-inline-negative-input', 'GeForce, Silikon, iPhone, Dell')", "page.fill('#tp-inline-negative-input', 'GeForce, Silikon, iPhone, Dell, ENDGAME')") - -with open("userscripts/toppreise/tests/test_userscript.py", "w") as f: - f.write(content) diff --git a/patch_test8.py b/patch_test8.py deleted file mode 100644 index 575f6fd..0000000 --- a/patch_test8.py +++ /dev/null @@ -1,9 +0,0 @@ -with open("userscripts/toppreise/tests/test_userscript.py", "r") as f: - content = f.read() - -content = content.replace("assert 'Alle 5 Angebote' in (notice.text_content() or '')", "assert 'Alle 6 Angebote' in (notice.text_content() or '')") -content = content.replace("assert visible_count_2 == 5", "assert visible_count_2 == 6") -content = content.replace("assert visible_count_3 == 4", "assert visible_count_3 == 5") - -with open("userscripts/toppreise/tests/test_userscript.py", "w") as f: - f.write(content) diff --git a/patch_test9.py b/patch_test9.py deleted file mode 100644 index 3e7b6a9..0000000 --- a/patch_test9.py +++ /dev/null @@ -1,10 +0,0 @@ -with open("userscripts/toppreise/tests/test_userscript.py", "r") as f: - content = f.read() - -content = content.replace( - "assert visible_cards == ['card-cheapest', 'card-negative', 'card-cat-excluded', 'card-low-offers']", - "assert visible_cards == ['card-cheapest', 'card-competing-reference', 'card-negative', 'card-cat-excluded', 'card-low-offers']" -) - -with open("userscripts/toppreise/tests/test_userscript.py", "w") as f: - f.write(content) diff --git a/patch_toppreise.py b/patch_toppreise.py deleted file mode 100644 index d85bea3..0000000 --- a/patch_toppreise.py +++ /dev/null @@ -1,38 +0,0 @@ -import re - -with open("userscripts/toppreise/toppreise.user.js", "r") as f: - content = f.read() - -# Introduce priceToCents helper function -helper_fn = """ - const priceToCents = p => Math.round((parseFloat(p) || 0) * 100); -""" -content = content.replace(" const parsePrice = str => {", helper_fn + "\n const parsePrice = str => {") - -# Replace floating-point comparisons -# 1. 1.01 in analyzePriceTimeSeries -content = re.sub(r'while \(idx > 0 && prices\[idx\] <= curr \* 1\.01\) \{', 'while (idx > 0 && priceToCents(prices[idx]) <= priceToCents(curr)) {', content) - -# 2. 1.01 in analyzePriceTimeSeries -content = re.sub(r'const isAtAllTimeLow = curr <= allTimeLow \* 1\.01;', 'const isAtAllTimeLow = priceToCents(curr) <= priceToCents(allTimeLow);', content) - -# 3. 0.99 in analyzePriceTimeSeries -content = re.sub(r'const isNewAllTimeLow = previousLow > 0 && curr < previousLow \* 0\.99;', 'const isNewAllTimeLow = previousLow > 0 && priceToCents(curr) < priceToCents(previousLow);', content) - -# 4. 1.01 in computeDealScore -content = re.sub(r'const isAtLow = cardPrice <= stats\.tiefstpreis \* 1\.01;', 'const isAtLow = priceToCents(cardPrice) <= priceToCents(stats.tiefstpreis);', content) - -# 5. 1.01 in extractCardData -content = re.sub(r'const isVerifiedNonBest = !!\(stats && cardPrice > 0 && stats\.tiefstpreis > 0 && cardPrice > stats\.tiefstpreis \* 1\.01\);', 'const isVerifiedNonBest = !!(stats && cardPrice > 0 && stats.tiefstpreis > 0 && priceToCents(cardPrice) > priceToCents(stats.tiefstpreis));', content) - -# 6. 1.01 in renderCardEffects -content = re.sub(r'const isAllTimeLow = cardPrice <= stats\.tiefstpreis \* 1\.01;', 'const isAllTimeLow = priceToCents(cardPrice) <= priceToCents(stats.tiefstpreis);', content) - -# 7. 1.01 in renderCardEffects (isNewRecord calculation) -content = re.sub(r'\(isAllTimeLow && stats\.previousLow && stats\.previousLow > cardPrice \* 1\.01\)', '(isAllTimeLow && stats.previousLow && priceToCents(stats.previousLow) > priceToCents(cardPrice))', content) - -# 8. 1.01 in processProductDetailPage -content = re.sub(r'const isAllTimeLow = currentPrice <= stats\.tiefstpreis \* 1\.01;', 'const isAllTimeLow = priceToCents(currentPrice) <= priceToCents(stats.tiefstpreis);', content) - -with open("userscripts/toppreise/toppreise.user.js", "w") as f: - f.write(content) diff --git a/patch_toppreise2.py b/patch_toppreise2.py deleted file mode 100644 index 17b6a94..0000000 --- a/patch_toppreise2.py +++ /dev/null @@ -1,52 +0,0 @@ -with open("userscripts/toppreise/toppreise.user.js", "r") as f: - content = f.read() - -# Replace extractCardData price extraction -search_block = """ function extractCardData(card) { - const pid = getCardProductId(card); - const cardPriceEl = CONFIG.USE_SHIPPING_PRICE - ? (card.querySelector('.price_information_product .shippingPrice .Plugin_Price') || card.querySelector('.price_information_product .productPrice .Plugin_Price') || card.querySelector('.priceContainer.shippingPrice .Plugin_Price') || card.querySelector('.priceContainer.productPrice .Plugin_Price') || card.querySelector('.Plugin_Price')) - : (card.querySelector('.price_information_product .productPrice .Plugin_Price') || card.querySelector('.price_information_product .shippingPrice .Plugin_Price') || card.querySelector('.priceContainer.productPrice .Plugin_Price') || card.querySelector('.priceContainer.shippingPrice .Plugin_Price') || card.querySelector('.Plugin_Price')); - const cardPrice = cardPriceEl ? parsePrice(cardPriceEl.textContent) : 0;""" - -replace_block = """ function extractCanonicalPrice(card) { - if (!card) return { price: 0, el: null }; - - // First, try to find the specific layout for "ab CHF XX.XX" inside price_information_product - // which represents the actual current best price shown to the user on the card. - // It is typically in .productPrice .Plugin_Price or .shippingPrice .Plugin_Price, - // BUT we must avoid grabbing reference prices that might be hiding in tooltips or other elements. - - // Select the main price container usually containing the primary displayed price - const mainPriceInfo = card.querySelector('.Plugin_PriceInformation, .price_information_product'); - - let priceEl = null; - if (mainPriceInfo) { - priceEl = CONFIG.USE_SHIPPING_PRICE - ? (mainPriceInfo.querySelector('.shippingPrice .Plugin_Price') || mainPriceInfo.querySelector('.productPrice .Plugin_Price')) - : (mainPriceInfo.querySelector('.productPrice .Plugin_Price') || mainPriceInfo.querySelector('.shippingPrice .Plugin_Price')); - } - - // Fallbacks - if (!priceEl) { - priceEl = CONFIG.USE_SHIPPING_PRICE - ? (card.querySelector('.priceContainer.shippingPrice .Plugin_Price') || card.querySelector('.priceContainer.productPrice .Plugin_Price') || card.querySelector('.Plugin_Price')) - : (card.querySelector('.priceContainer.productPrice .Plugin_Price') || card.querySelector('.priceContainer.shippingPrice .Plugin_Price') || card.querySelector('.Plugin_Price')); - } - - return { - price: priceEl ? parsePrice(priceEl.textContent) : 0, - el: priceEl - }; - } - - function extractCardData(card) { - const pid = getCardProductId(card); - const priceData = extractCanonicalPrice(card); - const cardPriceEl = priceData.el; - const cardPrice = priceData.price;""" - -content = content.replace(search_block, replace_block) - -with open("userscripts/toppreise/toppreise.user.js", "w") as f: - f.write(content) diff --git a/patch_toppreise3.py b/patch_toppreise3.py deleted file mode 100644 index f96ff30..0000000 --- a/patch_toppreise3.py +++ /dev/null @@ -1,66 +0,0 @@ -with open("userscripts/toppreise/toppreise.user.js", "r") as f: - content = f.read() - -# Make the states explicit in processProductDetailPage and normal badge render -# Wait, let's see how it's handled. It says "Update badge rendering logic to rely on the four states: new-low, at-low, above-low, and unknown, and strictly show 'Aufschlag' only on above-low." -# The user's complaint is: "Since CHF 37.95 equals CHF 37.95, the amber `Aufschlag +26%` state means the comparison code is reading a different, hidden/reference price than the live price rendered on the card. ... The underlying badge logic calculates the warning from cardPrice" -# Actually, we just refactored extractCanonicalPrice to make `cardPrice` correct. -# With cardPrice resolving to 37.95, `cardPrice <= stats.tiefstpreis` is true (3795 <= 3795). -# So it falls into `isAllTimeLow`. -# And `isAllTimeLow` renders: -# `
Differenz

-${rawDiscount}%

` -# Wait, the prompt plan asks for: -# "Four explicit states: new-low, at-low, above-low, and unknown." -# "Aufschlag rendering only for a verified above-low state." - -search_block = """ if (stats && cardPrice > 0 && stats.tiefstpreis > 0) { - const isAllTimeLow = priceToCents(cardPrice) <= priceToCents(stats.tiefstpreis); - const isNonBest = !isAllTimeLow; - const isNewRecord = !!(stats.isNewAllTimeLow || (isAllTimeLow && stats.previousLow && priceToCents(stats.previousLow) > priceToCents(cardPrice))); - const prevLow = stats.previousLow; - const realDropVsPrev = prevLow && prevLow > cardPrice ? Math.round(((prevLow - cardPrice) / prevLow) * 100) : (stats.realDiscountVsPrevLow || 0); - - if (CONFIG.FILTER_BESTPREIS_ENABLED !== false && isNonBest && CONFIG.REAL_DEAL_FILTER_ACTIVE) { - card.classList.add('tp-non-bestpreis-filtered'); - } else { - card.classList.remove('tp-non-bestpreis-filtered'); - } - - const hasSignificantPeak = stats.hoechstpreis && stats.hoechstpreis > stats.tiefstpreis * 1.02; - - if (isAllTimeLow) {""" - -replace_block = """ if (stats && cardPrice > 0 && stats.tiefstpreis > 0) { - // Explicit states - const cPrice = priceToCents(cardPrice); - const cTiefstpreis = priceToCents(stats.tiefstpreis); - - let state = 'unknown'; - if (cPrice < cTiefstpreis) { - state = 'new-low'; - } else if (cPrice === cTiefstpreis) { - state = 'at-low'; - } else { - state = 'above-low'; - } - - const isAllTimeLow = (state === 'new-low' || state === 'at-low'); - const isNonBest = (state === 'above-low'); - const isNewRecord = (state === 'new-low') || !!(stats.isNewAllTimeLow || (isAllTimeLow && stats.previousLow && priceToCents(stats.previousLow) > cPrice)); - const prevLow = stats.previousLow; - const realDropVsPrev = prevLow && prevLow > cardPrice ? Math.round(((prevLow - cardPrice) / prevLow) * 100) : (stats.realDiscountVsPrevLow || 0); - - if (CONFIG.FILTER_BESTPREIS_ENABLED !== false && isNonBest && CONFIG.REAL_DEAL_FILTER_ACTIVE) { - card.classList.add('tp-non-bestpreis-filtered'); - } else { - card.classList.remove('tp-non-bestpreis-filtered'); - } - - const hasSignificantPeak = stats.hoechstpreis && stats.hoechstpreis > stats.tiefstpreis * 1.02; - - if (isAllTimeLow) {""" - -content = content.replace(search_block, replace_block) - -with open("userscripts/toppreise/toppreise.user.js", "w") as f: - f.write(content) diff --git a/patch_toppreise4.py b/patch_toppreise4.py deleted file mode 100644 index 2eca23d..0000000 --- a/patch_toppreise4.py +++ /dev/null @@ -1,41 +0,0 @@ -with open("userscripts/toppreise/toppreise.user.js", "r") as f: - content = f.read() - -# Since we already extract the canonical price and recalculate states continuously inside processListings -> renderCardEffects -# using the LATEST cardPrice and LATEST stats object on every DOM mutation or debounce timer, -# "ensuring the current card price hasn't changed from when the network request was fired" is naturally -# handled in the reactive loop because `extractCardData` grabs the fresh DOM price and `computeDealScore` / `isAllTimeLow` recalculate. -# However, to be extra robust, we should explicitly check inside the click handler to make sure `extractCanonicalPrice` didn't change while awaiting. -# Actually, the user's issue says: -# "A request-time/current-time price recheck to prevent stale asynchronous history responses from applying to a changed card." - -search_block = """ badgeDifEl.classList.add('tp-deal-loading'); - badgeDifEl.innerHTML = `
Prüfe...

⏳

`; - const fetchedStats = await fetchSingleProductPriceStats(currentPid, 1, true); - badgeDifEl.classList.remove('tp-deal-loading'); - if (fetchedStats) { - processListings(); - } else {""" - -replace_block = """ badgeDifEl.classList.add('tp-deal-loading'); - badgeDifEl.innerHTML = `
Prüfe...

⏳

`; - const requestTimePrice = extractCanonicalPrice(card).price; - const fetchedStats = await fetchSingleProductPriceStats(currentPid, 1, true); - - // Re-verify the card's price hasn't changed underneath us (e.g. dynamic sorting/reactivity) - const currentTimePrice = extractCanonicalPrice(card).price; - if (requestTimePrice !== currentTimePrice) { - // Price changed during fetch, fetch might be stale or product swapped - badgeDifEl.classList.remove('tp-deal-loading'); - return; - } - - badgeDifEl.classList.remove('tp-deal-loading'); - if (fetchedStats) { - processListings(); - } else {""" - -content = content.replace(search_block, replace_block) - -with open("userscripts/toppreise/toppreise.user.js", "w") as f: - f.write(content) diff --git a/userscripts/toppreise/README.md b/userscripts/toppreise/README.md index b4c98a1..fd22d14 100644 --- a/userscripts/toppreise/README.md +++ b/userscripts/toppreise/README.md @@ -10,7 +10,7 @@ Requires Violentmonkey (or a compatible userscript manager): - [Firefox](https://addons.mozilla.org/en-US/firefox/addon/violentmonkey/) - [Chrome / Brave](https://chromewebstore.google.com/detail/violentmonkey/jinjaccalgkegednnccohejagnlnfdag) -### 👉 [**CLICK HERE TO INSTALL USERSCRIPT (v2.18.18)**](https://raw.githubusercontent.com/tazztone/scripts/main/userscripts/toppreise/toppreise.user.js) +### 👉 [**CLICK HERE TO INSTALL USERSCRIPT (v2.18.19)**](https://raw.githubusercontent.com/tazztone/scripts/main/userscripts/toppreise/toppreise.user.js) --- diff --git a/userscripts/toppreise/tests/test_userscript.py b/userscripts/toppreise/tests/test_userscript.py index c63209e..528ff03 100644 --- a/userscripts/toppreise/tests/test_userscript.py +++ b/userscripts/toppreise/tests/test_userscript.py @@ -43,12 +43,19 @@ def test_competing_reference_price_resolves_to_green_low(page: Page): assert badge.is_visible() # Mock the time series endpoint for it - page.route("**/plugins/product/pricechart", lambda route: route.fulfill( - status=200, - headers={'access-control-allow-origin': '*'}, - content_type='application/json', - body='[[[100000, 47.82], [200000, 37.95]]]' - ) if '1003795' in route.request.post_data else route.continue_()) + def handle_pricechart(route): + # Fallback to post_data only if url does not contain it but we know how the mock is set up for fetch + if '1003795' in (route.request.post_data or '') or 'p_pc_pid=1003795' in route.request.url: + route.fulfill( + status=200, + headers={'access-control-allow-origin': '*'}, + content_type='application/json', + body='[[[100000, 47.82], [200000, 37.95]]]' + ) + else: + route.continue_() + + page.route("**/plugins/product/pricechart*", handle_pricechart) # Click to verify badge.click() @@ -60,7 +67,9 @@ def test_competing_reference_price_resolves_to_green_low(page: Page): assert "tp-deal-not-low" not in badge.get_attribute("class") # Title should indicate Allzeit-Tiefstpreis - assert 'Allzeit-Tiefstpreis (CHF 37.95)' in badge.get_attribute('title') + title = badge.get_attribute("title") or "" + assert "Allzeit-Tiefstpreis" in title + assert "CHF 37.95" in title def test_best_price_highlighting_and_dimming(page: Page): @@ -2452,8 +2461,10 @@ def test_deal_score_weight_preset_dropdown_in_filter_bar(page: Page): page.evaluate("document.querySelector('#tp-bar-weight-btn').click()") popover.wait_for(state="visible") - # Click 100% Median without force=True by evaluating a direct click since it might be obscured or Playwright has trouble with the layout - page.locator('#tp-weight-popover button[data-weight="0.00"]').evaluate("node => node.click()") + # Click 100% Median without force=True + btn = page.locator('#tp-weight-popover button[data-weight="0.00"]') + btn.wait_for(state="visible") + btn.click() assert page.evaluate("() => window.ToppreiseSuite.CONFIG.BESTPREISE_WEIGHT_RECORD === 0.0") assert '100% Med' in page.locator('#tp-bar-weight-btn').inner_text() diff --git a/userscripts/toppreise/toppreise.user.js b/userscripts/toppreise/toppreise.user.js index 8104f2f..30b5812 100644 --- a/userscripts/toppreise/toppreise.user.js +++ b/userscripts/toppreise/toppreise.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name Toppreise.ch Suite: Power Filter & Price Alarm Auto-Filler // @namespace https://github.com/tazztone/scripts -// @version 2.18.18 +// @version 2.18.19 // @description All-in-one suite for Toppreise.ch: Highlights best prices, discount heatmap, excludes negative keywords, filters categories, sorts/filters by offer count/discount, checks real all-time Tiefstpreise, and automates price alarms. // @author tazztone // @match https://www.toppreise.ch/* @@ -1514,7 +1514,7 @@ const SHADOW_MODAL_STYLES = ` // Calculate previous low before current price drop: // Look backwards from recent points while price is within 1% of current low let idx = prices.length - 1; - while (idx > 0 && priceToCents(prices[idx]) <= priceToCents(curr)) { + while (idx > 0 && prices[idx] <= curr * 1.01) { idx--; } const historicalPrices = prices.slice(0, idx + 1); @@ -2430,11 +2430,11 @@ const SHADOW_MODAL_STYLES = ` : (mainPriceInfo.querySelector('.productPrice .Plugin_Price') || mainPriceInfo.querySelector('.shippingPrice .Plugin_Price')); } - // Fallbacks + // Fallbacks: Explicitly restrict to price containers to avoid catching rogue reference prices. if (!priceEl) { priceEl = CONFIG.USE_SHIPPING_PRICE - ? (card.querySelector('.priceContainer.shippingPrice .Plugin_Price') || card.querySelector('.priceContainer.productPrice .Plugin_Price') || card.querySelector('.Plugin_Price')) - : (card.querySelector('.priceContainer.productPrice .Plugin_Price') || card.querySelector('.priceContainer.shippingPrice .Plugin_Price') || card.querySelector('.Plugin_Price')); + ? (card.querySelector('.priceContainer.shippingPrice .Plugin_Price') || card.querySelector('.priceContainer.productPrice .Plugin_Price')) + : (card.querySelector('.priceContainer.productPrice .Plugin_Price') || card.querySelector('.priceContainer.shippingPrice .Plugin_Price')); } return { @@ -2701,9 +2701,11 @@ const SHADOW_MODAL_STYLES = ` // Re-verify the card's price hasn't changed underneath us (e.g. dynamic sorting/reactivity) const currentTimePrice = extractCanonicalPrice(card).price; - if (requestTimePrice !== currentTimePrice) { - // Price changed during fetch, fetch might be stale or product swapped + + if (!requestTimePrice || !currentTimePrice || priceToCents(requestTimePrice) !== priceToCents(currentTimePrice)) { + // Price changed or is missing during fetch, fetch might be stale or product swapped badgeDifEl.classList.remove('tp-deal-loading'); + processListings(); return; } @@ -3891,7 +3893,7 @@ const SHADOW_MODAL_STYLES = ` exportBtn?.addEventListener('click', () => { const exportData = { _meta: { - version: (typeof GM_info !== 'undefined' && GM_info?.script?.version) || '2.18.18', + version: (typeof GM_info !== 'undefined' && GM_info?.script?.version) || '2.18.19', exported: new Date().toISOString() }, config: { ...CONFIG } From 46b6e30a651f1b63ac63a2084e6c4be0074d2b04 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:58:43 +0000 Subject: [PATCH 3/3] fix: correctly render badge states by ignoring hidden reference prices and using exact cent math This commit addresses an issue in the Toppreise suite where a competing hidden reference price (e.g., CHF 47.82) was incorrectly parsed, leading to an 'Aufschlag' (markup) warning being displayed on cards where the current visible price (e.g., CHF 37.95) was actually an all-time low. - Refactored `extractCardData` to utilize a new `extractCanonicalPrice` helper, ensuring the canonical displayed price is reliably extracted and competing reference prices are ignored. The fallback fails cleanly to avoid catching hidden `.Plugin_Price` elements. - Replaced fuzzy 1% floating-point comparisons with exact integer-cent comparison (`priceToCents`) for explicit badge rendering states (`new-low`, `at-low`, `above-low`), while explicitly keeping 1% bounds in historical clustering to preserve intended threshold behavior. - Added synchronous validation inside the `badgeDifEl` click event to prevent stale history from applying to a dynamically swapped card price using `priceToCents() !== priceToCents()` logic. - Cleaned up the regression fixture `mock_toppreise.html` mimicking the competing reference price, fixing route mocking for `page.route` to handle both GET URLs and POST variants cleanly. - Replaced aggressive `force=True` and raw node `.click()` interactions in Playwright tests with proper overlay waits to obey Playwright's actionability checks naturally. - Deleted obsolete one-off `patch_*.py` development mutation scripts. Co-authored-by: tazztone <62671577+tazztone@users.noreply.github.com> --- .../toppreise/tests/test_userscript.py | 73 ++++++++++++++++++- userscripts/toppreise/toppreise.user.js | 30 ++++---- 2 files changed, 86 insertions(+), 17 deletions(-) diff --git a/userscripts/toppreise/tests/test_userscript.py b/userscripts/toppreise/tests/test_userscript.py index 528ff03..8900f87 100644 --- a/userscripts/toppreise/tests/test_userscript.py +++ b/userscripts/toppreise/tests/test_userscript.py @@ -49,8 +49,23 @@ def handle_pricechart(route): route.fulfill( status=200, headers={'access-control-allow-origin': '*'}, - content_type='application/json', - body='[[[100000, 47.82], [200000, 37.95]]]' + content_type='text/html', + body=''' +
+
+
aktueller Toppreis
+
37.95
+
+
+
Tiefstpreis
+
37.95
+
+
+
Höchstpreis
+
55.00
+
+
+ ''' ) else: route.continue_() @@ -72,6 +87,60 @@ def handle_pricechart(route): assert "CHF 37.95" in title + +def test_exact_cent_boundary_badge_states(page: Page): + """ + Validates that the userscript accurately distinguishes between new-low, at-low, and above-low + based strictly on integer cents, not floating point tolerances. + """ + + # We will test this by evaluating the renderCardEffects logic or directly checking DOM after mocking + # Disable REAL_DEAL_FILTER_ACTIVE so the card stays in the DOM and we can inspect its badge properties + page.evaluate("() => { window.ToppreiseSuite.CONFIG.REAL_DEAL_FILTER_ACTIVE = false; }") + + cases = [ + # currentPrice, expected_state (Allzeit-Tiefstpreis string), expected_class, not_expected_class + (37.94, 'Neuer Allzeit-Tiefstpreis (CHF 37.94)', 'tp-deal-alltime-low', 'tp-deal-not-low'), # new low + (37.95, 'Allzeit-Tiefstpreis (CHF 37.95)', 'tp-deal-alltime-low', 'tp-deal-not-low'), # at low + (37.96, 'Historischer Tiefstpreis lag bei CHF 37.95', 'tp-deal-not-low', 'tp-deal-alltime-low'), # above low + (38.00, 'Historischer Tiefstpreis lag bei CHF 37.95', 'tp-deal-not-low', 'tp-deal-alltime-low'), # above low + (37.9500001, 'Allzeit-Tiefstpreis (CHF 37.95)', 'tp-deal-alltime-low', 'tp-deal-not-low'), # at low normalized + ] + + for (curr_price, title_match, expected_class, unexpected_class) in cases: + page.evaluate(f"""(price) => {{ + const card = document.getElementById('card-competing-reference'); + // Overwrite price container + const pEl = card.querySelector('.Plugin_PriceInformation .Plugin_Price'); + pEl.textContent = price; + + // Seed a cached history where tiefstpreis = 37.95 + localStorage.setItem('tp_hist_v1_1003795', JSON.stringify({{ + tiefstpreis: 37.95, + hoechstpreis: 55.00, + medianPrice: 45.00, + previousLow: 47.82, + isNewAllTimeLow: price < 37.95, + dataPointCount: 10, + time: Date.now() + }})); + window.ToppreiseSuite.processListings(); + }}""", curr_price) + + # Wait a tick for mutations + page.wait_for_timeout(100) + badge = page.locator('#card-competing-reference .badge-dif') + + # Verify classes + badge_class = badge.get_attribute("class") or "" + assert expected_class in badge_class, f"Expected {expected_class} but got {badge_class} for price {curr_price}" + assert unexpected_class not in badge_class, f"Did not expect {unexpected_class} but got it for price {curr_price}" + + # Verify title string logic + title = badge.get_attribute("title") or "" + assert title_match in title, f"Expected {title_match} in {title} for price {curr_price}" + + def test_best_price_highlighting_and_dimming(page: Page): # Card 1 is cheapest store price -> highlighted page.wait_for_selector('#card-cheapest.tp-is-cheapest') diff --git a/userscripts/toppreise/toppreise.user.js b/userscripts/toppreise/toppreise.user.js index 30b5812..441089e 100644 --- a/userscripts/toppreise/toppreise.user.js +++ b/userscripts/toppreise/toppreise.user.js @@ -1059,6 +1059,16 @@ const SHADOW_MODAL_STYLES = ` const priceToCents = p => Math.round((parseFloat(p) || 0) * 100); + function getDealState(cardPrice, tiefstpreis) { + if (!cardPrice || !tiefstpreis || cardPrice <= 0 || tiefstpreis <= 0) return 'unknown'; + const cPrice = priceToCents(cardPrice); + const cTiefstpreis = priceToCents(tiefstpreis); + if (cPrice < cTiefstpreis) return 'new-low'; + if (cPrice === cTiefstpreis) return 'at-low'; + return 'above-low'; + } + + const parsePrice = str => { if (!str) return 0; let clean = str.replace(/[.–\-]\s*$/g, '.00'); @@ -2827,23 +2837,12 @@ const SHADOW_MODAL_STYLES = ` badgeDifEl.classList.remove('tp-deal-loading'); } - if (stats && cardPrice > 0 && stats.tiefstpreis > 0) { - // Explicit states - const cPrice = priceToCents(cardPrice); - const cTiefstpreis = priceToCents(stats.tiefstpreis); - - let state = 'unknown'; - if (cPrice < cTiefstpreis) { - state = 'new-low'; - } else if (cPrice === cTiefstpreis) { - state = 'at-low'; - } else { - state = 'above-low'; - } + const state = getDealState(cardPrice, stats?.tiefstpreis); + if (state !== 'unknown') { const isAllTimeLow = (state === 'new-low' || state === 'at-low'); const isNonBest = (state === 'above-low'); - const isNewRecord = (state === 'new-low') || !!(stats.isNewAllTimeLow || (isAllTimeLow && stats.previousLow && priceToCents(stats.previousLow) > cPrice)); + const isNewRecord = (state === 'new-low') || !!(stats.isNewAllTimeLow || (isAllTimeLow && stats.previousLow && priceToCents(stats.previousLow) > priceToCents(cardPrice))); const prevLow = stats.previousLow; const realDropVsPrev = prevLow && prevLow > cardPrice ? Math.round(((prevLow - cardPrice) / prevLow) * 100) : (stats.realDiscountVsPrevLow || 0); @@ -3319,7 +3318,8 @@ const SHADOW_MODAL_STYLES = ` headingEl.appendChild(badge); } - const isAllTimeLow = priceToCents(currentPrice) <= priceToCents(stats.tiefstpreis); + const state = getDealState(currentPrice, stats.tiefstpreis); + const isAllTimeLow = (state === 'new-low' || state === 'at-low'); const hasSignificantPeak = stats.hoechstpreis && stats.hoechstpreis > stats.tiefstpreis * 1.02; if (isAllTimeLow) {