diff --git a/CenterGossipFrame-Cata.toc b/CenterGossipFrame-Cata.toc index c09cc69..17bc936 100644 --- a/CenterGossipFrame-Cata.toc +++ b/CenterGossipFrame-Cata.toc @@ -2,12 +2,17 @@ ## Interface: 40400 ## Name: Center Gossip Frame ## Notes: A very simple addon to centralize the main gossip and quest frames. -## Title: Center Gossip Frame |cffffee771.2.0|r -## Version: 1.2.0 +## Title: Center Gossip Frame |cffffee771.3.0|r +## Version: 1.3.0 ## X-Curse-Project-ID: 1051711 lib\stormwind-library.lua CenterGossipFrame.lua -src\Integrations\TradeSkillMasterIntegration.lua \ No newline at end of file +src\Integrations\TradeSkillMasterIntegration.lua + +src\Models\AbstractCoveredFrame.lua +src\Models\ClassTrainerCoveredFrame.lua +src\Models\GenericCoveredFrame.lua +src\Models\MerchantCoveredFrame.lua \ No newline at end of file diff --git a/CenterGossipFrame-Classic.toc b/CenterGossipFrame-Classic.toc index ad93678..4a3163c 100644 --- a/CenterGossipFrame-Classic.toc +++ b/CenterGossipFrame-Classic.toc @@ -2,12 +2,17 @@ ## Interface: 11503 ## Name: Center Gossip Frame ## Notes: A very simple addon to centralize the main gossip and quest frames. -## Title: Center Gossip Frame |cffffee771.2.0|r -## Version: 1.2.0 +## Title: Center Gossip Frame |cffffee771.3.0|r +## Version: 1.3.0 ## X-Curse-Project-ID: 1051711 lib\stormwind-library.lua CenterGossipFrame.lua -src\Integrations\TradeSkillMasterIntegration.lua \ No newline at end of file +src\Integrations\TradeSkillMasterIntegration.lua + +src\Models\AbstractCoveredFrame.lua +src\Models\ClassTrainerCoveredFrame.lua +src\Models\GenericCoveredFrame.lua +src\Models\MerchantCoveredFrame.lua \ No newline at end of file diff --git a/CenterGossipFrame.lua b/CenterGossipFrame.lua index e474df3..2900153 100644 --- a/CenterGossipFrame.lua +++ b/CenterGossipFrame.lua @@ -1,131 +1,28 @@ ---@diagnostic disable: duplicate-set-field -CenterGossipFrame = StormwindLibrary_v1_12_1.new({ +CenterGossipFrame = StormwindLibrary_v1_13_0.new({ colors = { primary = 'ED5859' }, name = 'Center Gossip Frame', - version = '1.2.0', + version = '1.3.0', }) ---[[ -Apply a listener to a frame. - -The listener will be responsible for detecting when the frame is updated and -centralizing it if necessary. When the frame is already centralized, no changes -will be made, so the listener will not be invoked repeatedly. - -@param gameFrame The frame to be listened to -]] -function CenterGossipFrame:applyListener(gameFrame) - if not self:canBeCentralized(gameFrame) then return end - - gameFrame:HookScript('OnUpdate', function() - CenterGossipFrame:maybeCentralizeFrame(gameFrame) - end) -end - ---[[ -Determines whether a frame can be centralized or not. - -@param gameFrame The frame to be checked -]] -function CenterGossipFrame:canBeCentralized(gameFrame) - return - gameFrame ~= nil and - gameFrame.ClearAllPoints ~= nil and - gameFrame.SetPoint ~= nil -end - ---[[ -Positions a frame in the center of the screen. - -This method was designed to be used with the GossipFrame and QuestFrame. It -will probably work with many other frames, but must be well tested if the -addon covers other frames in the future. - -@param gameFrame The frame to be centralized -]] -function CenterGossipFrame:centralizeFrame(gameFrame) - gameFrame:ClearAllPoints() - gameFrame:SetPoint('CENTER', UIParent, 'CENTER', 0, 0) -end - ---[[ -Determines whether a frame is already centered or not. - -@param gameFrame The frame to be checked -]] -function CenterGossipFrame:isFrameCentered(frame) - local point, relativeTo, relativePoint, offsetX, offsetY = frame:GetPoint() - - return - point == 'CENTER' and - relativeTo == UIParent and - relativePoint == 'CENTER' and - offsetX == 0 and - offsetY == 0 -end - ---[[ -May centralize a frame if it can be centralized. - -@param frame The frame to be centralized -]] -function CenterGossipFrame:maybeCentralizeFrame(frame) - -- @TODO: Remove this condition when the frame class implementation is done <2024.08.21> - if not self:shouldCentralizeIfMerchantFrame(frame) then return end - - if not self:isFrameCentered(frame) then - self:centralizeFrame(frame) - end -end - ---[[ -May register the ClassTrainerFrame listener. - -During development, it was noticed that ClassTrainerFrame doesn't exist until -players interact with a class trainer. That said, it can't be registered when the -addon initializes like the other frames. - -Still, it must be registered only once, and that's why this method manages a flag. -]] -function CenterGossipFrame:maybeRegisterClassTrainerFrame() - if CenterGossipFrame.classTrainerFrameRegistered then return end - - CenterGossipFrame:applyListener(ClassTrainerFrame) - - CenterGossipFrame.classTrainerFrameRegistered = true -end - ---[[ -Determines whether the GossipFrame should be centralized or not if it's -MerchantFrame. - -@NOTE: This is a temporary solution that will be migrated in upcoming versions when - every frame will be encapsulated in a class, so these conditions will be - later refactored. -]] -function CenterGossipFrame:shouldCentralizeIfMerchantFrame(frame) - local isMerchantFrame = frame == MerchantFrame - - local isTsmEnabledForMerchantFrame = isMerchantFrame and CenterGossipFrame.tsmIntegration:isMerchantFrameVisible() - - return not isMerchantFrame or not isTsmEnabledForMerchantFrame -end - -CenterGossipFrame:applyListener(GossipFrame) -CenterGossipFrame:applyListener(MerchantFrame) -CenterGossipFrame:applyListener(QuestFrame) -CenterGossipFrame:applyListener(TaxiFrame) - local events = CenterGossipFrame.events events:listen(events.EVENT_NAME_PLAYER_LOGIN, function() -- initializes the TSM integration CenterGossipFrame.tsmIntegration = CenterGossipFrame:new('CenterGossipFrame/TradeSkillMasterIntegration') -end) -events:listenOriginal('TRAINER_UPDATE', function () - CenterGossipFrame:maybeRegisterClassTrainerFrame() + local coveredFrames = { + CenterGossipFrame:new('CenterGossipFrame/GenericCoveredFrame', GossipFrame), + CenterGossipFrame:new('CenterGossipFrame/GenericCoveredFrame', QuestFrame), + CenterGossipFrame:new('CenterGossipFrame/GenericCoveredFrame', TaxiFrame), + CenterGossipFrame:new('CenterGossipFrame/MerchantCoveredFrame'), + CenterGossipFrame:new('CenterGossipFrame/ClassTrainerCoveredFrame'), + } + + CenterGossipFrame.arr:each(coveredFrames, function(coveredFrame) + coveredFrame:register() + end) end) \ No newline at end of file diff --git a/CenterGossipFrame.toc b/CenterGossipFrame.toc index d6bfb77..aebe0c7 100644 --- a/CenterGossipFrame.toc +++ b/CenterGossipFrame.toc @@ -2,12 +2,17 @@ ## Interface: 110002 ## Name: Center Gossip Frame ## Notes: A very simple addon to centralize the main gossip and quest frames. -## Title: Center Gossip Frame |cffffee771.2.0|r -## Version: 1.2.0 +## Title: Center Gossip Frame |cffffee771.3.0|r +## Version: 1.3.0 ## X-Curse-Project-ID: 1051711 lib\stormwind-library.lua CenterGossipFrame.lua -src\Integrations\TradeSkillMasterIntegration.lua \ No newline at end of file +src\Integrations\TradeSkillMasterIntegration.lua + +src\Models\AbstractCoveredFrame.lua +src\Models\ClassTrainerCoveredFrame.lua +src\Models\GenericCoveredFrame.lua +src\Models\MerchantCoveredFrame.lua \ No newline at end of file diff --git a/README.md b/README.md index 0b79c26..843e0ea 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,11 @@ For now, there are no commands available for this addon. ## Changelog +### 2024.09.11 - version 1.3.0 + +* Internal improvements to the centralization process +* Update Stormwind Library to version 1.13.0 + ### 2024.08.21 - version 1.2.0 * New frames covered: Trainer Frame for learning class skills (Classic only) and diff --git a/lib/stormwind-library.lua b/lib/stormwind-library.lua index bf56e3a..79d5da9 100644 --- a/lib/stormwind-library.lua +++ b/lib/stormwind-library.lua @@ -1,19 +1,19 @@ --- Stormwind Library -- @module stormwind-library -if (StormwindLibrary_v1_12_1) then return end +if (StormwindLibrary_v1_13_0) then return end -StormwindLibrary_v1_12_1 = {} -StormwindLibrary_v1_12_1.__index = StormwindLibrary_v1_12_1 +StormwindLibrary_v1_13_0 = {} +StormwindLibrary_v1_13_0.__index = StormwindLibrary_v1_13_0 -function StormwindLibrary_v1_12_1.new(props) - local self = setmetatable({}, StormwindLibrary_v1_12_1) - -- Library version = '1.12.1' +function StormwindLibrary_v1_13_0.new(props) + local self = setmetatable({}, StormwindLibrary_v1_13_0) + -- Library version = '1.13.0' -- list of callbacks to be invoked when the library is loaded self.loadCallbacks = {} ---[[-- +--[[ Removes the callback loader and its properties. ]] function self:destroyCallbackLoader() @@ -23,7 +23,7 @@ function self:destroyCallbackLoader() self.onLoad = nil end ---[[-- +--[[ Invokes all the callbacks that have been enqueued. ]] function self:invokeLoadCallbacks() @@ -34,7 +34,7 @@ function self:invokeLoadCallbacks() self:destroyCallbackLoader() end ---[[-- +--[[ Enqueues a callback function to be invoked when the library is loaded. @tparam function callback The callback function to be invoked when the library is loaded @@ -52,7 +52,7 @@ self:onLoad(function() end) ---[[-- +--[[ Dumps the values of variables and tables in the output, then dies. The dd() stands for "dump and die" and it's a helper function inspired by a PHP framework @@ -229,6 +229,45 @@ local Arr = {} for i, val in pairs(list) do callback(val, i) end end + --[[-- + Filters the list values based on a callback function. + + The callback function must be a function that accepts (val) or (val, i) + where val is the object in the interaction and i it's index. It must return + a boolean value. When it evaluates to true, the value is stored in the results + table. + + If list is an array, the results will be stored in a new array. If it's an + associative array, the results will be stored in a new associative array with + the same keys. + + @tparam table list The list to be filtered + @tparam function callback The function to be called for each item in the list + + @treturn table The filtered list + + @usage + local list = {1, 2, 3} + local results = library.arr:filter(list, function(val) return val > 1 end) + -- results = {2, 3} + ]] + function Arr:filter(list, callback) + local results = {} + + self:each(list, function(val, i) + if callback(val, i) then + if self:isArray(list) then + table.insert(results, val) + return + end + + results[i] = val + end + end) + + return results + end + --[[-- Freezes a table, making it immutable. @@ -928,6 +967,27 @@ local Str = {} function Str:trim(value) return value and value:gsub("^%s*(.-)%s*$", "%1") or value end + + --[[-- + Returns the given string with the first character capitalized. + + @NOTE: This function may not work properly if the string starts with a special + character, like an accent, because it uses Lua's default implementations + for sub and upper functions. It should be used with caution and better + when the string is known to start with a letter. Future implementations + may improve this behavior if needed. + + @tparam string value The string to have the first character capitalized + + @treturn string The string with the first character capitalized + + @usage + local value = "hello, world!" + library.str:ucFirst(value) -- "Hello, world!" + ]] + function Str:ucFirst(value) + return value:sub(1, 1):upper() .. value:sub(2) + end -- end of Str self.str = Str @@ -1052,6 +1112,7 @@ Allowed properties = { inventory: table, optional track: boolean, optional name: string, optional + settings: table, optional, read the documentation for more information version: string, optional } ]] @@ -1064,6 +1125,7 @@ self.addon.inventory = self.arr:get(props or {}, 'inventory', { track = false, }) self.addon.name = self.arr:get(props or {}, 'name') +self.addon.settings = self.arr:get(props or {}, 'settings') self.addon.version = self.arr:get(props or {}, 'version') local requiredProperties = { @@ -1076,13 +1138,13 @@ for _, property in ipairs(requiredProperties) do end end ---[[-- +--[[ Contains a list of class structures that Stormwind Library can handle to allow instantiation, protection in case of abstractions, and inheritance. ]] self.classes = {} ---[[-- +--[[ Maps all the possible class types Stormwind Library can handle. ]] self.classTypes = self.arr:freeze({ @@ -1090,7 +1152,7 @@ self.classTypes = self.arr:freeze({ CLASS_TYPE_CONCRETE = 2, }) ---[[-- +--[[ Registers an abstract class. @tparam string classname The name of the abstract class to be registered @@ -1101,7 +1163,7 @@ function self:addAbstractClass(classname, classStructure, clientFlavors) self:addClass(classname, classStructure, clientFlavors, self.classTypes.CLASS_TYPE_ABSTRACT) end ---[[-- +--[[ Helper method that extends a class structure with another by a parent class name and also adds the class. @@ -1118,7 +1180,7 @@ function self:addChildClass(classname, classStructure, parentClassname, clientFl self:addClass(classname, classStructure, clientFlavors, classType) end ---[[-- +--[[ Registers a class so the library is able to instantiate it later. This method just updates the library classes table by registering a class @@ -1150,7 +1212,7 @@ function self:addClass(classname, classStructure, clientFlavors, classType) end) end ---[[-- +--[[ Provides class inheritance by extending a class structure with another by its name. @@ -1170,7 +1232,7 @@ function self:extend(classStructure, parentClassname) setmetatable(classStructure, parentStructure) end ---[[-- +--[[ Returns a class structure by its name. This method's the same as accessing self.classes[classname]. @@ -1186,7 +1248,7 @@ function self:getClass(classname, output) return self.classes[clientFlavor][classname][output or 'structure'] end ---[[-- +--[[ This method emulates the new keyword in OOP languages by instantiating a class by its name as long as the class has a __construct() method with or without parameters. @@ -1206,6 +1268,611 @@ function self:new(classname, ...) return self:getClass(classname).__construct(...) end +--[[-- +A setting is basically a configuration value that can be changed by players. + +Although a setting instance uses Core.Configuration internally for storage purposes, +it's not meant to be used as a configuration value. Configuration values are wider +and can be used in many ways, like saving UI states and even addon data. Settings +are meant to allow players to change the behavior of the addon. + +@classmod Core.Settings.Setting +]] +local Setting = {} + Setting.__index = Setting + Setting.__ = self + self:addClass('Setting', Setting) + + Setting.constants = self.arr:freeze({ + PREFIX = '__settings', + SCOPE_GLOBAL = 'global', + SCOPE_PLAYER = 'player', + TYPE_BOOLEAN = 'boolean', + TYPE_NUMBER = 'number', + TYPE_STRING = 'string', + }) + + --[[-- + Setting constructor. + ]] + function Setting.__construct() + local self = setmetatable({}, Setting) + + self.accessibleByCommand = true + self.scope = self.constants.SCOPE_PLAYER + self.type = self.constants.TYPE_STRING + + return self + end + + --[[-- + Gets a text that explains how to use the setting in a command. + + @treturn string The command help content + ]] + function Setting:getCommandHelpContent() + return self.__.str:trim( + self:getFullyQualifiedId() .. + ' <' .. self.type .. '> ' .. + (self.description or '') + ) + end + + --[[-- + Gets the configuration method to be used when saving the setting. + + The configuration method varies between global and player settings, so this + method returns the proper method to be used in this instance. + + @local + + @treturn string The configuration method + ]] + function Setting:getConfigurationMethod() + return self.scope == self.constants.SCOPE_GLOBAL and 'config' or 'playerConfig' + end + + --[[-- + Gets the setting fully qualified id. + + The fully qualified id is the setting id prefixed by the group id. + + @treturn string The setting fully qualified id + ]] + function Setting:getFullyQualifiedId() + return self.group.id .. '.' .. self.id + end + + --[[-- + Gets the setting key, used to store the setting value in the configuration. + + @local + + @treturn string The setting key + ]] + function Setting:getKey() + return self.constants.PREFIX .. '.' .. self:getFullyQualifiedId() + end + + --[[-- + Gets the setting stored value. + + @treturn any The setting stored value + ]] + function Setting:getValue() + local method = self:getConfigurationMethod() + local key = self:getKey() + + return self.__[method](self.__, key, self.default) + end + + --[[-- + Determines whether the setting stored value evaluates to true. + + This is a helper method that returns true if the stored value is any kind of + value that should be considered true using Support.Bool. + + @see Support.Bool.isTrue + + @treturn boolean Whether the setting stored value evaluates to true + ]] + function Setting:isTrue() + return self.__.bool:isTrue(self:getValue()) + end + + --[[-- + Sets whether the setting is accessible by a command. + + @tparam boolean value Whether the setting is accessible by a command + + @treturn Core.Settings.Setting self + ]] + function Setting:setAccessibleByCommand(value) + self.accessibleByCommand = value + return self + end + + --[[-- + Sets the default value of this setting. + + @tparam mixed value The setting's default value + + @treturn Core.Settings.Setting self + ]] + function Setting:setDefault(value) + self.default = value + return self + end + + --[[-- + Sets the setting description. + + @tparam string value The setting's description + + @treturn Core.Settings.Setting self + ]] + function Setting:setDescription(value) + self.description = value + return self + end + + --[[-- + Sets the setting group. + + @tparam Core.Settings.SettingGroup value The setting's group + + @treturn Core.Settings.Setting self + ]] + function Setting:setGroup(value) + self.group = value + return self + end + + --[[-- + Sets the setting id. + + @tparam string value The setting's id + + @treturn Core.Settings.Setting self + ]] + function Setting:setId(value) + self.id = value + return self + end + + --[[-- + Sets the setting label. + + @tparam string value The setting's label + + @treturn Core.Settings.Setting self + ]] + function Setting:setLabel(value) + self.label = value + return self + end + + --[[-- + Sets the setting scope. + + @tparam string value The setting's scope, listed in Setting.constants + + @treturn Core.Settings.Setting self + ]] + function Setting:setScope(value) + self.scope = value + return self + end + + --[[-- + Sets the setting type. + + @tparam string value The setting's type, listed in Setting.constants + + @treturn Core.Settings.Setting self + ]] + function Setting:setType(value) + self.type = value + return self + end + + --[[-- + Sets the setting value and saves it. + + @tparam any value The setting value + + @treturn Core.Settings.Setting self + ]] + function Setting:setValue(value) + + local oldValue = self:getValue() + + if oldValue == value then + return self + end + + local id = self:getFullyQualifiedId() + local key = self:getKey() + local method = self:getConfigurationMethod() + + self.__[method](self.__, {[key] = value}) + + self.__.events:notify('SETTING_UPDATED', id, oldValue, value) + + return self + end +-- end of Setting + +--[[-- +A SettingGroup is a collection of Setting instances. + +Settings must belong to a group to exist in an addon context. Groups are mostly used +to organize settings in a logical way and present them to players in a user-friendly +manner. + +If an addon doesn't include any groups, all settings will be placed in a general +group. + +@classmod Core.Settings.SettingGroup +]] +local SettingGroup = {} + SettingGroup.__index = SettingGroup + SettingGroup.__ = self + self:addClass('SettingGroup', SettingGroup) + + --[[-- + SettingGroup constructor. + ]] + function SettingGroup.__construct() + local self = setmetatable({}, SettingGroup) + + self.settings = {} + + return self + end + + --[[-- + Adds a setting to the group. + + @tparam Core.Settings.Setting setting The setting to be added + + @treturn Core.Settings.SettingGroup self + ]] + function SettingGroup:addSetting(setting) + self.settings[setting.id] = setting + setting:setGroup(self) + return self + end + + --[[-- + Gets all settings in this group. + + @treturn table[Core.Settings.Setting] All the settings in this group + ]] + function SettingGroup:all() + return self.settings + end + + --[[-- + Gets all settings in this group that are accessible by a command. + + @treturn table[Core.Settings.Setting] All the settings in this group that are accessible by a command + ]] + function SettingGroup:allAccessibleByCommand() + return self.__.arr:filter(self.settings, function(setting) + return setting.accessibleByCommand + end) + end + + --[[-- + Gets a setting in this group by its id. + + It's important to pass the setting id, not the fully qualified id. + + @tparam string id The setting id + + @treturn Core.Settings.Setting|nil The setting instance + ]] + function SettingGroup:getSetting(id) + return self.settings[id] + end + + --[[-- + Gets a setting value in this group by its id. + + It's important to pass the setting id, not the fully qualified id. + + If the setting doesn't exist, this method won't throw an error, but return nil. + + @tparam string id The setting id + + @treturn any|nil The setting value + ]] + function SettingGroup:getSettingValue(id) + local setting = self:getSetting(id) + + if setting then + -- this can't be placed in an inline return statement because it would + -- return nil if the setting value is falsy + return setting:getValue() + end + + return nil + end + + --[[-- + Determines whether this group has settings. + + @treturn boolean Whether this group has settings + ]] + function SettingGroup:hasSettings() + return self.__.arr:count(self.settings) > 0 + end + + --[[-- + Determines whether this group has at least one settings that's accessible by a command. + + @treturn boolean Whether this group has at least one settings that's accessible by a command + ]] + function SettingGroup:hasSettingsAccessibleByCommand() + return self.__.arr:any(self.settings, function(setting) + return setting.accessibleByCommand + end) + end + + --[[-- + Sets the setting group id. + + @tparam string value The setting group's id + + @treturn Core.Settings.SettingGroup self + ]] + function SettingGroup:setId(value) + self.id = value + return self + end + + --[[-- + Sets the setting group label. + + @tparam string value The setting group's label + + @treturn Core.Settings.SettingGroup self + ]] + function SettingGroup:setLabel(value) + self.label = value + return self + end +-- end of SettingGroup + +--[[-- +Settings is the class that holds all the settings for the addon. + +It maintains a list of setting groups, which in turn maintain a list of settings. + +@classmod Core.Settings.Settings +]] +local Settings = {} + Settings.__index = Settings + Settings.__ = self + self:addClass('Settings', Settings) + + --[[-- + Settings constructor. + ]] + function Settings.__construct() + local self = setmetatable({}, Settings) + + self.settingGroups = {} + + return self + end + + --[[-- + Adds a setting to a group represented by its id. + + @tparam Core.Settings.Setting setting The setting to be added + @tparam[opt='general'] string group The id of the group to which the setting will be added + + @treturn Core.Settings.Settings self + ]] + function Settings:addSetting(setting, group) + group = group or 'general' + + self:maybeAddGeneralGroup() + + self.settingGroups[group]:addSetting(setting) + end + + --[[-- + Adds a setting group to the list of setting groups. + + @tparam Core.Settings.SettingGroup settingGroup The setting group to be added + + @treturn Core.Settings.Settings self + ]] + function Settings:addSettingGroup(settingGroup) + self.settingGroups[settingGroup.id] = settingGroup + end + + --[[-- + Gets all the setting instances that are stored in the setting groups. + + @treturn table[Core.Settings.Setting] The setting instances + ]] + function Settings:all() + return self:listSettings('all') + end + + --[[-- + Gets all the command accessible setting instances that are stored in the + setting groups. + + @treturn table[Core.Settings.Setting] The setting instances that are accessible by command + ]] + function Settings:allAccessibleByCommand() + return self:listSettings('allAccessibleByCommand') + end + + --[[-- + Determines whether the addon has at least one setting. + + This method accepts an optional parameter that allows the caller to specify + the method to be called for each setting group to determine whether it has + at least one setting. With that, it's possible to add more filters in the future + without replicating the same logic. + + @tparam[opt='hasSettings'] string settingGroupMethod The method to be called for each setting group + + @treturn boolean Whether the addon has at least one setting + ]] + function Settings:hasSettings(settingGroupMethod) + settingGroupMethod = settingGroupMethod or 'hasSettings' + return self.__.arr:any(self.settingGroups, function(settingGroup) + return settingGroup[settingGroupMethod](settingGroup) + end) + end + + --[[-- + Determines whether the addon has at least one setting that is accessible by + command. + + @treturn boolean Whether the addon has at least one setting that is accessible by command + ]] + function Settings:hasSettingsAccessibleByCommand() + return self:hasSettings('hasSettingsAccessibleByCommand') + end + + --[[-- + Lists settings by invoking a method on each setting group. + + @local + + @tparam string groupMethod The method to be called for each setting group + + @treturn table[Core.Settings.Setting] The setting instances + ]] + function Settings:listSettings(groupMethod) + local settings = {} + + self.__.arr:each(self.settingGroups, function(settingGroup) + settings = self.__.arr:concat(settings, settingGroup[groupMethod](settingGroup)) + end) + + return settings + end + + --[[-- + Gets all the settings that were configured in the addon properties to convert + them into real setting and setting group instances. + + Although not a local method, addons shouldn't call this method directly as it + is meant to be called by the library during the initialization process. + ]] + function Settings:mapFromAddonProperties() + if self.__.addon.settings == nil then + return + end + + self.__.arr:each(self.__.addon.settings.groups, function(group) + local settingGroup = self.__ + :new('SettingGroup') + :setId(group.id) + :setLabel(group.label) + + self:addSettingGroup(settingGroup) + + self.__.arr:each(group.settings, function(setting) + local settingInstance = self.__ + :new('Setting') + :setId(setting.id) + :setLabel(setting.label) + :setDescription(setting.description) + :setType(setting.type) + :setDefault(setting.default) + :setScope(setting.scope) + :setAccessibleByCommand(setting.accessibleByCommand) + + self:addSetting(settingInstance, group.id) + end) + end) + end + + --[[-- + Maybe adds the general setting group to the list of setting groups if it doesn't + already exist. + + @local + ]] + function Settings:maybeAddGeneralGroup() + if not self.settingGroups['general'] then + local generalGroup = self.__ + :new('SettingGroup') + :setId('general') + :setLabel('General') + + self:addSettingGroup(generalGroup) + end + end + + --[[-- + Maybe creates the library Settings instance if the library configuration is + enabled. + + This method works as a static method that shouldn't be called directly by + addons. It's just a way to isolate its logic from the library onLoad callback, + which is the only place where it should be called. + + If configuration is not enabled, this method will bail out early and won't + create any Settings instance. + + @local + ]] + function Settings.maybeCreateLibraryInstance() + local library = Settings.__ + if library:isConfigEnabled() then + library.settings = library:new('Settings') + library.settings:mapFromAddonProperties() + library.commands:maybeAddSettingsOperations() + -- proxy method to get a setting instance by its fully qualified id + library.setting = function(self, settingFullyQualifiedId) + return self.settings:setting(settingFullyQualifiedId) + end + end + end + + --[[-- + Gets a setting instance by its fully qualified id. + + The fully qualified id is the id of the setting group followed by a dot and + the id of the setting. If a single setting id is passed, it's assumed to be + part of the general group. + + @tparam string settingFullyQualifiedId The fully qualified id of the setting + + @treturn Core.Settings.Setting|nil The setting instance + ]] + function Settings:setting(settingFullyQualifiedId) + local id = self.__.str:split(settingFullyQualifiedId, '.') + + local isFullyQualified = #id == 2 + + local groupId = isFullyQualified and id[1] or 'general' + local settingId = isFullyQualified and id[2] or id[1] + + local group = self.settingGroups[groupId] + + return group and group:getSetting(settingId) or nil + end +-- end of Settings + +self:onLoad(function() + self.events:listen(self.events.EVENT_NAME_PLAYER_LOGIN, function() + -- initializes the library Settings instance + self:getClass('Settings').maybeCreateLibraryInstance() + end) +end) + --[[-- The Interval class is a utility class that is capable of executing a given @@ -1882,7 +2549,36 @@ local CommandsHandler = {} end --[[-- - This method adds a help operation to the commands handler. + Adds a get operation to the commands handler. + + The get operation is a default operation that can be overridden in case the + addon wants to provide a custom get command. This implementation gets the value + of a setting and prints it to the chat frame. + + To be accessible by the get operation, the setting must be registered in the + settings handler as accessible by command. + + @TODO: Move the callback in this method to a separate function or class <2024.09.10> + + @see Core.Settings.Setting.setAccessibleByCommand + + @local + ]] + function CommandsHandler:addGetOperation() + self:addOperation('get', 'Gets the value of a setting identified by its id', function (settingId) + local setting = self.__:setting(settingId) + + if setting and setting.accessibleByCommand then + self.__.output:out(settingId.. ' = '..tostring(setting:getValue())) + return + end + + self.__.output:out('Setting not found: '..settingId) + end) + end + + --[[-- + Adds a help operation to the commands handler. The help operation is a default operation that can be overridden in case the addon wants to provide a custom help command. For that, just @@ -1895,13 +2591,110 @@ local CommandsHandler = {} @local ]] function CommandsHandler:addHelpOperation() - local helpCommand = self.__:new('Command') + self:addOperation('help', 'Shows the available operations for this command', function () self:printHelp() end) + end + + --[[-- + Adds a new operation to the commands handler. + + This is a local method and should not be called directly by addons. + + @local - helpCommand:setOperation('help') - helpCommand:setDescription('Shows the available operations for this command.') - helpCommand:setCallback(function () self:printHelp() end) + @tparam string operation The operation that will trigger the callback + @tparam string description The description of the operation + @tparam function callback The callback that will be triggered when the operation is called + ]] + function CommandsHandler:addOperation(operation, description, callback) + local command = self.__:new('Command') + + command:setOperation(operation) + command:setDescription(description) + command:setCallback(callback) - self:add(helpCommand) + self:add(command) + end + + --[[-- + Adds a set operation to the commands handler. + + The set operation is a default operation that can be overridden in case the + addon wants to provide a custom set command. This implementation sets the value + of a setting and prints the result to the chat frame. + + To be accessible by the set operation, the setting must be registered in the + settings handler as accessible by command. + + @TODO: Move the callback in this method to a separate function or class <2024.09.10> + + @see Core.Settings.Setting.setAccessibleByCommand + + @local + ]] + function CommandsHandler:addSetOperation() + self:addOperation('set', 'Sets the value of a setting identified by its id', function (settingId, newValue) + local setting = self.__:setting(settingId) + + if setting and setting.accessibleByCommand then + setting:setValue(newValue) + self.__.output:out(settingId.. ' set with '..newValue) + return + end + + self.__.output:out('Setting not found: '..settingId) + end) + end + + --[[-- + Adds a settings operation to the commands handler. + + The settings operation is a default operation that can be overridden in case the + addon wants to provide a custom settings command. This implementation prints a + list of all settings that are accessible by command and their descriptions. + + To be listed by the settings operation, the setting must be registered in the + settings handler as accessible by command. + + @TODO: Move the callback in this method to a separate function or class <2024.09.10> + + @see Core.Settings.Setting.setAccessibleByCommand + + @local + ]] + function CommandsHandler:addSettingsOperation() + self:addOperation('settings', 'Lists all the setting ids that can be used by get or set', function () + local introduction = + 'Available settings, that can be retrieved with '.. + self.__.output:color(self.slashCommand..' get {id}')..' '.. + 'and updated with '.. + self.__.output:color(self.slashCommand..' set {id} {value}')..' '.. + 'by replacing '.. + self.__.output:color('{id}')..' '.. + 'with any of the ids listed below and '.. + self.__.output:color('{value}')..' '.. + 'with the new value' + + local helpContent = {introduction} + + self.__.arr:each(self.__.settings:allAccessibleByCommand(), function (setting) + table.insert(helpContent, setting:getCommandHelpContent()) + end) + + self.__.output:out(helpContent) + end) + end + + --[[-- + Adds all the default operations related to the settings structure. + + @TODO: Move the callback in this method to a separate function or class <2024.09.10> + + @local + ]] + function CommandsHandler:addSettingsOperations() + self:addGetOperation() + self:addSetOperation() + self:addSettingsOperation() end --[[-- @@ -1971,6 +2764,20 @@ local CommandsHandler = {} ) end + --[[-- + May add the default settings operations to the commands handler if there's at + least one setting that is accessible by command. + + @TODO: Move this method to a separate function or class <2024.09.10> + + @local + ]] + function CommandsHandler:maybeAddSettingsOperations() + if self.__.settings and self.__.settings:hasSettingsAccessibleByCommand() then + self:addSettingsOperations() + end + end + --[[-- This method is responsible for invoking the callback that was registered for the operation, if it exists, or the default one otherwise. @@ -2763,7 +3570,7 @@ local RetailTooltip = {} ]] function RetailTooltip:registerTooltipHandlers() TooltipDataProcessor.AddTooltipPostCall(Enum.TooltipDataType.Item, function(tooltip, data) - self:onItemTooltipShow(tooltip); + self:onItemTooltipShow(tooltip) end) TooltipDataProcessor.AddTooltipPostCall(Enum.TooltipDataType.Unit, function(tooltip, data) diff --git a/src/Models/AbstractCoveredFrame.lua b/src/Models/AbstractCoveredFrame.lua new file mode 100644 index 0000000..4904c19 --- /dev/null +++ b/src/Models/AbstractCoveredFrame.lua @@ -0,0 +1,96 @@ +--[[ +An abstract covered frame is a class that wraps a game frame that is covered by +Center Gossip Frame. + +This class is abstract given that the way frames are handled may vary from one +to another and we need a standard way to perform all the operations that are +required to determine whether a frame should be centered and also how to center +it. +]] +local AbstractCoveredFrame = {} + AbstractCoveredFrame.__index = AbstractCoveredFrame + AbstractCoveredFrame.gameFrame = nil + + CenterGossipFrame:addAbstractClass('CenterGossipFrame/AbstractCoveredFrame', AbstractCoveredFrame) + + --[[ + Applies a listener to the game frame OnUpdate event. + + The listener is called every time the game frame is updated so it can be + centered if necessary. + ]] + function AbstractCoveredFrame:applyListener() + if not self:canBeCentralized() then return end + + self.gameFrame:HookScript('OnUpdate', function() + self:maybeCentralizeFrame() + end) + end + + --[[ + Determines whether the frame can be centralized. + + @treturn boolean + ]] + function AbstractCoveredFrame:canBeCentralized() + return + self.gameFrame ~= nil and + self.gameFrame.ClearAllPoints ~= nil and + self.gameFrame.SetPoint ~= nil + end + + --[[ + Centralizes the game frame. + ]] + function AbstractCoveredFrame:centralizeFrame() + self.gameFrame:ClearAllPoints() + self.gameFrame:SetPoint('CENTER', UIParent, 'CENTER', 0, 0) + end + + --[[ + Determines whether the frame is centered. + + @treturn boolean + ]] + function AbstractCoveredFrame:isFrameCentered() + local point, relativeTo, relativePoint, offsetX, offsetY = self.gameFrame:GetPoint() + + return + point == 'CENTER' and + relativeTo == UIParent and + relativePoint == 'CENTER' and + offsetX == 0 and + offsetY == 0 + end + + --[[ + May centralize the frame. + ]] + function AbstractCoveredFrame:maybeCentralizeFrame() + if self:shouldCentralize() and not self:isFrameCentered() then + self:centralizeFrame() + end + end + + --[[ + Registers the covered frame in the addon. + + It stores an instance of the covered frame so Center Gossip Frame can centralize + the game frame. + ]] + function AbstractCoveredFrame:register() + error('This is an abstract method and should be implemented by this class inheritances') + end + + --[[ + Determines whether the frame should be centralized. + + This method should be implemented by the concrete classes that inherit from + this one, as the way frames are handled may vary from one to another. + + @treturn boolean + ]] + function AbstractCoveredFrame:shouldCentralize() + error('This is an abstract method and should be implemented by this class inheritances') + end +-- end of AbstractCoveredFrame \ No newline at end of file diff --git a/src/Models/ClassTrainerCoveredFrame.lua b/src/Models/ClassTrainerCoveredFrame.lua new file mode 100644 index 0000000..f8f485f --- /dev/null +++ b/src/Models/ClassTrainerCoveredFrame.lua @@ -0,0 +1,45 @@ +--[[ +The ClassTrainerCoveredFrame is a specific implementation for the ClassTrainer +frame. + +This frame deserves a special treatment because due to when it's instantiated in the +game. As of the time of implementation, the ClassTrainer is nil when the game opens +and it's only a valid frame after the player interacts with the trainer. + +That said, the way this covered frame is registered is a bit different from the +others. +]] +local ClassTrainerCoveredFrame = {} + ClassTrainerCoveredFrame.__index = ClassTrainerCoveredFrame + + CenterGossipFrame:addChildClass('CenterGossipFrame/ClassTrainerCoveredFrame', ClassTrainerCoveredFrame, 'CenterGossipFrame/AbstractCoveredFrame') + + --[[ + ClassTrainerCoveredFrame constructor. + ]] + function ClassTrainerCoveredFrame.__construct() + return setmetatable({}, ClassTrainerCoveredFrame) + end + + --[[ + May register the frame if it's not already registered. + ]] + function ClassTrainerCoveredFrame:maybeRegisterOnTrainerUpdate() + if not self.gameFrame then + self.gameFrame = ClassTrainerFrame + self:applyListener() + end + end + + --[[ @inheritDoc ]] + function ClassTrainerCoveredFrame:register() + CenterGossipFrame.events:listenOriginal('TRAINER_UPDATE', function() + self:maybeRegisterOnTrainerUpdate() + end) + end + + --[[ @inheritDoc ]] + function ClassTrainerCoveredFrame:shouldCentralize() + return true + end +-- end of ClassTrainerCoveredFrame \ No newline at end of file diff --git a/src/Models/GenericCoveredFrame.lua b/src/Models/GenericCoveredFrame.lua new file mode 100644 index 0000000..c979c0d --- /dev/null +++ b/src/Models/GenericCoveredFrame.lua @@ -0,0 +1,30 @@ +--[[ +GenericCoveredFrame covers the majority of the frames in World of Warcraft and it's +the default implementation of AbstractCoveredFrame. +]] +local GenericCoveredFrame = {} + GenericCoveredFrame.__index = GenericCoveredFrame + + CenterGossipFrame:addChildClass('CenterGossipFrame/GenericCoveredFrame', GenericCoveredFrame, 'CenterGossipFrame/AbstractCoveredFrame') + + --[[ + GenericCoveredFrame constructor. + ]] + function GenericCoveredFrame.__construct(gameFrame) + local self = setmetatable({}, GenericCoveredFrame) + + self.gameFrame = gameFrame + + return self + end + + --[[ @inheritDoc ]] + function GenericCoveredFrame:register() + self:applyListener() + end + + --[[ @inheritDoc ]] + function GenericCoveredFrame:shouldCentralize() + return true + end +-- end of GenericCoveredFrame \ No newline at end of file diff --git a/src/Models/MerchantCoveredFrame.lua b/src/Models/MerchantCoveredFrame.lua new file mode 100644 index 0000000..cee0cf0 --- /dev/null +++ b/src/Models/MerchantCoveredFrame.lua @@ -0,0 +1,27 @@ +--[[ +The MerchantCoveredFrame is a specific implementation for the MerchantFrame frame. + +This frame deserves a special treatment due to the fact that some other addons can +take over it and change its behavior, like TradeSkillMaster. +]] +local MerchantCoveredFrame = {} + MerchantCoveredFrame.__index = MerchantCoveredFrame + + CenterGossipFrame:addChildClass('CenterGossipFrame/MerchantCoveredFrame', MerchantCoveredFrame, 'CenterGossipFrame/GenericCoveredFrame') + + --[[ + MerchantCoveredFrame constructor. + ]] + function MerchantCoveredFrame.__construct() + local self = setmetatable({}, MerchantCoveredFrame) + + self.gameFrame = MerchantFrame + + return self + end + + --[[ @inheritDoc ]] + function MerchantCoveredFrame:shouldCentralize() + return not CenterGossipFrame.tsmIntegration:isMerchantFrameVisible() + end +-- end of MerchantCoveredFrame \ No newline at end of file diff --git a/tests/CenterGossipFrameTest.lua b/tests/CenterGossipFrameTest.lua index 865e87f..1e322a1 100644 --- a/tests/CenterGossipFrameTest.lua +++ b/tests/CenterGossipFrameTest.lua @@ -1,126 +1,5 @@ TestCenterGossipFrame = BaseTestClass:new() --- @covers CenterGossipFrame:applyListener() -TestCase.new() - :setName('applyListener') - :setTestClass(TestCenterGossipFrame) - :setExecution(function(data) - CenterGossipFrame = Spy - .new(CenterGossipFrame) - :mockMethod('canBeCentralized', function() return data.canBeCentralized end) - :mockMethod('maybeCentralizeFrame') - - local frameSpy = Spy - .new() - :mockMethod('HookScript') - - CenterGossipFrame:applyListener(frameSpy) - - CenterGossipFrame:getMethod('canBeCentralized'):assertCalledOnceWith(frameSpy) - frameSpy:getMethod('HookScript'):assertCalledOrNot(data.shouldHookScript) - end) - :setScenarios({ - ['can be centralized'] = { - canBeCentralized = true, - shouldHookScript = true, - }, - ["can't be centralized"] = { - canBeCentralized = false, - shouldHookScript = false, - }, - }) - :register() - --- @covers CenterGossipFrame:canBeCentralized() -TestCase.new() - :setName('canBeCentralized') - :setTestClass(TestCenterGossipFrame) - :setExecution(function(data) - local result = CenterGossipFrame:canBeCentralized(data.frame) - - lu.assertEquals(data.expected, result) - end) - :setScenarios({ - ['frame is nil'] = { - frame = nil, - expected = false, - }, - ['frame.ClearAllPoints is nil'] = { - frame = { SetPoint = function() end }, - expected = false, - }, - ['frame.SetPoint is nil'] = { - frame = { ClearAllPoints = function() end }, - expected = false, - }, - ['frame is valid'] = { - frame = { ClearAllPoints = function() end, SetPoint = function() end }, - expected = true, - }, - }) - :register() - --- @covers CenterGossipFrame:centralizeFrame() -TestCase.new() - :setName('centralizeFrame') - :setTestClass(TestCenterGossipFrame) - :setExecution(function(data) - local frame = CreateFrame() - - CenterGossipFrame:centralizeFrame(frame) - - lu.assertTrue(frame.clearAllPointsInvoked) - lu.assertEquals({ - ['CENTER'] = { - relativeFrame = UIParent, - relativePoint = 'CENTER', - xOfs = 0, - yOfs = 0, - } - }, frame.points) - end) - :register() - --- @covers CenterGossipFrame:isFrameCentered() -TestCase.new() - :setName('isFrameCentered') - :setTestClass(TestCenterGossipFrame) - :setExecution(function(data) - local frameSpy = Spy - .new() - :mockMethod('GetPoint', function() - return - data.point, - data.relativeTo, - data.relativePoint, - data.offsetX, - data.offsetY - end) - - local result = CenterGossipFrame:isFrameCentered(frameSpy) - - lu.assertEquals(data.expected, result) - end) - :setScenarios({ - ['frame is not centered'] = { - point = 'TOPLEFT', - relativeTo = UIParent, - relativePoint = 'TOPLEFT', - offsetX = 0, - offsetY = 0, - expected = false, - }, - ['frame is centered'] = { - point = 'CENTER', - relativeTo = UIParent, - relativePoint = 'CENTER', - offsetX = 0, - offsetY = 0, - expected = true, - }, - }) - :register() - -- @covers CenterGossipFrame TestCase.new() :setName('mainAddonFile') @@ -130,145 +9,4 @@ TestCase.new() lu.assertNotIsNil(CenterGossipFrame.tsmIntegration) end) :register() - --- @covers CenterGossipFrame:maybeCentralizeFrame() -TestCase.new() - :setName('maybeCentralizeFrame') - :setTestClass(TestCenterGossipFrame) - :setExecution(function(data) - local frame = Spy.new() - - CenterGossipFrame = Spy - .new(CenterGossipFrame) - :mockMethod('isFrameCentered', function() return data.isFrameCentered end) - :mockMethod('centralizeFrame') - :mockMethod('shouldCentralizeIfMerchantFrame', function() return data.shouldCentralizeIfMerchantFrame end) - - CenterGossipFrame:maybeCentralizeFrame(frame) - - if data.shouldInvokeCentralizeFrame then - CenterGossipFrame:getMethod('isFrameCentered'):assertCalledOnceWith(frame) - return - end - - CenterGossipFrame:getMethod('centralizeFrame'):assertNotCalled() - end) - :setScenarios({ - ['frame is centered'] = { - isFrameCentered = true, - shouldCentralizeIfMerchantFrame = true, - shouldInvokeCentralizeFrame = false, - }, - ['frame is not centered'] = { - isFrameCentered = false, - shouldCentralizeIfMerchantFrame = true, - shouldInvokeCentralizeFrame = true, - }, - ['MerchantFrame'] = { - isFrameCentered = true, - shouldCentralizeIfMerchantFrame = false, - shouldInvokeCentralizeFrame = false, - }, - }) - :register() - --- @covers CenterGossipFrame:maybeRegisterClassTrainerFrame() -TestCase.new() - :setName('maybeRegisterClassTrainerFrame') - :setTestClass(TestCenterGossipFrame) - :setExecution(function(data) - _G['ClassTrainerFrame'] = Spy.new() - - CenterGossipFrame = Spy - .new(CenterGossipFrame) - :mockMethod('applyListener') - - CenterGossipFrame.classTrainerFrameRegistered = data.classTrainerFrameRegistered - - CenterGossipFrame:maybeRegisterClassTrainerFrame() - - lu.assertTrue(CenterGossipFrame.classTrainerFrameRegistered) - - if data.shouldRegister then - CenterGossipFrame:getMethod('applyListener'):assertCalledOnceWith(ClassTrainerFrame) - return - end - - CenterGossipFrame:getMethod('applyListener'):assertNotCalled() - end) - :setScenarios({ - ['flag is nil'] = { - classTrainerFrameRegistered = nil, - shouldRegister = true, - }, - ['not registered'] = { - classTrainerFrameRegistered = false, - shouldRegister = true, - }, - ['already registered'] = { - classTrainerFrameRegistered = true, - shouldRegister = false, - }, - }) - --- @covers CenterGossipFrame:shouldCentralizeIfMerchantFrame() -TestCase.new() - :setName('shouldCentralizeIfMerchantFrame') - :setTestClass(TestCenterGossipFrame) - :setExecution(function(data) - _G['MerchantFrame'] = data.merchantFrame - - CenterGossipFrame.tsmIntegration = Spy - .new() - :mockMethod('isMerchantFrameVisible', function() return data.isTsmMerchantFrameVisible end) - - local result = CenterGossipFrame:shouldCentralizeIfMerchantFrame(data.frame) - - lu.assertEquals(data.expectedResult, result) - end) - :setScenarios({ - ['not MerchantFrame'] = { - frame = Spy.new(), - merchantFrame = Spy.new(), - isTsmMerchantFrameVisible = true, - expectedResult = true, - }, - ['TSM not visible'] = function() - local merchantFrame = Spy.new() - - return { - frame = merchantFrame, - merchantFrame = merchantFrame, - isTsmMerchantFrameVisible = false, - expectedResult = true, - } - end, - ['TSM visible'] = function() - local merchantFrame = Spy.new() - - return { - frame = merchantFrame, - merchantFrame = merchantFrame, - isTsmMerchantFrameVisible = true, - expectedResult = false, - } - end, - }) - :register() - -TestCase.new() - :setName('TRAINER_UPDATE listener') - :setTestClass(TestCenterGossipFrame) - :setExecution(function() - _G['CenterGossipFrame'] = Spy - .new(CenterGossipFrame) - :mockMethod('maybeRegisterClassTrainerFrame') - - CenterGossipFrame.events:handleOriginal(nil, 'TRAINER_UPDATE') - - CenterGossipFrame - :getMethod('maybeRegisterClassTrainerFrame') - :assertCalledOnce() - end) - :register() --- end of MultiTargetsTest +-- end of TestCenterGossipFrame diff --git a/tests/Models/AbstractCoveredFrameTest.lua b/tests/Models/AbstractCoveredFrameTest.lua new file mode 100644 index 0000000..5e029ff --- /dev/null +++ b/tests/Models/AbstractCoveredFrameTest.lua @@ -0,0 +1,200 @@ +TestAbstractCoveredFrame = BaseTestClass:new() + +-- helper method to instantiate the abstract class +function TestAbstractCoveredFrame:instance() + -- instantiating an abstract class here is ok for the sake of testing + return setmetatable({}, CenterGossipFrame:getClass('CenterGossipFrame/AbstractCoveredFrame')) +end + +-- @covers AbstractCoveredFrame +TestCase.new() + :setName('abstraction') + :setTestClass(TestAbstractCoveredFrame) + :setExecution(function() + local instance = TestAbstractCoveredFrame:instance() + + local expectedMsg = 'This is an abstract method and should be implemented by this class inheritances' + + lu.assertErrorMsgContains(expectedMsg, instance.register) + lu.assertErrorMsgContains(expectedMsg, instance.shouldCentralize) + end) + :register() + +-- @covers AbstractCoveredFrame:applyListener() +TestCase.new() + :setName('applyListener') + :setTestClass(TestAbstractCoveredFrame) + :setExecution(function(data) + local instance = Spy + .new(TestAbstractCoveredFrame:instance()) + :mockMethod('canBeCentralized', function() return data.canBeCentralized end) + :mockMethod('maybeCentralizeFrame') + + local gameFrame = Spy + .new({}) + :mockMethod('HookScript', function(_, _, callback) + callback() + end) + + instance.gameFrame = gameFrame + + instance:applyListener() + + instance + :getMethod('maybeCentralizeFrame') + :assertCalledOrNot(data.shouldCentralize) + end) + :setScenarios({ + ['can be centralized'] = { + canBeCentralized = true, + shouldCentralize = true, + }, + ['cannot be centralized'] = { + canBeCentralized = false, + shouldCentralize = false, + }, + }) + :register() + +-- @covers AbstractCoveredFrame:canBeCentralized() +TestCase.new() + :setName('canBeCentralized') + :setTestClass(TestAbstractCoveredFrame) + :setExecution(function(data) + local instance = TestAbstractCoveredFrame:instance() + + instance.gameFrame = data.gameFrame + + lu.assertEquals(data.expectedResult, instance:canBeCentralized()) + end) + :setScenarios({ + ['nil gameFrame'] = { + gameFrame = nil, + expectedResult = false, + }, + ['no ClearAllPoints'] = { + gameFrame = { + SetPoint = function() end, + }, + expectedResult = false, + }, + ['no SetPoint'] = { + gameFrame = { + ClearAllPoints = function() end, + }, + expectedResult = false, + }, + ['all methods available'] = { + gameFrame = { + ClearAllPoints = function() end, + SetPoint = function() end, + }, + expectedResult = true, + }, + }) + :register() + +-- @covers AbstractCoveredFrame:centralizeFrame() +TestCase.new() + :setName('centralizeFrame') + :setTestClass(TestAbstractCoveredFrame) + :setExecution(function() + _G['UIParent'] = Spy.new({}) + + local gameFrame = Spy + .new({}) + :mockMethod('ClearAllPoints') + :mockMethod('SetPoint') + + local instance = TestAbstractCoveredFrame:instance() + + instance.gameFrame = gameFrame + + instance:centralizeFrame() + + gameFrame:getMethod('ClearAllPoints'):assertCalledOnce() + gameFrame:getMethod('SetPoint'):assertCalledOnceWith('CENTER', UIParent, 'CENTER', 0, 0) + end) + :register() + +-- @covers AbstractCoveredFrame:isFrameCentered() +TestCase.new() + :setName('isFrameCentered') + :setTestClass(TestAbstractCoveredFrame) + :setExecution(function(data) + _G['UIParent'] = 'UIParent' + + local gameFrame = Spy + .new() + :mockMethod('GetPoint', function() + return + data.point, + data.relativeTo, + data.relativePoint, + data.offsetX, + data.offsetY + end) + + local instance = TestAbstractCoveredFrame:instance() + + instance.gameFrame = gameFrame + + local result = instance:isFrameCentered(gameFrame) + + lu.assertEquals(data.expectedResult, result) + end) + :setScenarios({ + ['frame is not centered'] = { + point = 'TOPLEFT', + relativeTo = 'UIParent', + relativePoint = 'TOPLEFT', + offsetX = 0, + offsetY = 0, + expectedResult = false, + }, + ['frame is centered'] = { + point = 'CENTER', + relativeTo = 'UIParent', + relativePoint = 'CENTER', + offsetX = 0, + offsetY = 0, + expectedResult = true, + }, + }) + :register() + +-- @covers AbstractCoveredFrame:maybeCentralizeFrame() +TestCase.new() + :setName('maybeCentralizeFrame') + :setTestClass(TestAbstractCoveredFrame) + :setExecution(function(data) + local instance = Spy + .new(TestAbstractCoveredFrame:instance()) + :mockMethod('shouldCentralize', function() return data.shouldCentralize end) + :mockMethod('isFrameCentered', function() return data.isFrameCentered end) + :mockMethod('centralizeFrame') + + instance:maybeCentralizeFrame() + + instance + :getMethod('centralizeFrame') + :assertCalledOrNot(data.shouldCallCentralizeFrame) + end) + :setScenarios({ + ['should not centralize'] = { + shouldCentralize = false, + isFrameCentered = false, + shouldCallCentralizeFrame = false, + }, + ['frame is centered'] = { + shouldCentralize = true, + isFrameCentered = true, + shouldCallCentralizeFrame = false, + }, + ['should centralize'] = { + shouldCentralize = true, + isFrameCentered = false, + shouldCallCentralizeFrame = true, + }, + }) + :register() \ No newline at end of file diff --git a/tests/Models/ClassTrainerCoveredFrameTest.lua b/tests/Models/ClassTrainerCoveredFrameTest.lua new file mode 100644 index 0000000..dee4a17 --- /dev/null +++ b/tests/Models/ClassTrainerCoveredFrameTest.lua @@ -0,0 +1,72 @@ +TestClassTrainerCoveredFrame = BaseTestClass:new() + +-- @covers ClassTrainerCoveredFrame:__construct() +TestCase.new() + :setName('__construct') + :setTestClass(TestClassTrainerCoveredFrame) + :setExecution(function() + local instance = CenterGossipFrame:new('CenterGossipFrame/ClassTrainerCoveredFrame') + + lu.assertNotNil(instance) + end) + :register() + +-- @covers ClassTrainerCoveredFrame:maybeRegisterOnTrainerUpdate() +TestCase.new() + :setName('maybeRegisterOnTrainerUpdate') + :setTestClass(TestClassTrainerCoveredFrame) + :setExecution(function(data) + _G['ClassTrainerFrame'] = 'ClassTrainerFrame' + + local instance = Spy + .new(CenterGossipFrame:new('CenterGossipFrame/ClassTrainerCoveredFrame')) + :mockMethod('applyListener') + + instance.gameFrame = data.gameFrame + + instance:maybeRegisterOnTrainerUpdate() + + lu.assertEquals(ClassTrainerFrame, instance.gameFrame) + instance:getMethod('applyListener'):assertCalledOrNot(data.shouldCallApplyListener) + end) + :setScenarios({ + ['gameFrame is set'] = { + gameFrame = 'ClassTrainerFrame', + shouldCallApplyListener = false, + }, + ['gameFrame is not set'] = { + gameFrame = nil, + shouldCallApplyListener = true, + }, + }) + :register() + +-- @covers ClassTrainerCoveredFrame:register() +TestCase.new() + :setName('register') + :setTestClass(TestClassTrainerCoveredFrame) + :setExecution(function() + local instance = Spy + .new(CenterGossipFrame:new('CenterGossipFrame/ClassTrainerCoveredFrame')) + :mockMethod('maybeRegisterOnTrainerUpdate') + + instance:register() + + instance:getMethod('maybeRegisterOnTrainerUpdate'):assertNotCalled() + + CenterGossipFrame.events:handleOriginal(nil, 'TRAINER_UPDATE') + + instance:getMethod('maybeRegisterOnTrainerUpdate'):assertCalledOnce() + end) + :register() + +-- @covers ClassTrainerCoveredFrame:shouldCentralize() +TestCase.new() + :setName('shouldCentralize') + :setTestClass(TestClassTrainerCoveredFrame) + :setExecution(function() + local instance = CenterGossipFrame:new('CenterGossipFrame/ClassTrainerCoveredFrame') + + lu.assertIsTrue(instance:shouldCentralize()) + end) + :register() \ No newline at end of file diff --git a/tests/Models/GenericCoveredFrameTest.lua b/tests/Models/GenericCoveredFrameTest.lua new file mode 100644 index 0000000..67370f4 --- /dev/null +++ b/tests/Models/GenericCoveredFrameTest.lua @@ -0,0 +1,40 @@ +TestGenericCoveredFrame = BaseTestClass:new() + +-- @covers GenericCoveredFrame:__construct() +TestCase.new() + :setName('__construct') + :setTestClass(TestGenericCoveredFrame) + :setExecution(function() + local gameFrame = Spy.new() + + local instance = CenterGossipFrame:new('CenterGossipFrame/GenericCoveredFrame', gameFrame) + + lu.assertEquals(gameFrame, instance.gameFrame) + end) + :register() + +-- @covers GenericCoveredFrame:register() +TestCase.new() + :setName('register') + :setTestClass(TestGenericCoveredFrame) + :setExecution(function() + local instance = Spy + .new(CenterGossipFrame:new('CenterGossipFrame/GenericCoveredFrame', Spy.new())) + :mockMethod('applyListener') + + instance:register() + + instance:getMethod('applyListener'):assertCalledOnce() + end) + :register() + +-- @covers GenericCoveredFrame:shouldCentralize() +TestCase.new() + :setName('shouldCentralize') + :setTestClass(TestGenericCoveredFrame) + :setExecution(function() + local instance = CenterGossipFrame:new('CenterGossipFrame/GenericCoveredFrame', Spy.new()) + + lu.assertIsTrue(instance:shouldCentralize()) + end) + :register() \ No newline at end of file diff --git a/tests/Models/MerchantCoveredFrameTest.lua b/tests/Models/MerchantCoveredFrameTest.lua new file mode 100644 index 0000000..7c0e7be --- /dev/null +++ b/tests/Models/MerchantCoveredFrameTest.lua @@ -0,0 +1,39 @@ +TestMerchantCoveredFrame = BaseTestClass:new() + +-- @covers MerchantCoveredFrame:__construct() +TestCase.new() + :setName('__construct') + :setTestClass(TestMerchantCoveredFrame) + :setExecution(function() + _G['MerchantFrame'] = 'MerchantFrame' + + local instance = CenterGossipFrame:new('CenterGossipFrame/MerchantCoveredFrame') + + lu.assertEquals('MerchantFrame', instance.gameFrame) + end) + :register() + +-- @covers MerchantCoveredFrame:shouldCentralize() +TestCase.new() + :setName('shouldCentralize') + :setTestClass(TestMerchantCoveredFrame) + :setExecution(function(data) + CenterGossipFrame.tsmIntegration = Spy + .new() + :mockMethod('isMerchantFrameVisible', function() return data.isMerchantFrameVisible end) + + local instance = CenterGossipFrame:new('CenterGossipFrame/MerchantCoveredFrame', Spy.new()) + + lu.assertEquals(data.expectedResult, instance:shouldCentralize()) + end) + :setScenarios({ + ['MerchantFrame is visible'] = { + isMerchantFrameVisible = true, + expectedResult = false, + }, + ['MerchantFrame is not visible'] = { + isMerchantFrameVisible = false, + expectedResult = true, + }, + }) + :register() \ No newline at end of file diff --git a/tests/unit.lua b/tests/unit.lua index b3a664c..6b79902 100644 --- a/tests/unit.lua +++ b/tests/unit.lua @@ -30,6 +30,11 @@ BaseTestClass = { dofile('./src/Integrations/TradeSkillMasterIntegration.lua') + dofile('./src/Models/AbstractCoveredFrame.lua') + dofile('./src/Models/ClassTrainerCoveredFrame.lua') + dofile('./src/Models/GenericCoveredFrame.lua') + dofile('./src/Models/MerchantCoveredFrame.lua') + CenterGossipFrame.events:handleOriginal(nil, 'PLAYER_LOGIN') CenterGossipFrame.output:setTestingMode() @@ -86,6 +91,11 @@ dofile('./tests/CenterGossipFrameTest.lua') dofile('./tests/Integrations/TradeSkillMasterIntegrationTest.lua') +dofile('./tests/Models/AbstractCoveredFrameTest.lua') +dofile('./tests/Models/ClassTrainerCoveredFrameTest.lua') +dofile('./tests/Models/GenericCoveredFrameTest.lua') +dofile('./tests/Models/MerchantCoveredFrameTest.lua') + lu.ORDER_ACTUAL_EXPECTED=false os.exit(lu.LuaUnit.run()) \ No newline at end of file