-- Unit tests for SpeakerStats in mod_speakerstats_component.lua -- Run with busted from resources/prosody-plugins/: -- busted spec/lua/ -- -- Stubs every Prosody dependency so no Prosody installation is needed. -- Only the pure SpeakerStats class (new_SpeakerStats / setDominantSpeaker / -- isDominantSpeaker / isSilent) is exercised here. Functions that depend on -- live rooms, stanzas, or XMPP routing are covered by the integration tests. -- --------------------------------------------------------------------------- -- Controllable clock -- socket.gettime() returns seconds (float); the module multiplies by 1000. -- Call set_clock(ms) to advance time before each action. -- --------------------------------------------------------------------------- local _clock_ms = 0 local function set_clock(ms) _clock_ms = ms end -- --------------------------------------------------------------------------- -- Package preloads — registered before dofile() so the module sees them -- --------------------------------------------------------------------------- package.preload['socket'] = function() return { gettime = function() return _clock_ms / 1000 end, } end package.preload['util.jid'] = function() local function split(jid) if not jid then return nil, nil, nil end local node = jid:match('^([^@]+)@') local host = jid:match('@([^/]+)') local res = jid:match('/(.+)$') return node, host, res end return { resource = function(jid) if not jid then return nil end return jid:match('/(.+)$') end, split = split, } end package.preload['util.stanza'] = function() -- Only used by occupant_joined (route_stanza) — not exercised here. return { message = function() local s = {} s.tag = function(self, _, _) return self end s.text = function(self, _) return self end s.up = function(self) return self end return s end, } end package.preload['cjson.safe'] = function() return { encode = function(_) return '{}', nil end, decode = function(_) return {}, nil end, } end package.preload['util.queue'] = function() return { new = function(max_size) local items = {} local q = {} function q:push(v) table.insert(items, v) while #items > max_size do table.remove(items, 1) end end function q:items() local i = 0 return function() i = i + 1 return items[i] end end function q:count() return #items end return q end, } end package.preload['util.array'] = function() -- Prosody's util.array module is itself callable as a constructor. local arr_mt = {} arr_mt.__index = arr_mt return setmetatable({}, { __call = function(_, iter) local t = setmetatable({}, arr_mt) if iter then for v in iter do t[#t + 1] = v end end return t end, }) end package.preload['util.async'] = function() return {} end -- --------------------------------------------------------------------------- -- Global stubs required by mod_speakerstats_component.lua at load time -- --------------------------------------------------------------------------- _G.prosody = { hosts = {}, version = 'test' } -- Stub for the util library loaded via module:require "util" local util_stub = { is_admin = function() return false end, get_room_from_jid = function() return nil end, room_jid_match_rewrite = function(jid) return jid end, is_jibri = function() return false end, is_healthcheck_room = function() return false end, is_transcriber = function() return false end, -- noop: prevents hook registration against live muc components process_host_module = function() end, } local mod = { host = 'speakerstats.test.example.com' } mod.log = function() end mod.get_option_string = function(_, key, default) if key == 'muc_component' then return 'conference.test.example.com' end if key == 'muc_mapper_domain_base' then return 'test.example.com' end return default end mod.get_option_number = function(_, _, default) return default end mod.hook = function() end mod.fire_event = function() end mod.context = function() return { fire_event = function() end } end mod.require = function(_, name) if name == 'util' then return util_stub end error('unexpected module:require("' .. tostring(name) .. '")') end _G.module = mod -- --------------------------------------------------------------------------- -- Load the module under test -- --------------------------------------------------------------------------- local ok, err = pcall(dofile, 'mod_speakerstats_component.lua') if not ok then describe('mod_speakerstats_component', function() it('skipped — failed to load', function() pending(tostring(err):match('([^\n]+)') or tostring(err)) end) end) return end -- new_SpeakerStats is a module-level global (no `local` prefix in the source). assert(type(new_SpeakerStats) == 'function', 'new_SpeakerStats must be a global function after loading the module') -- --------------------------------------------------------------------------- -- Helpers -- --------------------------------------------------------------------------- local function new_stats(nick) return new_SpeakerStats(nick or 'testnick', nil) end -- --------------------------------------------------------------------------- -- Tests -- --------------------------------------------------------------------------- describe('SpeakerStats', function() -- Reset clock before every test so time never leaks between cases. before_each(function() set_clock(0) end) -- ----------------------------------------------------------------------- describe('initial state', function() it('is not dominant speaker', function() assert.is_false(new_stats():isDominantSpeaker()) end) it('is not silent', function() assert.is_false(new_stats():isSilent()) end) it('has zero total speaking time', function() assert.equal(0, new_stats().totalDominantSpeakerTime) end) end) -- ----------------------------------------------------------------------- describe('setDominantSpeaker', function() it('accumulates time when dominant speaker stops', function() local s = new_stats() set_clock(0) s:setDominantSpeaker(true, false) set_clock(1000) s:setDominantSpeaker(false, false) assert.equal(1000, s.totalDominantSpeakerTime) end) it('does not count time when starting dominant+silent', function() local s = new_stats() set_clock(0) s:setDominantSpeaker(true, true) -- dominant but muted from the start set_clock(5000) s:setDominantSpeaker(false, false) assert.equal(0, s.totalDominantSpeakerTime) end) it('accumulates time up to the moment of going silent', function() local s = new_stats() set_clock(0) s:setDominantSpeaker(true, false) -- start speaking set_clock(500) s:setDominantSpeaker(true, true) -- go silent while still dominant assert.equal(500, s.totalDominantSpeakerTime) end) it('resumes accumulating after returning from silence', function() local s = new_stats() set_clock(0) s:setDominantSpeaker(true, false) -- speak: timer starts at 0 set_clock(500) s:setDominantSpeaker(true, true) -- mute: +500 ms accumulated set_clock(700) s:setDominantSpeaker(true, false) -- unmute: timer restarts at 700 set_clock(900) s:setDominantSpeaker(false, false) -- stop: +200 ms assert.equal(700, s.totalDominantSpeakerTime) -- 500 + 200 end) it('does not double-count when stopped twice in a row', function() local s = new_stats() set_clock(0) s:setDominantSpeaker(true, false) set_clock(300) s:setDominantSpeaker(false, false) -- first stop: +300 ms set_clock(9999) s:setDominantSpeaker(false, false) -- second stop — must be a no-op assert.equal(300, s.totalDominantSpeakerTime) end) it('accumulates across multiple dominant speaker episodes', function() local s = new_stats() set_clock(0) s:setDominantSpeaker(true, false) set_clock(400) s:setDominantSpeaker(false, false) -- first episode: +400 ms set_clock(1000) s:setDominantSpeaker(true, false) set_clock(1600) s:setDominantSpeaker(false, false) -- second episode: +600 ms assert.equal(1000, s.totalDominantSpeakerTime) end) it('updates isDominantSpeaker state', function() local s = new_stats() s:setDominantSpeaker(true, false) assert.is_true(s:isDominantSpeaker()) s:setDominantSpeaker(false, false) assert.is_false(s:isDominantSpeaker()) end) it('updates isSilent state', function() local s = new_stats() s:setDominantSpeaker(true, false) assert.is_false(s:isSilent()) s:setDominantSpeaker(true, true) assert.is_true(s:isSilent()) s:setDominantSpeaker(true, false) assert.is_false(s:isSilent()) end) it('does not start timer when becoming dominant while silent', function() -- Silence=true on the transition to dominant → no timer started. -- Stopping later must not add spurious time. local s = new_stats() set_clock(0) s:setDominantSpeaker(true, true) -- dominant+silent, no timer set_clock(2000) s:setDominantSpeaker(true, false) -- unmute: timer starts NOW at t=2000 set_clock(2500) s:setDominantSpeaker(false, false) -- stop: +500 ms assert.equal(500, s.totalDominantSpeakerTime) end) end) -- setDominantSpeaker end) -- SpeakerStats