// // PluginTests.swift // Metrika // // Author: Simon-Pierre Boucher // Contact: contact@spboucher.ai // Copyright © 2026 Simon-Pierre Boucher. All rights reserved. // import Foundation import Testing import ZQData import ZQEngine import ZQParser import ZQPlugins /// User-command tests: `.zyq` script commands with `args` substitution, /// and native ZQCommandPlugin registration, validation, and mutation /// gating. @Suite("Plugin protocol", .serialized) struct PluginTests { let fixtures: Fixtures let commandsDirectory: URL init() throws { self.fixtures = try Fixtures() self.commandsDirectory = FileManager.default.temporaryDirectory .appendingPathComponent("metrika_commands_\(UUID().uuidString)") try FileManager.default.createDirectory( at: commandsDirectory, withIntermediateDirectories: true ) let script = """ // robust regression of a logged response — ado-file analog args response predictor gen __log_response = ln(`response') reg __log_response `predictor', robust drop __log_response """ try Data(script.utf8).write( to: commandsDirectory.appendingPathComponent("logreg.zyq") ) } private func makeSession(plugins: [any ZQCommandPlugin] = []) async throws -> ZQSession { let session = try ZQSession( discoverUserCommands: true, commandsDirectory: commandsDirectory, plugins: plugins ) _ = try await session.execute("use \(fixtures.datasetURL.path)") return session } @Test("script command with args substitution matches the built-in path") func scriptCommand() async throws { let session = try await makeSession() let result = try await session.execute("logreg revenue price") // Same estimate as running the commands directly. expectClose( try #require(result.scalars["b_price"]), fixtures["ols_b_price"], "b[price] via script command" ) // The scratch variable was dropped by the script. let count = try await session.execute("count") #expect(count.scalars["N"] == 60) await #expect(throws: (any Error).self) { _ = try await session.execute("summarize __log_response") } } @Test("args parsing strips the declaration and names parameters") func argsDeclaration() throws { let script = ZQScriptCommand( verb: "demo", fileURL: URL(fileURLWithPath: "/tmp/demo.zyq"), lines: ["* comment first", "args a b", "reg `a' `b'", "count if `a' > `2'"] ) #expect(script.parameterNames == ["a", "b"]) #expect(script.lines == ["* comment first", "reg `a' `b'", "count if `a' > `2'"]) #expect(script.expandedLines(arguments: "y x") == [ "* comment first", "reg y x", "count if y > x", ]) } @Test("native plugin executes and its mutation is installed") func nativePlugin() async throws { let session = try await makeSession(plugins: [ZScorePlugin()]) let result = try await session.execute("zscore revenue") #expect(result.text.contains("z_revenue")) // The generated column exists and is standardized. let summary = try await session.execute("summarize z_revenue") #expect(abs(try #require(summary.scalars["mean"])) < 1e-12) expectClose(try #require(summary.scalars["sd"]), 1, "sd = 1") } @Test("plugin syntax validation rejects undeclared qualifiers") func syntaxValidation() async throws { let session = try await makeSession(plugins: [ZScorePlugin()]) // ZScorePlugin declares acceptsCondition: false. await #expect(throws: ZQEngineError.self) { _ = try await session.execute("zscore revenue if price > 10") } } @Test("a plugin shadowing a built-in verb is rejected at startup") func shadowingRejected() { struct Impostor: ZQCommandPlugin { static let verb = "regress" static let syntax = ZQSyntaxSpec() func execute(_ ctx: ZQContext) async throws -> ZQResult { ZQResult(text: "hijacked") } } #expect(throws: ZQPluginRegistry.ShadowingError.self) { _ = try ZQSession( discoverUserCommands: false, plugins: [Impostor()] ) } } @Test("non-mutating plugin cannot smuggle a replacement frame") func mutationGating() async throws { struct Sneaky: ZQCommandPlugin { static let verb = "sneaky" static let syntax = ZQSyntaxSpec(mutates: false) func execute(_ ctx: ZQContext) async throws -> ZQResult { ZQResult( text: "", replacementFrame: try ZQDataFrame(columns: []) ) } } let session = try await makeSession(plugins: [Sneaky()]) await #expect(throws: ZQEngineError.self) { _ = try await session.execute("sneaky") } // Working dataset untouched. let count = try await session.execute("count") #expect(count.scalars["N"] == 60) } }