from __future__ import annotations

import unittest
import xml.etree.ElementTree as ET
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1] / "windows-node"
PROJECT = ROOT / "Zcbot.WindowsNode"


class WindowsNodeSourceTests(unittest.TestCase):
    def test_project_targets_net10_windows_forms_with_json_schema_validator(self) -> None:
        tree = ET.parse(PROJECT / "Zcbot.WindowsNode.csproj")
        root = tree.getroot()
        self.assertEqual(root.findtext("./PropertyGroup/TargetFramework"), "net10.0-windows")
        self.assertEqual(root.findtext("./PropertyGroup/UseWindowsForms"), "true")
        self.assertEqual(root.findtext("./PropertyGroup/OutputType"), "WinExe")
        packages = root.findall("./ItemGroup/PackageReference")
        self.assertEqual([item.attrib["Include"] for item in packages], ["JsonSchema.Net"])

    def test_node_protocol_and_secret_storage_markers_are_present(self) -> None:
        source = "\n".join(path.read_text(encoding="utf-8") for path in PROJECT.glob("*.cs"))
        for marker in (
            "v1/software-nodes/enroll",
            "v1/software-nodes/connect",
            'SetRequestHeader("Authorization"',
            'SetRequestHeader("X-Node-Id"',
            "DataProtectionScope.LocalMachine",
            "SetAccessRuleProtection(isProtected: true",
            '"adapter.json"',
            "NotifyIcon",
            "ConfigurationForm",
            "TrayIconFactory.Create",
            '"--headless"',
        ):
            self.assertIn(marker, source)

    def test_workspace_storage_is_global_by_workspace_not_user_directory(self) -> None:
        models = (PROJECT / "NodeModels.cs").read_text(encoding="utf-8")
        store = (PROJECT / "WorkspaceStore.cs").read_text(encoding="utf-8")
        runner = (PROJECT / "AdapterProcessRunner.cs").read_text(encoding="utf-8")
        self.assertIn('Path.Combine(root, "workspaces")', models)
        self.assertIn('Binding(job).WorkspaceId.ToString("D")', store)
        self.assertNotIn("UserId", store)
        self.assertIn('Path.Combine(root, "current")', store)
        self.assertIn('Path.Combine(root, "rollback")', store)
        self.assertIn("workspaceStore.Promote", runner)
        self.assertIn("workspaceStore.Restore", runner)

    def test_runtime_auto_reports_all_discovered_adapter_capabilities(self) -> None:
        connection = (PROJECT / "NodeConnectionLoop.cs").read_text(encoding="utf-8")
        config_store = (PROJECT / "NodeConfigStore.cs").read_text(encoding="utf-8")
        self.assertIn(
            "var installedCapabilities = adapters.All.Select(item => item.Capability).ToArray()",
            connection,
        )
        self.assertIn("capabilities = installedCapabilities", connection)
        self.assertIn("installed_capabilities = installedCapabilities", connection)
        self.assertNotIn("config.Capabilities.Contains", connection)
        self.assertNotIn("InstalledCapabilities.Contains", config_store)

    def test_node_does_not_expose_arbitrary_execution_primitives(self) -> None:
        source = "\n".join(
            path.read_text(encoding="utf-8")
            for path in PROJECT.glob("*.cs")
            if path.name not in {
                "AdapterProcessRunner.cs",
                "ConfigurationForm.cs",
                "SoftwareRuntimeManager.cs",
            }
        )
        for forbidden in ("Process.Start", "cmd.exe", "powershell.exe", "LabTalk"):
            self.assertNotIn(forbidden, source)

        form = (PROJECT / "ConfigurationForm.cs").read_text(encoding="utf-8")
        self.assertIn('"explorer.exe"', form)
        self.assertIn("startInfo.ArgumentList.Add(dataRoot.Text)", form)
        self.assertNotIn("UseShellExecute = true", form)

    def test_data_root_is_configurable_and_migrated_before_switching(self) -> None:
        settings = (PROJECT / "NodeDataRoot.cs").read_text(encoding="utf-8")
        models = (PROJECT / "NodeModels.cs").read_text(encoding="utf-8")
        form = (PROJECT / "ConfigurationForm.cs").read_text(encoding="utf-8")
        tray = (PROJECT / "TrayApplicationContext.cs").read_text(encoding="utf-8")
        connection = (PROJECT / "NodeConnectionLoop.cs").read_text(encoding="utf-8")

        self.assertIn('"ZCBOT_WINDOWS_NODE_DATA_DIR"', settings)
        self.assertIn("Environment.SpecialFolder.LocalApplicationData", settings)
        self.assertIn("Environment.SpecialFolder.CommonApplicationData", settings)
        self.assertIn('Directory.Exists(legacy)', settings)
        self.assertLess(
            settings.index("Environment.GetEnvironmentVariable"),
            settings.index("Registry.CurrentUser.OpenSubKey"),
        )
        self.assertIn("NodeDataRootSettings.Resolve().RootDirectory", models)
        self.assertIn("SHA256.HashData(stream)", settings)
        self.assertIn("Directory.Move(staging, target)", settings)
        self.assertLess(
            settings.index("VerifyTree(source, staging"),
            settings.index("Directory.Move(staging, target)"),
        )
        self.assertIn("DataRootMigrationRequested", form)
        self.assertIn("jobInbox.HasPendingJobs", form)
        self.assertIn(
            "NodeDataRootSettings.SaveUserRoot(result.TargetDirectory)", tray
        )
        self.assertIn("jobPipelines.Values.Concat(exportPipelines.Values)", connection)
        self.assertIn(
            "Path.GetPathRoot(NodePaths.ForCurrentMachine().RootDirectory)", connection
        )

    def test_config_field_names_do_not_serialize_plain_node_token(self) -> None:
        models = (PROJECT / "NodeModels.cs").read_text(encoding="utf-8")
        stored_record = models.split("internal sealed record StoredNodeConfig", 1)[1].split(");", 1)[0]
        self.assertIn("ProtectedNodeToken", stored_record)
        self.assertNotIn("string NodeToken", stored_record)

    def test_configuration_ui_never_displays_or_copies_token(self) -> None:
        form = (PROJECT / "ConfigurationForm.cs").read_text(encoding="utf-8")
        self.assertIn("CreateTextBox(usePassword: true)", form)
        self.assertIn("UseSystemPasswordChar = usePassword", form)
        self.assertNotIn("NodeToken", form)
        self.assertIn("Clipboard.SetText(BuildDiagnosticText(currentConfig))", form)
        diagnostics = form.split("private string BuildDiagnosticText", 1)[1].split(
            "private async Task RunAnsysAcceptanceAsync", 1
        )[0]
        self.assertNotIn("NodeToken", diagnostics)
        self.assertIn("内容不包含 Node Token", form)
        self.assertIn("清除本机身份", form)
        self.assertIn("管理后台删除或禁用云端旧节点", form)

    def test_ansys_acceptance_ui_runs_only_fixed_bundled_suite_before_gate(self) -> None:
        form = (PROJECT / "ConfigurationForm.cs").read_text(encoding="utf-8")
        adapters = (PROJECT / "NodeAdapters.cs").read_text(encoding="utf-8")
        runner = (PROJECT / "AdapterProcessRunner.cs").read_text(encoding="utf-8")
        self.assertIn("ANSYS Mechanical 真机验收", form)
        self.assertIn("运行内置基准验收", form)
        self.assertIn("AcceptanceReportPassed", form)
        self.assertIn('"ZCBOT_ANSYS_242_VALIDATED", "1"', form)
        self.assertIn("EnvironmentVariableTarget.Machine", form)
        self.assertIn("SupportsLocalAcceptance", adapters)
        self.assertIn('Path.Combine(descriptor.DirectoryPath, "acceptance.py")', runner)
        self.assertIn(
            'Path.Combine(descriptor.DirectoryPath, "acceptance-coupon.step")', runner
        )
        self.assertIn('["--work-root", root, "--repeat", "20"', runner)
        self.assertIn("Adapter acceptance cannot run while a dispatched job is active", runner)
        acceptance = form.split("private async Task RunAnsysAcceptanceAsync", 1)[1].split(
            "private void EnableAnsysGate", 1
        )[0]
        self.assertNotIn("new OpenFileDialog", acceptance)
        self.assertNotIn("Process.Start", acceptance)

    def test_professional_software_ui_uses_fixed_per_runtime_installers(self) -> None:
        form = (PROJECT / "ConfigurationForm.cs").read_text(encoding="utf-8")
        manager = (PROJECT / "SoftwareRuntimeManager.cs").read_text(encoding="utf-8")
        runner = (PROJECT / "AdapterProcessRunner.cs").read_text(encoding="utf-8")

        self.assertIn('CreateSectionTitle("专业软件")', form)
        self.assertIn('CreateButton("自动检测", 92)', form)
        self.assertIn('CreateButton("选择位置", 92)', form)
        self.assertIn('CreateButton("安装 / 更新环境", 150', form)
        self.assertIn("panel.SetColumnSpan(path, 2)", form)
        self.assertIn("path.ReadOnly = true", form)
        software_panel = form.split("private Control CreateSoftwarePanel", 1)[1].split(
            "private void RefreshSoftwareCards", 1
        )[0]
        self.assertIn("panel.SetColumnSpan(status, 2)", software_panel)
        self.assertIn("panel.SetColumnSpan(actions, 2)", software_panel)
        self.assertIn("SoftwareRuntimeManager.InstallRuntimeAsync", form)
        self.assertIn("item.HasActiveJobs", form)
        for runtime_id in ('"origin"', '"ansys"', '"blender"'):
            self.assertIn(runtime_id, manager)
        self.assertIn('@"Software\\Zcbot\\WindowsNode\\Software"', manager)
        self.assertIn("RegistryHive.LocalMachine", manager)
        self.assertIn("RegistryHive.CurrentUser", manager)
        self.assertIn('"ZCBOT_BLENDER_EXE"', manager)
        self.assertIn('"AWP_ROOT242"', manager)
        self.assertIn('"ZCBOT_ORIGIN_EXE"', manager)
        self.assertIn('"Origin*.exe"', manager)
        self.assertIn("OriginExecutables", manager)
        self.assertIn('Path.Combine(runtimes, definition.RuntimeId)', manager)
        self.assertIn("ArgumentList.Add(argument)", manager)
        self.assertNotIn('"cmd.exe"', manager.lower())
        self.assertNotIn('"powershell.exe"', manager.lower())
        self.assertIn("SoftwareRuntimeManager.ApplyProcessEnvironment", runner)

    def test_configuration_window_is_resizable_and_dpi_safe(self) -> None:
        form = (PROJECT / "ConfigurationForm.cs").read_text(encoding="utf-8")
        self.assertIn("ClientSize = new Size(1080, 1050)", form)
        self.assertIn("MinimumSize = new Size(760, 560)", form)
        self.assertIn("FormBorderStyle.Sizable", form)
        self.assertIn("AutoScaleMode.Dpi", form)
        self.assertIn("AutoScroll = true", form)
        self.assertNotIn("MaximumSize = new Size(410", form)
        self.assertIn("注册码默认 10 分钟有效", form)
        self.assertIn("成功注册一次后立即失效", form)
        self.assertIn("CreateSection", form)
        self.assertIn("注册并连接", form)
        self.assertNotIn("ContentWidth", form)
        self.assertIn("new Padding(24, 20, 24, 20)", form)
        self.assertIn("new ColumnStyle(SizeType.Percent, 100)", form)
        self.assertIn('AddTab(tabs, "节点概览")', form)
        self.assertIn('AddTab(tabs, "专业软件")', form)
        self.assertIn('AddTab(tabs, "本机任务")', form)
        self.assertIn('AddTab(tabs, "运行设置")', form)
        self.assertIn("SizeMode = TabSizeMode.FillToRight", form)
        self.assertIn("new NavigationTabControl", form)
        self.assertIn("DrawMode = TabDrawMode.OwnerDrawFixed", form)
        self.assertIn("Appearance = TabAppearance.FlatButtons", form)
        self.assertNotIn("protected override void OnResize", form)
        self.assertIn("tab.Controls.Add(content)", form)
        self.assertIn("CreateSectionTitle(\"节点信息\")", form)
        self.assertIn("CreateSubsectionTitle(\"可用软件\")", form)
        self.assertIn("heading.Controls.Add(state, 1, 0)", form)
        self.assertNotIn("headingHint", form)
        self.assertIn("panel.SetColumnSpan(path, 2)", form)
        self.assertIn("panel.SetColumnSpan(status, 2)", form)
        self.assertIn("panel.SetColumnSpan(actions, 2)", form)
        self.assertIn("BorderedTableLayoutPanel", form)
        self.assertIn(
            'runtime.Software == "OriginPro" ? "Origin" : runtime.Software', form
        )
        self.assertIn("FormatCapabilitySummary(adapters.All)", form)
        self.assertIn("GroupBy(entry => entry.SoftwareName", form)
        self.assertIn("GroupBy(entry => entry.CapabilityName", form)
        self.assertIn("ShortCapabilityName(adapter.DisplayName, softwareName)", form)
        self.assertIn('CreateButton("立即重连", 112, primary: true)', form)
        self.assertIn("ReconnectRequested?.Invoke()", form)
        self.assertIn("registrationCard.Visible = !registered", form)
        self.assertIn("reconnect.Visible = registered", form)
        self.assertIn("resetIdentity.Visible = registered", form)
        self.assertIn('CreateSectionTitle("本机任务")', form)
        self.assertIn("DataGridView", form)
        self.assertIn("jobInbox.ReadJobSnapshots()", form)
        self.assertIn('"software_running" => "软件执行中"', form)
        self.assertNotIn("HttpClient", form)

        tray = (PROJECT / "TrayApplicationContext.cs").read_text(encoding="utf-8")
        self.assertIn("form.ReconnectRequested += RestartConnection", tray)
        self.assertIn("statusItem.Text = CompactMenuText(status.Message, 24)", tray)
        self.assertIn('singleLine[..(length - 1)] + "…"', tray)

    def test_tray_exit_waits_for_or_cancels_active_jobs(self) -> None:
        tray = (PROJECT / "TrayApplicationContext.cs").read_text(encoding="utf-8")
        connection = (PROJECT / "NodeConnectionLoop.cs").read_text(encoding="utf-8")

        self.assertIn("private async Task ExitNodeAsync()", tray)
        self.assertIn("if (exiting || config is null", tray)
        self.assertIn("ReadJobSnapshots()", tray)
        self.assertIn("MessageBoxButtons.YesNoCancel", tray)
        self.assertIn("connectionLoop?.CancelActiveJobsForExit()", tray)
        self.assertIn("tray.ContextMenuStrip.Enabled = false", tray)
        self.assertLess(tray.index("await connectionTask"), tray.index("ExitThread();"))
        self.assertIn("internal void CancelActiveJobsForExit()", connection)
        self.assertIn("adapters.Find(job.Capability)?.Cancel(job.JobId)", connection)
        self.assertIn("inputDownloader.DownloadAsync(job, forcedStop.Token)", connection)
        self.assertIn('"NODE_EXIT_CANCELLED"', connection)
        self.assertIn("catch (OperationCanceledException) when (forcedStop.IsCancellationRequested)", connection)

    def test_local_job_monitor_is_persisted_and_software_neutral(self) -> None:
        inbox = (PROJECT / "JobInboxStore.cs").read_text(encoding="utf-8")
        connection = (PROJECT / "NodeConnectionLoop.cs").read_text(encoding="utf-8")
        models = (PROJECT / "JobMonitorModels.cs").read_text(encoding="utf-8")
        self.assertIn('"state.json"', inbox)
        self.assertIn("ReadJobSnapshots", inbox)
        self.assertIn("AtomicWrite(path, content, overwrite: true)", inbox)
        for stage in (
            "accepted",
            "downloading_inputs",
            "ready_to_run",
            "software_running",
            "uploading_outputs",
            "succeeded",
            "failed",
            "cancelled",
        ):
            self.assertIn(f'"{stage}"', inbox + connection)
        self.assertIn("JobDisplaySnapshot", models)
        self.assertNotIn("origin_running", inbox + connection + models)

    def test_startup_and_runtime_installation_are_owned_by_the_ui(self) -> None:
        startup = (PROJECT / "StartupRegistration.cs").read_text(encoding="utf-8")
        form = (PROJECT / "ConfigurationForm.cs").read_text(encoding="utf-8")
        manager = (PROJECT / "SoftwareRuntimeManager.cs").read_text(encoding="utf-8")

        self.assertFalse((ROOT / "install-windows-node.bat").exists())
        self.assertIn('Text = "登录 Windows 后自动启动节点"', form)
        self.assertIn("StartupRegistration.SetEnabled", form)
        self.assertIn('@"Software\\Microsoft\\Windows\\CurrentVersion\\Run"', startup)
        self.assertIn("Environment.ProcessPath", startup)
        self.assertIn("FindPython312Async", manager)
        self.assertIn('new BootstrapPython("py.exe", ["-3.12"])', manager)
        self.assertIn('new BootstrapPython("python.exe", [])', manager)
        self.assertIn('"ZCBOT_PIP_INDEX_URL"', manager)
        self.assertIn(
            '"https://pypi.tuna.tsinghua.edu.cn/simple/"', manager
        )

    def test_publish_output_contains_the_complete_node_payload(self) -> None:
        project = (PROJECT / "Zcbot.WindowsNode.csproj").read_text(encoding="utf-8")
        self.assertNotIn("install-windows-node.bat", project)
        self.assertIn("..\\adapters\\**\\*", project)
        self.assertIn("adapters\\%(RecursiveDir)%(Filename)%(Extension)", project)
        self.assertIn("..\\..\\software-contracts\\origin.plot.v2.json", project)
        self.assertIn("adapters\\origin.plot@v2\\origin.plot.v2.json", project)
        self.assertIn(
            "..\\..\\software-contracts\\ansys.geometry.inspect.v1.json",
            project,
        )
        self.assertIn(
            "adapters\\ansys.geometry.inspect@v1\\ansys.geometry.inspect.v1.json",
            project,
        )
        self.assertIn(
            "..\\..\\software-contracts\\ansys.mechanical.static_structural.v2.json",
            project,
        )
        self.assertIn(
            "adapters\\ansys.mechanical.static_structural@v2\\"
            "ansys.mechanical.static_structural.v2.json",
            project,
        )
        self.assertIn(
            "..\\..\\software-contracts\\blender.scene.author.v3.json",
            project,
        )
        self.assertIn(
            "adapters\\blender.scene.author@v3\\blender.scene.author.v3.json",
            project,
        )
        self.assertNotIn("software-contracts\\%(Filename)%(Extension)", project)
        self.assertFalse((ROOT / "install-windows-node.ps1").exists())
        self.assertFalse((ROOT / "install-origin-runtime.ps1").exists())
        self.assertFalse((ROOT / "install-startup.ps1").exists())

    def test_packager_defaults_to_framework_dependent_verified_zip(self) -> None:
        script = (ROOT / "package-windows-node.bat").read_text(encoding="utf-8")
        self.assertNotIn("powershell", script.lower())
        self.assertIn('set "SELF_CONTAINED=false"', script)
        self.assertIn('if /I "%~1"=="--self-contained"', script)
        self.assertIn("dotnet publish", script)
        self.assertNotIn('"install-windows-node.bat"', script)
        self.assertIn("start Zcbot.WindowsNode.exe", script)
        self.assertIn('for /r "%PUBLISH_DIR%\\adapters" %%F in (adapter.json)', script)
        self.assertIn('do if exist "%%~fF"', script)
        self.assertIn("ADAPTER_CONTRACT_COUNT", script)
        self.assertIn("Published adapter has no colocated contract", script)
        self.assertNotIn('"!PUBLISH_DIR!\\software-contracts\\*.json"', script)
        self.assertIn("tar.exe -a -c -f", script)
        self.assertIn("certutil.exe -hashfile", script)
        self.assertFalse((ROOT / "package-windows-node.ps1").exists())

    def test_auth_rejection_is_distinct_from_http_websocket_handshake_failure(self) -> None:
        connection = (PROJECT / "NodeConnectionLoop.cs").read_text(encoding="utf-8")
        self.assertIn('socket.HttpStatusCode is HttpStatusCode.Unauthorized', connection)
        self.assertIn('or HttpStatusCode.Forbidden', connection)
        self.assertIn("WebSocket 握手被拒绝，请检查服务端或反向代理", connection)
        self.assertIn("catch (NodeEndpointException exception)", connection)
        self.assertIn("throw new NodeEndpointException", connection)
        self.assertIn("(int?)result.CloseStatus == 4003", connection)
        self.assertIn("节点身份已被服务端拒绝", connection)
        self.assertNotIn("Node credentials were rejected", connection)

    def test_adapter_runtime_probe_is_worker_owned_and_reported(self) -> None:
        runner = (PROJECT / "AdapterProcessRunner.cs").read_text(encoding="utf-8")
        worker = (
            ROOT / "adapters" / "origin.plot@v2" / "worker.py"
        ).read_text(encoding="utf-8")
        connection = (PROJECT / "NodeConnectionLoop.cs").read_text(encoding="utf-8")
        self.assertIn('r"Origin.ApplicationSI\\CLSID"', worker)
        self.assertIn('["--probe"]', runner)
        self.assertIn('root.GetProperty("adapter_version")', runner)
        self.assertIn('startInfo.Environment["PYTHONUTF8"] = "1"', runner)
        self.assertIn('startInfo.Environment["PYTHONIOENCODING"] = "utf-8"', runner)
        form = (PROJECT / "ConfigurationForm.cs").read_text(encoding="utf-8")
        self.assertIn('AppendLine($"Adapter: {adapter.AdapterVersion}")', form)
        self.assertIn('$"{group.Key} · 版本未知"', form)
        self.assertIn(
            "&& !jobInbox.HasPendingJobs",
            connection,
        )
        self.assertIn("originAdapter?.HasActiveJobs", connection)
        self.assertIn(
            "ReadRecoverableJobs().Any(item => item.Terminal is null)",
            (PROJECT / "JobInboxStore.cs").read_text(encoding="utf-8"),
        )
        self.assertNotIn("CreateInstance", worker)
        for marker in (
            "software_version = origin.SoftwareVersion",
            "adapter_version = origin.AdapterVersion",
            "health = origin.Health",
            "detail = origin.Detail",
        ):
            self.assertIn(marker, connection)

    def test_job_offer_is_persisted_before_acceptance(self) -> None:
        inbox = (PROJECT / "JobInboxStore.cs").read_text(encoding="utf-8")
        connection = (PROJECT / "NodeConnectionLoop.cs").read_text(encoding="utf-8")
        adapters = (PROJECT / "NodeAdapters.cs").read_text(encoding="utf-8")
        self.assertIn("adapters.Find(capability)", inbox)
        self.assertIn("adapter.ValidateRequest(request)", inbox)
        self.assertIn(
            'JsonSchema.Build(contractRoot.GetProperty("request_schema").Clone())',
            adapters,
        )
        self.assertIn("var contractPath = ResolveFile(root, manifest.Contract)", adapters)
        self.assertNotIn("ResolveContractFile", adapters)
        self.assertNotIn('AppContext.BaseDirectory, "software-contracts"', adapters)
        self.assertNotIn("IsValidOriginRequest", inbox)
        self.assertNotIn("IsValidPlot", inbox)
        self.assertIn('root.TryGetProperty("input_transfers"', inbox)
        self.assertIn("transfers.GetArrayLength() > 16", inbox)
        self.assertNotIn("transfers.GetArrayLength() is < 1 or > 16", inbox)
        self.assertIn('"input", key, filename', inbox)
        self.assertIn("FileOptions.WriteThrough", inbox)
        self.assertIn("stream.Flush(flushToDisk: true)", inbox)
        new_record = inbox.split("var record =", 1)[1].split("private static JsonElement?", 1)[0]
        self.assertLess(
            new_record.index("AtomicWrite(requestPath, record"),
            new_record.index("JobOfferResult.Accept"),
        )
        self.assertIn('offerResult.Accepted ? "job_accept" : "job_reject"', connection)
        self.assertIn("sendLock.WaitAsync", connection)
        self.assertIn("!jobInbox.HasPendingJobs", connection)
        self.assertIn("capability_runtime = capabilityRuntime", connection)
        self.assertIn("adapter.RunAsync(job)", connection)
        self.assertIn("ReportRecoverableJobsAsync", connection)
        self.assertIn("ConcurrentDictionary<Guid, Task> jobPipelines", connection)
        self.assertIn("StartJobPipeline(socket, acceptedJob)", connection)
        self.assertIn("inputDownloader.DownloadAsync(job, forcedStop.Token)", connection)
        self.assertNotIn("CreateLinkedTokenSource(cancellationToken, forcedStop.Token)", connection)
        self.assertIn('stage = "uploading_outputs"', connection)
        self.assertIn('stage = "software_running"', connection)
        self.assertNotIn('stage = "origin_running"', connection)
        self.assertIn("&& !job.UploadComplete", connection)
        self.assertIn("&& !job.CloudTerminal", connection)
        self.assertIn(
            "await Task.WhenAll(jobPipelines.Values.Concat(exportPipelines.Values).ToArray())",
            connection,
        )
        self.assertIn("StartJobPipeline(socket, job)", connection)
        self.assertIn('stage = "downloading_inputs"', connection)
        self.assertIn('Path.Combine(jobDirectory, "terminal.json")', inbox)
        self.assertIn("AtomicWrite(requestPath, updated, overwrite: true)", inbox)

        downloader = (PROJECT / "JobInputDownloader.cs").read_text(encoding="utf-8")
        self.assertIn('new AuthenticationHeaderValue("Bearer", config.NodeToken)', downloader)
        self.assertIn('DefaultRequestHeaders.Add("X-Node-Id"', downloader)
        self.assertIn("HttpCompletionOption.ResponseHeadersRead", downloader)
        self.assertIn("IncrementalHash.CreateHash", downloader)
        self.assertIn("total > expectedSize", downloader)
        self.assertIn("File.Move(temporaryPath, destination, overwrite: false)", downloader)
        self.assertNotIn("Process.Start", downloader)

    def test_adapter_worker_launch_is_manifest_driven_and_terminal_driven(self) -> None:
        runner = (PROJECT / "AdapterProcessRunner.cs").read_text(encoding="utf-8")
        connection = (PROJECT / "NodeConnectionLoop.cs").read_text(encoding="utf-8")
        project = (PROJECT / "Zcbot.WindowsNode.csproj").read_text(encoding="utf-8")
        worker = (
            ROOT / "adapters" / "origin.plot@v2" / "worker.py"
        ).read_text(encoding="utf-8")
        self.assertIn('Environment.GetEnvironmentVariable("ZCBOT_ORIGIN_PYTHON")', runner)
        self.assertIn(
            'Path.Combine(paths.RootDirectory, "runtimes", runtimeId, "Scripts", "python.exe")',
            runner,
        )
        self.assertIn("UseShellExecute = false", runner)
        self.assertIn("command.PrefixArguments.Concat(arguments)", runner)
        self.assertIn("descriptor.EntrypointPath", runner)
        self.assertIn('Path.Combine(jobDirectory, "terminal.json")', runner)
        self.assertIn('"NODE_RESTARTED_DURING_JOB"', runner)
        self.assertIn("CancellationTokenSource.CreateLinkedTokenSource", runner)
        self.assertIn("process.Kill(entireProcessTree: true)", runner)
        self.assertIn('type.GetString() == "job_cancel"', connection)
        self.assertIn('"cancelled", "USER_CANCELLED"', connection)
        self.assertIn("..\\adapters\\**\\*", project)
        self.assertIn("descriptor.Manifest.WorkerTimeoutMinutes", runner)
        adapters = (PROJECT / "NodeAdapters.cs").read_text(encoding="utf-8")
        self.assertIn(
            'OptionalInteger(manifestRoot, "worker_timeout_minutes", 30, 1, 1440)',
            adapters,
        )
        self.assertIn("if op.oext:", worker)
        self.assertIn("op.exit()", worker)
        self.assertIn("op.new_graph", worker)
        self.assertIn("layer.add_plot", worker)
        self.assertIn("op.save", worker)
        self.assertIn("graph.save_fig", worker)
        self.assertIn('_atomic_json(job_dir / "terminal.json"', worker)
        for forbidden in ("subprocess", "eval(", "exec(", "os.system"):
            self.assertNotIn(forbidden, worker)

    def test_origin_adapter_can_be_packaged_without_building_node(self) -> None:
        script = (ROOT / "package-origin-adapter.bat").read_text(encoding="utf-8")
        connection = (PROJECT / "NodeConnectionLoop.cs").read_text(encoding="utf-8")
        self.assertIn('"adapters\\origin.plot@v2\\adapter.json"', script)
        self.assertIn('"adapters\\origin.plot@v2\\worker.py"', script)
        self.assertIn('"adapters\\origin.plot@v2\\acceptance.py"', script)
        self.assertIn('"..\\software-contracts\\origin.plot.v2.json"', script)
        self.assertNotIn("dotnet", script.lower())

        form = (PROJECT / "ConfigurationForm.cs").read_text(encoding="utf-8")
        self.assertIn("Contract SHA-256: {adapter.ContractSha256}", form)
        self.assertIn("Workspace protocol:", form)
        self.assertIn("Workspace state:", form)
        self.assertIn("workspace_protocol_version", connection)
        self.assertIn("contract_sha256", connection)

        uploader = (PROJECT / "JobOutputUploader.cs").read_text(encoding="utf-8")
        self.assertIn('new AuthenticationHeaderValue("Bearer", config.NodeToken)', uploader)
        self.assertIn('DefaultRequestHeaders.Add("X-Node-Id"', uploader)
        self.assertIn('DefaultRequestHeaders.Add("X-Lease-Id"', uploader)
        self.assertIn("SHA256.HashDataAsync", uploader)
        self.assertIn("upload-complete.json", connection + uploader)
        self.assertIn("adapter.PreviewOutputIds", connection)
        self.assertIn("adapter.WorkspaceStateFilename", connection)
        self.assertIn("WorkspaceSize(jobDirectory), allowConflict: true", uploader)
        self.assertIn("completeResponse.StatusCode == HttpStatusCode.Conflict", uploader)
        self.assertIn('"JOB_ALREADY_TERMINAL"', uploader)
        self.assertIn('"cloud-terminal.json"', uploader)
        self.assertIn("OpenOutputWithRetryAsync", uploader)
        self.assertIn("IsSharingViolation", uploader)
        self.assertIn("(exception.HResult & 0xFFFF) is 32 or 33", uploader)
        self.assertIn("artifact={artifactId}", uploader)
        self.assertIn("hresult=0x{exception.HResult:X8}", connection + uploader)
        self.assertIn('"node-output-upload.log"', uploader)
        self.assertIn("MaxDiagnosticLogBytes", uploader)
        self.assertIn("outputUploader.RecordDeferred(current, exception)", connection)
        self.assertIn('completionPath + ".pending"', uploader)
        self.assertIn("PreparePendingCompletion(pendingPath, responseBody)", uploader)
        self.assertIn("File.Move(pendingPath, completionPath, overwrite: false)", uploader)
        self.assertIn("operation=move_pending", uploader)
        self.assertNotIn('".tmp-" + Guid.NewGuid()', uploader)
        self.assertNotIn("Process.Start", uploader)

        tray = (PROJECT / "TrayApplicationContext.cs").read_text(encoding="utf-8")
        self.assertIn("_ = form.Handle", tray)
        self.assertIn(
            'UpdateStatus(NodeStatus.Create(NodeState.Connecting, "正在连接 zcbot…"))',
            tray,
        )

    def test_blender_adapter_can_be_packaged_without_building_node(self) -> None:
        script = (ROOT / "package-blender-adapter.bat").read_text(encoding="utf-8")
        self.assertIn("adapters\\blender.scene.author@v3", script)
        self.assertIn("\\worker.py", script)
        self.assertIn("\\blender_worker.py", script)
        self.assertIn("\\scene_recipe.py", script)
        self.assertIn("\\rotary_kiln.py", script)
        self.assertIn("..\\software-contracts\\blender.scene.author.v3.json", script)
        self.assertIn("tar.exe -a -c -f", script)
        self.assertNotIn("dotnet", script.lower())

    def test_local_job_monitor_never_shows_update_before_acceptance(self) -> None:
        inbox = (PROJECT / "JobInboxStore.cs").read_text(encoding="utf-8")
        self.assertIn("state.UpdatedAt > acceptedAt", inbox)


if __name__ == "__main__":
    unittest.main()
