diff --git a/userscripts/toppreise/tests/test_userscript.py b/userscripts/toppreise/tests/test_userscript.py
index 73cae4a..8900f87 100644
--- a/userscripts/toppreise/tests/test_userscript.py
+++ b/userscripts/toppreise/tests/test_userscript.py
@@ -23,6 +23,124 @@ 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
+ 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='text/html',
+ body='''
+
+
+
aktueller Toppreis
+
37.95
+
+
+
+
+ '''
+ )
+ else:
+ route.continue_()
+
+ page.route("**/plugins/product/pricechart*", handle_pricechart)
+
+ # 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
+ title = badge.get_attribute("title") or ""
+ assert "Allzeit-Tiefstpreis" in title
+ 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')
@@ -713,12 +831,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 +2312,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 +2342,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 +2366,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 +2386,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 +2403,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 +2526,14 @@ 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
+ 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 3cbd8df..441089e 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/*
@@ -1056,6 +1056,19 @@ 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);
+
+ 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');
@@ -1537,8 +1550,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 +1605,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 +2422,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: 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('.priceContainer.productPrice .Plugin_Price') || card.querySelector('.priceContainer.shippingPrice .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 +2706,19 @@ 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 || 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;
+ }
+
badgeDifEl.classList.remove('tp-deal-loading');
if (fetchedStats) {
processListings();
@@ -2782,10 +2837,12 @@ const SHADOW_MODAL_STYLES = `
badgeDifEl.classList.remove('tp-deal-loading');
}
- 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));
+ 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) > priceToCents(cardPrice)));
const prevLow = stats.previousLow;
const realDropVsPrev = prevLow && prevLow > cardPrice ? Math.round(((prevLow - cardPrice) / prevLow) * 100) : (stats.realDiscountVsPrevLow || 0);
@@ -3261,7 +3318,8 @@ const SHADOW_MODAL_STYLES = `
headingEl.appendChild(badge);
}
- const isAllTimeLow = currentPrice <= stats.tiefstpreis * 1.01;
+ 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) {
@@ -3835,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 }