Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .luacheckrc
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,10 @@ files["tests/test_guild_item_level_import.lua"] = {
globals = { "WowStub", "SideQuestDB" },
}

files["tests/test_guild_battlenet_fallback.lua"] = {
globals = { "WowStub", "SideQuestDB" },
}

files["tests/test_minimap.lua"] = {
globals = { "WowStub", "SideQuestDB" },
}
Expand Down
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,10 @@ The full rules and prices are on the [Ledger page](https://sidequest-guild.com/l
| News | Guild announcements and recent activity. |

Click someone on the Guild tab to see their level, spec, item level, professions, Onyxia attunement, tracked
reputations and cooldowns; item level comes from the site's last sync when they haven't logged in with the addon
running. Hover any item to see which guildmates craft, sell, buy or hold it, how many have it on their wishlist, and
the guild's lowest auction-house price if a sync has priced it.
reputations and cooldowns; whichever of item level, spec, professions and reputations the addon hasn't scanned live
comes from the site's last sync instead, marked "(from Battle.net)" since it can be a few hours stale. Hover any item
to see which guildmates craft, sell, buy or hold it, how many have it on their wishlist, and the guild's lowest
auction-house price if a sync has priced it.

Your event sign-ups reach the website the next time an officer syncs. You don't need to do anything else.

Expand All @@ -69,8 +70,9 @@ SideQuest keeps a record of your character and shares it with the guild automati
your level, class, spec and item level, your craft cooldowns, your Onyxia attunement and the few reputations the guild
tracks, what you sell and want to buy, and the crafting materials in your bags and bank (never gold or gear). That's
how the Crafters and Market tabs work for guildmates who are offline but have run the addon recently; a site sync
fills in the rest -- crafters, item level and wishlist counts -- for guildies who are offline or have never installed
it, and officers' syncs carry it to the website, where only signed-in guild members see it.
fills in the rest -- crafters, item level, spec, professions, tracked reputations and wishlist counts -- for guildies
who are offline or have never installed it, and officers' syncs carry it to the website, where only signed-in guild
members see it.

Turn any part off on the You tab, or with `/sq share <prof|char|cd|market|stock> on|off`. `/sq dir` shows what you
share and how many guildmates the addon has heard from. Open each profession window once so the guild knows your
Expand Down
83 changes: 68 additions & 15 deletions SideQuest/UI/Main.lua
Original file line number Diff line number Diff line change
Expand Up @@ -1637,22 +1637,60 @@ local function buildGuildDetail(content)
return block
end

--- A character's equipped item level from the last site import ("Game
-- import" contract GameImportCharacter.item_level, additive): nil when
-- the import has no entry for them, or when an import predating the
-- field is all the addon has. Only consulted when the live directory
-- hasn't scanned this character's gear yet.
local function importItemLevel(character)
--- A character's entry from the last site import ("Game import" contract
-- GameImportCharacter, additive fields all nil/empty when the import
-- predates them or has no record for the character). Only consulted
-- when the live directory hasn't supplied that section's data yet; a
-- live scan always wins.
local function importedCharacter(character)
for _, member in ipairs((SideQuestDB.guild and SideQuestDB.guild.members) or {}) do
for _, char in ipairs(member.characters or {}) do
if char.name == character then
return char.item_level
return char
end
end
end
return nil
end

--- Imported reputations (GameImportReputation), filtered to
-- SideQuest.Data.REPUTATIONS and ordered by that list, or nil when the
-- import has nothing on the tracked list.
local function importedTrackedReputations(imported)
local tracked = SideQuest.Data and SideQuest.Data.REPUTATIONS
if not (tracked and imported and imported.reputations and #imported.reputations > 0) then
return nil
end
local byFaction = {}
for _, rep in ipairs(imported.reputations) do
byFaction[rep.faction_id] = rep
end
local out = {}
for _, def in ipairs(tracked) do
local rep = byFaction[def.faction_id]
if rep then table.insert(out, rep) end
end
if #out == 0 then return nil end
return out
end

--- A live reputation row: "Label: Standing (value)".
local function liveReputationLine(rep)
local label = reputationLabel(rep.faction_id)
return label .. ": " .. (STANDING_WORD[rep.standing] or "--") .. (rep.value and (" (" .. rep.value .. ")") or "")
end

--- An imported reputation row: "Label: Standing (value/max)", or just the
-- standing word at Exalted or when the import has no max.
local function importedReputationLine(rep)
local label = reputationLabel(rep.faction_id)
local word = STANDING_WORD[rep.standing] or "--"
if rep.standing == 8 or not rep.max or rep.max == 0 then
return label .. ": " .. word
end
return label .. ": " .. word .. " (" .. (rep.value or 0) .. "/" .. rep.max .. ")"
end

--- Fills `block` with `character`'s directory record and returns the
-- height it used, so the caller can offset rows below it.
function UI.PaintGuildDetail(block, character)
Expand All @@ -1661,12 +1699,17 @@ function UI.PaintGuildDetail(block, character)
local charData = entry and entry.char and entry.char.d
local profData = entry and entry.prof and entry.prof.d
local cdData = entry and entry.cd and entry.cd.d
local imported = importedCharacter(character)

setNameRow(block.nameRow, character, charData and charData.class)
local metaParts = {}
if charData and charData.level then table.insert(metaParts, "Level " .. charData.level) end
if charData and charData.spec then table.insert(metaParts, charData.spec) end
local ilvl = (charData and charData.item_level) or importItemLevel(character)
local spec, specFromImport = charData and charData.spec, false
if not spec and imported and imported.spec then
spec, specFromImport = imported.spec, true
end
if spec then table.insert(metaParts, spec .. (specFromImport and " (from Battle.net)" or "")) end
local ilvl = (charData and charData.item_level) or (imported and imported.item_level)
if ilvl then table.insert(metaParts, "ilvl " .. string.format("%.1f", ilvl)) end
block.metaText:SetText(table.concat(metaParts, " -- "))
local freshness = recordFreshness(entry)
Expand Down Expand Up @@ -1705,7 +1748,12 @@ function UI.PaintGuildDetail(block, character)
y = y + 6
end

listOrEmpty(block.profHeading, profData and profData.professions, block.profRows, "profEmptyText",
local profList, profFromImport = profData and profData.professions, false
if not (profList and #profList > 0) and imported and imported.professions and #imported.professions > 0 then
profList, profFromImport = imported.professions, true
end
block.profHeading.label:SetText(trackedCaps(profFromImport and "Professions (from Battle.net)" or "Professions"))
listOrEmpty(block.profHeading, profList, block.profRows, "profEmptyText",
"No professions on record.", function(p)
return p.name .. ": " .. (p.skill or 0) .. " / " .. (p.max_skill or "--")
end)
Expand All @@ -1726,11 +1774,16 @@ function UI.PaintGuildDetail(block, character)
end
y = y + GUILD_DETAIL_LINE + 6

listOrEmpty(block.repHeading, charData and charData.reputations, block.repRows, "repEmptyText",
"No tracked reputations on record.", function(rep)
local label = reputationLabel(rep.faction_id)
return label .. ": " .. (STANDING_WORD[rep.standing] or "--") .. (rep.value and (" (" .. rep.value .. ")") or "")
end)
local repList, repFromImport = charData and charData.reputations, false
if not (repList and #repList > 0) then
local trackedImported = importedTrackedReputations(imported)
if trackedImported then
repList, repFromImport = trackedImported, true
end
end
block.repHeading.label:SetText(trackedCaps(repFromImport and "Reputations (from Battle.net)" or "Reputations"))
listOrEmpty(block.repHeading, repList, block.repRows, "repEmptyText",
"No tracked reputations on record.", repFromImport and importedReputationLine or liveReputationLine)

listOrEmpty(block.cdHeading, cdData and cdData.cooldowns, block.cdRows, "cdEmptyText",
"No tracked cooldowns on record.", function(cd)
Expand Down
178 changes: 178 additions & 0 deletions tests/test_guild_battlenet_fallback.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
-- tests/test_guild_battlenet_fallback.lua
-- GameImportCharacter.spec/professions/reputations (SQI1, additive): the Guild
-- tab's member detail falls back to each one, independently, only when the live
-- directory has no data for that section. A live scan always wins per section,
-- imported reputations are filtered to SideQuest.Data.REPUTATIONS (ordered by
-- that list), and an import predating these fields leaves the detail panel
-- working exactly as before (no error, same empty texts).

return function(h)
local test, assertTrue, assertEq, resetAddon = h.test, h.assertTrue, h.assertEq, h.resetAddon
local TrackedCaps = SideQuest.UI.TrackedCaps

local function setupPlayer()
WowStub.realm = "Whitemane"
WowStub.SetUnit("player", { name = "Demo1", realm = "Whitemane", class = "PRIEST", guid = "Player-1-GBF0001" })
end

local function openGuildDetail(character)
SideQuest.UI.main = nil
SideQuest.UI.Show()
SideQuest.UI.ShowTab("Guild")
local chamber = SideQuest.UI.panels.Guild.chamber
chamber.selectedCharacter = character
SideQuest.UI.RefreshGuild()
return chamber.detailBlock
end

local function setRoster(character)
SideQuestDB.roster = { taken_at = WowStub.time, members = {
{ character = character, online = true, rank = "Member" },
} }
end

test("guild detail: spec, professions and reputations come from the import when the live directory has none", function()
resetAddon()
setupPlayer()
WowStub.inCombat = false
setRoster("Imported-Whitemane")
SideQuestDB.guild.members = {
{ member_id = 1, name = "Imported", main = "Imported-Whitemane", class = "WARRIOR", rank = "Member",
raid_status = nil, guild_ep = 0, attendance_rate = 0, events_attended = 0, badges = {},
characters = { { name = "Imported-Whitemane", class = "WARRIOR", level = 60, is_main = true,
spec = "Arms",
professions = { { name = "Tailoring", skill = 300, max_skill = 300 } },
reputations = { { faction_id = 529, standing = 7, value = 4000, max = 21000 } },
} } },
}

local block = openGuildDetail("Imported-Whitemane")
assertTrue(block.metaText:GetText():find("Arms %(from Battle.net%)") ~= nil, "spec shows, marked as imported")
assertEq(block.profHeading.label:GetText(), TrackedCaps("Professions (from Battle.net)"), "professions heading marked")
assertEq(block.profRows[1]:GetText(), "Tailoring: 300 / 300", "imported profession row formatted like a live one")
assertEq(block.repHeading.label:GetText(), TrackedCaps("Reputations (from Battle.net)"), "reputations heading marked")
assertEq(block.repRows[1]:GetText(), "Argent Dawn: Revered (4000/21000)", "imported reputation row shows value/max")
end)

test("guild detail: a live section wins over the import, independently per section", function()
resetAddon()
setupPlayer()
WowStub.inCombat = false
local charData = { level = 60 } -- no spec, no reputations live
local profData = { professions = { { name = "Mining", skill = 250, max_skill = 300 } } }
SideQuestDB.directory["Fresher-Whitemane"] = {
char = { v = SideQuest.Directory.Hash(charData), at = WowStub.time, d = charData },
prof = { v = SideQuest.Directory.Hash(profData), at = WowStub.time, d = profData },
seen_at = WowStub.time,
}
setRoster("Fresher-Whitemane")
SideQuestDB.guild.members = {
{ member_id = 2, name = "Fresher", main = "Fresher-Whitemane", class = "WARRIOR", rank = "Member",
raid_status = nil, guild_ep = 0, attendance_rate = 0, events_attended = 0, badges = {},
characters = { { name = "Fresher-Whitemane", class = "WARRIOR", level = 60, is_main = true,
spec = "Stale Spec",
professions = { { name = "Stale Prof", skill = 1, max_skill = 1 } },
reputations = { { faction_id = 529, standing = 5, value = 100, max = 6000 } },
} } },
}

local block = openGuildDetail("Fresher-Whitemane")
-- spec: live has none, so the import's wins
assertTrue(block.metaText:GetText():find("Stale Spec %(from Battle.net%)") ~= nil, "spec falls back to import")
-- professions: live has data, so the import is ignored and unmarked
assertEq(block.profHeading.label:GetText(), TrackedCaps("Professions"), "professions heading unmarked when live wins")
assertEq(block.profRows[1]:GetText(), "Mining: 250 / 300", "live profession wins over import")
-- reputations: live has none, so the import's wins
assertEq(block.repHeading.label:GetText(), TrackedCaps("Reputations (from Battle.net)"), "reputations heading marked")
assertEq(block.repRows[1]:GetText(), "Argent Dawn: Friendly (100/6000)", "imported reputation wins when live has none")
end)

test("guild detail: imported reputations are filtered to the tracked list and ordered by it", function()
resetAddon()
setupPlayer()
WowStub.inCombat = false
local originalReputations = SideQuest.Data.REPUTATIONS
SideQuest.Data.REPUTATIONS = {
{ faction_id = 529, label = "Argent Dawn" },
{ faction_id = 909, label = "Thorium Brotherhood" },
}
setRoster("Tracked-Whitemane")
SideQuestDB.guild.members = {
{ member_id = 4, name = "Tracked", main = "Tracked-Whitemane", class = "WARRIOR", rank = "Member",
raid_status = nil, guild_ep = 0, attendance_rate = 0, events_attended = 0, badges = {},
characters = { { name = "Tracked-Whitemane", class = "WARRIOR", level = 60, is_main = true,
reputations = {
-- import lists them reversed, plus one faction that isn't tracked at all
{ faction_id = 909, standing = 4, value = 1000, max = 6000 },
{ faction_id = 1, standing = 2, value = 0, max = 3000 },
{ faction_id = 529, standing = 6, value = 2000, max = 12000 },
},
} } },
}

local ok, err = pcall(function()
local block = openGuildDetail("Tracked-Whitemane")
assertEq(#block.repRows, 2, "only the two tracked factions show")
assertEq(block.repRows[1]:GetText(), "Argent Dawn: Honored (2000/12000)", "tracked list order, not import order")
assertEq(block.repRows[2]:GetText(), "Thorium Brotherhood: Neutral (1000/6000)", "second tracked faction follows")
end)
SideQuest.Data.REPUTATIONS = originalReputations
assertTrue(ok, err)
end)

test("guild detail: imported reputation standing words", function()
resetAddon()
setupPlayer()
WowStub.inCombat = false
setRoster("Standing-Whitemane")
SideQuestDB.guild.members = {
{ member_id = 5, name = "Standing", main = "Standing-Whitemane", class = "WARRIOR", rank = "Member",
raid_status = nil, guild_ep = 0, attendance_rate = 0, events_attended = 0, badges = {},
characters = { { name = "Standing-Whitemane", class = "WARRIOR", level = 60, is_main = true,
reputations = { { faction_id = 529, standing = 8, value = 21000, max = 21000 } },
} } },
}

local block = openGuildDetail("Standing-Whitemane")
assertEq(block.repRows[1]:GetText(), "Argent Dawn: Exalted", "Exalted shows just the standing word")
end)

test("guild detail: an imported reputation with no max shows just the standing word", function()
resetAddon()
setupPlayer()
WowStub.inCombat = false
setRoster("Nomax-Whitemane")
SideQuestDB.guild.members = {
{ member_id = 6, name = "Nomax", main = "Nomax-Whitemane", class = "WARRIOR", rank = "Member",
raid_status = nil, guild_ep = 0, attendance_rate = 0, events_attended = 0, badges = {},
characters = { { name = "Nomax-Whitemane", class = "WARRIOR", level = 60, is_main = true,
reputations = { { faction_id = 529, standing = 5, value = 100, max = 0 } },
} } },
}

local block = openGuildDetail("Nomax-Whitemane")
assertEq(block.repRows[1]:GetText(), "Argent Dawn: Friendly", "max 0 shows just the standing word")
end)

test("guild detail: an import from before spec/professions/reputations existed shows the old empty texts, without erroring", function()
resetAddon()
setupPlayer()
WowStub.inCombat = false
setRoster("Old-Whitemane")
SideQuestDB.guild.members = {
-- Old-shaped GameImportCharacter: no spec/professions/reputations keys at all.
{ member_id = 7, name = "Old", main = "Old-Whitemane", class = "WARRIOR", rank = "Member",
raid_status = nil, guild_ep = 0, attendance_rate = 0, events_attended = 0, badges = {},
characters = { { name = "Old-Whitemane", class = "WARRIOR", level = 60, is_main = true } } },
}

local ok, block = pcall(openGuildDetail, "Old-Whitemane")
assertTrue(ok, "painting the detail panel for an old-shaped import doesn't error")
assertTrue(block:IsShown(), "the detail block actually painted (sanity check)")
assertTrue(not block.metaText:GetText():find("from Battle.net"), "no spec marker when neither source has one")
assertEq(block.profHeading.label:GetText(), TrackedCaps("Professions"), "professions heading stays unmarked")
assertEq(block.profEmptyText:GetText(), "No professions on record.", "same empty text as before this change")
assertEq(block.repHeading.label:GetText(), TrackedCaps("Reputations"), "reputations heading stays unmarked")
assertEq(block.repEmptyText:GetText(), "No tracked reputations on record.", "same empty text as before this change")
end)
end
Loading