148 lines
5.4 KiB
Python
148 lines
5.4 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import subprocess
|
|
import xml.etree.ElementTree as ET
|
|
from datetime import date
|
|
from pathlib import Path
|
|
|
|
from gyxx_flow.catalog import WorkflowCatalog
|
|
from gyxx_flow.scheduler import (
|
|
CurrentScheduledTask,
|
|
PowerShellCurrentTaskProvider,
|
|
SchedulerConfig,
|
|
build_schedule_plan,
|
|
detect_schedule_drift,
|
|
write_schedule_plan_bundle,
|
|
)
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
NS = {"t": "http://schemas.microsoft.com/windows/2004/02/mit/task"}
|
|
|
|
|
|
def test_windows_provider_reads_managed_tasks_without_mutation(monkeypatch) -> None:
|
|
captured: dict[str, object] = {}
|
|
|
|
def fake_run(command, **kwargs):
|
|
captured["command"] = command
|
|
captured["kwargs"] = kwargs
|
|
return subprocess.CompletedProcess(
|
|
command,
|
|
0,
|
|
stdout=json.dumps(
|
|
[{"full_name": r"\GYXX\product.daily", "xml": "<Task />"}]
|
|
),
|
|
stderr="",
|
|
)
|
|
|
|
monkeypatch.setattr(subprocess, "run", fake_run)
|
|
tasks = PowerShellCurrentTaskProvider().current_tasks("\\GYXX\\")
|
|
|
|
assert tasks == [CurrentScheduledTask(r"\GYXX\product.daily", "<Task />")]
|
|
assert captured["command"][0] == "powershell.exe"
|
|
assert "Register-ScheduledTask" not in captured["command"][-1]
|
|
assert captured["kwargs"]["shell"] is False
|
|
|
|
|
|
def _plan(tmp_path: Path):
|
|
catalog = WorkflowCatalog.load(PROJECT_ROOT / "config")
|
|
return build_schedule_plan(
|
|
catalog,
|
|
SchedulerConfig(
|
|
python_executable=Path(r"C:\Portable\Python312\python.exe"),
|
|
project_root=tmp_path / "portable-project",
|
|
),
|
|
start_date=date(2026, 7, 27),
|
|
)
|
|
|
|
|
|
def test_plan_generates_21_inert_portable_windows_task_definitions(tmp_path: Path) -> None:
|
|
plan = _plan(tmp_path)
|
|
|
|
assert plan.timezone == "Asia/Shanghai"
|
|
assert len(plan.tasks) == 21
|
|
assert len({task.full_name for task in plan.tasks}) == 21
|
|
assert all(task.full_name.startswith("\\GYXX\\") for task in plan.tasks)
|
|
assert all("Register-ScheduledTask" not in task.xml for task in plan.tasks)
|
|
|
|
sample = next(task for task in plan.tasks if task.workflow_id == "product.daily")
|
|
root = ET.fromstring(sample.xml)
|
|
assert root.findtext(".//t:Exec/t:Command", namespaces=NS) == str(
|
|
plan.config.python_executable
|
|
)
|
|
assert root.findtext(".//t:Exec/t:Arguments", namespaces=NS) == (
|
|
"-m gyxx_flow run product.daily --scheduled"
|
|
)
|
|
assert root.findtext(".//t:Exec/t:WorkingDirectory", namespaces=NS) == str(
|
|
plan.config.project_root
|
|
)
|
|
assert root.findtext(".//t:MultipleInstancesPolicy", namespaces=NS) == "IgnoreNew"
|
|
|
|
|
|
def test_plan_preserves_daily_weekly_monthly_and_interval_semantics(tmp_path: Path) -> None:
|
|
plan = _plan(tmp_path)
|
|
by_id = {task.workflow_id: ET.fromstring(task.xml) for task in plan.tasks}
|
|
|
|
assert by_id["product.daily"].findtext(
|
|
".//t:ScheduleByDay/t:DaysInterval", namespaces=NS
|
|
) == "1"
|
|
weekly = by_id["content.comments.weekly"]
|
|
assert weekly.find(".//t:ScheduleByWeek/t:DaysOfWeek/t:Sunday", NS) is not None
|
|
monthly = by_id["content.summary.monthly"]
|
|
assert monthly.findtext(
|
|
".//t:ScheduleByMonth/t:DaysOfMonth/t:Day", namespaces=NS
|
|
) == "1"
|
|
interval = by_id["product.style_analysis.interval"]
|
|
assert interval.findtext(
|
|
".//t:ScheduleByDay/t:DaysInterval", namespaces=NS
|
|
) == "3"
|
|
assert interval.findtext(".//t:StartBoundary", namespaces=NS).startswith(
|
|
"2026-07-25T11:00:00"
|
|
)
|
|
|
|
|
|
def test_bundle_write_is_a_plan_only_and_never_applies_tasks(tmp_path: Path) -> None:
|
|
plan = _plan(tmp_path)
|
|
destination = tmp_path / "schedule-plan"
|
|
|
|
written = write_schedule_plan_bundle(destination, plan)
|
|
|
|
assert len(list((destination / "tasks").glob("*.xml"))) == 21
|
|
assert written == destination.resolve()
|
|
payload = json.loads((destination / "plan.json").read_text(encoding="utf-8"))
|
|
assert payload["apply_required"] is True
|
|
assert payload["applied"] is False
|
|
install = (destination / "install.ps1").read_text(encoding="utf-8")
|
|
assert "Register-ScheduledTask" in install
|
|
assert "China Standard Time" in install
|
|
assert "-WhatIf" in install
|
|
assert "[string]$WorkflowId" in install
|
|
assert "Apply requires exactly one safe -WorkflowId" in install
|
|
assert "ForEach-Object" not in install
|
|
|
|
|
|
def test_drift_reports_missing_changed_and_managed_extra_without_values(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
plan = _plan(tmp_path)
|
|
first, second, *rest = plan.tasks
|
|
changed_xml = second.xml.replace("--scheduled", "--changed")
|
|
current = [
|
|
CurrentScheduledTask(first.full_name, first.xml),
|
|
CurrentScheduledTask(second.full_name, changed_xml),
|
|
*[CurrentScheduledTask(task.full_name, task.xml) for task in rest],
|
|
CurrentScheduledTask(r"\GYXX\obsolete.workflow", first.xml),
|
|
CurrentScheduledTask(r"\Unrelated\keep", "secret-value-must-not-appear"),
|
|
]
|
|
current = [item for item in current if item.full_name != rest[-1].full_name]
|
|
|
|
report = detect_schedule_drift(plan, current)
|
|
|
|
assert report.is_clean is False
|
|
assert [(item.full_name, item.status) for item in report.items] == [
|
|
(second.full_name, "changed"),
|
|
(rest[-1].full_name, "missing"),
|
|
(r"\GYXX\obsolete.workflow", "extra"),
|
|
]
|
|
assert "secret-value-must-not-appear" not in json.dumps(report.as_dict())
|