test: fix cli mocks, add pytest config and coverage gaps
- tests/test_cli.py: fix 26 fetch_issues_with_spent_time mocks to return 3-element tuples (issue, hours, activities) matching the real signature - pyproject.toml: add [tool.pytest.ini_options] with testpaths and pythonpath so bare pytest works - tests/test_client.py: add pagination test (>100 time entries arriving in pages, hours aggregated across all pages) - tests/test_formatters.py: add structural tests — XLSX full header row and grand total row via openpyxl, HTML thead with all columns and tbody row count - add strict xfail trap-tests for known bugs: #58 (naive created_on vs aware dedup cutoff TypeError) and #59 (parse_date_range rejects datetime range from dynamic+datetime period) Closes #67
This commit is contained in:
@@ -45,6 +45,10 @@ include = ["redmine_reporter*"]
|
|||||||
[tool.setuptools.package-data]
|
[tool.setuptools.package-data]
|
||||||
"redmine_reporter" = ["templates/template.odt"]
|
"redmine_reporter" = ["templates/template.odt"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
pythonpath = ["."]
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
warn_unused_configs = true
|
warn_unused_configs = true
|
||||||
|
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ class _MockIssue:
|
|||||||
def test_cli_unknown_output_extension(mock_fetch, tmp_path):
|
def test_cli_unknown_output_extension(mock_fetch, tmp_path):
|
||||||
"""Неизвестное расширение файла -- выход 1."""
|
"""Неизвестное расширение файла -- выход 1."""
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
output = str(tmp_path / "report.xyz")
|
output = str(tmp_path / "report.xyz")
|
||||||
code = main(["--date", "2026-01-01--2026-01-31", "--output", output])
|
code = main(["--date", "2026-01-01--2026-01-31", "--output", output])
|
||||||
assert code == 1
|
assert code == 1
|
||||||
@@ -103,7 +103,7 @@ def test_cli_unknown_output_extension(mock_fetch, tmp_path):
|
|||||||
def test_cli_output_without_extension(mock_get, mock_fetch, tmp_path):
|
def test_cli_output_without_extension(mock_get, mock_fetch, tmp_path):
|
||||||
"""Файл без расширения — приложение расширяет default_format (#43)."""
|
"""Файл без расширения — приложение расширяет default_format (#43)."""
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
mock_formatter = mock.MagicMock()
|
mock_formatter = mock.MagicMock()
|
||||||
mock_get.return_value = mock_formatter
|
mock_get.return_value = mock_formatter
|
||||||
output = str(tmp_path / "report")
|
output = str(tmp_path / "report")
|
||||||
@@ -128,7 +128,7 @@ def test_cli_output_without_extension(mock_get, mock_fetch, tmp_path):
|
|||||||
def test_cli_odt_missing_odfpy_message(mock_gf, mock_fetch, tmp_path, capsys):
|
def test_cli_odt_missing_odfpy_message(mock_gf, mock_fetch, tmp_path, capsys):
|
||||||
"""При запросе .odt без odfpy — выход 1, понятное сообщение про odfpy."""
|
"""При запросе .odt без odfpy — выход 1, понятное сообщение про odfpy."""
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
mock_gf.return_value = None
|
mock_gf.return_value = None
|
||||||
output = str(tmp_path / "report.odt")
|
output = str(tmp_path / "report.odt")
|
||||||
code = main(["--date", "2026-01-01--2026-01-31", "--output", output])
|
code = main(["--date", "2026-01-01--2026-01-31", "--output", output])
|
||||||
@@ -198,7 +198,7 @@ def test_cli_config_file_loading(mock_fetch, tmp_path):
|
|||||||
def test_cli_summary_flag_prints_totals(mock_fetch, capsys):
|
def test_cli_summary_flag_prints_totals(mock_fetch, capsys):
|
||||||
"""--summary выводит общее время и разбивку по проектам в stderr."""
|
"""--summary выводит общее время и разбивку по проектам в stderr."""
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
main(["--date", "2026-01-01--2026-01-31", "--summary"])
|
main(["--date", "2026-01-01--2026-01-31", "--summary"])
|
||||||
captured = capsys.readouterr()
|
captured = capsys.readouterr()
|
||||||
assert "Total time" in captured.err
|
assert "Total time" in captured.err
|
||||||
@@ -210,7 +210,7 @@ def test_cli_summary_flag_prints_totals(mock_fetch, capsys):
|
|||||||
def test_total_issues_message_goes_to_stderr(mock_fetch, capsys):
|
def test_total_issues_message_goes_to_stderr(mock_fetch, capsys):
|
||||||
"""«Total issues» пишется в stderr, не загрязняя stdout при pipe (#28)."""
|
"""«Total issues» пишется в stderr, не загрязняя stdout при pipe (#28)."""
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
main(["--date", "2026-01-01--2026-01-31"])
|
main(["--date", "2026-01-01--2026-01-31"])
|
||||||
captured = capsys.readouterr()
|
captured = capsys.readouterr()
|
||||||
assert "Total issues" not in captured.out
|
assert "Total issues" not in captured.out
|
||||||
@@ -264,7 +264,7 @@ def test_cli_prints_readable_timeout_error(mock_fetch, capsys):
|
|||||||
def test_cli_no_time_passed_to_formatter(mock_fetch, tmp_path):
|
def test_cli_no_time_passed_to_formatter(mock_fetch, tmp_path):
|
||||||
"""CLI --no-time передаётся в файловый форматтер."""
|
"""CLI --no-time передаётся в файловый форматтер."""
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
|
|
||||||
output = str(tmp_path / "report.xlsx")
|
output = str(tmp_path / "report.xlsx")
|
||||||
with mock.patch(
|
with mock.patch(
|
||||||
@@ -495,7 +495,7 @@ class TestOutputPathResolution:
|
|||||||
):
|
):
|
||||||
"""--output report без расширения → добавляет .xlsx."""
|
"""--output report без расширения → добавляет .xlsx."""
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
|
|
||||||
output = str(tmp_path / "report")
|
output = str(tmp_path / "report")
|
||||||
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
||||||
@@ -521,7 +521,7 @@ class TestOutputPathResolution:
|
|||||||
def test_output_with_known_extension_unchanged(self, mock_fetch, tmp_path):
|
def test_output_with_known_extension_unchanged(self, mock_fetch, tmp_path):
|
||||||
"""--output report.csv с явным расширением передаётся как есть."""
|
"""--output report.csv с явным расширением передаётся как есть."""
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
|
|
||||||
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
||||||
mock_formatter = mock.MagicMock()
|
mock_formatter = mock.MagicMock()
|
||||||
@@ -546,7 +546,7 @@ class TestOutputPathResolution:
|
|||||||
def test_output_bare_format_invalid_is_treated_as_path(self, mock_fetch, tmp_path):
|
def test_output_bare_format_invalid_is_treated_as_path(self, mock_fetch, tmp_path):
|
||||||
"""--output notanxlsx (no path, not known format) → treated as path + .xlsx appended."""
|
"""--output notanxlsx (no path, not known format) → treated as path + .xlsx appended."""
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
|
|
||||||
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
|
||||||
mock_formatter = mock.MagicMock()
|
mock_formatter = mock.MagicMock()
|
||||||
@@ -583,7 +583,7 @@ class TestCommitFlag:
|
|||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
|
|
||||||
config_path = tmp_path / "config.yml"
|
config_path = tmp_path / "config.yml"
|
||||||
config_path.write_text(
|
config_path.write_text(
|
||||||
@@ -620,7 +620,7 @@ class TestCommitFlag:
|
|||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
|
|
||||||
config_path = tmp_path / "config.yml"
|
config_path = tmp_path / "config.yml"
|
||||||
config_path.write_text(
|
config_path.write_text(
|
||||||
@@ -679,7 +679,7 @@ class TestCommitFlag:
|
|||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
|
|
||||||
config_path = tmp_path / "config.yml"
|
config_path = tmp_path / "config.yml"
|
||||||
config_path.write_text(
|
config_path.write_text(
|
||||||
@@ -716,7 +716,7 @@ class TestCommitFlag:
|
|||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
|
|
||||||
config_path = tmp_path / "config.yml"
|
config_path = tmp_path / "config.yml"
|
||||||
config_path.write_text(
|
config_path.write_text(
|
||||||
@@ -757,7 +757,7 @@ class TestCommitFlag:
|
|||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
|
|
||||||
config_path = tmp_path / "config.yml"
|
config_path = tmp_path / "config.yml"
|
||||||
config_path.write_text(
|
config_path.write_text(
|
||||||
@@ -825,7 +825,7 @@ class TestSendFlag:
|
|||||||
def test_send_triggers_mailer(self, mock_get, mock_send, mock_fetch, tmp_path):
|
def test_send_triggers_mailer(self, mock_get, mock_send, mock_fetch, tmp_path):
|
||||||
"""--send с --output вызывает send_report после сохранения."""
|
"""--send с --output вызывает send_report после сохранения."""
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
mock_formatter = mock.MagicMock()
|
mock_formatter = mock.MagicMock()
|
||||||
mock_get.return_value = mock_formatter
|
mock_get.return_value = mock_formatter
|
||||||
|
|
||||||
@@ -866,7 +866,7 @@ class TestSendFlag:
|
|||||||
):
|
):
|
||||||
"""--send без --output сохраняет файл по шаблону, затем отправляет."""
|
"""--send без --output сохраняет файл по шаблону, затем отправляет."""
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
mock_formatter = mock.MagicMock()
|
mock_formatter = mock.MagicMock()
|
||||||
mock_get.return_value = mock_formatter
|
mock_get.return_value = mock_formatter
|
||||||
|
|
||||||
@@ -905,7 +905,7 @@ class TestSendFlag:
|
|||||||
def test_send_without_email_config_is_error(self, mock_fetch, tmp_path, capsys):
|
def test_send_without_email_config_is_error(self, mock_fetch, tmp_path, capsys):
|
||||||
"""--send без email-конфига — ошибка и выход 1."""
|
"""--send без email-конфига — ошибка и выход 1."""
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
|
|
||||||
config_path = tmp_path / "config.yml"
|
config_path = tmp_path / "config.yml"
|
||||||
config_path.write_text("redmine:\n url: https://x.com\n api_key: token\n")
|
config_path.write_text("redmine:\n url: https://x.com\n api_key: token\n")
|
||||||
@@ -936,7 +936,7 @@ class TestSendFlag:
|
|||||||
from redmine_reporter.client import RedmineAPIError
|
from redmine_reporter.client import RedmineAPIError
|
||||||
|
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
mock_formatter = mock.MagicMock()
|
mock_formatter = mock.MagicMock()
|
||||||
mock_get.return_value = mock_formatter
|
mock_get.return_value = mock_formatter
|
||||||
mock_send.side_effect = RedmineAPIError(
|
mock_send.side_effect = RedmineAPIError(
|
||||||
@@ -981,7 +981,7 @@ class TestSendFlag:
|
|||||||
):
|
):
|
||||||
"""--send и --commit работают вместе без конфликтов."""
|
"""--send и --commit работают вместе без конфликтов."""
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
mock_formatter = mock.MagicMock()
|
mock_formatter = mock.MagicMock()
|
||||||
mock_get.return_value = mock_formatter
|
mock_get.return_value = mock_formatter
|
||||||
|
|
||||||
@@ -1038,7 +1038,7 @@ class TestSendHtmlBody:
|
|||||||
):
|
):
|
||||||
"""--send передаёт rows в send_report для генерации HTML."""
|
"""--send передаёт rows в send_report для генерации HTML."""
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
mock_formatter = mock.MagicMock()
|
mock_formatter = mock.MagicMock()
|
||||||
mock_get.return_value = mock_formatter
|
mock_get.return_value = mock_formatter
|
||||||
|
|
||||||
@@ -1083,7 +1083,7 @@ class TestSendHtmlBody:
|
|||||||
):
|
):
|
||||||
"""При email.html: false письмо отправляется без HTML-части."""
|
"""При email.html: false письмо отправляется без HTML-части."""
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
mock_formatter = mock.MagicMock()
|
mock_formatter = mock.MagicMock()
|
||||||
mock_get.return_value = mock_formatter
|
mock_get.return_value = mock_formatter
|
||||||
|
|
||||||
@@ -1167,7 +1167,7 @@ class TestReportNoTimeIntegration:
|
|||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
|
|
||||||
config_path = tmp_path / "config.yml"
|
config_path = tmp_path / "config.yml"
|
||||||
config_path.write_text(
|
config_path.write_text(
|
||||||
@@ -1207,7 +1207,7 @@ class TestReportNoTimeIntegration:
|
|||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
|
|
||||||
config_path = tmp_path / "config.yml"
|
config_path = tmp_path / "config.yml"
|
||||||
config_path.write_text(
|
config_path.write_text(
|
||||||
@@ -1247,7 +1247,7 @@ class TestReportNoTimeIntegration:
|
|||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
|
|
||||||
config_path = tmp_path / "config.yml"
|
config_path = tmp_path / "config.yml"
|
||||||
config_path.write_text(
|
config_path.write_text(
|
||||||
@@ -1287,7 +1287,7 @@ class TestReportNoTimeIntegration:
|
|||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
|
|
||||||
config_path = tmp_path / "config.yml"
|
config_path = tmp_path / "config.yml"
|
||||||
config_path.write_text(
|
config_path.write_text(
|
||||||
@@ -1325,7 +1325,7 @@ class TestReportNoTimeIntegration:
|
|||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
issue = _MockIssue()
|
issue = _MockIssue()
|
||||||
mock_fetch.return_value = [(issue, 1.0)]
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
|
|
||||||
config_path = tmp_path / "config.yml"
|
config_path = tmp_path / "config.yml"
|
||||||
config_path.write_text(
|
config_path.write_text(
|
||||||
@@ -1365,3 +1365,45 @@ class TestReportNoTimeIntegration:
|
|||||||
|
|
||||||
_, kwargs = mock_get.call_args
|
_, kwargs = mock_get.call_args
|
||||||
assert kwargs.get("no_time") is True
|
assert kwargs.get("no_time") is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# #59: dynamic + precision=datetime без --date
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.xfail(
|
||||||
|
strict=True,
|
||||||
|
reason="bug #59: parse_date_range rejects datetime range produced by "
|
||||||
|
"dynamic period with precision=datetime when --date is omitted",
|
||||||
|
)
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
def test_cli_dynamic_datetime_period_without_date(mock_fetch, tmp_path):
|
||||||
|
"""dynamic+datetime без --date: datetime-диапазон из last_used принимается.
|
||||||
|
|
||||||
|
Ожидаемое поведение после фикса #59: parse_date_range принимает диапазон
|
||||||
|
с временем (YYYY-MM-DDTHH:MM:SS), следующий период вычисляется от last_used,
|
||||||
|
отчёт строится без ошибки.
|
||||||
|
"""
|
||||||
|
issue = _MockIssue()
|
||||||
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
|
|
||||||
|
config_path = tmp_path / "config.yml"
|
||||||
|
config_path.write_text(
|
||||||
|
"period:\n"
|
||||||
|
" precision: datetime\n"
|
||||||
|
" dynamic: true\n"
|
||||||
|
" last_used:\n"
|
||||||
|
" from: '2026-06-01T00:00:00'\n"
|
||||||
|
" to: '2026-06-30T23:59:59'\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
code = main(["--config-path", str(config_path)])
|
||||||
|
|
||||||
|
assert code == 0
|
||||||
|
args, _ = mock_fetch.call_args
|
||||||
|
from_date, to_date = args[0], args[1]
|
||||||
|
# Следующий период после 2026-06-01T00:00:00--2026-06-30T23:59:59
|
||||||
|
assert from_date == "2026-07-01T00:00:00"
|
||||||
|
assert to_date == "2026-07-30T23:59:59"
|
||||||
|
|||||||
@@ -691,3 +691,104 @@ def test_dedup_mixed_entries_correct_filtering(mock_redmine_class):
|
|||||||
assert len(result) == 2
|
assert len(result) == 2
|
||||||
ids = {r[0].id for r in result}
|
ids = {r[0].id for r in result}
|
||||||
assert ids == {20, 30}
|
assert ids == {20, 30}
|
||||||
|
|
||||||
|
|
||||||
|
# -- Пагинация time entries (>100 записей) --
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_fetch_aggregates_hours_across_all_pages(mock_redmine_class):
|
||||||
|
"""Time entries приходят страницами по 100 — часы агрегируются по всем страницам."""
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
_configure_current_user(mock_redmine, user_id=123)
|
||||||
|
|
||||||
|
def make_entry(issue_id, hours):
|
||||||
|
e = mock.MagicMock()
|
||||||
|
e.issue.id = issue_id
|
||||||
|
e.hours = hours
|
||||||
|
return e
|
||||||
|
|
||||||
|
# Эмулируем ленивую пагинацию redminelib ResourceSet: записи отдаются
|
||||||
|
# генератором порциями ("страницами" по 100), как при догрузке с сервера.
|
||||||
|
# Если клиент прочитает только первую страницу, агрегация будет неверной.
|
||||||
|
pages = [
|
||||||
|
[make_entry(1, 0.5) for _ in range(100)],
|
||||||
|
[make_entry(2, 1.0) for _ in range(100)],
|
||||||
|
[make_entry(1, 0.5) for _ in range(50)],
|
||||||
|
]
|
||||||
|
|
||||||
|
def paged_entries():
|
||||||
|
for page in pages:
|
||||||
|
yield from page
|
||||||
|
|
||||||
|
mock_redmine.time_entry.filter.return_value = paged_entries()
|
||||||
|
|
||||||
|
mock_issue1 = mock.MagicMock()
|
||||||
|
mock_issue1.id = 1
|
||||||
|
mock_issue1.project = "P"
|
||||||
|
mock_issue2 = mock.MagicMock()
|
||||||
|
mock_issue2.id = 2
|
||||||
|
mock_issue2.project = "P"
|
||||||
|
mock_redmine.issue.filter.return_value = [mock_issue1, mock_issue2]
|
||||||
|
|
||||||
|
result = fetch_issues_with_spent_time("2026-01-01", "2026-03-31")
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
hours_by_id = {issue.id: hours for issue, hours, _ in result}
|
||||||
|
# issue 1: 100×0.5 (стр. 1) + 50×0.5 (стр. 3) = 75.0
|
||||||
|
assert hours_by_id[1] == 75.0
|
||||||
|
# issue 2: 100×1.0 (стр. 2) = 100.0
|
||||||
|
assert hours_by_id[2] == 100.0
|
||||||
|
|
||||||
|
|
||||||
|
# -- #58: дедупликация при precision=datetime с naive created_on --
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.xfail(
|
||||||
|
strict=True,
|
||||||
|
reason="bug #58: naive created_on vs aware dedup cutoff raises TypeError; "
|
||||||
|
"fix must normalize entry datetimes to aware (UTC)",
|
||||||
|
)
|
||||||
|
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.client.Redmine")
|
||||||
|
def test_dedup_normalizes_naive_created_on_to_utc(mock_redmine_class):
|
||||||
|
"""python-redmine отдаёт naive created_on; cutoff aware (UTC) — фильтрация не падает.
|
||||||
|
|
||||||
|
Ожидаемое поведение после фикса #58: naive datetime трактуется как UTC,
|
||||||
|
старые записи отсекаются, новые сохраняются.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
mock_redmine = mock_redmine_class.return_value
|
||||||
|
_configure_current_user(mock_redmine, user_id=123)
|
||||||
|
|
||||||
|
dedup_cutoff = datetime(2026, 7, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
e_old = mock.MagicMock()
|
||||||
|
e_old.issue.id = 1
|
||||||
|
e_old.hours = 2.0
|
||||||
|
e_old.created_on = datetime(2026, 7, 1, 10, 0, 0) # naive, как из python-redmine
|
||||||
|
e_old.updated_on = datetime(2026, 7, 1, 10, 0, 0)
|
||||||
|
|
||||||
|
e_new = mock.MagicMock()
|
||||||
|
e_new.issue.id = 2
|
||||||
|
e_new.hours = 1.0
|
||||||
|
e_new.created_on = datetime(2026, 7, 1, 14, 0, 0) # naive
|
||||||
|
e_new.updated_on = datetime(2026, 7, 1, 14, 0, 0)
|
||||||
|
|
||||||
|
mock_redmine.time_entry.filter.return_value = [e_old, e_new]
|
||||||
|
|
||||||
|
mock_issue2 = mock.MagicMock()
|
||||||
|
mock_issue2.id = 2
|
||||||
|
mock_issue2.project = "P"
|
||||||
|
mock_issue2.subject = "T"
|
||||||
|
mock_issue2.status = "New"
|
||||||
|
mock_redmine.issue.filter.return_value = [mock_issue2]
|
||||||
|
|
||||||
|
result = fetch_issues_with_spent_time(
|
||||||
|
"2026-07-01", "2026-07-01", dedup_before=dedup_cutoff
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert [r[0].id for r in result] == [2]
|
||||||
|
|||||||
@@ -396,6 +396,32 @@ def test_xlsx_has_totals(fake_rows, tmp_path):
|
|||||||
assert ws["F2"].number_format == "0.00"
|
assert ws["F2"].number_format == "0.00"
|
||||||
|
|
||||||
|
|
||||||
|
def test_xlsx_has_full_header_row_and_grand_total_row(fake_rows, tmp_path):
|
||||||
|
"""XLSX содержит полный ряд заголовков колонок и финальную строку общего итога."""
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
|
||||||
|
output = tmp_path / "report.xlsx"
|
||||||
|
XLSXFormatter().save(fake_rows, str(output))
|
||||||
|
|
||||||
|
wb = load_workbook(str(output))
|
||||||
|
ws = wb.active
|
||||||
|
|
||||||
|
headers = [ws.cell(row=1, column=c).value for c in range(1, 8)]
|
||||||
|
assert headers == [
|
||||||
|
"Project",
|
||||||
|
"Version",
|
||||||
|
"Issue ID",
|
||||||
|
"Subject",
|
||||||
|
"Status",
|
||||||
|
"Hours",
|
||||||
|
"Spent Time",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Последняя строка — общий итог по всем проектам
|
||||||
|
assert ws.cell(row=ws.max_row, column=1).value == "Total"
|
||||||
|
assert ws.cell(row=ws.max_row, column=6).value == 22.5
|
||||||
|
|
||||||
|
|
||||||
def test_xlsx_no_time_keeps_columns_empty_and_skips_totals(fake_rows, tmp_path):
|
def test_xlsx_no_time_keeps_columns_empty_and_skips_totals(fake_rows, tmp_path):
|
||||||
"""XLSX с no_time: колонки времени пустые, итогов нет."""
|
"""XLSX с no_time: колонки времени пустые, итогов нет."""
|
||||||
from openpyxl import load_workbook
|
from openpyxl import load_workbook
|
||||||
@@ -456,6 +482,25 @@ def test_html_output_has_doctype_and_charset(fake_rows):
|
|||||||
assert '<meta charset="utf-8">' in output
|
assert '<meta charset="utf-8">' in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_html_has_table_structure_with_all_columns(fake_rows):
|
||||||
|
"""HTML-отчёт содержит thead со всеми колонками и по строке на каждую задачу."""
|
||||||
|
output = HTMLFormatter().format(fake_rows)
|
||||||
|
|
||||||
|
assert "<thead>" in output
|
||||||
|
assert "<tbody>" in output
|
||||||
|
for header in (
|
||||||
|
"Наименование Проекта",
|
||||||
|
"Номер версии*",
|
||||||
|
"Задача",
|
||||||
|
"Статус Готовность*",
|
||||||
|
"Затрачено за отчетный период",
|
||||||
|
):
|
||||||
|
assert f"<th>{header}</th>" in output
|
||||||
|
|
||||||
|
tbody = output.split("<tbody>", 1)[1]
|
||||||
|
assert tbody.count("<tr>") == len(fake_rows)
|
||||||
|
|
||||||
|
|
||||||
# -- Тесты ODT форматтера --
|
# -- Тесты ODT форматтера --
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user