Add GMCP support and shared media layer
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
# encoding: UTF-8
|
||||
|
||||
require_relative "test_helper"
|
||||
require_relative "../lib/meow/gmcp"
|
||||
require_relative "../lib/meow/telnet"
|
||||
|
||||
module MeowGMCPTest
|
||||
Telnet = MeowClient::Telnet
|
||||
GMCP = MeowClient::GMCP
|
||||
|
||||
def self.bytes(*values)
|
||||
values.pack("C*")
|
||||
end
|
||||
|
||||
def self.frame(payload)
|
||||
bytes(Telnet::IAC, Telnet::SB, Telnet::GMCP) + payload.b + bytes(Telnet::IAC, Telnet::SE)
|
||||
end
|
||||
|
||||
MeowTest.test("GMCP negotiation sends DO and emits enabled once") do
|
||||
parser = Telnet::Parser.new
|
||||
packet = bytes(Telnet::IAC, Telnet::WILL, Telnet::GMCP)
|
||||
first = parser.feed(packet)
|
||||
duplicate = parser.feed(packet)
|
||||
MeowTest.assert_equal([bytes(Telnet::IAC, Telnet::DO, Telnet::GMCP)], first.replies)
|
||||
MeowTest.assert_equal([[:gmcp_enabled, true]], first.events)
|
||||
MeowTest.assert_empty(duplicate.replies)
|
||||
MeowTest.assert_empty(duplicate.events)
|
||||
end
|
||||
|
||||
MeowTest.test("GMCP parses fragmented UTF-8 JSON without entering text output") do
|
||||
parser = Telnet::Parser.new
|
||||
parser.feed(bytes(Telnet::IAC, Telnet::WILL, Telnet::GMCP))
|
||||
packet = frame('Room.Info {"name":"Café","exits":{"n":2}}'.encode("UTF-8"))
|
||||
result = {:data => +"".b, :events => []}
|
||||
packet.each_byte do |byte|
|
||||
parsed = parser.feed(bytes(byte))
|
||||
result[:data] << parsed.data
|
||||
result[:events].concat(parsed.events)
|
||||
end
|
||||
message = result[:events][0][1]
|
||||
MeowTest.assert_empty(result[:data])
|
||||
MeowTest.assert_equal("Room.Info", message.package)
|
||||
MeowTest.assert_equal("Café", message.data["name"])
|
||||
end
|
||||
|
||||
MeowTest.test("GMCP reports invalid JSON and continues parsing") do
|
||||
parser = Telnet::Parser.new
|
||||
parser.feed(bytes(Telnet::IAC, Telnet::WILL, Telnet::GMCP))
|
||||
bad = parser.feed(frame("Char.Vitals {bad"))
|
||||
good = parser.feed(frame('Char.Vitals {"hp":10}'))
|
||||
MeowTest.assert_equal(:gmcp_error, bad.events[0][0])
|
||||
MeowTest.assert_equal(10, good.events[0][1].data["hp"])
|
||||
end
|
||||
|
||||
MeowTest.test("GMCP outbound packets JSON encode and escape IAC") do
|
||||
packet = Telnet.gmcp_packet("Core.Hello", {"client" => "Meow", "marker" => "\u00ff"})
|
||||
MeowTest.assert_equal(bytes(Telnet::IAC, Telnet::SB, Telnet::GMCP), packet.byteslice(0, 3))
|
||||
MeowTest.assert_equal(bytes(Telnet::IAC, Telnet::SE), packet.byteslice(-2, 2))
|
||||
MeowTest.assert(packet.include?('"client":"Meow"'))
|
||||
end
|
||||
|
||||
MeowTest.test("GMCP state applies incremental room and item updates") do
|
||||
state = GMCP::State.new
|
||||
state.apply(GMCP::Message.new(:package => "Room.Players", :data => [{"name" => "One"}]))
|
||||
state.apply(GMCP::Message.new(:package => "room.addplayer", :data => {"name" => "Two"}))
|
||||
state.apply(GMCP::Message.new(:package => "ROOM.REMOVEPLAYER", :data => "One"))
|
||||
state.apply(GMCP::Message.new(:package => "Char.Items.List", :data => {"location" => "inv", "items" => [{"id" => 1, "name" => "coin"}]}))
|
||||
state.apply(GMCP::Message.new(:package => "Char.Items.Update", :data => {"location" => "inv", "item" => {"id" => 1, "attrib" => "t"}}))
|
||||
MeowTest.assert_equal(["Two"], state.room["players"].map { |row| row["name"] })
|
||||
MeowTest.assert_equal("coin", state.items["inv"][0]["name"])
|
||||
MeowTest.assert_equal("t", state.items["inv"][0]["attrib"])
|
||||
end
|
||||
|
||||
MeowTest.test("GMCP codec enforces payload and nesting bounds") do
|
||||
oversized = "Core.Ping " + ("x" * GMCP::MAX_PAYLOAD_BYTES)
|
||||
MeowTest.assert_raises(GMCP::ParseError) { GMCP::Codec.parse(oversized) }
|
||||
nested = "null"
|
||||
(GMCP::MAX_DEPTH + 2).times { nested = "[#{nested}]" }
|
||||
MeowTest.assert_raises(GMCP::ParseError) { GMCP::Codec.parse("Test.Value #{nested}") }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,154 @@
|
||||
# encoding: UTF-8
|
||||
|
||||
require_relative "test_helper"
|
||||
require_relative "../lib/meow/media"
|
||||
|
||||
module MeowMediaTest
|
||||
Media = MeowClient::Media
|
||||
|
||||
class FakeDownloader
|
||||
attr_reader :results, :requests
|
||||
|
||||
def initialize
|
||||
@results = Queue.new
|
||||
@requests = []
|
||||
end
|
||||
|
||||
def enqueue(id, uri)
|
||||
@requests << [id, uri]
|
||||
true
|
||||
end
|
||||
|
||||
def close
|
||||
end
|
||||
end
|
||||
|
||||
class FakeHandle
|
||||
attr_accessor :position_ms
|
||||
attr_reader :closed, :restarts, :fade
|
||||
|
||||
def initialize
|
||||
@position_ms = 0
|
||||
@closed = false
|
||||
@finished = false
|
||||
@restarts = []
|
||||
end
|
||||
|
||||
def finished?
|
||||
@finished
|
||||
end
|
||||
|
||||
def finish!
|
||||
@finished = true
|
||||
end
|
||||
|
||||
def restart(position)
|
||||
@finished = false
|
||||
@restarts << position
|
||||
end
|
||||
|
||||
def stop(fade = 0)
|
||||
@fade = fade
|
||||
@closed = true
|
||||
end
|
||||
|
||||
def close
|
||||
@closed = true
|
||||
end
|
||||
end
|
||||
|
||||
class FakeBackend
|
||||
attr_reader :starts
|
||||
|
||||
def initialize
|
||||
@starts = []
|
||||
end
|
||||
|
||||
def start(path, request, volume)
|
||||
handle = FakeHandle.new
|
||||
@starts << [path, request, volume, handle]
|
||||
handle
|
||||
end
|
||||
end
|
||||
|
||||
MeowTest.test("Media URL policy accepts safe relative HTTPS audio") do
|
||||
uri = Media::URLPolicy.new.resolve("https://media.example/game/", "weather/rain.ogg")
|
||||
MeowTest.assert_equal("https://media.example/game/weather/rain.ogg", uri.to_s)
|
||||
end
|
||||
|
||||
MeowTest.test("Media URL policy rejects insecure private and traversing URLs") do
|
||||
policy = Media::URLPolicy.new
|
||||
MeowTest.assert_raises(ArgumentError) { policy.resolve("http://example.com/", "sound.mp3") }
|
||||
MeowTest.assert_raises(ArgumentError) { policy.resolve("https://127.0.0.1/", "sound.mp3") }
|
||||
MeowTest.assert_raises(ArgumentError) { policy.resolve("https://example.com/", "../secret.mp3") }
|
||||
end
|
||||
|
||||
MeowTest.test("Client.Media adapter normalizes play and stop commands") do
|
||||
play = Media::ClientMedia.play_request({"name" => "rain.ogg", "type" => "music", "loops" => -1, "caption" => "Rain"})
|
||||
stop = Media::ClientMedia.stop_request({"type" => "music", "fadeaway" => true})
|
||||
MeowTest.assert_equal("rain.ogg", play.name)
|
||||
MeowTest.assert_equal(-1, play.loops)
|
||||
MeowTest.assert(stop.fade_away)
|
||||
end
|
||||
|
||||
MeowTest.test("Media stop cancels queued playback before its download completes") do
|
||||
downloader = FakeDownloader.new
|
||||
backend = FakeBackend.new
|
||||
manager = Media::Manager.new("unused", :downloader => downloader, :backend => backend)
|
||||
manager.default_url = "https://media.example/game/"
|
||||
request = Media::PlayRequest.new(:name => "late.mp3", :kind => "sound", :key => "door")
|
||||
id = manager.play(request)
|
||||
MeowTest.assert_equal(1, manager.stop(Media::StopRequest.new(:key => "door")))
|
||||
downloader.results << Media::DownloadResult.new(:id => id, :path => "cached.mp3", :url => "https://media.example/game/late.mp3")
|
||||
manager.tick
|
||||
MeowTest.assert_empty(backend.starts)
|
||||
ensure
|
||||
manager.close if manager != nil
|
||||
end
|
||||
|
||||
MeowTest.test("Media priority prevents lower-priority playback") do
|
||||
downloader = FakeDownloader.new
|
||||
backend = FakeBackend.new
|
||||
manager = Media::Manager.new("unused", :downloader => downloader, :backend => backend)
|
||||
manager.default_url = "https://media.example/game/"
|
||||
|
||||
high = Media::PlayRequest.new(:name => "high.mp3", :kind => "sound", :priority => 80)
|
||||
high_id = manager.play(high)
|
||||
downloader.results << Media::DownloadResult.new(:id => high_id, :path => "high.mp3", :url => "https://media.example/game/high.mp3")
|
||||
manager.tick
|
||||
|
||||
low = Media::PlayRequest.new(:name => "low.mp3", :kind => "sound", :priority => 20)
|
||||
low_id = manager.play(low)
|
||||
downloader.results << Media::DownloadResult.new(:id => low_id, :path => "low.mp3", :url => "https://media.example/game/low.mp3")
|
||||
manager.tick
|
||||
MeowTest.assert_equal(1, backend.starts.size)
|
||||
MeowTest.assert_equal("high.mp3", backend.starts[0][0])
|
||||
ensure
|
||||
manager.close if manager != nil
|
||||
end
|
||||
|
||||
MeowTest.test("Media manager downloads starts loops and selectively stops") do
|
||||
downloader = FakeDownloader.new
|
||||
backend = FakeBackend.new
|
||||
manager = Media::Manager.new("unused", :downloader => downloader, :backend => backend, :music_volume => 0.5)
|
||||
manager.default_url = "https://media.example/game/"
|
||||
request = Media::PlayRequest.new(:name => "theme.mp3", :kind => "music", :loops => 2, :volume => 80, :caption => "Theme")
|
||||
id = manager.play(request)
|
||||
downloader.results << Media::DownloadResult.new(:id => id, :path => "cached.mp3", :url => "https://media.example/game/theme.mp3")
|
||||
manager.tick
|
||||
MeowTest.assert_equal(1, manager.tracks.size)
|
||||
MeowTest.assert_equal(0.5, backend.starts[0][2])
|
||||
MeowTest.assert_equal(["Theme"], manager.captions)
|
||||
MeowTest.assert_equal(["Theme"], manager.drain_captions)
|
||||
MeowTest.assert_empty(manager.drain_captions)
|
||||
handle = backend.starts[0][3]
|
||||
handle.finish!
|
||||
manager.tick
|
||||
MeowTest.assert_equal([0], handle.restarts)
|
||||
manager.stop(Media::StopRequest.new(:kind => "music", :fade_away => true))
|
||||
MeowTest.assert(handle.closed)
|
||||
MeowTest.assert_equal(5_000, handle.fade)
|
||||
ensure
|
||||
manager.close if manager != nil
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,7 @@
|
||||
require_relative "test_helper"
|
||||
require_relative "telnet_test"
|
||||
require_relative "gmcp_test"
|
||||
require_relative "media_test"
|
||||
require_relative "text_pipeline_test"
|
||||
|
||||
begin
|
||||
|
||||
Reference in New Issue
Block a user