sdlc-hub/tests/test_commands.py
Andrey Epifancev e20977ef49 Финальное ревью: пробельные поля ensure_fields, версия агента, чтение файлов, форма issues (#129, I-1, I-2, I-3, M-2, T2.1)
I-1 (R-8): ensure_fields с полем из одних пробелов даёт 422, очистка только "" или null.
I-2: agent_view не падает на нецифровой версии агента (GET /agent, /agents).
I-3 (R-9): files.size через fstat у открытого файла, _intact ловит OSError, put переписывает нечитаемый файл.
M-2: /agent/state отвергает пустые не-списки в issues.
T2.1, T3.1, T4.1-T4.3, T5.2, T6.1, T1.5: тесты и мелкие правки.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 03:16:39 +04:00

408 lines
26 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import re
import unicodedata
import pytest
from sdlc_hub.modules.jira import commands, mirror
from sdlc_hub.modules.jira.commands import CommandError
NOW = "strftime('%Y-%m-%dT%H:%M:%S.000+0300', 'now')"
def test_validate_ok_and_tail_keys():
cmds = commands.validate([
{"type": "ensure_issue", "params": {"temp_key": "new:60", "issue_type": "task", "summary": "Т"}},
{"type": "ensure_link", "params": {"source": "new:60", "target": "A-1", "link_type": "связанные"}},
{"type": "refresh", "params": {"keys": ["A-1", "new:60"]}},
])
assert [c["type"] for c in cmds] == ["ensure_issue", "ensure_link", "refresh"]
assert commands.referenced_keys(cmds[1]) == ["new:60", "A-1"]
assert commands.temp_keys_used(cmds[1]) == ["new:60"]
assert commands.temp_keys_used(cmds[0]) == []
assert commands.temp_keys_used(cmds[2]) == ["new:60"]
@pytest.mark.parametrize("bad, msg", [
([], "без команд"),
([{"type": "ensure_all"}], "неизвестный тип"),
([{"type": "ensure_fields", "params": {"key": "A-1"}}], "без полей"),
([{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23", "foo": 1}}], "лишние параметры"),
([{"type": "ensure_status", "params": {"key": "A-1"}}], "нет target"),
([{"type": "refresh", "params": {}}], "без keys и jql"),
([{"type": "ensure_link", "params": {"source": "new:3", "target": "A-1", "link_type": "связанные"}}], "не объявлен"),
([{"type": "ensure_issue", "params": {"temp_key": "A-1", "issue_type": "task", "summary": "x"}}], "new:<n>"),
([{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "story", "summary": "x"}}],
"task, epic или bug"),
([{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "x"}},
{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "y"}}], "уже объявлен"),
])
def test_validate_errors(bad, msg):
with pytest.raises(CommandError, match=msg):
commands.validate(bad)
def test_missing_param_error_names_expected():
"""Клиент со старым описанием исправляет пакет с одной попытки (#44): ensure_status {key, status}."""
with pytest.raises(CommandError) as e:
commands.validate([{"type": "ensure_status", "params": {"key": "A-1", "status": "Готово"}}])
assert str(e.value) == "команда 1 (ensure_status): нет target; ожидаются key, target"
def test_unknown_param_error_names_expected():
with pytest.raises(CommandError) as e:
commands.validate([{"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "Спринт 2", "board": "1"}}])
assert str(e.value) == "команда 1 (ensure_sprint): лишние параметры board; ожидаются key, sprint"
with pytest.raises(CommandError) as e:
commands.validate([{"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные",
"force": True}}])
assert str(e.value) == ("команда 1 (ensure_link): лишние параметры force; "
"ожидаются source, target, link_type; необязательные replace")
def test_validate_accepts_bug_issue_type():
"""bug-and-attachments: тип «Ошибка» — issue_type = bug; наличие настройки проверяет постановка (queue)."""
cmds = commands.validate([{"type": "ensure_issue", "params": {
"temp_key": "new:1", "issue_type": "bug", "summary": "ОСВ 60 не распознана", "labels": ["AWPCA"]}}])
assert cmds[0]["params"]["issue_type"] == "bug"
assert commands.SPEC["ensure_issue"]["hints"]["issue_type"] == "task|epic|bug"
ABC = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" # sha256("abc")
def test_ensure_attachment_contract():
ok = commands.validate([
{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "bug", "summary": "Ошибка"}},
{"type": "ensure_attachment", "params": {"key": "new:1", "file": ABC, "filename": "ОСВ 60.xlsx"}}])
assert commands.referenced_keys(ok[1]) == ["new:1"] and commands.temp_keys_used(ok[1]) == ["new:1"]
assert "ensure_attachment" in commands.WRITE_TYPES
assert ("- ensure_attachment: key, file (sha256 из POST /files; в MCP — path к локальному файлу), "
"filename (имя вложения)") in commands.describe().splitlines()
with pytest.raises(CommandError) as e:
commands.validate([{"type": "ensure_attachment", "params": {"key": "A-1", "file": ABC}}])
assert str(e.value) == "команда 1 (ensure_attachment): нет filename; ожидаются key, file, filename"
with pytest.raises(CommandError) as e:
commands.validate([{"type": "ensure_attachment", "params": {"key": "A-1", "file": ABC, "filename": "a", "size": 3}}])
assert str(e.value) == "команда 1 (ensure_attachment): лишние параметры size; ожидаются key, file, filename"
with pytest.raises(CommandError, match="file — строка"):
commands.validate([{"type": "ensure_attachment", "params": {"key": "A-1", "file": 5, "filename": "a"}}])
def test_spec_hints_name_known_params():
for ctype, spec in commands.SPEC.items():
assert set(spec.get("hints", {})) <= set(spec["required"]) | set(spec["optional"]), ctype
def test_substitute_and_estimate():
p = commands.substitute("ensure_link", {"source": "new:60", "target": "A-1", "link_type": "связанные"}, {"new:60": "A-9"})
assert p["source"] == "A-9"
p = commands.substitute("refresh", {"keys": ["new:60", "A-1"]}, {"new:60": "A-9"})
assert p["keys"] == ["A-9", "A-1"]
p = commands.substitute("ensure_issue", {"temp_key": "new:60", "issue_type": "task", "summary": "x", "epic_key": "new:2"},
{"new:2": "A-7", "new:60": "A-9"})
assert p["temp_key"] == "new:60" and p["epic_key"] == "A-7"
@pytest.fixture
def conn(open_db):
"""Зеркало в памяти: A-1 (Готово, Спринт 2, метки AWPCA и kind-dev) и A-2, связь A-1 → A-2; закрывается после теста."""
c = open_db()
mirror.apply_state(c, [
{"key": "A-1", "summary": "Тема", "status": "Готово", "priority": "Высокий",
"duedate": "2026-10-23", "labels": ["AWPCA", "kind-dev"], "sprint": "Спринт 2",
"created": "2026-10-10T10:00:00.000+0300", "assignee": "ivanov", "reporter": "petrov"},
{"key": "A-2", "summary": "Вторая", "status": "Разработка", "created": "2026-10-10T11:00:00.000+0300"},
], [{"source_key": "A-1", "target_key": "A-2", "link_type": "ASUPR_Связанные"}])
return c
LT = {"связанные": "ASUPR_Связанные", "блокирует начало": "ASUPR_Блокер_Начала"}
@pytest.mark.parametrize("cmd, expected", [
({"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23", "priority": "Высокий"}}, True),
({"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-24"}}, False),
({"type": "ensure_fields", "params": {"key": "A-1", "labels_add": ["kind-dev"], "labels_remove": ["fact-done"]}}, True),
({"type": "ensure_fields", "params": {"key": "A-1", "labels_remove": ["kind-dev"]}}, False),
({"type": "ensure_status", "params": {"key": "A-1", "target": ["Разработка", "Готово"]}}, True),
({"type": "ensure_status", "params": {"key": "A-1", "target": "Разработка"}}, False),
({"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные"}}, True),
({"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "блокирует начало"}}, False),
({"type": "ensure_link", "params": {"source": "A-2", "target": "A-1", "link_type": "связанные"}}, False),
({"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "Спринт 2"}}, True),
({"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "backlog"}}, False),
({"type": "ensure_comment", "params": {"key": "A-1", "marker": "m", "body": "b"}}, False),
({"type": "ensure_attachment", "params": {"key": "A-1", "file": "a" * 64, "filename": "a.xlsx"}}, False),
({"type": "refresh", "params": {"keys": ["A-1"]}}, False),
({"type": "ensure_fields", "params": {"key": "A-404", "duedate": "2026-10-23"}}, False),
])
def test_check_confirmed(conn, cmd, expected):
ok, _ = commands.check_confirmed(conn, cmd, LT)
assert ok is expected
def test_ensure_link_reverse_direction_with_both_keys_in_mirror(conn):
# A-1 -> A-2 есть, спрашиваем A-2 -> A-1 при обоих ключах в зеркале: обратная связь — конфликт
ok, _ = commands.check_confirmed(conn, {"type": "ensure_link", "params": {"source": "A-2", "target": "A-1", "link_type": "связанные"}}, LT)
assert ok is False
def test_ensure_link_conflict_other_type_blocks_confirmation(conn):
conn.execute("INSERT INTO issue_link(source_key, target_key, link_type) VALUES ('A-1', 'A-2', 'ASUPR_Блокер_Начала')")
ok, _ = commands.check_confirmed(conn, {"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные"}}, LT)
assert ok is False
def test_ensure_link_conflict_reverse_blocks_confirmation(conn):
conn.execute("INSERT INTO issue_link(source_key, target_key, link_type) VALUES ('A-2', 'A-1', 'ASUPR_Связанные')")
ok, _ = commands.check_confirmed(conn, {"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные"}}, LT)
assert ok is False
def _ensure_issue(summary):
return {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": summary}}
def test_ensure_issue_never_confirmed_by_state(conn):
"""bridge-reliability, решение 4: потерянный отчёт ensure_issue закрывается довыдачей, не сверкой с зеркалом."""
conn.execute(f"UPDATE issue SET created = {NOW} WHERE key = 'A-1'")
assert commands.check_confirmed(conn, _ensure_issue("Тема"), LT) == (False, None)
def test_validate_rejects_duplicate_issue_summary():
cmds = [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Отчёт"}},
{"type": "ensure_issue", "params": {"temp_key": "new:2", "issue_type": "task", "summary": " Отчёт "}},
{"type": "ensure_issue", "params": {"temp_key": "new:3", "issue_type": "task", "summary": "отчёт"}}]
with pytest.raises(commands.CommandError) as e:
commands.validate(cmds)
assert "повторяется тема ensure_issue: «Отчёт»" in str(e.value)
commands.validate([cmds[0], cmds[2]]) # регистр различается — пакет допустим
@pytest.mark.parametrize("cmd, name", [
({"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": 42}}, "summary"),
({"type": "ensure_fields", "params": {"key": "A-1", "labels_add": "AWPCA"}}, "labels_add"),
({"type": "ensure_fields", "params": {"key": "A-1", "labels_remove": ["x", 1]}}, "labels_remove"),
({"type": "ensure_fields", "params": {"key": ["A-1"], "duedate": "2026-10-23"}}, "key"),
({"type": "ensure_comment", "params": {"key": "A-1", "marker": "m", "body": {"t": 1}}}, "body"),
({"type": "ensure_status", "params": {"key": "A-1", "target": 5}}, "target"),
({"type": "ensure_status", "params": {"key": "A-1", "target": ["Разработка", 2]}}, "target"),
({"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные", "replace": "да"}}, "replace"),
({"type": "refresh", "params": {"keys": "A-1"}}, "keys"),
])
def test_validate_rejects_wrong_types(cmd, name):
with pytest.raises(commands.CommandError) as e:
commands.validate([cmd])
assert name in str(e.value)
def test_validate_accepts_null_for_clearable_and_path_target():
out = commands.validate([
{"type": "ensure_fields", "params": {"key": "A-1", "assignee": None, "epic_key": None, "duedate": None}},
{"type": "ensure_status", "params": {"key": "A-1", "target": ["Разработка", "Готово"]}},
{"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные", "replace": True}},
])
assert len(out) == 3
@pytest.mark.parametrize("mirror_value, param, expected", [
("a\r\nb", "a\nb", True), ("a\nb", "a\r\nb", True), ("a\r\nb\r\n", "a\nb\n", True),
("a\r\nb", "a b", False), (None, "", True), ("", None, True), ("a\rb", "a\nb", False),
])
def test_ensure_fields_text_compare_ignores_crlf(conn, mirror_value, param, expected):
"""O-9 #16, решение 11: CRLF и LF не различаются, null и пустая строка равны; одиночный CR — другой текст.
summary с CRLF в зеркале и LF в команде — в каждом случае: сравнение одно для всех строковых полей."""
conn.execute("UPDATE issue SET description = ?, summary = ? WHERE key = 'A-1'", (mirror_value, "x\r\ny"))
ok, _ = commands.check_confirmed(conn, {"type": "ensure_fields",
"params": {"key": "A-1", "description": param, "summary": "x\ny"}}, LT)
assert ok is expected
def test_ensure_fields_summary_difference_is_detected(conn):
"""T4.1 #32: CRLF в summary игнорируется, но реальное расхождение текста — False."""
conn.execute("UPDATE issue SET summary = ? WHERE key = 'A-1'", ("x\r\ny",))
ok, _ = commands.check_confirmed(conn, {"type": "ensure_fields",
"params": {"key": "A-1", "summary": "x\nz"}}, LT)
assert ok is False
@pytest.mark.parametrize("ctype, name, value, msg", [
("ensure_fields", "duedate", True, "duedate — строка"),
("ensure_fields", "labels_add", "AWPCA", "labels_add — список строк"),
("ensure_fields", "labels_add", ["x", 1], "labels_add — список строк"),
("ensure_link", "replace", "да", "replace — true или false"),
("ensure_status", "target", 5, "target — строка или список строк"),
("ensure_status", "target", ["Разработка", "Готово"], None),
("ensure_status", "target", "Готово", None),
("ensure_fields", "labels_add", [], None),
("ensure_fields", "assignee", None, None),
("ensure_link", "replace", False, None),
])
def test_type_error_messages(ctype, name, value, msg):
"""T3.1 #16, решение 15: _type_error по веткам — текст ошибки для каждого вида параметра (до и после правки один)."""
assert commands._type_error(ctype, name, value) == msg
@pytest.mark.parametrize("cmd, name", [
({"type": "ensure_fields", "params": {"key": "A-1", "duedate": True}}, "duedate"),
({"type": "ensure_comment", "params": {"key": "A-1", "marker": "m", "body": False}}, "body"),
({"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": True}}, "summary"),
])
def test_validate_rejects_bool_in_string_param(cmd, name):
"""T3.2 #16: true/false в строковом параметре — 422 с именем параметра, а не запись «True» в Jira."""
with pytest.raises(CommandError, match=f"{name} — строка"):
commands.validate([cmd])
def test_validate_empty_list_params():
"""T3.2 #16: [] в необязательном списке допустим; [] в обязательном или единственном — как отсутствие."""
out = commands.validate([{"type": "ensure_fields", "params": {"key": "A-1", "labels_add": [], "labels_remove": []}}])
assert out[0]["params"] == {"key": "A-1", "labels_add": [], "labels_remove": []}
with pytest.raises(CommandError, match="нет target"):
commands.validate([{"type": "ensure_status", "params": {"key": "A-1", "target": []}}])
with pytest.raises(CommandError, match="refresh без keys и jql"):
commands.validate([{"type": "refresh", "params": {"keys": []}}])
def test_fetch_not_confirmed_by_state(conn):
ok, result = commands.check_confirmed(conn, {"type": "fetch_attachment", "params": {"key": "A-1", "attachment_id": "5"}}, LT)
assert (ok, result) == (False, None)
assert "fetch_attachment" not in commands.WRITE_TYPES
assert commands.SPEC["fetch_attachment"]["required"] == ["key", "attachment_id"]
META = {"type": "ensure_sprint_meta", "params": {"sprint": "AWPCA_Sprint_4", "start_date": "2026-10-28", "end_date": "2026-11-11", "goal": "свод ВГО"}}
def state(**p): return {"type": "ensure_sprint_state", "params": {"sprint": "AWPCA_Sprint_3_Corrections", **p}}
def rank(**p): return {"type": "ensure_rank", "params": {"key": "AURORA-1612", **p}}
def test_new_types_validate_ok():
out = commands.validate([META, state(state="active"), state(state="closed", move_open_to="backlog"), rank(before="AURORA-1601"),
{"type": "ensure_fields", "params": {"key": "A-1", "story_points": 2.5}},
{"type": "ensure_fields", "params": {"key": "A-1", "story_points": None}}])
assert [c["type"] for c in out][:4] == ["ensure_sprint_meta", "ensure_sprint_state", "ensure_sprint_state", "ensure_rank"]
@pytest.mark.parametrize("cmd, msg", [
({**META, "params": {**META["params"], "start_date": "28.10.2026"}}, "start_date — дата ГГГГ-ММ-ДД"),
({**META, "params": {**META["params"], "end_date": "2026-02-30"}}, "end_date — дата ГГГГ-ММ-ДД"),
({**META, "params": {**META["params"], "start_date": "2026-11-12"}}, "start_date позже end_date"),
({"type": "ensure_sprint_meta", "params": {"sprint": " "}}, "нет sprint; ожидаются sprint"),
({"type": "ensure_sprint_meta", "params": {"sprint": "S", "rename_to": " "}}, "rename_to — непустое имя"),
({"type": "ensure_sprint_meta", "params": {"sprint": "S", "board_id": 4216}}, "лишние параметры board_id"),
(state(state="open"), "state — active или closed"),
(state(state="closed"), "move_open_to обязателен при state = closed"),
(state(state="active", move_open_to="backlog"), "move_open_to — только при state = closed"),
(state(state="closed", move_open_to="AWPCA_Sprint_3_Corrections"), "move_open_to — другой спринт"),
(rank(), "ровно одно из before, after"), (rank(before="A-2", after="A-3"), "ровно одно из before, after"),
(rank(before="AURORA-1612"), "before — другая задача"), (rank(after="AURORA-1612"), "after — другая задача"),
({"type": "ensure_fields", "params": {"key": "A-1", "story_points": -1}}, "story_points — число ≥ 0 или null"),
({"type": "ensure_fields", "params": {"key": "A-1", "story_points": "3"}}, "story_points — число ≥ 0 или null"),
({"type": "ensure_fields", "params": {"key": "A-1", "story_points": True}}, "story_points — число ≥ 0 или null"),
({"type": "ensure_fields", "params": {"key": "A-1", "story_points": float("nan")}}, "story_points — число ≥ 0 или null"), # Review Focus 5
({"type": "ensure_fields", "params": {"key": "A-1", "story_points": float("inf")}}, "story_points — число ≥ 0 или null"),
({"type": "ensure_fields", "params": {"key": "A-1", "story_points": 10**400}}, "story_points — число ≥ 0 или null"), # OverflowError
({"type": "ensure_sprint_state", "params": {"sprint": " ", "state": "active"}}, "нет sprint; ожидаются sprint, state"),
(state(state="closed", move_open_to=" "), "move_open_to обязателен при state = closed"),
(state(state="closed", move_open_to=" AWPCA_Sprint_3_Corrections "), "move_open_to — другой спринт"),
(state(state="active", move_open_to="x"), "move_open_to — только при state = closed"),
(rank(before=" ", after=" "), "ровно одно из before, after"),
(rank(before=" AURORA-1612 "), "before — другая задача"),
])
def test_new_types_errors(cmd, msg):
with pytest.raises(CommandError, match=re.escape(msg)):
commands.validate([cmd])
def test_blank_state_move_open_to_is_unset_when_active(): # R-31: пробельная строка — «не задано»
assert commands.validate([state(state="active", move_open_to=" ")])
assert commands.validate([rank(before="A-2", after=" ")])
def test_rank_temp_keys():
ok = [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Н"}}, rank(after="new:1")]
assert commands.temp_keys_used(commands.validate(ok)[1]) == ["new:1"]
with pytest.raises(CommandError, match="временный ключ new:2 не объявлен"):
commands.validate([rank(before="new:2")])
def test_new_types_not_confirmed_by_state(conn): # design #18
for cmd in (META, state(state="active"), rank(before="A-2")):
assert commands.check_confirmed(conn, cmd, {}) == (False, None)
def test_story_points_confirmation(conn): # R-16
mirror.apply_state(conn, [{"key": "A-1", "labels": [], "story_points": 3}, {"key": "A-2", "labels": []}], [])
sp = lambda key, v: {"type": "ensure_fields", "params": {"key": key, "story_points": v}}
assert commands.check_confirmed(conn, sp("A-1", 3), {})[0] is True
assert commands.check_confirmed(conn, sp("A-2", 0), {})[0] is False
assert commands.check_confirmed(conn, sp("A-2", None), {})[0] is True
def test_normalize_trims_identifiers_keeps_text():
out = commands.validate([{"type": "ensure_comment", "params": {"key": " A-1", "marker": "Итог ", "body": " отступ\n"}}])
assert out[0]["params"] == {"key": "A-1", "marker": "Итог", "body": " отступ\n"}
def test_normalize_rank_before():
out = commands.validate([{"type": "ensure_rank", "params": {"key": "A-1", "before": " A-2 "}}])
assert out[0]["params"] == {"key": "A-1", "before": "A-2"}
def test_normalize_temp_key_with_spaces():
out = commands.validate([
{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "Т"}},
{"type": "ensure_fields", "params": {"key": "A-5", "epic_key": " new:1 "}},
{"type": "refresh", "params": {"keys": [" new:1"]}}])
assert out[1]["params"]["epic_key"] == "new:1" and out[2]["params"]["keys"] == ["new:1"]
def test_normalize_blank_required_is_missing():
with pytest.raises(CommandError, match=r"^команда 1 \(ensure_status\): нет key; ожидаются key, target"):
commands.validate([{"type": "ensure_status", "params": {"key": " ", "target": "Готово"}}])
def test_normalize_blank_list_item():
with pytest.raises(CommandError, match="labels_add — без пустых элементов"):
commands.validate([{"type": "ensure_fields", "params": {"key": "A-1", "labels_add": ["x", " "]}}])
def test_normalize_filename_nfc():
nfd = unicodedata.normalize("NFD", "отчёт.pdf")
assert nfd != unicodedata.normalize("NFC", "отчёт.pdf")
out = commands.validate([{"type": "ensure_attachment", "params": {"key": "A-1", "file": "a" * 64, "filename": f" {nfd} "}}])
assert out[0]["params"]["filename"] == unicodedata.normalize("NFC", "отчёт.pdf")
def test_normalize_does_not_mutate_input():
params = {"key": " A-1 ", "target": [" В работе ", "Готово "]}
out = commands.validate([{"type": "ensure_status", "params": params}])
assert params["key"] == " A-1 " and out[0]["params"]["target"] == ["В работе", "Готово"]
def test_normalize_summary_description_goal_untouched():
out = commands.validate([{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task",
"summary": " Т ", "description": " d\n"}}])
assert out[0]["params"]["summary"] == " Т " and out[0]["params"]["description"] == " d\n"
def test_is_digits_ascii_only():
from sdlc_hub.core.http import is_digits
assert is_digits("123") is True
for bad in ("", "²", "١٢", "12a", 7, None):
assert is_digits(bad) is False
@pytest.mark.parametrize("field", ["assignee", "epic_key", "priority", "duedate", "planned_start"])
def test_ensure_fields_whitespace_only_rejected(field):
"""I-1 (R-8): « » после обрезки стало бы «» («очистить поле») — отвергается."""
with pytest.raises(CommandError) as e:
commands.validate([{"type": "ensure_fields", "params": {"key": "A-1", field: " "}}])
assert f'{field} — из одних пробелов; для очистки — "" или null' in str(e.value)
assert "команда 1 (ensure_fields)" in str(e.value)
@pytest.mark.parametrize("value", ["", None])
def test_ensure_fields_explicit_clear_accepted(value):
cmds = commands.validate([{"type": "ensure_fields", "params": {"key": "A-1", "assignee": value}}])
assert cmds[0]["params"]["assignee"] == value
def test_rank_before_after_whitespace_still_means_not_given():
cmds = commands.validate([{"type": "ensure_rank", "params": {"key": "A-1", "before": "A-2", "after": " "}}])
assert cmds[0]["params"]["after"] == ""