import XCTest @testable import ShadowsocksX_NG2 /// 目录提交 → 运行时收敛语义与 GLOSSARY.md「有效值未变的提交不做 /// 运行时收敛」):与主测试类同构扩展,按既有模式独立成文件。 final class ProxyRuntimeCatalogConvergenceTests: ProxyRuntimeControllerTests { override func makeDefaultRuleSnapshots() -> BuiltinRuleSnapshots { BuiltinRuleSnapshots(loader: ProxyRuntimeFixture.controlFlowRuleSnapshot) } // MARK: 目录重展开消费 /// 模拟 wrapper 在跑(pid 指向本测试进程,kill(pid, 0) 判活通过)。 private func simulateRunningWrapper() throws { try Data("\(ProcessInfo.processInfo.processIdentifier)".utf8).write(to: runtime.pidFile) } private func committedCatalog() throws -> ConfigurationCatalog { try makeTestCatalogStore(fileURL: catalogFileURL).load().catalog } private func replacedFields( _ fields: ServerFields, address: String? = nil, remark: String? = nil ) -> ServerFields { ServerFields( address: address ?? fields.address, port: fields.port, encryptionMethod: fields.encryptionMethod, password: fields.password, remark: remark ?? fields.remark, pluginProgram: fields.pluginProgram, pluginOptions: fields.pluginOptions) } func testCatalogValueEditOfActiveTargetRewritesAndSignalsRunningWrapper() async throws { let seeded = try makeSeededCatalog() let controller = makeController(probe: ProxyRuntimeFixture.FakeProbe.reachable()) try await controller.activate(seeded.server) await controller.setAgentEnabled(true) try simulateRunningWrapper() // 修改激活服务器的连接地址(契约有效值变化),提交目录变更。 var catalog = try makeTestCatalogStore(fileURL: catalogFileURL).load().catalog let fields = try ActivationFixture.serverFields(of: seeded.server, in: catalog) try catalog.updateServer( seeded.server, with: replacedFields(fields, address: "203.0.113.9")) try makeTestCatalogStore(fileURL: catalogFileURL).save(CatalogDocument(catalog: catalog)) // 协调器生产适配入口:以刚提交的内存快照重展开。 _ = await controller.catalogDidCommit(snapshot: try committedCatalog()) XCTAssertEqual(controller.state, .running) let onDisk = try XCTUnwrap(RuntimeFileStore(fileURL: runtime.contract).loadDocument()) XCTAssertEqual(onDisk.servers.first?.server, "203.0.113.9", "运行时文档已原子更新") let signalsSent = signals.signalsSent.filter { $0.signal != 0 } XCTAssertEqual(signalsSent.count, 1, "代理运行中:重写后应发一次 SIGUSR1") XCTAssertEqual(signalsSent.first?.signal, SIGUSR1) } func testCatalogRemarkOnlyEditOfActiveTargetKeepsRuntimeUntouched() async throws { let seeded = try makeSeededCatalog() let probe = ProxyRuntimeFixture.FakeProbe.reachable() let controller = makeController(probe: probe) try await controller.activate(seeded.server) await controller.setAgentEnabled(true) try simulateRunningWrapper() let contractDataBefore = try Data(contentsOf: runtime.contract) // 修改激活服务器的备注:显示名是目录元数据,不进契约 → 零收敛 // (不写盘、不发信号、不闪 starting、不重走健康门)。 var catalog = try makeTestCatalogStore(fileURL: catalogFileURL).load().catalog let fields = try ActivationFixture.serverFields(of: seeded.server, in: catalog) try catalog.updateServer( seeded.server, with: replacedFields(fields, remark: "新加坡 01")) try makeTestCatalogStore(fileURL: catalogFileURL).save(CatalogDocument(catalog: catalog)) let outcome = await controller.catalogDidCommit(snapshot: try committedCatalog()) XCTAssertEqual(outcome, .converged(skippedServers: [])) XCTAssertEqual(controller.state, .running, "状态面原地不动,不闪回 starting") XCTAssertEqual(try Data(contentsOf: runtime.contract), contractDataBefore, "契约逐字节未变") XCTAssertEqual( signals.signalsSent.filter { $0.signal != 0 }.count, 0, "不向 wrapper 发信号") XCTAssertEqual(probe.callCount, 2, "不重走健康门(初始部署探测过 SOCKS+HTTP 各一次)") } func testCatalogValueEditOutsideActiveSubtreeKeepsRuntimeUntouched() async throws { var catalog = ConfigurationCatalog() let activeServer = try ActivationFixture.addPlainServer( "香港 01", in: &catalog) let idleServer = try ActivationFixture.addPlainServer( "备用 02", in: &catalog) try makeTestCatalogStore(fileURL: catalogFileURL).save(CatalogDocument(catalog: catalog)) let probe = ProxyRuntimeFixture.FakeProbe.reachable() let controller = makeController(probe: probe) try await controller.activate(activeServer) await controller.setAgentEnabled(true) try simulateRunningWrapper() let contractDataBefore = try Data(contentsOf: runtime.contract) // 修改目标子树之外服务器的连接地址:派生契约不变 → 零收敛。 var working = try makeTestCatalogStore(fileURL: catalogFileURL).load().catalog let fields = try ActivationFixture.serverFields(of: idleServer, in: working) try working.updateServer(idleServer, with: replacedFields(fields, address: "198.51.100.9")) try makeTestCatalogStore(fileURL: catalogFileURL).save(CatalogDocument(catalog: working)) let outcome = await controller.catalogDidCommit(snapshot: try committedCatalog()) XCTAssertEqual(outcome, .converged(skippedServers: [])) XCTAssertEqual(controller.state, .running, "状态面原地不动,不闪回 starting") XCTAssertEqual(try Data(contentsOf: runtime.contract), contractDataBefore, "契约逐字节未变") XCTAssertEqual(signals.signalsSent.filter { $0.signal != 0 }.count, 0, "不向 wrapper 发信号") XCTAssertEqual(probe.callCount, 2, "不重走健康门") } func testCatalogGroupMemberValueEditRewritesAndSignalsRunningWrapper() async throws { var catalog = ConfigurationCatalog() let group = try catalog.addGroup("分组") let member = try ActivationFixture.addPlainServer( "香港 01", to: group, in: &catalog) try makeTestCatalogStore(fileURL: catalogFileURL).save(CatalogDocument(catalog: catalog)) let controller = makeController(probe: ProxyRuntimeFixture.FakeProbe.reachable()) try await controller.activate(group) await controller.setAgentEnabled(true) try simulateRunningWrapper() // 激活目标是分组:成员连接值变化是活动子树的真实值变化 → 正常收敛。 var working = try makeTestCatalogStore(fileURL: catalogFileURL).load().catalog let fields = try ActivationFixture.serverFields(of: member, in: working) try working.updateServer(member, with: replacedFields(fields, address: "203.0.113.9")) try makeTestCatalogStore(fileURL: catalogFileURL).save(CatalogDocument(catalog: working)) _ = await controller.catalogDidCommit(snapshot: try committedCatalog()) XCTAssertEqual(controller.state, .running) let onDisk = try XCTUnwrap(RuntimeFileStore(fileURL: runtime.contract).loadDocument()) XCTAssertEqual(onDisk.servers.first?.server, "203.0.113.9", "成员值变化进入运行时文档") XCTAssertEqual(signals.signalsSent.filter { $0.signal != 0 }.count, 1, "转发一次 SIGUSR1") } }