feat: consolidate legacy workflows into gyxx-flow
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
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())
|
||||
Reference in New Issue
Block a user