mirror of
https://github.com/Second-Hand-Friends/kleinanzeigen-bot.git
synced 2026-07-09 12:41:05 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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_`).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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("############################################")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user