# encoding: UTF-8 require_relative "test_helper" require_relative "../lib/meow/automation" module MeowAutomationTest Automation = MeowClient::Automation class FakeProgram def initialize @documents = {} end def read_json(name, default:) Marshal.load(Marshal.dump(@documents.fetch(name, default))) end def update_json(name, default:) value = Marshal.load(Marshal.dump(@documents.fetch(name, default))) yield value @documents[name] = value end end MeowTest.test("Automation templates expand aliases and regular expression captures") do MeowTest.assert_equal("cast fire goblin $", Automation::Template.alias_expand("cast $1 $2 $$", "fire goblin")) match = /(red) (dragon)/.match("red dragon") MeowTest.assert_equal("dragon/red dragon/$", Automation::Template.match_expand("$2/$0/$$", match)) end MeowTest.test("Automation repository round trips normalized profile rules") do repository = Automation::Repository.new(FakeProgram.new) configuration = repository.default_configuration configuration["aliases"] << {"name" => "Heal", "key" => "h", "command" => "cast heal $*", "enabled" => true} configuration["shortcuts"] << {"name" => "Look", "digit" => 1, "shift" => false, "command" => "look", "enabled" => true} saved = repository.save("profile", configuration) loaded = repository.load("profile") MeowTest.assert_equal(saved, loaded) MeowTest.assert(saved["aliases"][0]["id"].to_s != "") end MeowTest.test("Automation repository rejects duplicate aliases and shortcuts") do repository = Automation::Repository.new(FakeProgram.new) configuration = repository.default_configuration configuration["aliases"] = [ {"name" => "One", "key" => "x", "command" => "one"}, {"name" => "Two", "key" => "X", "command" => "two"} ] MeowTest.assert_raises(ArgumentError) { repository.save("profile", configuration) } configuration = repository.default_configuration configuration["shortcuts"] = [ {"name" => "One", "digit" => 1, "shift" => false, "command" => "one"}, {"name" => "Two", "digit" => 1, "shift" => false, "command" => "two"} ] MeowTest.assert_raises(ArgumentError) { repository.save("profile", configuration) } end MeowTest.test("Command processor expands only one exact first-word alias") do processor = Automation::CommandProcessor.new([ {"key" => "k", "command" => "kill $1", "enabled" => true}, {"key" => "disabled", "command" => "say no", "enabled" => false} ]) MeowTest.assert_equal("kill orc", processor.process("K orc")) MeowTest.assert_equal("kk orc", processor.process("kk orc")) MeowTest.assert_equal("k orc", processor.process("k orc", :expand_aliases => false)) end MeowTest.test("Output processor applies ordered actions against original input") do spoken = [] sent = [] media = [] triggers = [ { "enabled" => true, "pattern" => "HP: (\\d+)", "case_sensitive" => false, "actions" => [ {"type" => "rewrite", "text" => "Health $1"}, {"type" => "highlight", "label" => "Warning", "source" => "tone.wav"}, {"type" => "speak", "text" => "Health is $1"}, {"type" => "send", "text" => "score"} ] } ] processor = Automation::OutputProcessor.new( triggers, { :speak => proc { |text| spoken << text }, :send => proc { |text| sent << text }, :play_media => proc { |source, _action| media << source } } ) result = processor.process("hp: 12") MeowTest.assert_equal("Warning: Health 12", result.text) MeowTest.refute(result.gagged) MeowTest.assert_equal(["Health is 12"], spoken) MeowTest.assert_equal(["score"], sent) MeowTest.assert_equal(["tone.wav"], media) end MeowTest.test("Gag suppresses output while preserving side effects") do spoken = [] processor = Automation::OutputProcessor.new( [{"enabled" => true, "pattern" => "secret", "actions" => [{"type" => "gag"}, {"type" => "speak", "text" => "noticed"}]}], {:speak => proc { |text| spoken << text }} ) result = processor.process("secret") MeowTest.assert(result.gagged) MeowTest.assert_equal(["noticed"], spoken) end MeowTest.test("Automation send limiter suspends a runaway rule") do now = 10.0 sent = [] suspended = 0 processor = Automation::OutputProcessor.new( [], {:send => proc { |text| sent << text }, :suspended => proc { suspended += 1 }}, :clock => proc { now } ) actions = [{"type" => "send", "text" => "look"}] (Automation::MAX_AUTOMATED_SENDS_PER_SECOND + 1).times { processor.execute_actions(actions) } MeowTest.assert_equal(Automation::MAX_AUTOMATED_SENDS_PER_SECOND, sent.size) MeowTest.assert_equal(1, suspended) end MeowTest.test("Timer scheduler starts at session time and skips missed bursts") do now = 100.0 scheduler = Automation::TimerScheduler.new( [{"name" => "pulse", "enabled" => true, "interval" => 5.0, "actions" => []}], :clock => proc { now } ) MeowTest.assert_empty(scheduler.due) now = 105.0 MeowTest.assert_equal(["pulse"], scheduler.due.map { |row| row["name"] }) now = 130.0 MeowTest.assert_equal(["pulse"], scheduler.due.map { |row| row["name"] }) MeowTest.assert_empty(scheduler.due) end MeowTest.test("Automation media distinguishes Windows files from secure URLs") do MeowTest.assert_equal(:local, Automation::MediaSource.classify("C:\\sounds\\alert.ogg")) MeowTest.assert_equal(:local, Automation::MediaSource.classify("\\\\server\\sounds\\alert.wav")) MeowTest.assert_equal(:https, Automation::MediaSource.classify("https://media.example/alert.mp3")) MeowTest.assert_raises(ArgumentError) { Automation::MediaSource.classify("http://media.example/alert.mp3") } end end