mirror of
https://github.com/Second-Hand-Friends/kleinanzeigen-bot.git
synced 2026-07-09 12:41:05 +02:00
docs: generate README usage section (#1191)
## ℹ️ Description - Link to the related issue(s): N/A - Keeps the README Usage command block in sync with the CLI help by generating it as part of the artifact pipeline. ## 📋 Changes Summary - Added a README Usage generator that renders normalized English CLI help between explicit README markers. - Added `generate-readme-commands` and wired it into `generate-artifacts`. - Extended generated artifact checks to detect README Usage drift without mutating files. - Added focused unit tests for marker replacement, locale/executable normalization, ANSI stripping, and command coverage. - No dependency changes introduced. ### ⚙️ Type of Change Select the type(s) of change(s) included in this pull request: - [ ] 🐞 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 Before requesting a review, confirm the following: - [x] I have reviewed my changes to ensure they meet the project's 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. 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** * Kept the README “Usage” section automatically synchronized with the app’s CLI help. * Added a new `--preserve-local-settings` option to the CLI help output. * **Bug Fixes** * Improved and clarified CLI command/option descriptions (notably around `verify`, `extend`, and `update` behavior). * CI now fails early if the generated README “Usage” content is out of date. * **Documentation** * Regenerated the README “Usage” console block to reflect the latest help text and command details. * Updated agent guidance to match the expanded README generation behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -88,7 +88,7 @@ When changing models, config defaults, schema-affecting validators, or `create-c
|
||||
|
||||
- `pdm run generate-schemas` — regenerates `schemas/*.json`
|
||||
- `pdm run generate-config` — regenerates `docs/config.default.yaml`
|
||||
- `pdm run generate-artifacts` — runs both
|
||||
- `pdm run generate-artifacts` — runs schemas, config, and README usage regeneration
|
||||
|
||||
CI and workflows are the source of truth for the exact required checks, coverage gates, generated-artifact verification, and PR title validation.
|
||||
|
||||
|
||||
21
README.md
21
README.md
@@ -200,6 +200,7 @@ Die Nutzung erfolgt auf eigenes Risiko. Jede rechtswidrige Verwendung ist unters
|
||||
|
||||
## <a name="usage"></a>Usage
|
||||
|
||||
<!-- readme-usage:generated:start -->
|
||||
```console
|
||||
Usage: kleinanzeigen-bot COMMAND [OPTIONS]
|
||||
|
||||
@@ -208,13 +209,14 @@ Commands:
|
||||
verify - verifies the configuration files and previews automatic price reduction outcomes
|
||||
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)
|
||||
download - downloads one or multiple ads
|
||||
extend - extends active ads that expire soon (keeps watchers/savers and does not count towards the monthly ad quota)
|
||||
update-check - checks for available updates
|
||||
update-content-hash – recalculates each ad's content_hash based on the current ad_defaults;
|
||||
use this after changing config.yaml/ad_defaults to avoid every ad being marked "changed" and republished
|
||||
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
|
||||
--
|
||||
help - displays this help (default command)
|
||||
version - displays the application version
|
||||
@@ -233,23 +235,26 @@ Options:
|
||||
* all: downloads all ads from your profile
|
||||
* new: downloads ads from your profile that are not locally saved yet
|
||||
* <id(s)>: provide one or several ads by ID to download, like e.g. "--ads=1,2,3"
|
||||
--ads=all|<id(s)> (extend) - specifies which ads to extend (DEFAULT: all)
|
||||
Possible values:
|
||||
* all: extend all eligible ads in your profile
|
||||
* <id(s)>: provide one or several ads by ID to extend, like e.g. "--ads=1,2,3"
|
||||
* Note: kleinanzeigen.de only allows extending ads within 8 days of expiry; ads outside this window are skipped.
|
||||
--ads=changed|<id(s)> (update) - specifies which ads to update (DEFAULT: changed)
|
||||
--ads=all|changed|<id(s)> (update) - specifies which ads to update (DEFAULT: changed)
|
||||
Possible values:
|
||||
* all: update all ads
|
||||
* changed: only update ads that have been modified since last publication
|
||||
* <id(s)>: provide one or several ads by ID to update, like e.g. "--ads=1,2,3"
|
||||
--ads=all|<id(s)> (extend) - specifies which ads to extend (DEFAULT: all)
|
||||
Possible values:
|
||||
* all: extend all ads expiring within 8 days
|
||||
* <id(s)>: specify ad IDs to extend, e.g. "--ads=1,2,3"
|
||||
* Note: ads outside the 8-day window are skipped.
|
||||
--force - alias for '--ads=all'
|
||||
--keep-old - don't delete old ads on republication
|
||||
--preserve-local-settings - force-enable preservation of local-only settings on re-download (overrides config value of false)
|
||||
--config=<PATH> - path to the config YAML or JSON file (does not implicitly change workspace mode)
|
||||
--workspace-mode=portable|xdg - overrides workspace mode for this run
|
||||
--logfile=<PATH> - path to the logfile (DEFAULT: depends on active workspace mode)
|
||||
--lang=en|de - display language (STANDARD: system language if supported, otherwise English)
|
||||
-v, --verbose - enables verbose output - only useful when troubleshooting issues
|
||||
```
|
||||
<!-- readme-usage:generated:end -->
|
||||
|
||||
> **Note:** The output of `kleinanzeigen-bot help` is always the most up-to-date reference for available commands and options.
|
||||
|
||||
|
||||
@@ -91,7 +91,8 @@ debug = "python -m pdb -m kleinanzeigen_bot"
|
||||
# build & packaging
|
||||
generate-schemas = "python scripts/generate_schemas.py"
|
||||
generate-config = { shell = "python -c \"from pathlib import Path; Path('docs/config.default.yaml').unlink(missing_ok=True)\" && python -m kleinanzeigen_bot --config docs/config.default.yaml create-config" }
|
||||
generate-artifacts = { composite = ["generate-schemas", "generate-config"] }
|
||||
generate-readme-commands = "python scripts/generate_readme_commands.py"
|
||||
generate-artifacts = { composite = ["generate-schemas", "generate-config", "generate-readme-commands"] }
|
||||
compile.cmd = "python -O -m PyInstaller pyinstaller.spec --clean --workpath .temp"
|
||||
compile.env = {PYTHONHASHSEED = "1", SOURCE_DATE_EPOCH = "0"} # https://pyinstaller.org/en/stable/advanced-topics.html#creating-a-reproducible-build
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# SPDX-FileCopyrightText: © Jens Bergmann and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||
"""CI guard: verifies generated schema and default-config artifacts are up-to-date."""
|
||||
"""CI guard: verifies generated schema, default-config, and README artifacts are up-to-date."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -12,6 +12,7 @@ import tempfile
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from generate_readme_commands import build_usage_block, replace_marked_section
|
||||
from schema_utils import generate_schema_content
|
||||
|
||||
from kleinanzeigen_bot.model.ad_model import AdPartial
|
||||
@@ -111,13 +112,48 @@ def get_default_config_diff(repo_root:Path) -> str:
|
||||
)
|
||||
|
||||
|
||||
def get_readme_diff(repo_root:Path) -> str:
|
||||
"""
|
||||
Compare committed README.md with in-memory generated content and return a unified diff string.
|
||||
|
||||
Never mutates README.md — computes expected content entirely in memory.
|
||||
"""
|
||||
readme_path = repo_root / "README.md"
|
||||
if not readme_path.is_file():
|
||||
raise FileNotFoundError(f"Missing README.md: {readme_path}")
|
||||
|
||||
committed = readme_path.read_text(encoding = "utf-8")
|
||||
new_block = build_usage_block()
|
||||
|
||||
try:
|
||||
expected = replace_marked_section(committed, new_block)
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(f"README marker validation failed: {exc}") from exc
|
||||
|
||||
if committed == expected:
|
||||
return ""
|
||||
|
||||
return "".join(
|
||||
difflib.unified_diff(
|
||||
committed.splitlines(keepends = True),
|
||||
expected.splitlines(keepends = True),
|
||||
fromfile = "README.md",
|
||||
tofile = "<generated via: pdm run generate-readme-commands>",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
repo_root = Path(__file__).resolve().parent.parent
|
||||
|
||||
schema_diffs = get_schema_diffs(repo_root)
|
||||
default_config_diff = get_default_config_diff(repo_root)
|
||||
try:
|
||||
schema_diffs = get_schema_diffs(repo_root)
|
||||
default_config_diff = get_default_config_diff(repo_root)
|
||||
readme_diff = get_readme_diff(repo_root)
|
||||
except (FileNotFoundError, RuntimeError) as exc:
|
||||
raise SystemExit(f"Error while checking generated artifacts: {exc}") from exc
|
||||
|
||||
if schema_diffs or default_config_diff:
|
||||
if schema_diffs or default_config_diff or readme_diff:
|
||||
messages:list[str] = ["Generated artifacts are not up-to-date."]
|
||||
|
||||
if schema_diffs:
|
||||
@@ -130,13 +166,18 @@ def main() -> None:
|
||||
messages.append("Outdated docs/config.default.yaml detected.")
|
||||
messages.append(default_config_diff)
|
||||
|
||||
if readme_diff:
|
||||
messages.append("Outdated README.md detected (Usage section).")
|
||||
messages.append(readme_diff)
|
||||
|
||||
messages.append("Regenerate with one of the following:")
|
||||
messages.append("- Schema files: pdm run generate-schemas")
|
||||
messages.append("- Schema files: pdm run generate-schemas")
|
||||
messages.append("- Default config snapshot: pdm run generate-config")
|
||||
messages.append("- Both: pdm run generate-artifacts")
|
||||
messages.append("- README Usage section: pdm run generate-readme-commands")
|
||||
messages.append("- All: pdm run generate-artifacts")
|
||||
raise SystemExit("\n".join(messages))
|
||||
|
||||
print("Generated schemas and docs/config.default.yaml are up-to-date.")
|
||||
print("Generated schemas, docs/config.default.yaml, and README.md are up-to-date.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
136
scripts/generate_readme_commands.py
Normal file
136
scripts/generate_readme_commands.py
Normal file
@@ -0,0 +1,136 @@
|
||||
# SPDX-FileCopyrightText: © Jens Bergmann and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||
"""Generate the CLI Usage console block in README.md from source of truth.
|
||||
|
||||
Replaces the content between ``<!-- readme-usage:generated:start -->`` and
|
||||
``<!-- readme-usage:generated:end -->`` markers in README.md with the
|
||||
normalized English help text from :func:`kleinanzeigen_bot.cli.help_text`.
|
||||
|
||||
Pure helpers (:func:`build_usage_block`, :func:`replace_marked_section`) are
|
||||
importable for in-memory use by the artifact checker.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
|
||||
from kleinanzeigen_bot.cli import help_text
|
||||
|
||||
START_MARKER:str = "<!-- readme-usage:generated:start -->"
|
||||
END_MARKER:str = "<!-- readme-usage:generated:end -->"
|
||||
README_PATH:pathlib.Path = pathlib.Path(__file__).resolve().parent.parent / "README.md"
|
||||
_ANSI_RE = re.compile(r"\x1B(?:\[[0-9;]*[A-Za-z]|\[)")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pure helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_usage_block() -> str:
|
||||
"""Build the normalized Usage console block for the README.
|
||||
|
||||
Returns a markdown console-fenced block (including leading/trailing
|
||||
newlines) ready to be placed between the start/end markers.
|
||||
|
||||
Forces:
|
||||
* executable name → ``kleinanzeigen-bot``
|
||||
* language → English (``en``)
|
||||
* no ANSI escape codes
|
||||
* no subprocess (imports ``help_text`` directly)
|
||||
"""
|
||||
raw = help_text(executable = "kleinanzeigen-bot", language = "en")
|
||||
clean = _strip_ansi(raw)
|
||||
return f"\n```console\n{clean}\n```\n"
|
||||
|
||||
|
||||
def replace_marked_section(text:str, new_block:str) -> str:
|
||||
"""Replace content between README markers with *new_block*.
|
||||
|
||||
Validates that both markers exist exactly once and are in the correct
|
||||
order. Raises :class:`ValueError` with a clear message on failure.
|
||||
|
||||
The markers themselves are preserved; only the content between them
|
||||
(including surrounding whitespace) is replaced.
|
||||
"""
|
||||
_validate_markers(text)
|
||||
|
||||
start_idx = text.find(START_MARKER) + len(START_MARKER)
|
||||
end_idx = text.find(END_MARKER)
|
||||
|
||||
before = text[:start_idx]
|
||||
after = text[end_idx:]
|
||||
return before + new_block + after
|
||||
|
||||
|
||||
def _validate_markers(text:str) -> None:
|
||||
"""Check marker presence, uniqueness, and order."""
|
||||
start_count = text.count(START_MARKER)
|
||||
end_count = text.count(END_MARKER)
|
||||
|
||||
if start_count == 0:
|
||||
raise ValueError(
|
||||
f"Missing start marker {START_MARKER!r} in README. "
|
||||
f"Add it before the generated Usage console block."
|
||||
)
|
||||
if end_count == 0:
|
||||
raise ValueError(
|
||||
f"Missing end marker {END_MARKER!r} in README. "
|
||||
f"Add it after the generated Usage console block."
|
||||
)
|
||||
if start_count > 1:
|
||||
raise ValueError(
|
||||
f"Duplicate start marker {START_MARKER!r}: found {start_count} occurrences. "
|
||||
f"Exactly one is required."
|
||||
)
|
||||
if end_count > 1:
|
||||
raise ValueError(
|
||||
f"Duplicate end marker {END_MARKER!r}: found {end_count} occurrences. "
|
||||
f"Exactly one is required."
|
||||
)
|
||||
|
||||
start_pos = text.find(START_MARKER)
|
||||
end_pos = text.find(END_MARKER)
|
||||
if start_pos > end_pos:
|
||||
raise ValueError(
|
||||
f"Markers are reversed: start marker found at position {start_pos} "
|
||||
f"after end marker at position {end_pos}."
|
||||
)
|
||||
|
||||
|
||||
def _strip_ansi(text:str) -> str:
|
||||
"""Remove ANSI escape sequences from *text*."""
|
||||
return _ANSI_RE.sub("", text)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entrypoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def update_readme() -> None:
|
||||
"""Read README.md, replace the marked section, and write back.
|
||||
|
||||
Exits with status 1 on validation errors.
|
||||
"""
|
||||
original = README_PATH.read_text(encoding = "utf-8")
|
||||
new_block = build_usage_block()
|
||||
|
||||
try:
|
||||
updated = replace_marked_section(original, new_block)
|
||||
except ValueError as exc:
|
||||
print(f"Error: {exc}", file = sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
README_PATH.write_text(updated, encoding = "utf-8")
|
||||
print(f"[OK] Updated {README_PATH}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
update_readme()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -117,19 +117,20 @@ def _help_executable() -> str:
|
||||
return "python -m kleinanzeigen_bot"
|
||||
|
||||
|
||||
def _help_text() -> str:
|
||||
exe = _help_executable()
|
||||
if get_current_locale().language == "de":
|
||||
def help_text(*, executable:str | None = None, language:str | None = None) -> str:
|
||||
exe = executable if executable is not None else _help_executable()
|
||||
lang = language if language is not None else get_current_locale().language
|
||||
if lang == "de":
|
||||
return textwrap.dedent(
|
||||
f"""\
|
||||
Verwendung: {colorama.Fore.LIGHTMAGENTA_EX}{exe} BEFEHL [OPTIONEN]{colorama.Style.RESET_ALL}
|
||||
|
||||
Befehle:
|
||||
publish - (Wieder-)Veröffentlicht Anzeigen
|
||||
verify - Überprüft die Konfigurationsdateien
|
||||
verify - Überprüft die Konfigurationsdateien und zeigt Preissenkungsvorschauen an
|
||||
delete - Löscht Anzeigen
|
||||
update - Aktualisiert bestehende Anzeigen
|
||||
extend - Verlängert Anzeigen innerhalb des 8-Tage-Zeitfensters
|
||||
extend - Verlängert Anzeigen im 8-Tage-Zeitfenster (behält Beobachter/Interessenten bei und zählt nicht zum monatlichen Anzeigenkontingent)
|
||||
download - Lädt eine oder mehrere Anzeigen herunter
|
||||
update-check - Prüft auf verfügbare Updates
|
||||
update-content-hash - Berechnet den content_hash aller Anzeigen anhand der aktuellen ad_defaults neu;
|
||||
@@ -166,6 +167,7 @@ def _help_text() -> str:
|
||||
Mögliche Werte:
|
||||
* all: Verlängert alle Anzeigen, die innerhalb von 8 Tagen ablaufen
|
||||
* <id(s)>: Gibt bestimmte Anzeigen-IDs an, z. B. "--ads=1,2,3"
|
||||
* Hinweis: Anzeigen außerhalb des 8-Tage-Fensters werden übersprungen.
|
||||
--force - Alias für '--ads=all'
|
||||
--keep-old - Verhindert das Löschen alter Anzeigen bei erneuter Veröffentlichung
|
||||
--preserve-local-settings - Erzwingt das Beibehalten lokaler Einstellungen bei erneutem Download (überschreibt config-Wert false)
|
||||
@@ -183,10 +185,10 @@ def _help_text() -> str:
|
||||
|
||||
Commands:
|
||||
publish - (re-)publishes ads
|
||||
verify - verifies the configuration files
|
||||
verify - verifies the configuration files and previews automatic price reduction outcomes
|
||||
delete - deletes ads
|
||||
update - updates published ads
|
||||
extend - extends ads within the 8-day window before expiry
|
||||
extend - extends ads within the 8-day window before expiry (keeps watchers/savers and does not count towards the monthly ad quota)
|
||||
download - downloads one or multiple ads
|
||||
update-check - checks for available updates
|
||||
update-content-hash – recalculates each ad's content_hash based on the current ad_defaults;
|
||||
@@ -221,6 +223,7 @@ def _help_text() -> str:
|
||||
Possible values:
|
||||
* all: extend all ads expiring within 8 days
|
||||
* <id(s)>: specify ad IDs to extend, e.g. "--ads=1,2,3"
|
||||
* Note: ads outside the 8-day window are skipped.
|
||||
--force - alias for '--ads=all'
|
||||
--keep-old - don't delete old ads on republication
|
||||
--preserve-local-settings - force-enable preservation of local-only settings on re-download (overrides config value of false)
|
||||
@@ -234,7 +237,7 @@ def _help_text() -> str:
|
||||
|
||||
|
||||
def show_help() -> None:
|
||||
print(_help_text())
|
||||
print(help_text())
|
||||
|
||||
|
||||
def parse_args(args:Sequence[str]) -> ParsedArgs:
|
||||
|
||||
152
tests/unit/test_generate_readme_commands.py
Normal file
152
tests/unit/test_generate_readme_commands.py
Normal file
@@ -0,0 +1,152 @@
|
||||
# SPDX-FileCopyrightText: © Jens Bergmann and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# SPDX-ArtifactOfProjectHomePage: https://github.com/Second-Hand-Friends/kleinanzeigen-bot/
|
||||
"""Unit tests for scripts/generate_readme_commands.py helpers."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from kleinanzeigen_bot import runtime_config
|
||||
from kleinanzeigen_bot.utils.i18n import Locale, get_current_locale, set_current_locale
|
||||
|
||||
# Ensure scripts/ is importable as a top-level module, matching the import style
|
||||
# used by scripts/check_generated_artifacts.py and avoiding the mypy
|
||||
# double-module mapping ("generate_readme_commands" vs "scripts.generate_readme_commands").
|
||||
_SCRIPTS_DIR = str(Path(__file__).resolve().parent.parent.parent / "scripts")
|
||||
if _SCRIPTS_DIR not in sys.path:
|
||||
sys.path.insert(0, _SCRIPTS_DIR)
|
||||
|
||||
from generate_readme_commands import ( # noqa: E402 # pyright: ignore[reportMissingImports]
|
||||
END_MARKER,
|
||||
START_MARKER,
|
||||
build_usage_block,
|
||||
replace_marked_section,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Marker replacement — success cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestReplaceMarkers:
|
||||
def test_replaces_content_between_markers(self) -> None:
|
||||
text = f"before\n{START_MARKER}\nold\n{END_MARKER}\nafter"
|
||||
result = replace_marked_section(text, "\nNEW\n")
|
||||
assert result == f"before\n{START_MARKER}\nNEW\n{END_MARKER}\nafter"
|
||||
|
||||
def test_preserves_surrounding_context(self) -> None:
|
||||
text = f"HEADER\n{START_MARKER}\nSTALE\n{END_MARKER}\nFOOTER"
|
||||
result = replace_marked_section(text, "\nFRESH\n")
|
||||
assert "HEADER" in result
|
||||
assert "FOOTER" in result
|
||||
assert "STALE" not in result
|
||||
|
||||
def test_noop_when_content_identical(self) -> None:
|
||||
block = "\n```console\nUsage: kleinanzeigen-bot\n```\n"
|
||||
text = f"preamble\n{START_MARKER}{block}{END_MARKER}\npostamble"
|
||||
result = replace_marked_section(text, block)
|
||||
assert result == text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Marker validation — failure cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestReplaceMarkersErrors:
|
||||
@pytest.mark.parametrize(
|
||||
("text", "expected_substring"),
|
||||
[
|
||||
pytest.param(
|
||||
"no markers here",
|
||||
"Missing start marker",
|
||||
id = "missing-start",
|
||||
),
|
||||
pytest.param(
|
||||
f"only {END_MARKER}",
|
||||
"Missing start marker",
|
||||
id = "missing-start-but-has-end",
|
||||
),
|
||||
pytest.param(
|
||||
f"{START_MARKER}\nno end",
|
||||
"Missing end marker",
|
||||
id = "missing-end",
|
||||
),
|
||||
pytest.param(
|
||||
f"{END_MARKER}\nno start",
|
||||
"Missing start marker",
|
||||
id = "missing-both-start-actually-end-first",
|
||||
),
|
||||
pytest.param(
|
||||
f"{START_MARKER}\n{START_MARKER}\n{END_MARKER}",
|
||||
"Duplicate start marker",
|
||||
id = "duplicate-start",
|
||||
),
|
||||
pytest.param(
|
||||
f"{START_MARKER}\n{END_MARKER}\n{END_MARKER}",
|
||||
"Duplicate end marker",
|
||||
id = "duplicate-end",
|
||||
),
|
||||
pytest.param(
|
||||
f"{END_MARKER}\n{START_MARKER}",
|
||||
"reversed",
|
||||
id = "reversed-markers",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_raises_value_error(self, text:str, expected_substring:str) -> None:
|
||||
with pytest.raises(ValueError, match = expected_substring):
|
||||
replace_marked_section(text, "\nBLOCK\n")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Generated help block — normalization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuildUsageBlock:
|
||||
def test_contains_expected_executable_name(self) -> None:
|
||||
block = build_usage_block()
|
||||
assert "Usage: kleinanzeigen-bot COMMAND [OPTIONS]" in block
|
||||
|
||||
def test_contains_no_ansi_escape_codes(self) -> None:
|
||||
block = build_usage_block()
|
||||
assert "\x1b[" not in block
|
||||
|
||||
def test_wrapped_in_console_fence(self) -> None:
|
||||
block = build_usage_block()
|
||||
assert block.startswith("\n```console\n")
|
||||
assert block.endswith("\n```\n")
|
||||
|
||||
def test_is_english_regardless_of_current_locale(self) -> None:
|
||||
"""Force German locale, then verify generated help is still English."""
|
||||
saved = get_current_locale()
|
||||
try:
|
||||
set_current_locale(Locale("de"))
|
||||
block = build_usage_block()
|
||||
assert "Commands:" in block
|
||||
assert "Befehle:" not in block
|
||||
finally:
|
||||
set_current_locale(saved)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Generated help — command coverage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuildUsageBlockCommands:
|
||||
"""Verify that generated help covers the known command set."""
|
||||
|
||||
def test_every_valid_command_appears_in_generated_help(self) -> None:
|
||||
block = build_usage_block()
|
||||
for cmd in runtime_config.VALID_COMMANDS:
|
||||
assert cmd in block, (
|
||||
f"Command {cmd!r} is in VALID_COMMANDS but missing from generated help"
|
||||
)
|
||||
Reference in New Issue
Block a user