Add profile automation and transcripts

This commit is contained in:
2026-09-17 23:43:47 +02:00
parent 573f1b7acb
commit 90cb662334
12 changed files with 1253 additions and 53 deletions
+148
View File
@@ -0,0 +1,148 @@
# 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
+2
View File
@@ -4,6 +4,8 @@ require_relative "gmcp_test"
require_relative "media_test"
require_relative "msp_test"
require_relative "text_pipeline_test"
require_relative "automation_test"
require_relative "transcript_test"
begin
MeowTest.run!
+5
View File
@@ -1,3 +1,8 @@
module Kernel
def _(text)
text
end unless method_defined?(:_)
end
module MeowTest
Failure = Class.new(StandardError)
+45
View File
@@ -0,0 +1,45 @@
# encoding: UTF-8
require "tmpdir"
require_relative "test_helper"
require_relative "../lib/meow/transcript"
module MeowTranscriptTest
MeowTest.test("Transcript store writes timestamped typed records and lists them") do
Dir.mktmpdir("meow-transcript") do |directory|
now = Time.new(2026, 9, 17, 12, 30, 0, "+02:00")
store = MeowClient::TranscriptStore.new(directory, :clock => proc { now })
path = store.start("profile-1", "Test MUD")
store.write_record("mud", "Welcome\nPrompt>")
store.write_record("command", "> look")
store.close
text = File.binread(path)
MeowTest.assert(text.include?("[2026-09-17T12:30:00+02:00] [mud] Welcome"))
MeowTest.assert(text.include?("[command] > look"))
MeowTest.assert_equal(1, store.list("profile-1").size)
end
end
MeowTest.test("Transcript paths stay inside the transcript root") do
Dir.mktmpdir("meow-transcript") do |directory|
store = MeowClient::TranscriptStore.new(directory)
MeowTest.assert_raises(ArgumentError) { store.read(File.join(directory, "..", "outside.txt")) }
end
end
MeowTest.test("Transcript deletion removes one file and profile deletion removes the directory") do
Dir.mktmpdir("meow-transcript") do |directory|
store = MeowClient::TranscriptStore.new(directory)
first = store.start("profile", "One")
store.close
store.delete(first)
MeowTest.assert_empty(store.list("profile"))
store.start("profile", "Two")
store.close
store.delete_profile("profile")
MeowTest.assert_empty(store.list("profile"))
end
end
end