feat: improve status APR report (#1201)

## ℹ️ 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.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## 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.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Jens
2026-07-04 01:43:20 +02:00
committed by GitHub
parent 28da3865c6
commit 25e2970ed0
11 changed files with 478 additions and 346 deletions

View File

@@ -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

View File

@@ -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_`).

View File

@@ -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).

View File

@@ -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: <price>``, ``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(

View File

@@ -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("############################################")

View File

@@ -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

View File

@@ -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],

View File

@@ -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."

View File

@@ -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}"

View File

@@ -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}
# --------------------------------------------------------------------------- #

View File

@@ -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