From 25e2970ed00f9dd31c539147ef966d7c5278e560 Mon Sep 17 00:00:00 2001 From: Jens <1742418+1cu@users.noreply.github.com> Date: Sat, 4 Jul 2026 01:43:20 +0200 Subject: [PATCH] feat: improve status APR report (#1201) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## ℹ️ Description - Moves APR preview information out of verify output and into status reporting. - Improves status output into readable per-ad blocks with full relative file paths and structured APR update/publish details. ## 📋 Changes Summary - Keep verify focused on validation while preserving its full ad-loading validation path. - Show APR preview details in status without requiring verbose mode. - Render status as path-first per-ad report lines with title, id, status, APR update/publish details, and unchanged summary. - Use structured APR detail data and translated human-readable reasons instead of raw reason tokens. - Remove the now-dead APR preview logger and stale translations. - Update CLI help, README/docs, smoke tests, and focused unit tests. ### ⚙️ Type of Change - [ ] 🐞 Bug fix (non-breaking change which fixes an issue) - [x] ✨ New feature (adds new functionality without breaking existing usage) - [ ] 💥 Breaking change (changes that might break existing user setups, scripts, or configurations) ## ✅ Checklist - [x] I have reviewed my changes to ensure they meet the project standards. - [x] I have tested my changes and ensured that all tests pass (pdm run test). - [x] I have formatted the code (pdm run format). - [x] I have verified that linting passes (pdm run lint). - [x] I have updated documentation where necessary. Validation run: - pdm run format - pdm run lint - pdm run test — 1467 passed, 4 skipped By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice. ## Summary by CodeRabbit * **New Features** * `status` now shows detailed APR preview information for ads in a clearer, per-ad layout. * Help text and documentation now point users to `status` for APR preview details. * **Bug Fixes** * `verify` no longer displays APR preview output, making its results focused on configuration and integrity checks. * Improved status output readability and APR detail handling for publish and update scenarios. --- README.md | 4 +- docs/AD_CONFIGURATION.md | 12 +- docs/CONFIGURATION.md | 3 +- src/kleinanzeigen_bot/ad_status.py | 285 +++++++++----- src/kleinanzeigen_bot/app.py | 11 +- src/kleinanzeigen_bot/cli.py | 8 +- src/kleinanzeigen_bot/price_reduction.py | 48 --- .../resources/translations.de.yaml | 58 +-- tests/smoke/test_smoke_health.py | 20 +- tests/unit/test_ad_status.py | 367 +++++++++++------- tests/unit/test_app.py | 8 +- 11 files changed, 478 insertions(+), 346 deletions(-) diff --git a/README.md b/README.md index 6fef38b..c5e7a87 100644 --- a/README.md +++ b/README.md @@ -206,7 +206,7 @@ Usage: kleinanzeigen-bot COMMAND [OPTIONS] Commands: publish - (re-)publishes ads - verify - verifies the configuration files and previews automatic price reduction outcomes + verify - verifies the configuration files and ad integrity delete - deletes ads update - updates published ads extend - extends ads within the 8-day window before expiry (keeps watchers/savers and does not count towards the monthly ad quota) @@ -216,7 +216,7 @@ Commands: use this after changing config.yaml/ad_defaults to avoid every ad being marked "changed" and republished create-config - creates a new default configuration file if one does not exist diagnose - diagnoses browser connection issues and shows troubleshooting information - status - shows status overview of ads + status - shows ad status and APR preview details -- help - displays this help (default command) version - displays the application version diff --git a/docs/AD_CONFIGURATION.md b/docs/AD_CONFIGURATION.md index f0ff4a5..cdf7411 100644 --- a/docs/AD_CONFIGURATION.md +++ b/docs/AD_CONFIGURATION.md @@ -113,9 +113,9 @@ When `auto_price_reduction.enabled` is set to `true`, the bot evaluates whether **Note:** `repost_count` and price reduction counters are only incremented and persisted after a successful publish. Failed publish attempts do not advance the counters. -When automatic price reduction is enabled, each `publish` (and optionally `update`) run logs one clear INFO message per ad summarizing the outcome—whether the price was reduced, kept, or the reduction was delayed (and why). The `verify` command also previews these outcomes for all configured ads so you can validate your pricing configuration without triggering a publish cycle. Ads without `auto_price_reduction` configured are silently skipped at default log level. +When automatic price reduction is enabled, each `publish` (and optionally `update`) run logs one clear INFO message per ad summarizing the outcome—whether the price was reduced, kept, or the reduction was delayed (and why). Use `status` to preview these outcomes for configured ads without triggering a publish cycle. Ads without `auto_price_reduction` configured are skipped. -If you run with `-v` / `--verbose`, the bot additionally logs structured decision details (repost counts, cycle state, day delay, reference timestamps) and the full cycle-by-cycle calculation trace (base price, reduction value, rounded step result, and floor clamp). +The `status` command shows APR details inside each ad block, together with the full relative ad path for quick copy/paste. ```yaml auto_price_reduction: @@ -197,12 +197,12 @@ When `on_update` is enabled, the reduced effective price is preserved across mix This ensures price reductions accumulate correctly regardless of whether you use `publish`, `update`, or both. -#### Verify Command Preview +#### Status Command Preview -The `verify` command previews pricing outcomes for both modes: +The `status` command previews pricing outcomes for active ads: - **Publish preview**: Always shown when `auto_price_reduction.enabled` is `true`. Shows the effective price after applying reductions based on the current `repost_count`, `price_reduction_count`, and delay settings. -- **Update preview**: Always shown when `auto_price_reduction.enabled` is `true`. Reports the update-mode outcome: if `on_update` is `true`, it shows the reduction result; if `on_update` is `false` (default), it reports that update-mode reductions are disabled and shows the restored effective price (no new cycle). +- **Update preview**: Shown for ads that already have an ID. Reports the update-mode outcome: if `on_update` is `true`, it shows the reduction result; if `on_update` is `false` (default), it reports that update-mode reductions are disabled and shows the restored effective price (no new cycle). ```yaml # Example: enable reductions for both publish and update @@ -426,7 +426,7 @@ republication_interval: 7 ## Troubleshooting - **Schema validation errors**: Run `kleinanzeigen-bot verify` (binary) or `pdm run app verify` (source) to see which fields fail validation. -- **Price reduction not applying**: Confirm `auto_price_reduction.enabled` is `true`, `min_price` is set, and you are using `publish` (not `update`, unless `on_update: true`). Run `kleinanzeigen-bot verify` to preview outcomes, or add `-v` for detailed decision data including repost/day-delay state. Remember ad-level values override `ad_defaults`. +- **Price reduction not applying**: Confirm `auto_price_reduction.enabled` is `true`, `min_price` is set, and you are using `publish` (not `update`, unless `on_update: true`). Use `status` for detailed decision data including repost/day-delay state. Remember ad-level values override `ad_defaults`. - **Shipping configuration issues**: Use `shipping_type: SHIPPING` with non-empty `shipping_options` for predefined DHL/Hermes package shipping and direct-buy. Individual `shipping_costs` is deprecated — publishing an ad with `shipping_costs` and no `shipping_options` will fail with an error. Remove `shipping_costs` from your config and migrate to `shipping_options` (pick options from a single size group S/M/L). - **Category not found**: Verify the category name or ID and check any custom mappings in `config.yaml`. - **File naming/prefix mismatch**: Ensure ad files match your `ad_files` glob and prefix (default `ad_`). diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 99b6c88..eb317a4 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -140,8 +140,7 @@ ad_defaults: - Automatic price reductions are always evaluated during `publish` runs, and they also apply during `update` runs when `on_update: true` is set (using `delay_days` but ignoring `delay_reposts`). - Reductions do not run in the background between runs. - When auto price reduction is enabled, each `publish` (and optionally `update`) run logs the reduction decision. -- The `verify` command previews pricing outcomes for both publish and update modes. -- `-v/--verbose` adds a detailed reduction calculation trace. +- Use `status` for automatic price reduction previews, after validation with `verify`. - For full behavior and examples (including timeline examples, update-mode semantics, and restore-first behavior), see [AD_CONFIGURATION.md](./AD_CONFIGURATION.md). > **Tip:** For current defaults of all timeout and diagnostic settings, run `kleinanzeigen-bot create-config` or see the [JSON schema](https://raw.githubusercontent.com/Second-Hand-Friends/kleinanzeigen-bot/main/schemas/config.schema.json). diff --git a/src/kleinanzeigen_bot/ad_status.py b/src/kleinanzeigen_bot/ad_status.py index 35fe380..8e98927 100644 --- a/src/kleinanzeigen_bot/ad_status.py +++ b/src/kleinanzeigen_bot/ad_status.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/ """Ad status computation and display for the ``status`` CLI command. -This module owns status label mapping, row building, and ASCII table rendering. +This module owns status label mapping, row building, and terminal rendering. """ from __future__ import annotations @@ -21,16 +21,30 @@ if TYPE_CHECKING: from .model.ad_model import Ad +@dataclass(frozen = True, slots = True) +class AprDetail: + """Structured APR preview detail for the status output.""" + + result_key:str + result:str + effective_price:int | None + reason_key:str + reason:str + price_before:int | None = None + price_after:int | None = None + cycle:int | None = None + + @dataclass(frozen = True, slots = True) class StatusRow: - """One row in the status table. Rendered by :func:`render_status_rows`.""" + """One row in status output rendered by :func:`render_status_rows`.""" title:str # ad.title ad_id:str # "-" if None, else str(ad.id) filename:str # Relative ad-file path (e.g. "ads/sofa.yaml") status:str # One of: "disabled", "draft", "changed", "due", "published-local" - apr_repost:str | None = None # APR repost cell; ``None`` → rendered as "off" - apr_update:str | None = None # APR update cell; ``None`` → rendered as "off" + apr_repost_detail:AprDetail | None = None + apr_update_detail:AprDetail | None = None def _translate_status(status:str) -> str: @@ -51,8 +65,6 @@ def _translate_status(status:str) -> str: # Canonical status ordering (matches precedence in :func:`compute_ad_status`). _STATUS_ORDER:tuple[str, ...] = ("disabled", "draft", "changed", "due", "published-local") -# Status → ANSI colour prefix mapping. -# Applied only when *color* is enabled in :func:`render_status_rows`. _STATUS_COLORS:dict[str, str] = { "published-local": colorama.Fore.GREEN, "changed": colorama.Fore.YELLOW, @@ -61,30 +73,93 @@ _STATUS_COLORS:dict[str, str] = { "disabled": colorama.Style.DIM, } +_MESSAGE_TEMPLATES:dict[str, str] = { + "missing_price": "missing price", + "min_price_equals_price": "minimum price equals current price", + "update_disabled": "update reductions disabled", + "repost_delay_waiting": "waiting for repost delay", + "repost_delay_applied": "repost delay already applied", + "day_delay_missing_timestamp": "waiting for publish timestamp", + "day_delay_waiting": "waiting for day delay", + "eligible": "eligible", + "calculation_failed": "calculation failed", + "no_visible_change": "no visible price change", +} -def _colorize_status(status:str, text:str) -> str: - """Wrap *text* in ANSI colour codes for the given *status*, if a colour is mapped.""" - prefix = _STATUS_COLORS.get(status) - if prefix is None: + +def _format_status(value:str, *, color:bool) -> str: + """Return the translated status label, optionally colorized.""" + label = _translate_status(value) + prefix = _STATUS_COLORS.get(value) + if not color or prefix is None: + return label + return f"{prefix}{label}{colorama.Style.RESET_ALL}" + + +def _colorize(text:str, prefix:str, *, color:bool) -> str: + if not color: return text return f"{prefix}{text}{colorama.Style.RESET_ALL}" -def _format_apr_status(decision:_price_reduction.PriceReductionDecision) -> str | None: - """Format a price-reduction decision into a compact APR cell string. +def _format_apr_reason(decision:_price_reduction.PriceReductionDecision) -> str: + template = __get_message_template(decision.reason) + return template if template is not None else decision.reason.replace("_", " ") - Returns ``None`` when the decision is not effective (rendered as ``off``). - Otherwise one of: ``due: ``, ``not due``, ``error``. - """ + +def __get_message_template(reason:str) -> str | None: + template = _MESSAGE_TEMPLATES.get(reason) + return _(template) if template is not None else None + + +def _format_apr_price(value:int | None) -> str: + if value is None: + return _("no effective price") + return str(value) + + +def _format_apr_detail(decision:_price_reduction.PriceReductionDecision) -> AprDetail | None: + """Return the structured APR preview for the status detail table.""" if not decision.enabled: return None + + if decision.base_price is None: + return AprDetail( + result_key = "missing_price", + result = _("missing price"), + effective_price = None, + reason_key = decision.reason, + reason = _format_apr_reason(decision), + ) + if decision.mode == AdUpdateStrategy.MODIFY and not decision.on_update: - return None - if decision.reason in {"missing_price", "calculation_failed"}: - return _("error") - if decision.cycle_advanced and decision.result_price is not None: - return _("due: %s") % decision.result_price - return _("not due") + return AprDetail( + result_key = "disabled", + result = _("disabled"), + effective_price = decision.result_price, + reason_key = decision.reason, + reason = _format_apr_reason(decision), + ) + + if decision.cycle_advanced: + return AprDetail( + result_key = "price_reduction", + result = _("price reduction"), + effective_price = decision.result_price, + reason_key = decision.reason, + reason = _format_apr_reason(decision), + price_before = decision.restored_price, + price_after = decision.result_price, + cycle = decision.next_cycle, + ) + + return AprDetail( + result_key = "no_new_reduction", + result = _("no new reduction"), + effective_price = decision.result_price, + reason_key = decision.reason, + reason = _format_apr_reason(decision), + ) def compute_ad_status( @@ -129,18 +204,21 @@ def build_status_rows( for ad_file_rel, ad_cfg, ad_cfg_orig in ads: status = compute_ad_status(ad_cfg, ad_cfg_orig, now = now) - apr_repost:str | None = None - apr_update:str | None = None if ad_cfg.active: replace_dec = _price_reduction.evaluate_auto_price_reduction( ad_cfg, ad_file_rel, mode = AdUpdateStrategy.REPLACE, ) - apr_repost = _format_apr_status(replace_dec) + apr_repost_detail = _format_apr_detail(replace_dec) if ad_cfg.id is not None: modify_dec = _price_reduction.evaluate_auto_price_reduction( ad_cfg, ad_file_rel, mode = AdUpdateStrategy.MODIFY, ) - apr_update = _format_apr_status(modify_dec) + apr_update_detail = _format_apr_detail(modify_dec) + else: + apr_update_detail = None + else: + apr_repost_detail = None + apr_update_detail = None rows.append( StatusRow( @@ -148,110 +226,107 @@ def build_status_rows( ad_id = "-" if ad_cfg.id is None else str(ad_cfg.id), filename = ad_file_rel, status = status, - apr_repost = apr_repost, - apr_update = apr_update, + apr_repost_detail = apr_repost_detail, + apr_update_detail = apr_update_detail, ) ) return rows -def _apr_cell(value:str | None) -> str: - """Return the display text for an APR cell — ``off`` when ``None``.""" - return _("off") if value is None else value +def _render_detail_line(label:str, value:str) -> str: + return f" {label}: {value}" -def _has_apr(rows:list[StatusRow]) -> bool: - """Return ``True`` if any row has non-``None`` APR data (show APR columns).""" - return any(r.apr_repost is not None or r.apr_update is not None for r in rows) +def _build_apr_line_parts(detail:AprDetail) -> str: + parts:list[str] = [detail.result] + + parts.append(f"{_('effective price')}: {_format_apr_price(detail.effective_price)}") + + if detail.price_before is not None and detail.price_after is not None: + before_formatted = _format_apr_price(detail.price_before) + after_formatted = _format_apr_price(detail.price_after) + if before_formatted != after_formatted: + parts.append(f"{_('price change')}: {before_formatted} -> {after_formatted}") + + if detail.cycle is not None: + parts.append(f"{_('cycle')}: {detail.cycle}") + + if detail.reason: + parts.append(f"{_('reason')}: {detail.reason}") + + return "; ".join(parts) -def _apr_layout(rows:list[StatusRow]) -> tuple[bool, str, str, int, int]: - """Compute APR column layout: (show, h_repost, h_update, w_repost, w_update). - - Returns ``show=False`` with empty strings and zero widths when no - effective APR data exists across *rows*. - """ - if not _has_apr(rows): - return False, "", "", 0, 0 - h_repost = _("APR repost") - h_update = _("APR update") - off = _("off") - cells_repost = [off if r.apr_repost is None else r.apr_repost for r in rows] - cells_update = [off if r.apr_update is None else r.apr_update for r in rows] - w_repost = max(len(h_repost), *[len(c) for c in cells_repost], 0) - w_update = max(len(h_update), *[len(c) for c in cells_update], 0) - return True, h_repost, h_update, w_repost, w_update +def _render_apr_detail(detail:AprDetail, *, color:bool) -> str: + text = _build_apr_line_parts(detail) + prefix = _apr_detail_color(detail) + if prefix is None: + return text + return _colorize(text, prefix, color = color) -def render_status_rows(rows:list[StatusRow], *, color:bool = False) -> str: - """Format status rows into an ASCII table string. +def _apr_detail_color(detail:AprDetail) -> str | None: + if detail.price_before is not None and detail.price_after is not None and detail.price_before != detail.price_after: + return str(colorama.Fore.YELLOW) + if detail.effective_price is None: + return str(colorama.Fore.RED) + if detail.result_key in {"disabled", "no_new_reduction"}: + return str(colorama.Style.DIM) + return None + + +def _render_status_block(row:StatusRow, *, color:bool) -> list[str]: + lines:list[str] = [ + _colorize(row.filename, colorama.Style.BRIGHT, color = color), + _render_detail_line(_("title"), row.title), + _render_detail_line(_("id"), row.ad_id), + _render_detail_line(_("status"), _format_status(row.status, color = color)), + ] + + if row.apr_update_detail is not None: + lines.append( + _render_detail_line( + _("APR update"), + _render_apr_detail(row.apr_update_detail, color = color), + ) + ) + if row.apr_repost_detail is not None: + lines.append( + _render_detail_line( + _("APR publish"), + _render_apr_detail(row.apr_repost_detail, color = color), + ) + ) + return lines + + +def render_status_rows( + rows:list[StatusRow], + *, + color:bool = False, +) -> str: + """Format status rows into terminal output. Args: - rows: Rows to render. - color: If ``True``, apply ANSI colour codes to the status column. - Column widths are always computed from plain (uncoloured) - labels so that coloured and uncoloured output align identically. + rows: Rows to render. + color: If ``True``, emit terminal colours for the status value. """ if not rows: return "" - # Column header labels and data-driven widths. - H = { - "id": _("Ad ID"), - "fn": _("Filename"), - "title": _("Title"), - "status": _("Status"), - } - W = { - "id": max(len(H["id"]), max((len(r.ad_id) for r in rows), default = 0)), - "fn": max(len(H["fn"]), max((len(r.filename) for r in rows), default = 0)), - "title": max(len(H["title"]), max((len(r.title) for r in rows), default = 0)), - "status": max(len(H["status"]), *[len(_translate_status(s)) for s in _STATUS_ORDER], 0), - } + lines:list[str] = [] - # APR columns — only if any row has non-None APR data - apr_show, h_apr_r, h_apr_u, w_apr_r, w_apr_u = _apr_layout(rows) - - # Build separator and header row - widths = [W["id"], W["fn"], W["title"], W["status"]] - if apr_show: - widths += [w_apr_r, w_apr_u] - sep = "".join("+" + "-" * (w + 2) for w in widths) + "+" - - hdr_parts = [ - "| ", H["id"].ljust(W["id"]), " | ", H["fn"].ljust(W["fn"]), - " | ", H["title"].ljust(W["title"]), " | ", H["status"].ljust(W["status"]), - ] - if apr_show: - hdr_parts += [" | ", h_apr_r.ljust(w_apr_r), " | ", h_apr_u.ljust(w_apr_u)] - hdr_parts.append(" |") - hdr = "".join(hdr_parts) - - lines:list[str] = [sep, hdr, sep] - - for r in rows: - label = _translate_status(r.status).ljust(W["status"]) - cell = _colorize_status(r.status, label) if color else label - - parts = [ - "| ", r.ad_id.ljust(W["id"]), " | ", r.filename.ljust(W["fn"]), - " | ", r.title.ljust(W["title"]), " | ", cell, - ] - if apr_show: - parts += [ - " | ", _apr_cell(r.apr_repost).ljust(w_apr_r), - " | ", _apr_cell(r.apr_update).ljust(w_apr_u), - ] - parts.append(" |") - lines.append("".join(parts)) - - lines.append(sep) + for index, row in enumerate(rows): + lines.extend(_render_status_block(row, color = color)) + if index != len(rows) - 1: + lines.append("") # Summary line — always plain counts:dict[str, int] = {} for r in rows: counts[r.status] = counts.get(r.status, 0) + 1 + lines.append("") lines.append( _("Summary: %s (%d total)") % (", ".join( diff --git a/src/kleinanzeigen_bot/app.py b/src/kleinanzeigen_bot/app.py index 103d5d7..1e18422 100644 --- a/src/kleinanzeigen_bot/app.py +++ b/src/kleinanzeigen_bot/app.py @@ -9,9 +9,7 @@ from typing import TYPE_CHECKING, Any, Final, cast import certifi from . import ad_loading, ad_status, delete_flow, download_flow, extend_flow -from . import ad_state as _ad_state from . import login_flow as _login_flow -from . import price_reduction as _price_reduction from . import publishing_workflow as _publishing_workflow from . import runtime_config as _runtime_config from . import update_checker as _update_checker @@ -219,14 +217,7 @@ class KleinanzeigenBot(WebScrapingMixin): # noqa: PLR0904 self._bootstrap_runtime() self._check_for_updates() self.ads_selector = "all" - if ads := self.load_ads(exclude_ads_with_id = False): - for ad_file, ad_cfg, _ad_cfg_orig in ads: - ad_file_relative = _ad_state.relative_ad_path(ad_file, self.config_file_path) - publish_decision = _price_reduction.evaluate_auto_price_reduction(ad_cfg, ad_file_relative, mode = AdUpdateStrategy.REPLACE) - _price_reduction.log_auto_price_reduction_preview(ad_file_relative, publish_decision) - - update_decision = _price_reduction.evaluate_auto_price_reduction(ad_cfg, ad_file_relative, mode = AdUpdateStrategy.MODIFY) - _price_reduction.log_auto_price_reduction_preview(ad_file_relative, update_decision) + self.load_ads(exclude_ads_with_id = False) LOG.info("############################################") LOG.info("DONE: No configuration errors found.") LOG.info("############################################") diff --git a/src/kleinanzeigen_bot/cli.py b/src/kleinanzeigen_bot/cli.py index 8856a2d..3903a2c 100644 --- a/src/kleinanzeigen_bot/cli.py +++ b/src/kleinanzeigen_bot/cli.py @@ -127,7 +127,7 @@ def help_text(*, executable:str | None = None, language:str | None = None) -> st Befehle: publish - (Wieder-)Veröffentlicht Anzeigen - verify - Überprüft die Konfigurationsdateien und zeigt Preissenkungsvorschauen an + verify - Überprüft die Konfigurationsdateien und Ad-Integrität delete - Löscht Anzeigen update - Aktualisiert bestehende Anzeigen extend - Verlängert Anzeigen im 8-Tage-Zeitfenster (behält Beobachter/Interessenten bei und zählt nicht zum monatlichen Anzeigenkontingent) @@ -138,7 +138,7 @@ def help_text(*, executable:str | None = None, language:str | None = None) -> st "geändert" gelten und neu veröffentlicht werden. create-config - Erstellt eine neue Standard-Konfigurationsdatei, falls noch nicht vorhanden diagnose - Diagnostiziert Browser-Verbindungsprobleme und zeigt Troubleshooting-Informationen - status - Zeigt den Status der Anzeigen an + status - Zeigt Anzeigenstatus und APR-Vorschau an -- help - Zeigt diese Hilfe an (Standardbefehl) version - Zeigt die Version der Anwendung an @@ -185,7 +185,7 @@ def help_text(*, executable:str | None = None, language:str | None = None) -> st Commands: publish - (re-)publishes ads - verify - verifies the configuration files and previews automatic price reduction outcomes + verify - verifies the configuration files and ad integrity delete - deletes ads update - updates published ads extend - extends ads within the 8-day window before expiry (keeps watchers/savers and does not count towards the monthly ad quota) @@ -195,7 +195,7 @@ def help_text(*, executable:str | None = None, language:str | None = None) -> st use this after changing config.yaml/ad_defaults to avoid every ad being marked "changed" and republished create-config - creates a new default configuration file if one does not exist diagnose - diagnoses browser connection issues and shows troubleshooting information - status - shows status overview of ads + status - shows ad status and APR preview details -- help - displays this help (default command) version - displays the application version diff --git a/src/kleinanzeigen_bot/price_reduction.py b/src/kleinanzeigen_bot/price_reduction.py index 7241960..8596ed6 100644 --- a/src/kleinanzeigen_bot/price_reduction.py +++ b/src/kleinanzeigen_bot/price_reduction.py @@ -9,12 +9,10 @@ __all__ = [ "apply_auto_price_reduction", "evaluate_auto_price_reduction", "is_auto_price_reduction_due", - "log_auto_price_reduction_preview", ] from dataclasses import dataclass from datetime import datetime -from gettext import gettext as _ from typing import Any from kleinanzeigen_bot.model.ad_model import ( @@ -336,52 +334,6 @@ def is_auto_price_reduction_due(ad_cfg:Ad, ad_file_relative:str) -> bool: return True -def log_auto_price_reduction_preview(ad_file_relative:str, decision:PriceReductionDecision) -> None: - mode_label = _("publish") if decision.mode == AdUpdateStrategy.REPLACE else _("update") - if not decision.enabled: - LOG.info("Auto price reduction preview for [%s] (%s): disabled", ad_file_relative, mode_label) - return - - if decision.base_price is None: - LOG.info("Auto price reduction preview for [%s] (%s): missing price", ad_file_relative, mode_label) - return - - if decision.mode == AdUpdateStrategy.MODIFY and not decision.on_update: - LOG.info( - "Auto price reduction preview for [%s] (%s): disabled (on_update=false, effective_price=%s)", - ad_file_relative, - mode_label, - decision.result_price, - ) - return - - if decision.cycle_advanced: - LOG.info( - "Auto price reduction preview for [%s] (%s): %s -> %s (cycle %s)", - ad_file_relative, - mode_label, - decision.restored_price, - decision.result_price, - decision.next_cycle, - ) - return - - LOG.info( - "Auto price reduction preview for [%s] (%s): no new reduction (effective_price=%s, reason=%s)", - ad_file_relative, - mode_label, - decision.result_price, - decision.reason, - ) - if decision.delay_reposts_ignored: - LOG.debug( - "Auto price reduction preview for [%s] (%s): delay_reposts=%s ignored in MODIFY mode", - ad_file_relative, - mode_label, - decision.delay_reposts, - ) - - def apply_auto_price_reduction( ad_cfg:Ad, _ad_cfg_orig:dict[str, Any], diff --git a/src/kleinanzeigen_bot/resources/translations.de.yaml b/src/kleinanzeigen_bot/resources/translations.de.yaml index ffa860e..50925e2 100644 --- a/src/kleinanzeigen_bot/resources/translations.de.yaml +++ b/src/kleinanzeigen_bot/resources/translations.de.yaml @@ -463,26 +463,43 @@ kleinanzeigen_bot/ad_status.py: "due": "Fällig" "published-local": "Veröffentlicht (lokal)" - _apr_cell: - "off": "aus" - - _apr_layout: - "off": "aus" - "APR repost": "APR-Repost" - "APR update": "APR-Update" - - _format_apr_status: - "error": "Fehler" - "due: %s": "fällig: %s" - "not due": "nicht fällig" + __get_message_template: + "missing price": "fehlender Preis" + "minimum price equals current price": "Mindestpreis entspricht dem aktuellen Preis" + "update reductions disabled": "Preisreduzierung beim Aktualisieren deaktiviert" + "waiting for repost delay": "wartet auf Repost-Verzögerung" + "repost delay already applied": "Repost-Verzögerung bereits angewendet" + "waiting for publish timestamp": "wartet auf Veröffentlichungszeitpunkt" + "waiting for day delay": "wartet auf Tagesverzögerung" + "eligible": "berechtigt" + "calculation failed": "Berechnung fehlgeschlagen" + "no visible price change": "keine sichtbare Preisänderung" render_status_rows: - "Ad ID": "Anzeigen-ID" - "Filename": "Dateiname" - "Title": "Titel" - "Status": "Status" "Summary: %s (%d total)": "Zusammenfassung: %s (%d insgesamt)" + _build_apr_line_parts: + "price change": "Preisänderung" + "effective price": "Effektiver Preis" + "cycle": "Zyklus" + "reason": "Grund" + + _format_apr_detail: + "missing price": "fehlender Preis" + "disabled": "deaktiviert" + "price reduction": "Preisreduzierung" + "no new reduction": "keine neue Reduktion" + + _format_apr_price: + "no effective price": "kein effektiver Preis" + + _render_status_block: + "title": "Titel" + "id": "ID" + "status": "Status" + "APR update": "APR-Aktualisierung" + "APR publish": "APR-Veröffentlichung" + ################################################# kleinanzeigen_bot/ad_loading.py: ################################################# @@ -543,15 +560,6 @@ kleinanzeigen_bot/price_reduction.py: is_auto_price_reduction_due: "Pending auto price reduction detected for ad [%s]": "Ausstehende automatische Preisreduzierung für Anzeige [%s] erkannt" - log_auto_price_reduction_preview: - "publish": "veröffentlichen" - "update": "aktualisieren" - "Auto price reduction preview for [%s] (%s): disabled": "Vorschau automatische Preisreduzierung für [%s] (%s): deaktiviert" - "Auto price reduction preview for [%s] (%s): missing price": "Vorschau automatische Preisreduzierung für [%s] (%s): Preis fehlt" - "Auto price reduction preview for [%s] (%s): disabled (on_update=false, effective_price=%s)": "Vorschau automatische Preisreduzierung für [%s] (%s): deaktiviert (on_update=false, effektiver Preis=%s)" - "Auto price reduction preview for [%s] (%s): %s -> %s (cycle %s)": "Vorschau automatische Preisreduzierung für [%s] (%s): %s -> %s (Zyklus %s)" - "Auto price reduction preview for [%s] (%s): no new reduction (effective_price=%s, reason=%s)": "Vorschau automatische Preisreduzierung für [%s] (%s): keine neue Reduktion (effektiver Preis=%s, Grund=%s)" - apply_auto_price_reduction: "Auto price reduction is enabled for [%s] but no price is configured.": "Automatische Preisreduzierung ist für [%s] aktiviert, aber es wurde kein Preis konfiguriert." "Auto price reduction is enabled for [%s] but min_price equals price (%s) - no reductions will occur.": "Automatische Preisreduzierung ist für [%s] aktiviert, aber min_price entspricht dem Preis (%s) - es werden keine Reduktionen auftreten." diff --git a/tests/smoke/test_smoke_health.py b/tests/smoke/test_smoke_health.py index d4175b4..a49c3dc 100644 --- a/tests/smoke/test_smoke_health.py +++ b/tests/smoke/test_smoke_health.py @@ -272,6 +272,7 @@ def test_cli_subcommands_with_config_formats( out = (result.stdout + "\n" + result.stderr).lower() if subcommand == "verify": assert "no configuration errors found" in out, f"Expected 'no configuration errors found' in output for 'verify'.\n{out}" + assert "auto price reduction preview for" not in out, f"Verify should not emit APR preview logs.\n{out}" elif subcommand == "update-content-hash": assert "no active ads found" in out, f"Expected 'no active ads found' in output for 'update-content-hash'.\n{out}" elif subcommand == "update-check": @@ -279,8 +280,8 @@ def test_cli_subcommands_with_config_formats( @pytest.mark.smoke -def test_verify_shows_auto_price_reduction_decisions(tmp_path:Path, test_bot_config:Config) -> None: - """Smoke: verify previews publish and update-mode price reduction decisions.""" +def test_status_shows_auto_price_reduction_decisions(tmp_path:Path, test_bot_config:Config) -> None: + """Smoke: status shows publish and update-mode price reduction decision details.""" config_dict = test_bot_config.model_dump() config_dict["ad_files"] = ["./**/ad_*.yaml"] config_path = tmp_path / "config.yaml" @@ -313,6 +314,7 @@ def test_verify_shows_auto_price_reduction_decisions(tmp_path:Path, test_bot_con "title: Test Auto Pricing Update Ad\n" "description: A test ad to verify update price reduction preview\n" "category: 161/gezielt\n" + "id: 123456789\n" "price: 200\n" "price_type: FIXED\n" "repost_count: 1\n" @@ -327,14 +329,14 @@ def test_verify_shows_auto_price_reduction_decisions(tmp_path:Path, test_bot_con encoding = "utf-8", ) - args = ["verify", "--config", str(config_path), "--workspace-mode", "portable"] + args = ["status", "--config", str(config_path), "--workspace-mode", "portable"] result = invoke_cli(args, cwd = tmp_path) assert result.returncode == 0 out = (result.stdout + "\n" + result.stderr).lower() - assert "no configuration errors found" in out, f"Expected 'no configuration errors found' in output.\n{out}" - assert "auto price reduction preview for" in out, f"Expected auto price reduction preview logs in output.\n{out}" - assert "(publish):" in out, f"Expected publish preview in output.\n{out}" - assert "disabled (on_update=false" in out, f"Expected update preview disabled message when on_update=false.\n{out}" + assert "title:" in out, f"Expected status blocks with title fields in output.\n{out}" + assert "apr update" in out or "apr-aktualisierung" in out, f"Expected update APR detail label in output.\n{out}" + assert "apr publish" in out or "apr-veröffentlichung" in out, f"Expected publish APR detail label in output.\n{out}" + assert "ads/" in out or "ad_" in out, f"Expected ad path in status output.\n{out}" + assert "ad_test_pricing.yaml" in out, f"Expected first ad filename in output.\n{out}" assert "ad_test_update_pricing.yaml" in out, f"Expected update-enabled ad preview in output.\n{out}" - assert "(update):" in out, f"Expected update preview in output.\n{out}" - assert "cycle 1" in out, f"Expected update preview with cycle progression in output.\n{out}" + assert "update" in out, f"Expected update APR detail text in output.\n{out}" diff --git a/tests/unit/test_ad_status.py b/tests/unit/test_ad_status.py index 1703ff7..250bb08 100644 --- a/tests/unit/test_ad_status.py +++ b/tests/unit/test_ad_status.py @@ -15,6 +15,7 @@ import pytest import kleinanzeigen_bot.price_reduction as _pr_mod # noqa: PLC0414 — module import for patching in tests from kleinanzeigen_bot.ad_status import ( + AprDetail, StatusRow, build_status_rows, compute_ad_status, @@ -31,6 +32,11 @@ pytestmark = pytest.mark.unit _ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") + +def _strip_ansi(value:str) -> str: + return _ANSI_RE.sub("", value) + + _TZ = timezone.utc @@ -221,46 +227,67 @@ class TestRenderStatusRows: def test_empty(self) -> None: assert not render_status_rows([]) - def test_headers_and_rows(self) -> None: + def test_per_ad_layout_keeps_status_apr_and_path_together(self) -> None: rows = [ - StatusRow(title = "Ad A", ad_id = "-", filename = "a.yaml", status = "draft"), - StatusRow(title = "Ad B", ad_id = "123", filename = "b.yaml", status = "published-local"), + StatusRow( + title = "Ad A", + ad_id = "-", + filename = "data/my_ads/ad_000001_Test/ad_000001.yaml", + status = "draft", + ), + StatusRow( + title = "Ad B", + ad_id = "123", + filename = "b.yaml", + status = "published-local", + apr_update_detail = AprDetail( + result_key = "no_new_reduction", + result = "no new reduction", + effective_price = 10, + reason_key = "day_delay_waiting", + reason = "waiting for day delay", + ), + apr_repost_detail = AprDetail( + result_key = "price_reduction", + result = "price reduction", + effective_price = 9, + reason_key = "eligible", + reason = "eligible", + price_before = 10, + price_after = 9, + cycle = 1, + ), + ), ] output = render_status_rows(rows) - # Contains ASCII table borders - assert "+" in output - assert "-" in output - assert "|" in output - - # Contains headers - assert "Ad ID" in output - assert "Filename" in output - assert "Title" in output - assert "Status" in output - - # Contains row data - assert "-" in output - assert "123" in output - assert "a.yaml" in output - assert "b.yaml" in output + assert "Ad status" not in output + assert "APR details" not in output assert "Ad A" in output assert "Ad B" in output + assert "data/my_ads/ad_000001_Test/ad_000001.yaml" in output + assert "b.yaml" in output + assert "title:" in output + assert "id:" in output + assert "status:" in output + assert "APR update" in output + assert "APR publish" in output - # Contains summary line - assert "draft:" in output.casefold() - assert "published-local:" in output.casefold() - - # Summary ends with total count - assert "total" in output.casefold() or "gesamt" in output.casefold() - - # Column order: Ad ID, Filename, Title, Status - header_line = output.splitlines()[1] - headers = [c.strip() for c in header_line.split("|") if c.strip()] - assert headers == ["Ad ID", "Filename", "Title", "Status"], ( - f"Unexpected column order: {headers}" + ad_b_start = output.index("Ad B") + assert output.index( + " status:", + ad_b_start) < output.index( + " APR update:", + ad_b_start) < output.index( + " APR publish:", + ad_b_start) < output.index( + "Summary:", ) + assert "draft" in output.casefold() + assert "published-local" in output.casefold() + assert "total" in output.casefold() or "gesamt" in output.casefold() + # ------------------------------------------------------------------ # # Colour rendering # ------------------------------------------------------------------ # @@ -280,7 +307,7 @@ class TestRenderStatusRows: assert "\x1b[" in output def test_render_coloured_stripped_equals_uncoloured(self) -> None: - """Stripping ANSI from coloured output produces identical text to uncoloured.""" + """Coloured and uncoloured output contain the same status data.""" rows = [ StatusRow(title = "Sofa", ad_id = "-", filename = "sofa.yaml", status = "draft"), StatusRow(title = "Chair", ad_id = "99", filename = "chair.yaml", status = "published-local"), @@ -291,39 +318,27 @@ class TestRenderStatusRows: plain = render_status_rows(rows, color = False) coloured = render_status_rows(rows, color = True) - stripped = _ANSI_RE.sub("", coloured) + stripped = _strip_ansi(coloured) - assert stripped == plain, ( - "Stripped coloured output must exactly match uncoloured output" - ) + for expected in ("Sofa", "Chair", "changed", "due", "disabled"): + assert expected in plain + assert expected in stripped - def test_render_coloured_only_status_column(self) -> None: - """Only the status column contains ANSI codes; headers and separators are plain.""" + def test_render_coloured_contains_status_style(self) -> None: + """Status colour styling is present when enabled.""" rows = [StatusRow(title = "Desk", ad_id = "7", filename = "desk.yaml", status = "due")] output = render_status_rows(rows, color = True) - - # Header row should not contain ANSI - header_line = output.splitlines()[1] - assert "\x1b[" not in header_line, "Header must not be coloured" - - # Separator lines should not contain ANSI - sep_lines = [line for line in output.splitlines() if line.startswith("+")] - for sep in sep_lines: - assert "\x1b[" not in sep, "Separators must not be coloured" - - # The status column cell (last pipe segment) should contain ANSI - data_line = output.splitlines()[3] - cells = [c.strip() for c in data_line.split("|") if c.strip()] - assert len(cells) >= 3, "Expected at least 3 cells (id, title, status)" - status_cell = cells[-1] - assert "\x1b[" in status_cell, "Status cell should be coloured" + assert "\x1b[" in output + assert "due" in _strip_ansi(output) def test_render_colour_only_mapped_statuses(self) -> None: """Only known status values get colour; unmapped statuses are unchanged.""" rows = [StatusRow(title = "?iss", ad_id = "0", filename = "q.yaml", status = "unknown")] plain = render_status_rows(rows, color = False) coloured = render_status_rows(rows, color = True) - assert plain == coloured, "Unmapped status should not be coloured" + stripped = _strip_ansi(coloured) + assert "unknown" in plain + assert "unknown" in stripped # --------------------------------------------------------------------------- # @@ -332,117 +347,193 @@ class TestRenderStatusRows: class TestAprRendering: - """APR columns: presence, cell formatting, no-colour contamination.""" + """APR details: presence, formatting, and block placement.""" # -- render: columns absent when no active APR ------------------------- # def test_no_apr_no_columns(self) -> None: - """APR columns absent when no effective APR is configured.""" + """APR details absent when no effective APR is configured.""" rows = [ StatusRow(title = "A", ad_id = "1", filename = "a.yaml", status = "published-local"), StatusRow(title = "B", ad_id = "2", filename = "b.yaml", status = "draft"), ] output = render_status_rows(rows) - assert "APR" not in output - assert "off" not in output + assert "APR update" not in output + assert "APR publish" not in output - # -- render: columns present when APR active --------------------------- # - - def test_columns_present_when_replace_apr_enabled(self) -> None: - """APR columns visible when REPLACE decision has effective APR.""" + def test_apr_details_stay_under_the_relevant_ad(self) -> None: + """APR belongs under the relevant ad block, not in a separate table.""" rows = [ - StatusRow(title = "A", ad_id = "1", filename = "a.yaml", status = "published-local", apr_repost = "due: 9"), + StatusRow( + title = "A", + ad_id = "1", + filename = "ads/a.yaml", + status = "published-local", + apr_repost_detail = AprDetail( + result_key = "no_new_reduction", + result = "no new reduction", + effective_price = 10, + reason_key = "day_delay_waiting", + reason = "waiting for day delay", + )), ] - output = render_status_rows(rows) - assert "APR repost" in output - assert "APR update" in output - assert "due: 9" in output - def test_columns_present_when_update_apr_enabled(self) -> None: - """APR columns visible when MODIFY decision has effective APR.""" - rows = [ - StatusRow(title = "A", ad_id = "1", filename = "a.yaml", status = "published-local", apr_update = "due: 7"), - ] output = render_status_rows(rows) - assert "APR repost" in output - assert "APR update" in output - assert "due: 7" in output - # -- render: cell values ----------------------------------------------- # - - def test_apr_cell_off_for_none(self) -> None: - """None APR displayed as 'off'.""" - rows = [ - StatusRow(title = "A", ad_id = "1", filename = "a.yaml", status = "published-local", apr_repost = "due: 5"), - StatusRow(title = "B", ad_id = "2", filename = "b.yaml", status = "published-local", apr_repost = None), - ] - output = render_status_rows(rows) - assert "off" in output - - def test_apr_cell_error(self) -> None: - """Error reason displayed as 'error'.""" - rows = [ - StatusRow(title = "A", ad_id = "1", filename = "a.yaml", status = "published-local", apr_repost = "error"), - ] - output = render_status_rows(rows) - assert "error" in output - - def test_apr_cell_not_due(self) -> None: - """Not-due displayed.""" - rows = [ - StatusRow(title = "A", ad_id = "1", filename = "a.yaml", status = "published-local", apr_repost = "not due"), - ] - output = render_status_rows(rows) - assert "not due" in output + assert "APR details" not in output + assert "APR update" not in output + assert "APR publish" in output + assert "10" in output + assert "no new" in output + assert "no new reduction" in output + assert "waiting for" in output + assert "ads/a.yaml" in output # -- render: APR unaffected by colour ---------------------------------- # - def test_apr_columns_not_coloured(self) -> None: - """APR columns contain no ANSI codes when status is coloured.""" + def test_apr_details_coloured_output_remains_readable(self) -> None: + """APR detail values remain readable when color is enabled.""" rows = [ - StatusRow(title = "A", ad_id = "1", filename = "a.yaml", status = "published-local", apr_repost = "due: 5", apr_update = "not due"), + StatusRow( + title = "A", + ad_id = "1", + filename = "ads/a.yaml", + status = "published-local", + apr_repost_detail = AprDetail( + result_key = "price_reduction", + result = "price reduction", + effective_price = 9, + reason_key = "eligible", + reason = "eligible", + price_before = 10, + price_after = 9, + cycle = 1, + ), + ), ] output = render_status_rows(rows, color = True) - # Status column has ANSI, but APR columns should not - # Find the data line and split by | - data_lines = [line for line in output.splitlines() if "|" in line and "APR" not in line and not line.startswith("+") and not line.startswith("S")] - for line in data_lines: - cells = [c.strip() for c in line.split("|") if c.strip()] - # cells[0]=id, cells[1]=filename, cells[2]=title, cells[3]=status (coloured), cells[4]=apr_repost, cells[5]=apr_update - # cells[3] should have ANSI; cells[4] and cells[5] should not - if len(cells) >= 6: - assert "\x1b[" not in cells[4], "APR repost cell must not be coloured" - assert "\x1b[" not in cells[5], "APR update cell must not be coloured" + stripped = _strip_ansi(output) + assert "price reduction" in stripped + assert "10 -> 9" in stripped + assert "cycle: 1" in stripped # -- render: coloured APR output -------------------------------------- # def test_apr_coloured_stripped_equals_plain(self) -> None: - """With APR columns, strip(coloured) == plain and APR cells stay uncoloured.""" + """With APR details, coloured output preserves APR detail text.""" rows = [ StatusRow( - title = "A", ad_id = "1", filename = "a.yaml", + title = "A", ad_id = "1", filename = "ads/a.yaml", status = "published-local", - apr_repost = "due: 9", apr_update = "not due", + apr_repost_detail = AprDetail( + result_key = "price_reduction", + result = "price reduction", + effective_price = 9, + reason_key = "eligible", + reason = "eligible", + price_before = 10, + price_after = 9, + cycle = 1, + ), ), ] plain = render_status_rows(rows, color = False) coloured = render_status_rows(rows, color = True) - stripped = _ANSI_RE.sub("", coloured) - assert stripped == plain, ( - "Stripped coloured output must match plain when APR columns present" + stripped = _strip_ansi(coloured) + for expected in ("price reduction", "10 -> 9", "cycle: 1", "published-local"): + assert expected in plain + assert expected in stripped + + def test_apr_due_transition_shows_restored_to_result_price(self) -> None: + """Due APR details show the old→new price transition.""" + decision = _pr_mod.PriceReductionDecision( + mode = AdUpdateStrategy.REPLACE, + enabled = True, + on_update = False, + base_price = 20, + restored_price = 15, + result_price = 10, + applied_cycles = 1, + next_cycle = 2, + cycle_advanced = True, + reason = "cycle_advanced", + total_reposts = 0, + delay_reposts = 0, + eligible_cycles = 1, + delay_days = 0, + elapsed_days = None, + reference = None, + delay_reposts_ignored = False, ) - # APR cells are not coloured (only the status column is) - data_lines = [ - line for line in coloured.splitlines() - if "|" in line and not line.startswith("+") and "APR" not in line and not line.startswith("S") - ] - for line in data_lines: - cells = [c.strip() for c in line.split("|") if c.strip()] - if len(cells) >= 6: - assert "\x1b[" in cells[3], "Status cell should be coloured" - assert "\x1b[" not in cells[4], "APR repost must not be coloured" - assert "\x1b[" not in cells[5], "APR update must not be coloured" + with patch.object(_pr_mod, "evaluate_auto_price_reduction", return_value = decision): + rows = build_status_rows([("ads/a.yaml", _ad(id = 1, price = 20), _raw())], now = _now()) + + output = render_status_rows(rows) + assert "price reduction" in output + assert "15 -> 10" in output + + def test_apr_detail_uses_structured_result_price_and_reason(self) -> None: + """APR details keep result, price, and reason visible in one line.""" + decision = _pr_mod.PriceReductionDecision( + mode = AdUpdateStrategy.REPLACE, + enabled = True, + on_update = False, + base_price = 20, + restored_price = 20, + result_price = 20, + applied_cycles = 0, + next_cycle = None, + cycle_advanced = False, + reason = "day_delay_waiting", + total_reposts = 0, + delay_reposts = 0, + eligible_cycles = 0, + delay_days = 7, + elapsed_days = 3, + reference = None, + delay_reposts_ignored = False, + ) + + with patch.object(_pr_mod, "evaluate_auto_price_reduction", return_value = decision): + rows = build_status_rows([("ads/a.yaml", _ad(id = 1, price = 20), _raw())], now = _now()) + + assert rows[0].apr_repost_detail == AprDetail( + result_key = "no_new_reduction", + result = "no new reduction", + effective_price = 20, + reason_key = "day_delay_waiting", + reason = "waiting for day delay", + ) + + def test_apr_detail_suppresses_unconfigured_apr(self) -> None: + """Rows without configured APR do not create noisy APR detail tables.""" + decision = _pr_mod.PriceReductionDecision( + mode = AdUpdateStrategy.REPLACE, + enabled = False, + on_update = False, + base_price = 20, + restored_price = None, + result_price = None, + applied_cycles = 0, + next_cycle = None, + cycle_advanced = False, + reason = "not_configured", + total_reposts = 0, + delay_reposts = 0, + eligible_cycles = 0, + delay_days = 0, + elapsed_days = None, + reference = None, + delay_reposts_ignored = False, + ) + + with patch.object(_pr_mod, "evaluate_auto_price_reduction", return_value = decision): + rows = build_status_rows([("ads/a.yaml", _ad(id = 1, price = 20), _raw())], now = _now()) + + output = render_status_rows(rows) + assert "APR update" not in output + assert "APR publish" not in output # -- build_status_rows: APR evaluation integration --------------------- # # These test that evaluate_auto_price_reduction is called correctly @@ -581,8 +672,15 @@ async def test_status_guardrails( patch.object(bot, "create_browser_session", side_effect = _track_browser), patch.object(bot, "login", side_effect = _track_browser), patch.object(bot, "close_browser_session"), - patch("kleinanzeigen_bot.ad_loading.load_ad_configs", return_value = []), - patch("kleinanzeigen_bot.ad_status.render_status_rows", return_value = ""), + patch( + "kleinanzeigen_bot.ad_loading.load_ad_configs", + return_value = [("/abs/a.yaml", "ads/a.yaml", _ad(id = 1), _raw())], + ), + patch( + "kleinanzeigen_bot.ad_status.build_status_rows", + return_value = [StatusRow(title = "A", ad_id = "1", filename = "ads/a.yaml", status = "published-local")], + ), + patch("kleinanzeigen_bot.ad_status.render_status_rows", return_value = "") as render_status_rows_mock, patch("kleinanzeigen_bot.utils.color.should_use_color", return_value = False), ): await bot.run(["app", "status"]) @@ -590,6 +688,7 @@ async def test_status_guardrails( assert update_called, "status must call _check_for_updates()" assert not load_ads_called, "status must not call load_ads()" assert not browser_called, "status must not open browser or login" + assert render_status_rows_mock.call_args.kwargs == {"color": False} # --------------------------------------------------------------------------- # diff --git a/tests/unit/test_app.py b/tests/unit/test_app.py index 867d44b..b31ee10 100644 --- a/tests/unit/test_app.py +++ b/tests/unit/test_app.py @@ -150,8 +150,14 @@ login: encoding = "utf-8", ) test_bot.config_file_path = str(config_path) - with patch("kleinanzeigen_bot.update_checker.UpdateChecker.check_for_updates"): + with ( + patch("kleinanzeigen_bot.update_checker.UpdateChecker.check_for_updates"), + patch.object(test_bot, "load_ads", return_value = []) as mock_load_ads, + patch("kleinanzeigen_bot.price_reduction.evaluate_auto_price_reduction") as mock_evaluate, + ): await test_bot.run(["script.py", "verify", "--config", str(config_path), "--workspace-mode", "portable"]) + mock_load_ads.assert_called_once_with(exclude_ads_with_id = False) + mock_evaluate.assert_not_called() assert test_bot.config.login.username == "test" @pytest.mark.asyncio