add gs
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
stds.roblox = {
|
||||
globals = {
|
||||
"game"
|
||||
},
|
||||
read_globals = {
|
||||
-- Roblox globals
|
||||
"script",
|
||||
"utf8",
|
||||
|
||||
-- Extra functions
|
||||
"tick", "warn", "spawn",
|
||||
"wait", "settings", "typeof",
|
||||
|
||||
-- Types
|
||||
"Vector2", "Vector3",
|
||||
"Color3",
|
||||
"UDim", "UDim2",
|
||||
"Rect",
|
||||
"CFrame",
|
||||
"Enum",
|
||||
"Instance",
|
||||
}
|
||||
}
|
||||
|
||||
stds.testez = {
|
||||
read_globals = {
|
||||
"describe",
|
||||
"it", "itFOCUS", "itSKIP",
|
||||
"FOCUS", "SKIP", "HACK_NO_XPCALL",
|
||||
"expect",
|
||||
}
|
||||
}
|
||||
|
||||
ignore = {
|
||||
"212", -- unused arguments
|
||||
"421", -- shadowing local variable
|
||||
"422", -- shadowing argument
|
||||
"431", -- shadowing upvalue
|
||||
"432", -- shadowing upvalue argument
|
||||
}
|
||||
|
||||
std = "lua51+roblox"
|
||||
|
||||
files["**/*.spec.lua"] = {
|
||||
std = "+testez",
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
-----------------------------------------------------------------------------
|
||||
--- ---
|
||||
--- Under Migration to CorePackages ---
|
||||
--- ---
|
||||
--- Please put your changes in AppTempCommon. ---
|
||||
--- NOTE: You will have to rebuild for changes to kick in ---
|
||||
-----------------------------------------------------------------------------
|
||||
local CorePackages = game:GetService("CorePackages")
|
||||
return require(CorePackages.AppTempCommon.Common.Action)
|
||||
@@ -0,0 +1,57 @@
|
||||
--[[
|
||||
A centralized hub for basic metrics reporting.
|
||||
This class is designed to provide a baseline exposure to the reporters.
|
||||
|
||||
Context specific Analytics.lua objects should be created in sub-projects,
|
||||
to report specific actions like chat interactions, or game page interactions.
|
||||
|
||||
Analytics.lua and the reporters here in Common should serve to cover the
|
||||
most common interactions.
|
||||
]]
|
||||
|
||||
local AnalyticsService = game:GetService("AnalyticsService")
|
||||
local CoreGui = game:GetService("CoreGui")
|
||||
local Reporters = CoreGui.RobloxGui.Modules.Common.AnalyticsReporters
|
||||
|
||||
local DiagReporter = require(Reporters.Diag)
|
||||
local EventStreamReporter = require(Reporters.EventStream)
|
||||
local GoogleAnalyticsReporter = require(Reporters.GoogleAnalytics)
|
||||
local InfluxDbReporter = require(Reporters.Influx)
|
||||
|
||||
|
||||
local Analytics = {}
|
||||
Analytics.__index = Analytics
|
||||
|
||||
-- reportingService : (Service, optional) an object that exposes the same functions as AnalyticsService
|
||||
function Analytics.new(reportingService)
|
||||
if not reportingService then
|
||||
reportingService = AnalyticsService
|
||||
end
|
||||
|
||||
-- All public reporting functions are exposed by the objects defined in the properties
|
||||
local self = {}
|
||||
self.Diag = DiagReporter.new(reportingService)
|
||||
self.EventStream = EventStreamReporter.new(reportingService)
|
||||
self.GoogleAnalytics = GoogleAnalyticsReporter.new(reportingService)
|
||||
self.InfluxDb = InfluxDbReporter.new(reportingService)
|
||||
|
||||
setmetatable(self, Analytics)
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
function Analytics.mock()
|
||||
-- create a reporting service that does not fire any requests out to the world
|
||||
local fakeReportingService = {}
|
||||
function fakeReportingService.ReportCounter() end
|
||||
function fakeReportingService.ReportInfluxSeries() end
|
||||
function fakeReportingService.ReportStats() end
|
||||
function fakeReportingService.SetRBXEvent() end
|
||||
function fakeReportingService.SetRBXEventStream() end
|
||||
function fakeReportingService.TrackEvent() end
|
||||
function fakeReportingService.UpdateHeartbeatObject() end
|
||||
|
||||
return Analytics.new(fakeReportingService)
|
||||
end
|
||||
|
||||
return Analytics
|
||||
@@ -0,0 +1,69 @@
|
||||
return function()
|
||||
local Analytics = require(script.Parent.Analytics)
|
||||
|
||||
describe("new()", function()
|
||||
it("should properly construct a new object", function()
|
||||
local na = Analytics.new()
|
||||
expect(na).to.be.ok()
|
||||
end)
|
||||
|
||||
it("should accept a custom reporting service", function()
|
||||
local fakeService = {}
|
||||
local na = Analytics.new(fakeService)
|
||||
expect(na).to.be.ok()
|
||||
end)
|
||||
|
||||
it("should have a reporter specifically for Diag", function()
|
||||
local na = Analytics.new()
|
||||
expect(na.Diag).to.be.ok()
|
||||
end)
|
||||
|
||||
it("should have a reporter specifically for RBXEventStream", function()
|
||||
local na = Analytics.new()
|
||||
expect(na.EventStream).to.be.ok()
|
||||
end)
|
||||
|
||||
it("should have a reporter specifically for Google Analytics", function()
|
||||
local na = Analytics.new()
|
||||
expect(na.GoogleAnalytics).to.be.ok()
|
||||
end)
|
||||
|
||||
it("should have a reporter specifically for Influx", function()
|
||||
local na = Analytics.new()
|
||||
expect(na.InfluxDb).to.be.ok()
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("mock()", function()
|
||||
it("should properly construct a new object", function()
|
||||
local ma = Analytics.mock()
|
||||
expect(ma).to.be.ok()
|
||||
expect(ma.Diag).to.be.ok()
|
||||
expect(ma.EventStream).to.be.ok()
|
||||
expect(ma.GoogleAnalytics).to.be.ok()
|
||||
expect(ma.InfluxDb).to.be.ok()
|
||||
end)
|
||||
|
||||
it("should succeed for all function calls in Diag", function()
|
||||
local ma = Analytics.mock()
|
||||
ma.Diag:reportCounter("fakeCounter", 1)
|
||||
ma.Diag:reportStats("fakeCategory", 1)
|
||||
end)
|
||||
|
||||
it("should succeed for all function call in EventStream", function()
|
||||
local ma = Analytics.mock()
|
||||
ma.EventStream:setRBXEvent("fakeContext", "fakeEventName")
|
||||
ma.EventStream:setRBXEventStream("fakeContext", "fakeEventName")
|
||||
end)
|
||||
|
||||
it("should succeed for all function call in GoogleAnalytics", function()
|
||||
local ma = Analytics.mock()
|
||||
ma.GoogleAnalytics:trackEvent("fakeCategory", "fakeAction", "fakeLabel")
|
||||
end)
|
||||
|
||||
it("should succeed for all function call in Influx", function()
|
||||
local ma = Analytics.mock()
|
||||
ma.InfluxDb:reportSeries("fakeSeries", {}, 1)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
@@ -0,0 +1,64 @@
|
||||
--[[
|
||||
Specialized analytics reporter for ephemeral counters.
|
||||
Useful for tracking at-a-glance health of a feature.
|
||||
]]
|
||||
|
||||
local UserInputService = game:GetService("UserInputService")
|
||||
|
||||
local Diag = {}
|
||||
Diag.__index = Diag
|
||||
|
||||
-- reportingService - (object) any object that defines the same functions for Diag as AnalyticsService
|
||||
function Diag.new(reportingService)
|
||||
local rsType = type(reportingService)
|
||||
assert(rsType == "table" or rsType == "userdata", "Unexpected value for reportingService")
|
||||
|
||||
local self = {
|
||||
_reporter = reportingService,
|
||||
_isEnabled = true,
|
||||
}
|
||||
setmetatable(self, Diag)
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
-- isEnabled : (boolean)
|
||||
function Diag:setEnabled(isEnabled)
|
||||
assert(type(isEnabled) == "boolean", "Expected isEnabled to be a boolean")
|
||||
self._isEnabled = isEnabled
|
||||
end
|
||||
|
||||
-- counterName : (string) the name of the ephemeral counter to increment
|
||||
-- amount : (int) the value to increment the counter by
|
||||
function Diag:reportCounter(counterName, amount)
|
||||
assert(type(counterName) == "string", "Expected counterName to be a string")
|
||||
assert(type(amount) == "number", "Expected amount to be a number")
|
||||
assert(self._isEnabled, "This reporting service is disabled")
|
||||
|
||||
-- use special naming convention for Xbox counters
|
||||
-- the call to GetPlatform is wrapped in a pcall() because the Testing Service
|
||||
-- executes the scripts in the wrong authorization level
|
||||
local platformID = Enum.Platform.None
|
||||
pcall(function()
|
||||
platformID = UserInputService:GetPlatform()
|
||||
end)
|
||||
if platformID == Enum.Platform.XBoxOne then
|
||||
counterName = "Xbox-" .. tostring(counterName)
|
||||
end
|
||||
|
||||
-- the AnalyticsService should automatically handle batch reporting
|
||||
self._reporter:ReportCounter(counterName, amount)
|
||||
end
|
||||
|
||||
|
||||
-- category : (string) the name of the statistics buffer which to append the data
|
||||
-- value : (number) any type of numeric value
|
||||
function Diag:reportStats(category, value)
|
||||
assert(type(category) == "string", "Expected category to be a string")
|
||||
assert(type(value) == "number", "Expected value to be a number")
|
||||
assert(self._isEnabled, "This reporting service is disabled")
|
||||
|
||||
self._reporter:ReportStats(category, value)
|
||||
end
|
||||
|
||||
return Diag
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
return function()
|
||||
local Diag = require(script.Parent.Diag)
|
||||
|
||||
local testCounterName = "testCounter"
|
||||
local testCounterAmount = 1
|
||||
local testCategoryName = "testCategory"
|
||||
local testCategoryValue = 98
|
||||
|
||||
local badTestCounterName = 5
|
||||
local badTestCounterAmount = "hello"
|
||||
local badTestCategoryName = {}
|
||||
local badTestCategoryValue = {}
|
||||
|
||||
local DebugReportingService = {}
|
||||
function DebugReportingService:ReportCounter(counterName, amount)
|
||||
assert(counterName == testCounterName, "Unexpected value for counterName: " .. counterName)
|
||||
assert(amount == testCounterAmount, "Unexpected value for amount: " .. amount)
|
||||
end
|
||||
function DebugReportingService:ReportStats(categoryName, value)
|
||||
assert(categoryName == testCategoryName, "Unexpected value for category: " .. categoryName)
|
||||
if value then
|
||||
assert(value == testCategoryValue, "Unexpected value for value: " .. value)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
describe("new()", function()
|
||||
it("should construct with a Reporting Service", function()
|
||||
local diag = Diag.new(DebugReportingService)
|
||||
expect(diag).to.be.ok()
|
||||
end)
|
||||
|
||||
it("should throw an error to be constructed without a Reporting Service", function()
|
||||
expect(function()
|
||||
Diag.new(nil)
|
||||
end).to.throw()
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("setEnabled()", function()
|
||||
it("should succeed with valid input", function()
|
||||
local diag = Diag.new(DebugReportingService)
|
||||
diag:setEnabled(false)
|
||||
diag:setEnabled(true)
|
||||
end)
|
||||
it("should disable the reporter", function()
|
||||
local diag = Diag.new(DebugReportingService)
|
||||
diag:setEnabled(false)
|
||||
expect(function()
|
||||
diag:reportCounter(testCounterName, testCounterAmount)
|
||||
end).to.throw()
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("reportCounter()", function()
|
||||
it("should work when appropriately enabled / disabled", function()
|
||||
local diag = Diag.new(DebugReportingService)
|
||||
|
||||
expect(function()
|
||||
diag:setEnabled(false)
|
||||
diag:reportCounter(testCounterName, testCounterAmount)
|
||||
end).to.throw()
|
||||
|
||||
diag:setEnabled(true)
|
||||
diag:reportCounter(testCounterName, testCounterAmount)
|
||||
end)
|
||||
|
||||
it("should succeed with valid input", function()
|
||||
local diag = Diag.new(DebugReportingService)
|
||||
diag:reportCounter(testCounterName, testCounterAmount)
|
||||
end)
|
||||
|
||||
it("should throw an error with invalid input for the counter name", function()
|
||||
local diag = Diag.new(DebugReportingService)
|
||||
expect(function()
|
||||
diag:reportCounter(badTestCounterName, testCounterAmount)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should throw an error with invalid input for the amount", function()
|
||||
local diag = Diag.new(DebugReportingService)
|
||||
expect(function()
|
||||
diag:reportCounter(testCounterName, badTestCounterAmount)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should throw an error with completely invalid input", function()
|
||||
local diag = Diag.new(DebugReportingService)
|
||||
expect(function()
|
||||
diag:reportCounter(badTestCounterName, badTestCounterAmount)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should throw an error if it is missing a counter name", function()
|
||||
local diag = Diag.new(DebugReportingService)
|
||||
expect(function()
|
||||
diag:reportCounter(nil, testCounterAmount)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should throw an error if it is missing an amount", function()
|
||||
local diag = Diag.new(DebugReportingService)
|
||||
expect(function()
|
||||
diag:reportCounter(testCounterName, nil)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should throw an error if it is missing any input", function()
|
||||
local diag = Diag.new(DebugReportingService)
|
||||
expect(function()
|
||||
diag:reportCounter(nil, nil)
|
||||
end).to.throw()
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("reportStats()", function()
|
||||
it("should work when appropriately enabled / disabled", function()
|
||||
local diag = Diag.new(DebugReportingService)
|
||||
|
||||
expect(function()
|
||||
diag:setEnabled(false)
|
||||
diag:reportStats(testCategoryName, testCategoryValue)
|
||||
end).to.throw()
|
||||
|
||||
diag:setEnabled(true)
|
||||
diag:reportStats(testCategoryName, testCategoryValue)
|
||||
end)
|
||||
|
||||
it("should succeed with valid input", function()
|
||||
local diag = Diag.new(DebugReportingService)
|
||||
diag:reportStats(testCategoryName, testCategoryValue)
|
||||
end)
|
||||
|
||||
it("should throw an error with invalid input for the category name", function()
|
||||
local diag = Diag.new(DebugReportingService)
|
||||
expect(function()
|
||||
diag:reportStats(badTestCategoryName, testCategoryValue)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should throw an error with invalid input for the value", function()
|
||||
local diag = Diag.new(DebugReportingService)
|
||||
expect(function()
|
||||
diag:reportStats(testCategoryName, badTestCategoryValue)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should throw an error with completely invalid input", function()
|
||||
local diag = Diag.new(DebugReportingService)
|
||||
expect(function()
|
||||
diag:reportStats(badTestCategoryName, badTestCategoryValue)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should throw an error if it is missing a category name", function()
|
||||
local diag = Diag.new(DebugReportingService)
|
||||
expect(function()
|
||||
diag:reportStats(nil, testCategoryValue)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should throw an error if it is missing a value", function()
|
||||
local diag = Diag.new(DebugReportingService)
|
||||
expect(function()
|
||||
diag:reportStats(testCategoryName, nil)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should throw an error if it is missing any input", function()
|
||||
local diag = Diag.new(DebugReportingService)
|
||||
expect(function()
|
||||
diag:reportStats(nil, nil)
|
||||
end).to.throw()
|
||||
end)
|
||||
end)
|
||||
end
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
--[[
|
||||
Specialized reporter for RBX Event Ingest data.
|
||||
Useful for tracking explicit user interactions with screens and guis.
|
||||
]]
|
||||
|
||||
local UserInputService = game:GetService("UserInputService")
|
||||
|
||||
local function getPlatformTarget()
|
||||
local platformTarget = "unknownLua"
|
||||
local platformEnum = Enum.Platform.None
|
||||
|
||||
-- the call to GetPlatform is wrapped in a pcall() because the Testing Service
|
||||
-- executes the scripts in the wrong authorization level
|
||||
pcall(function()
|
||||
platformEnum = UserInputService:GetPlatform()
|
||||
end)
|
||||
|
||||
-- bucket the platform based on consumer platform
|
||||
local isDesktopClient = (platformEnum == Enum.Platform.Windows) or (platformEnum == Enum.Platform.OSX)
|
||||
|
||||
local isMobileClient = (platformEnum == Enum.Platform.IOS) or (platformEnum == Enum.Platform.Android)
|
||||
isMobileClient = isMobileClient or (platformEnum == Enum.Platform.UWP)
|
||||
|
||||
local isConsole = (platformEnum == Enum.Platform.XBox360) or (platformEnum == Enum.Platform.XBoxOne)
|
||||
isConsole = isConsole or (platformEnum == Enum.Platform.PS3) or (platformEnum == Enum.Platform.PS4)
|
||||
isConsole = isConsole or (platformEnum == Enum.Platform.WiiU)
|
||||
|
||||
-- assign a target based on the form factor
|
||||
if isDesktopClient then
|
||||
platformTarget = "client"
|
||||
elseif isMobileClient then
|
||||
platformTarget = "mobile"
|
||||
elseif isConsole then
|
||||
platformTarget = "console"
|
||||
else
|
||||
-- if we don't have a name for the form factor, report it here so that we can eventually track it down
|
||||
platformTarget = platformTarget .. tostring(platformEnum)
|
||||
end
|
||||
|
||||
|
||||
return platformTarget
|
||||
end
|
||||
|
||||
|
||||
local EventStream = {}
|
||||
EventStream.__index = EventStream
|
||||
|
||||
-- reportingService - (object) any object that defines the same functions for Event Stream as AnalyticsService
|
||||
function EventStream.new(reportingService)
|
||||
local rsType = type(reportingService)
|
||||
assert(rsType == "table" or rsType == "userdata", "Unexpected value for reportingService")
|
||||
|
||||
local self = {
|
||||
_reporter = reportingService,
|
||||
_isEnabled = true,
|
||||
}
|
||||
setmetatable(self, EventStream)
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
-- isEnabled : (boolean)
|
||||
function EventStream:setEnabled(isEnabled)
|
||||
assert(type(isEnabled) == "boolean", "Expected isEnabled to be a boolean")
|
||||
self._isEnabled = isEnabled
|
||||
end
|
||||
|
||||
-- eventContext : (string) the location or context in which the event is occurring.
|
||||
-- eventName : (string) the name corresponding to the type of event to be reported. "screenLoaded" for example.
|
||||
-- additionalArgs : (optional, map<string, Value>) table for additional information to appear in the event stream.
|
||||
function EventStream:setRBXEvent(eventContext, eventName, additionalArgs)
|
||||
local target = getPlatformTarget()
|
||||
additionalArgs = additionalArgs or {}
|
||||
|
||||
assert(type(eventContext) == "string", "Expected eventContext to be a string")
|
||||
assert(type(eventName) == "string", "Expected eventName to be a string")
|
||||
assert(type(additionalArgs) == "table", "Expected additionalArgs to be a table")
|
||||
assert(self._isEnabled, "This reporting service is disabled")
|
||||
|
||||
-- This function fires reports to the server right away
|
||||
self._reporter:SetRBXEvent(target, eventContext, eventName, additionalArgs)
|
||||
end
|
||||
|
||||
|
||||
-- eventContext : (string) the location or context in which the event is occurring.
|
||||
-- eventName : (string) the name corresponding to the type of event to be reported. "screenLoaded" for example.
|
||||
-- additionalArgs : (optional, map<string, Value>) map for extra keys to appear in the event stream.
|
||||
function EventStream:setRBXEventStream(eventContext, eventName, additionalArgs)
|
||||
local target = getPlatformTarget()
|
||||
additionalArgs = additionalArgs or {}
|
||||
|
||||
assert(type(eventContext) == "string", "Expected eventContext to be a string")
|
||||
assert(type(eventName) == "string", "Expected eventName to be a string")
|
||||
assert(type(additionalArgs) == "table", "Expected additionalArgs to be a table")
|
||||
assert(self._isEnabled, "This reporting service is disabled")
|
||||
|
||||
-- this function sends reports to the server in batches, not real-time
|
||||
self._reporter:SetRBXEventStream(target, eventContext, eventName, additionalArgs)
|
||||
end
|
||||
|
||||
|
||||
function EventStream:releaseRBXEventStream()
|
||||
assert(self._isEnabled, "This reporting service is disabled")
|
||||
|
||||
self._reporter:ReleaseRBXEventStream(getPlatformTarget())
|
||||
end
|
||||
|
||||
|
||||
-- eventContext : (string) the location or context in which the event is occurring.
|
||||
-- eventName : (string) the name corresponding to the type of event to be reported. "screenLoaded" for example.
|
||||
-- additionalArgs : (optional, map<string, Value>) map for extra keys to appear in the event stream.
|
||||
function EventStream:sendEventDeferred(eventContext, eventName, additionalArgs)
|
||||
local target = getPlatformTarget()
|
||||
additionalArgs = additionalArgs or {}
|
||||
|
||||
assert(type(eventContext) == "string", "Expected eventContext to be a string")
|
||||
assert(type(eventName) == "string", "Expected eventName to be a string")
|
||||
assert(type(additionalArgs) == "table", "Expected additionalArgs to be a table")
|
||||
assert(self._isEnabled, "This reporting service is disabled")
|
||||
|
||||
-- this function sends reports to the server in batches, not real-time
|
||||
self._reporter:SendEventDeferred(target, eventContext, eventName, additionalArgs)
|
||||
end
|
||||
|
||||
|
||||
-- eventContext : (string) the location or context in which the event is occurring.
|
||||
-- eventName : (string) the name corresponding to the type of event to be reported. "screenLoaded" for example.
|
||||
-- additionalArgs : (optional, map<string, Value>) map for extra keys to appear in the event stream.
|
||||
function EventStream:sendEventImmediately(eventContext, eventName, additionalArgs)
|
||||
local target = getPlatformTarget()
|
||||
additionalArgs = additionalArgs or {}
|
||||
|
||||
assert(type(eventContext) == "string", "Expected eventContext to be a string")
|
||||
assert(type(eventName) == "string", "Expected eventName to be a string")
|
||||
assert(type(additionalArgs) == "table", "Expected additionalArgs to be a table")
|
||||
assert(self._isEnabled, "This reporting service is disabled")
|
||||
|
||||
-- this function sends reports to the server in batches, not real-time
|
||||
self._reporter:SendEventImmediately(target, eventContext, eventName, additionalArgs)
|
||||
end
|
||||
|
||||
|
||||
-- additionalArgs : (optional, map<string, string>) table for extra keys to appear in the event stream.
|
||||
function EventStream:updateHeartbeatObject(additionalArgs)
|
||||
additionalArgs = additionalArgs or {}
|
||||
|
||||
assert(type(additionalArgs) == "table", "Expected additionalArgs to be a table")
|
||||
assert(self._isEnabled, "This reporting service is disabled")
|
||||
|
||||
self._reporter:UpdateHeartbeatObject(additionalArgs)
|
||||
end
|
||||
|
||||
|
||||
return EventStream
|
||||
+393
@@ -0,0 +1,393 @@
|
||||
return function()
|
||||
|
||||
local EventStream = require(script.Parent.EventStream)
|
||||
|
||||
local testArgs = {
|
||||
testKey = "testValue"
|
||||
}
|
||||
local testContext = "testContext"
|
||||
local testEvent = "testEventName"
|
||||
local badTestArgs = "hello"
|
||||
local badTestContext = {}
|
||||
local badTestEvent = {}
|
||||
|
||||
|
||||
local function isTableEqual(table1, table2)
|
||||
if table1 == table2 then
|
||||
return true
|
||||
end
|
||||
|
||||
if type(table1) ~= "table" then
|
||||
return false
|
||||
end
|
||||
|
||||
if type(table2) ~= "table" then
|
||||
return false
|
||||
end
|
||||
|
||||
for key, _ in pairs(table1) do
|
||||
if table1[key] ~= table2[key] then
|
||||
return false
|
||||
end
|
||||
end
|
||||
for key, _ in pairs(table2) do
|
||||
if table2[key] ~= table1[key] then
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
local function createDebugReportingService()
|
||||
local function validateInputs(eventTarget, eventContext, eventName, additionalArgs)
|
||||
assert(eventTarget, "no value found for eventTarget")
|
||||
assert(eventContext == testContext, "unexpected value for eventContext : " .. eventContext)
|
||||
assert(eventName == testEvent, "unexpected value for eventName : " .. eventName)
|
||||
if additionalArgs and not isTableEqual(additionalArgs, {}) then
|
||||
assert(isTableEqual(additionalArgs, testArgs), "unexpected value for additionalArgs")
|
||||
end
|
||||
end
|
||||
|
||||
local DebugReportingService = {}
|
||||
function DebugReportingService:SetRBXEvent(eventTarget, eventContext, eventName, additionalArgs)
|
||||
validateInputs(eventTarget, eventContext, eventName, additionalArgs)
|
||||
end
|
||||
function DebugReportingService:SetRBXEventStream(eventTarget, eventContext, eventName, additionalArgs)
|
||||
validateInputs(eventTarget, eventContext, eventName, additionalArgs)
|
||||
end
|
||||
function DebugReportingService:SendEventImmediately(eventTarget, eventContext, eventName, additionalArgs)
|
||||
validateInputs(eventTarget, eventContext, eventName, additionalArgs)
|
||||
end
|
||||
function DebugReportingService:SendEventDeferred(eventTarget, eventContext, eventName, additionalArgs)
|
||||
validateInputs(eventTarget, eventContext, eventName, additionalArgs)
|
||||
end
|
||||
function DebugReportingService:ReleaseRBXEventStream(eventTarget)
|
||||
assert(eventTarget, "no value found for eventTarget")
|
||||
end
|
||||
function DebugReportingService:UpdateHeartbeatObject(additionalArgs)
|
||||
if additionalArgs and not isTableEqual(additionalArgs, {}) then
|
||||
assert(isTableEqual(additionalArgs, testArgs), "unexpected value for additionalArgs")
|
||||
end
|
||||
end
|
||||
|
||||
return DebugReportingService
|
||||
end
|
||||
|
||||
|
||||
describe("new()", function()
|
||||
it("should construct with a Reporting Service", function()
|
||||
local es = EventStream.new(createDebugReportingService())
|
||||
expect(es).to.be.ok()
|
||||
end)
|
||||
|
||||
it("should not allow construction without a Reporting Service", function()
|
||||
expect(function()
|
||||
EventStream.new(nil)
|
||||
end).to.throw()
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("setEnabled()", function()
|
||||
it("should succeed with valid input", function()
|
||||
local reporter = EventStream.new(createDebugReportingService())
|
||||
reporter:setEnabled(false)
|
||||
reporter:setEnabled(true)
|
||||
end)
|
||||
it("should disable the reporter", function()
|
||||
local reporter = EventStream.new(createDebugReportingService())
|
||||
reporter:setEnabled(false)
|
||||
expect(function()
|
||||
reporter:updateHeartbeatObject()
|
||||
end).to.throw()
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("setRBXEvent()", function()
|
||||
it("should succeed with valid input", function()
|
||||
local es = EventStream.new(createDebugReportingService())
|
||||
es:setRBXEvent(testContext, testEvent, testArgs)
|
||||
end)
|
||||
|
||||
it("should work when appropriately enabled / disabled", function()
|
||||
local es = EventStream.new(createDebugReportingService())
|
||||
|
||||
expect(function()
|
||||
es:setEnabled(false)
|
||||
es:setRBXEvent(testContext, testEvent, testArgs)
|
||||
end).to.throw()
|
||||
|
||||
es:setEnabled(true)
|
||||
es:setRBXEvent(testContext, testEvent, testArgs)
|
||||
end)
|
||||
|
||||
it("should throw an error if it is missing a context", function()
|
||||
local es = EventStream.new(createDebugReportingService())
|
||||
expect(function()
|
||||
es:setRBXEvent(nil, testEvent, testArgs)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should throw an error if it is missing an event name", function()
|
||||
local es = EventStream.new(createDebugReportingService())
|
||||
expect(function()
|
||||
es:setRBXEvent(testContext, nil, testArgs)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should succeed even if there aren't any additional args", function()
|
||||
local es = EventStream.new(createDebugReportingService())
|
||||
es:setRBXEvent(testContext, testEvent, nil)
|
||||
end)
|
||||
|
||||
it("should throw an error if it is given bad input for a context", function()
|
||||
local es = EventStream.new(createDebugReportingService())
|
||||
expect(function()
|
||||
es:setRBXEvent(badTestContext, testEvent, testArgs)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should throw an error if it is given bad input for a event", function()
|
||||
local es = EventStream.new(createDebugReportingService())
|
||||
expect(function()
|
||||
es:setRBXEvent(testContext, badTestEvent, testArgs)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should throw an error if it is given bad input for additionalArgs", function()
|
||||
local es = EventStream.new(createDebugReportingService())
|
||||
expect(function()
|
||||
es:setRBXEvent(testContext, testEvent, badTestArgs)
|
||||
end).to.throw()
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("setRBXEventStream()", function()
|
||||
it("should succeed with valid input", function()
|
||||
local es = EventStream.new(createDebugReportingService())
|
||||
es:setRBXEventStream(testContext, testEvent, testArgs)
|
||||
end)
|
||||
|
||||
it("should work when appropriately enabled / disabled", function()
|
||||
local es = EventStream.new(createDebugReportingService())
|
||||
|
||||
expect(function()
|
||||
es:setEnabled(false)
|
||||
es:setRBXEventStream(testContext, testEvent, testArgs)
|
||||
end).to.throw()
|
||||
|
||||
es:setEnabled(true)
|
||||
es:setRBXEventStream(testContext, testEvent, testArgs)
|
||||
end)
|
||||
|
||||
it("should throw an error if it is missing a context", function()
|
||||
local es = EventStream.new(createDebugReportingService())
|
||||
expect(function()
|
||||
es:setRBXEventStream(nil, testEvent, testArgs)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should throw an error if it is missing an event name", function()
|
||||
local es = EventStream.new(createDebugReportingService())
|
||||
expect(function()
|
||||
es:setRBXEventStream(testContext, nil, testArgs)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should succeed even if there aren't any additional args", function()
|
||||
local es = EventStream.new(createDebugReportingService())
|
||||
es:setRBXEventStream(testContext, testEvent, nil)
|
||||
end)
|
||||
|
||||
it("should throw an error if it is given bad input for a context", function()
|
||||
local es = EventStream.new(createDebugReportingService())
|
||||
expect(function()
|
||||
es:setRBXEventStream(badTestContext, testEvent, testArgs)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should throw an error if it is given bad input for a event", function()
|
||||
local es = EventStream.new(createDebugReportingService())
|
||||
expect(function()
|
||||
es:setRBXEventStream(testContext, badTestEvent, testArgs)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should throw an error if it is given bad input for additionalArgs", function()
|
||||
local es = EventStream.new(createDebugReportingService())
|
||||
expect(function()
|
||||
es:setRBXEventStream(testContext, testEvent, badTestArgs)
|
||||
end).to.throw()
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("sendEventImmediately()", function()
|
||||
it("should succeed with valid input", function()
|
||||
local es = EventStream.new(createDebugReportingService())
|
||||
es:sendEventImmediately(testContext, testEvent, testArgs)
|
||||
end)
|
||||
|
||||
it("should call without a fuss when enabled and throw when disabled", function()
|
||||
local es = EventStream.new(createDebugReportingService())
|
||||
|
||||
expect(function()
|
||||
es:setEnabled(false)
|
||||
es:sendEventImmediately(testContext, testEvent, testArgs)
|
||||
end).to.throw()
|
||||
|
||||
es:setEnabled(true)
|
||||
es:sendEventImmediately(testContext, testEvent, testArgs)
|
||||
end)
|
||||
|
||||
it("should throw an error if it is missing a context", function()
|
||||
local es = EventStream.new(createDebugReportingService())
|
||||
expect(function()
|
||||
es:sendEventImmediately(nil, testEvent, testArgs)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should throw an error if it is missing an event name", function()
|
||||
local es = EventStream.new(createDebugReportingService())
|
||||
expect(function()
|
||||
es:sendEventImmediately(testContext, nil, testArgs)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should succeed even if there aren't any additional args", function()
|
||||
local es = EventStream.new(createDebugReportingService())
|
||||
es:sendEventImmediately(testContext, testEvent, nil)
|
||||
end)
|
||||
|
||||
it("should throw an error if it is given bad input for a context", function()
|
||||
local es = EventStream.new(createDebugReportingService())
|
||||
expect(function()
|
||||
es:sendEventImmediately(badTestContext, testEvent, testArgs)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should throw an error if it is given bad input for a event", function()
|
||||
local es = EventStream.new(createDebugReportingService())
|
||||
expect(function()
|
||||
es:sendEventImmediately(testContext, badTestEvent, testArgs)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should throw an error if it is given bad input for additionalArgs", function()
|
||||
local es = EventStream.new(createDebugReportingService())
|
||||
expect(function()
|
||||
es:sendEventImmediately(testContext, testEvent, badTestArgs)
|
||||
end).to.throw()
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("sendEventDeferred()", function()
|
||||
it("should succeed with valid input", function()
|
||||
local es = EventStream.new(createDebugReportingService())
|
||||
es:sendEventDeferred(testContext, testEvent, testArgs)
|
||||
end)
|
||||
|
||||
it("should call without a fuss when enabled and throw when disabled", function()
|
||||
local es = EventStream.new(createDebugReportingService())
|
||||
|
||||
expect(function()
|
||||
es:setEnabled(false)
|
||||
es:sendEventDeferred(testContext, testEvent, testArgs)
|
||||
end).to.throw()
|
||||
|
||||
es:setEnabled(true)
|
||||
es:sendEventDeferred(testContext, testEvent, testArgs)
|
||||
end)
|
||||
|
||||
it("should throw an error if it is missing a context", function()
|
||||
local es = EventStream.new(createDebugReportingService())
|
||||
expect(function()
|
||||
es:sendEventDeferred(nil, testEvent, testArgs)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should throw an error if it is missing an event name", function()
|
||||
local es = EventStream.new(createDebugReportingService())
|
||||
expect(function()
|
||||
es:sendEventDeferred(testContext, nil, testArgs)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should succeed even if there aren't any additional args", function()
|
||||
local es = EventStream.new(createDebugReportingService())
|
||||
es:sendEventDeferred(testContext, testEvent, nil)
|
||||
end)
|
||||
|
||||
it("should throw an error if it is given bad input for a context", function()
|
||||
local es = EventStream.new(createDebugReportingService())
|
||||
expect(function()
|
||||
es:sendEventDeferred(badTestContext, testEvent, testArgs)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should throw an error if it is given bad input for a event", function()
|
||||
local es = EventStream.new(createDebugReportingService())
|
||||
expect(function()
|
||||
es:sendEventDeferred(testContext, badTestEvent, testArgs)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should throw an error if it is given bad input for additionalArgs", function()
|
||||
local es = EventStream.new(createDebugReportingService())
|
||||
expect(function()
|
||||
es:sendEventDeferred(testContext, testEvent, badTestArgs)
|
||||
end).to.throw()
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("releaseRBXEventStream()", function()
|
||||
it("should succeed with valid input", function()
|
||||
local es = EventStream.new(createDebugReportingService())
|
||||
es:releaseRBXEventStream()
|
||||
end)
|
||||
|
||||
it("should throw when disabled and succeed when enabled", function()
|
||||
local es = EventStream.new(createDebugReportingService())
|
||||
|
||||
expect(function()
|
||||
es:setEnabled(false)
|
||||
es:releaseRBXEventStream()
|
||||
end).to.throw()
|
||||
|
||||
es:setEnabled(true)
|
||||
es:releaseRBXEventStream()
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("updateHeartbeatObject()", function()
|
||||
it("should work when appropriately enabled / disabled", function()
|
||||
local es = EventStream.new(createDebugReportingService())
|
||||
|
||||
expect(function()
|
||||
es:setEnabled(false)
|
||||
es:updateHeartbeatObject(testArgs)
|
||||
end).to.throw()
|
||||
|
||||
expect(function()
|
||||
es:setEnabled(true)
|
||||
es:updateHeartbeatObject(testArgs)
|
||||
end).never.to.throw()
|
||||
end)
|
||||
|
||||
it("should succeed with valid input", function()
|
||||
local es = EventStream.new(createDebugReportingService())
|
||||
es:updateHeartbeatObject(testArgs)
|
||||
end)
|
||||
|
||||
it("should succeed even if there aren't any additional args", function()
|
||||
local es = EventStream.new(createDebugReportingService())
|
||||
es:updateHeartbeatObject(nil)
|
||||
end)
|
||||
|
||||
it("should throw an error with invalid input", function()
|
||||
local es = EventStream.new(createDebugReportingService())
|
||||
expect(function()
|
||||
es:updateHeartbeatObject(badTestArgs)
|
||||
end).to.throw()
|
||||
end)
|
||||
end)
|
||||
end
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
--[[
|
||||
Specialized reporter for sending data to GA.
|
||||
Useful for creating a breadcrumb trail of user interactions.
|
||||
|
||||
Events in GA are aggregated and organized in order by category, action, label.
|
||||
]]
|
||||
|
||||
local GoogleAnalytics = {}
|
||||
GoogleAnalytics.__index = GoogleAnalytics
|
||||
|
||||
-- reportingService : (table or userdata) any object that defines the same functions for GA as AnalyticsService
|
||||
function GoogleAnalytics.new(reportingService)
|
||||
local rsType = type(reportingService)
|
||||
assert(rsType == "table" or rsType == "userdata", "Unexpected value for reportingService")
|
||||
|
||||
local self = {
|
||||
_reporter = reportingService,
|
||||
_isEnabled = true,
|
||||
}
|
||||
setmetatable(self, GoogleAnalytics)
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
-- isEnabled : (boolean)
|
||||
function GoogleAnalytics:setEnabled(isEnabled)
|
||||
assert(type(isEnabled) == "boolean", "Expected isEnabled to be a boolean")
|
||||
self._isEnabled = isEnabled
|
||||
end
|
||||
|
||||
-- category : (string) the most generic category by which to organize data, ex) LuaApp, Errors, GameSettings, etc.
|
||||
-- action : (string) a specific event to record, ex) ButtonPressed, GameExit
|
||||
-- label : (string, optional) a detail to differentiate one action over another, ex) LoginButton, Exit Code 0
|
||||
-- value : (integer, optional) the number of times this event has occurred
|
||||
function GoogleAnalytics:trackEvent(category, action, label, value)
|
||||
assert(type(category) == "string", "Expected category to be a string")
|
||||
assert(type(action) == "string", "Expected action to be a string")
|
||||
if label then
|
||||
assert(type(label) == "string", "Expected label to be a string")
|
||||
end
|
||||
if value then
|
||||
assert(type(value) == "number", "Expected value to be a number")
|
||||
assert(value >= 0, "Expected value must not be a negative value")
|
||||
end
|
||||
assert(self._isEnabled, "This reporting service is disabled")
|
||||
|
||||
self._reporter:TrackEvent(category, action, label, value)
|
||||
end
|
||||
|
||||
|
||||
return GoogleAnalytics
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
return function()
|
||||
local GoogleAnalytics = require(script.Parent.GoogleAnalytics)
|
||||
|
||||
local testCategory = "testCategory"
|
||||
local testAction = "testAction"
|
||||
local testLabel = "testLabel"
|
||||
local testValue = 6
|
||||
local badTestCategory = 13141
|
||||
local badTestAction = {}
|
||||
local badTestLabel = {}
|
||||
local badTestValue = "heyo"
|
||||
|
||||
|
||||
local DebugReportingService = {}
|
||||
function DebugReportingService:TrackEvent(category, action, label, value)
|
||||
if category ~= testCategory then
|
||||
error("unexpected value for category: " .. category)
|
||||
end
|
||||
if action ~= testAction then
|
||||
error("unexpected value for action: " .. action)
|
||||
end
|
||||
if label then
|
||||
if label ~= testLabel then
|
||||
error("unexpected value for label: " .. label)
|
||||
end
|
||||
end
|
||||
if value then
|
||||
if value ~= testValue then
|
||||
error("unexpected value for value: " .. value)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
describe("new()", function()
|
||||
it("should construct with a Reporting Service and Logging Service", function()
|
||||
local ga = GoogleAnalytics.new(DebugReportingService)
|
||||
expect(ga).to.be.ok()
|
||||
end)
|
||||
|
||||
it("should fail to be constructed without a Reporting Service", function()
|
||||
expect(function()
|
||||
GoogleAnalytics.new(nil)
|
||||
end).to.throw()
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("setEnabled()", function()
|
||||
it("should succeed with valid input", function()
|
||||
local reporter = GoogleAnalytics.new(DebugReportingService)
|
||||
reporter:setEnabled(false)
|
||||
reporter:setEnabled(true)
|
||||
end)
|
||||
it("should disable the reporter", function()
|
||||
local reporter = GoogleAnalytics.new(DebugReportingService)
|
||||
reporter:setEnabled(false)
|
||||
expect(function()
|
||||
reporter:trackEvent(testCategory, testAction, testLabel, testValue)
|
||||
end).to.throw()
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("trackEvent()", function()
|
||||
it("should work when appropriately enabled / disabled", function()
|
||||
local ga = GoogleAnalytics.new(DebugReportingService)
|
||||
|
||||
expect(function()
|
||||
ga:setEnabled(false)
|
||||
ga:trackEvent(testCategory, testAction, testLabel)
|
||||
end).to.throw()
|
||||
|
||||
ga:setEnabled(true)
|
||||
ga:trackEvent(testCategory, testAction, testLabel)
|
||||
end)
|
||||
|
||||
it("should succeed with valid input", function()
|
||||
local ga = GoogleAnalytics.new(DebugReportingService)
|
||||
ga:trackEvent(testCategory, testAction, testLabel, testValue)
|
||||
end)
|
||||
|
||||
it("should throw an error if it is missing a category", function()
|
||||
local ga = GoogleAnalytics.new(DebugReportingService)
|
||||
expect(function()
|
||||
ga:trackEvent(nil, testAction, testLabel, testValue)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should throw an error if it is missing a testAction", function()
|
||||
local ga = GoogleAnalytics.new(DebugReportingService)
|
||||
expect(function()
|
||||
ga:trackEvent(testCategory, nil, testLabel, testValue)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should not throw an error if it is missing a label", function()
|
||||
local ga = GoogleAnalytics.new(DebugReportingService)
|
||||
ga:trackEvent(testCategory, testAction, nil, testValue)
|
||||
end)
|
||||
|
||||
it("should not throw an error if it is missing a value", function()
|
||||
local ga = GoogleAnalytics.new(DebugReportingService)
|
||||
ga:trackEvent(testCategory, testAction, testLabel)
|
||||
end)
|
||||
|
||||
it("should throw an error if it is given invalid input for category", function()
|
||||
local ga = GoogleAnalytics.new(DebugReportingService)
|
||||
expect(function()
|
||||
ga:trackEvent(badTestCategory, testAction, testLabel, testValue)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should throw an error if it is given invalid input for action", function()
|
||||
local ga = GoogleAnalytics.new(DebugReportingService)
|
||||
expect(function()
|
||||
ga:trackEvent(testCategory, badTestAction, testLabel, testValue)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should throw an error if it is given invalid input for label", function()
|
||||
local ga = GoogleAnalytics.new(DebugReportingService)
|
||||
expect(function()
|
||||
ga:trackEvent(testCategory, testAction, badTestLabel, testValue)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should throw an error if it is given invalid input for value", function()
|
||||
local ga = GoogleAnalytics.new(DebugReportingService)
|
||||
expect(function()
|
||||
ga:trackEvent(testCategory, testAction, testLabel, badTestValue)
|
||||
end).to.throw()
|
||||
|
||||
expect(function()
|
||||
ga:trackEvent(testCategory, testAction, testLabel, -1)
|
||||
end).to.throw()
|
||||
end)
|
||||
end)
|
||||
|
||||
|
||||
|
||||
end
|
||||
@@ -0,0 +1,47 @@
|
||||
--[[
|
||||
Specialized reporter for sending data to InfluxDb.
|
||||
Useful for very detailed information about specific errors.
|
||||
|
||||
Due to how Influx sends data, it is disallowed on XBox.
|
||||
~Kyler Mulherin (9/12/2017)
|
||||
]]
|
||||
|
||||
local Influx = {}
|
||||
Influx.__index = Influx
|
||||
|
||||
-- reportingService - (object) any object that defines the same functions for Influx as AnalyticsService
|
||||
function Influx.new(reportingService)
|
||||
local rsType = type(reportingService)
|
||||
assert(rsType == "table" or rsType == "userdata", "Unexpected value for reportingService")
|
||||
|
||||
local self = {
|
||||
_reporter = reportingService,
|
||||
_isEnabled = true,
|
||||
}
|
||||
setmetatable(self, Influx)
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
-- isEnabled : (boolean)
|
||||
function Influx:setEnabled(isEnabled)
|
||||
assert(type(isEnabled) == "boolean", "Expected isEnabled to be a boolean")
|
||||
self._isEnabled = isEnabled
|
||||
end
|
||||
|
||||
-- seriesName : (string) the name of the series as it will appear in InfluxDb
|
||||
-- additionalArgs : (map<string, string>) extra key/values to appear in each series
|
||||
-- throttlingPercent : (int) the chance to actually report this series
|
||||
function Influx:reportSeries(seriesName, additionalArgs, throttlingPercent)
|
||||
additionalArgs = additionalArgs or {}
|
||||
|
||||
assert(type(seriesName) == "string", "Expected seriesName to be a string")
|
||||
assert(type(additionalArgs) == "table", "Expected additionalArgs to be a table")
|
||||
assert(type(throttlingPercent) == "number", "Expected throttlingPercent to be a number")
|
||||
assert(throttlingPercent >= 0 and throttlingPercent <= 10000, "throttlingPercent must be between 0 - 10,000")
|
||||
assert(self._isEnabled, "This reporting service is disabled")
|
||||
|
||||
self._reporter:ReportInfluxSeries(seriesName, additionalArgs, throttlingPercent)
|
||||
end
|
||||
|
||||
return Influx
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
return function()
|
||||
local Influx = require(script.Parent.Influx)
|
||||
|
||||
local testSeriesName = "testSeries"
|
||||
local testArgs = {
|
||||
testKey = "testValue"
|
||||
}
|
||||
local testThrottlingPercentage = 1000
|
||||
|
||||
local badTestSeriesName = 114
|
||||
local badTestArgs = "someString"
|
||||
local badThrottlingPercentage1 = -15
|
||||
local badThrottlingPercentage2 = 150000
|
||||
local badThrottlingPercentage3 = "a lot"
|
||||
|
||||
|
||||
local function isTableEqual(table1, table2)
|
||||
if table1 == table2 then
|
||||
return true
|
||||
end
|
||||
|
||||
if type(table2) ~= "table" then
|
||||
return false
|
||||
end
|
||||
|
||||
for key, _ in pairs(table1) do
|
||||
if table1[key] ~= table2[key] then
|
||||
return false
|
||||
end
|
||||
end
|
||||
for key, _ in pairs(table2) do
|
||||
if table2[key] ~= table1[key] then
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
|
||||
local DebugReportingService = {}
|
||||
function DebugReportingService:ReportInfluxSeries(seriesName, additionalArgs, throttlingPercentage)
|
||||
if seriesName ~= seriesName then
|
||||
error("Unexpected value for seriesName: " .. seriesName)
|
||||
end
|
||||
if throttlingPercentage ~= testThrottlingPercentage then
|
||||
error("Unexpected value for throttlingPercentage: " .. throttlingPercentage)
|
||||
end
|
||||
if isTableEqual(additionalArgs, {}) == false then
|
||||
if isTableEqual(additionalArgs, testArgs) == false then
|
||||
error("Unexpected value for additionalArgs")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
describe("new()", function()
|
||||
it("should construct with a Reporting Service", function()
|
||||
local influx = Influx.new(DebugReportingService)
|
||||
expect(influx).to.be.ok()
|
||||
end)
|
||||
|
||||
it("should fail to be constructed without a Reporting Service", function()
|
||||
expect(function()
|
||||
Influx.new(nil)
|
||||
end).to.throw()
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("setEnabled()", function()
|
||||
it("should succeed with valid input", function()
|
||||
local influx = Influx.new(DebugReportingService)
|
||||
influx:setEnabled(false)
|
||||
influx:setEnabled(true)
|
||||
end)
|
||||
it("should disable the reporter", function()
|
||||
local influx = Influx.new(DebugReportingService)
|
||||
influx:setEnabled(false)
|
||||
expect(function()
|
||||
influx:reportSeries(testSeriesName, testArgs, testThrottlingPercentage)
|
||||
end).to.throw()
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("reportSeries()", function()
|
||||
it("should work when appropriately enabled / disabled", function()
|
||||
local influx = Influx.new(DebugReportingService)
|
||||
|
||||
expect(function()
|
||||
influx:setEnabled(false)
|
||||
influx:reportSeries(testSeriesName, testArgs, testThrottlingPercentage)
|
||||
end).to.throw()
|
||||
|
||||
|
||||
influx:setEnabled(true)
|
||||
influx:reportSeries(testSeriesName, testArgs, testThrottlingPercentage)
|
||||
end)
|
||||
|
||||
it("should succeed with valid input", function()
|
||||
local influx = Influx.new(DebugReportingService)
|
||||
influx:reportSeries(testSeriesName, testArgs, testThrottlingPercentage)
|
||||
end)
|
||||
|
||||
it("should succeed even if it is missing any additionalArgs", function()
|
||||
local influx = Influx.new(DebugReportingService)
|
||||
influx:reportSeries(testSeriesName, nil, testThrottlingPercentage)
|
||||
end)
|
||||
|
||||
it("should throw an error with invalid input for the seriesName", function()
|
||||
local influx = Influx.new(DebugReportingService)
|
||||
expect(function()
|
||||
influx:reportSeries(badTestSeriesName, testArgs, testThrottlingPercentage)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should throw an error with invalid input for the throttlingPercentage - out of range - below zero", function()
|
||||
expect(function()
|
||||
local influx = Influx.new(DebugReportingService)
|
||||
influx:reportSeries(testSeriesName, testArgs, badThrottlingPercentage1)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should throw an error with invalid input for the throttlingPercentage - out of range - above cap", function()
|
||||
expect(function()
|
||||
local influx = Influx.new(DebugReportingService)
|
||||
influx:reportSeries(testSeriesName, testArgs, badThrottlingPercentage2)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should throw an error with invalid input for the throttlingPercentage - bad type", function()
|
||||
expect(function()
|
||||
local influx = Influx.new(DebugReportingService)
|
||||
influx:reportSeries(testSeriesName, testArgs, badThrottlingPercentage3)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should throw an error with completely invalid input", function()
|
||||
local influx = Influx.new(DebugReportingService)
|
||||
expect(function()
|
||||
influx:reportSeries(badTestSeriesName, badTestArgs, badThrottlingPercentage1)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should throw an error if it is missing a seriesName", function()
|
||||
local influx = Influx.new(DebugReportingService)
|
||||
expect(function()
|
||||
influx:reportSeries(nil, testArgs, testThrottlingPercentage)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should throw an error if it is missing a throttlingPercentage", function()
|
||||
local influx = Influx.new(DebugReportingService)
|
||||
expect(function()
|
||||
influx:reportSeries(testSeriesName, testArgs, nil)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should throw an error if it is missing any input", function()
|
||||
local influx = Influx.new(DebugReportingService)
|
||||
expect(function()
|
||||
influx:reportSeries(nil, nil, nil)
|
||||
end).to.throw()
|
||||
end)
|
||||
end)
|
||||
end
|
||||
@@ -0,0 +1,469 @@
|
||||
--[[
|
||||
Implements the start of an Asteroids clone using Roact and Rodux.
|
||||
|
||||
This is intended to be an advanced example that uses most edge cases of both
|
||||
Roact and Rodux.
|
||||
]]
|
||||
|
||||
local Workspace = game:GetService("Workspace")
|
||||
local RunService = game:GetService("RunService")
|
||||
local UserInputService = game:GetService("UserInputService")
|
||||
local PlayerGui = game.Players.LocalPlayer.PlayerGui
|
||||
|
||||
local Common = script.Parent.Parent
|
||||
|
||||
local Roact = require(Common.Roact)
|
||||
local ExternalEventConnection = require(Common.RoactUtilities.ExternalEventConnection)
|
||||
local Rodux = require(Common.Rodux)
|
||||
local RoactRodux = require(Common.RoactRodux)
|
||||
local Immutable = require(Common.Immutable)
|
||||
|
||||
local function playerInputReducer(state, action)
|
||||
state = state or {
|
||||
left = 0,
|
||||
right = 0,
|
||||
up = 0,
|
||||
pause = 0,
|
||||
}
|
||||
|
||||
if action.type == "SetPlayerInput" then
|
||||
return Immutable.Set(state, action.playerInput, action.value)
|
||||
end
|
||||
|
||||
return state
|
||||
end
|
||||
|
||||
local function playerReducer(state, action)
|
||||
state = state or {
|
||||
position = Vector2.new(0, 0),
|
||||
velocity = Vector2.new(0, 0),
|
||||
angle = 0,
|
||||
turnRate = math.pi / 2,
|
||||
throttleRate = 10,
|
||||
}
|
||||
|
||||
if action.type == "GameStep" then
|
||||
local inputs = action.inputs
|
||||
local delta = action.delta
|
||||
local direction = Vector2.new(math.sin(state.angle), -math.cos(state.angle))
|
||||
local turnImpulse = inputs["right"] - inputs["left"]
|
||||
local throttleImpulse = inputs["up"]
|
||||
|
||||
return Immutable.JoinDictionaries(state, {
|
||||
position = state.position + (state.velocity * delta),
|
||||
velocity = state.velocity + (throttleImpulse * state.throttleRate * delta * direction),
|
||||
angle = state.angle + (turnImpulse * state.turnRate * delta),
|
||||
})
|
||||
elseif action.type == "SetPlayerRotation" then
|
||||
return Immutable.Set(state, "angle", action.value)
|
||||
end
|
||||
|
||||
return state
|
||||
end
|
||||
|
||||
local function timeReducer(state, action)
|
||||
state = state or 0
|
||||
|
||||
if action.type == "GameStep" then
|
||||
return state + action.delta
|
||||
end
|
||||
|
||||
return state
|
||||
end
|
||||
|
||||
local function asteroidsReducer(state, action)
|
||||
|
||||
local function initAsteroids()
|
||||
return {
|
||||
{
|
||||
position = Vector2.new(-5,-5),
|
||||
velocity = Vector2.new(1,0),
|
||||
size = 5,
|
||||
}, {
|
||||
position = Vector2.new(-5,5),
|
||||
velocity = Vector2.new(1,0),
|
||||
size = 3,
|
||||
}, {
|
||||
position = Vector2.new(5,5),
|
||||
velocity = Vector2.new(0,-1),
|
||||
size = 5,
|
||||
}, {
|
||||
position = Vector2.new(5,-5),
|
||||
velocity = Vector2.new(0,1),
|
||||
size = 3,
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
state = state or initAsteroids()
|
||||
|
||||
if action.type == "GameStep" then
|
||||
local delta = action.delta
|
||||
local adjustedAsteroids = {}
|
||||
|
||||
for index, asteroid in pairs(state) do
|
||||
local posX = asteroid.position.X
|
||||
local posY = asteroid.position.Y
|
||||
local velX = asteroid.velocity.X
|
||||
local velY = asteroid.velocity.Y
|
||||
|
||||
adjustedAsteroids[index] = {
|
||||
position = Vector2.new(posX + velX * delta, posY + velY * delta),
|
||||
velocity = Vector2.new(velX, velY),
|
||||
size = asteroid.size
|
||||
}
|
||||
end
|
||||
|
||||
return adjustedAsteroids
|
||||
end
|
||||
|
||||
return state
|
||||
end
|
||||
|
||||
local function pausePressed(action)
|
||||
return action.playerInput == "pause" and action.value == 1
|
||||
end
|
||||
|
||||
local function pauseReducer(state, action)
|
||||
state = state or false
|
||||
|
||||
if action.type == "SetPlayerInput" and pausePressed(action) then
|
||||
return not state
|
||||
end
|
||||
|
||||
return state
|
||||
end
|
||||
|
||||
local function reducer(state, action)
|
||||
state = state or {}
|
||||
|
||||
return {
|
||||
playerInputs = playerInputReducer(state.playerInputs, action),
|
||||
paused = pauseReducer(state.paused, action),
|
||||
player = playerReducer(state.player, action),
|
||||
time = timeReducer(state.time, action),
|
||||
asteroids = asteroidsReducer(state.asteroids, action),
|
||||
}
|
||||
end
|
||||
|
||||
-- Defines the current camera declaratively
|
||||
local Camera = Roact.Component:extend("Camera")
|
||||
|
||||
function Camera:init()
|
||||
self.cameraRef = function(rbx)
|
||||
self.cameraRbx = rbx
|
||||
end
|
||||
end
|
||||
|
||||
function Camera:render()
|
||||
local focus = self.props.focus
|
||||
|
||||
return Roact.createElement("Camera", {
|
||||
CameraType = Enum.CameraType.Scriptable,
|
||||
CFrame = CFrame.new(Vector3.new(focus.x, 20, focus.y)) * CFrame.fromEulerAnglesXYZ(-math.pi / 2, 0, 0),
|
||||
FieldOfView = 90,
|
||||
|
||||
[Roact.Ref] = self.cameraRef,
|
||||
})
|
||||
end
|
||||
|
||||
function Camera:didMount()
|
||||
Workspace.CurrentCamera = self.cameraRbx
|
||||
end
|
||||
|
||||
-- A version of the camera that's tied to the player's position.
|
||||
local PlayerCamera = RoactRodux.connect(function(store)
|
||||
local player = store:getState().player
|
||||
|
||||
return {
|
||||
focus = player.position
|
||||
}
|
||||
end)(Camera)
|
||||
|
||||
-- Any asteroid in the game world.
|
||||
local Asteroid = Roact.Component:extend("Asteroid")
|
||||
|
||||
function Asteroid:render()
|
||||
local position = self.props.position
|
||||
local size = self.props.size
|
||||
|
||||
return Roact.createElement("Part", {
|
||||
Anchored = true,
|
||||
Shape = Enum.PartType.Ball,
|
||||
Size = Vector3.new(size, size, size),
|
||||
Color = Color3.fromRGB(170, 170, 170),
|
||||
CFrame = CFrame.new(Vector3.new(position.x, 0, position.y)),
|
||||
TopSurface = Enum.SurfaceType.Smooth,
|
||||
})
|
||||
end
|
||||
|
||||
-- A collection of Asteroids in the world.
|
||||
local Asteroids = Roact.Component:extend("Asteroids")
|
||||
|
||||
function Asteroids:render()
|
||||
local asteroids = self.props
|
||||
local asteroidComponentTable = {}
|
||||
|
||||
for index, asteroid in pairs(asteroids) do
|
||||
asteroidComponentTable[index] = Roact.createElement(Asteroid, {
|
||||
position = asteroid.position,
|
||||
size = asteroid.size,
|
||||
})
|
||||
end
|
||||
|
||||
return Roact.createElement("Folder", {
|
||||
Name = "Asteroids"
|
||||
}, asteroidComponentTable)
|
||||
end
|
||||
|
||||
-- Connect the Asteroids component to state.asteroids
|
||||
Asteroids = RoactRodux.connect(function(store)
|
||||
local state = store:getState()
|
||||
local asteroids = state.asteroids
|
||||
|
||||
return asteroids
|
||||
end)(Asteroids)
|
||||
|
||||
-- Any ship in the game world.
|
||||
local Ship = Roact.Component:extend("Ship")
|
||||
|
||||
function Ship:render()
|
||||
local position = self.props.position
|
||||
local angle = self.props.angle
|
||||
local time = self.props.time
|
||||
|
||||
local color = Color3.fromHSV((time % 5) / 5, 1, 1)
|
||||
|
||||
return Roact.createElement("Part", {
|
||||
Anchored = true,
|
||||
Size = Vector3.new(2, 2, 6),
|
||||
Color = color,
|
||||
CFrame = CFrame.new(Vector3.new(position.x, 0, position.y)) * CFrame.fromEulerAnglesXYZ(0, -angle, 0),
|
||||
FrontSurface = Enum.SurfaceType.Hinge,
|
||||
})
|
||||
end
|
||||
|
||||
-- A version of the ship that's tied to the player's data.
|
||||
local PlayerShip = RoactRodux.connect(function(store)
|
||||
local state = store:getState()
|
||||
local player = state.player
|
||||
|
||||
return {
|
||||
position = player.position,
|
||||
angle = player.angle,
|
||||
time = state.time,
|
||||
}
|
||||
end)(Ship)
|
||||
|
||||
-- A static background
|
||||
local GameBackground = Roact.Component:extend("GameBackground")
|
||||
|
||||
function GameBackground:render()
|
||||
return Roact.createElement("Part", {
|
||||
Anchored = true,
|
||||
Size = Vector3.new(512, 10, 512),
|
||||
CFrame = CFrame.new(Vector3.new(0, -5, 0)),
|
||||
Color = Color3.new(0, 0, 0),
|
||||
})
|
||||
end
|
||||
|
||||
-- Text displayed when paused
|
||||
local PauseText = Roact.Component:extend("PauseText")
|
||||
|
||||
function PauseText:render()
|
||||
return Roact.createElement("TextLabel", {
|
||||
Text = "Paused",
|
||||
TextColor3 = Color3.new(255, 255, 255),
|
||||
Font = Enum.Font.Arcade,
|
||||
TextSize = 70,
|
||||
Position = UDim2.new(0.5, 0, 0.5, 0),
|
||||
})
|
||||
end
|
||||
|
||||
-- Connects to UserInputService when created, disconnects when removed.
|
||||
local InputConnector = Roact.Component:extend("InputConnector")
|
||||
|
||||
function InputConnector:render()
|
||||
local function inputBeganCallback(input)
|
||||
if self.props.onInputBegan then
|
||||
self.props.onInputBegan(input)
|
||||
end
|
||||
end
|
||||
|
||||
local function inputChangedCallback(input)
|
||||
if self.props.onInputChanged then
|
||||
self.props.onInputChanged(input)
|
||||
end
|
||||
end
|
||||
|
||||
local function inputEndedCallback(input)
|
||||
if self.props.onInputEnded then
|
||||
self.props.onInputEnded(input)
|
||||
end
|
||||
end
|
||||
|
||||
return Roact.createElement(ExternalEventConnection, {
|
||||
event = UserInputService.InputBegan,
|
||||
callback = inputBeganCallback,
|
||||
}, {
|
||||
Roact.createElement(ExternalEventConnection, {
|
||||
event = UserInputService.InputChanged,
|
||||
callback = inputChangedCallback,
|
||||
}, {
|
||||
Roact.createElement(ExternalEventConnection, {
|
||||
event = UserInputService.InputEnded,
|
||||
callback = inputEndedCallback,
|
||||
})
|
||||
})
|
||||
})
|
||||
end
|
||||
|
||||
local KEYBOARD_KEY_MAPPING = {
|
||||
[Enum.KeyCode.Left] = "left",
|
||||
[Enum.KeyCode.A] = "left",
|
||||
[Enum.KeyCode.Right] = "right",
|
||||
[Enum.KeyCode.D] = "right",
|
||||
[Enum.KeyCode.Up] = "up",
|
||||
[Enum.KeyCode.W] = "up",
|
||||
[Enum.KeyCode.P] = "pause",
|
||||
}
|
||||
|
||||
local function getPlayerInput(input)
|
||||
if input.UserInputType ~= Enum.UserInputType.Keyboard then
|
||||
return nil
|
||||
end
|
||||
|
||||
return KEYBOARD_KEY_MAPPING[input.KeyCode]
|
||||
end
|
||||
|
||||
local KeyboardInputConnector = RoactRodux.connect(function(store)
|
||||
local function onInputBegan(input)
|
||||
local playerInput = getPlayerInput(input)
|
||||
|
||||
if playerInput then
|
||||
store:dispatch({
|
||||
type = "SetPlayerInput",
|
||||
playerInput = playerInput,
|
||||
value = 1,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
local function onInputEnded(input)
|
||||
local playerInput = getPlayerInput(input)
|
||||
|
||||
if playerInput then
|
||||
store:dispatch({
|
||||
type = "SetPlayerInput",
|
||||
playerInput = playerInput,
|
||||
value = 0,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
return {
|
||||
onInputBegan = onInputBegan,
|
||||
onInputEnded = onInputEnded,
|
||||
}
|
||||
end)(InputConnector)
|
||||
|
||||
local RenderSteppedConnector = Roact.Component:extend("RenderSteppedConnector")
|
||||
|
||||
function RenderSteppedConnector:render()
|
||||
return Roact.createElement(ExternalEventConnection, {
|
||||
event = RunService.RenderStepped,
|
||||
callback = self.props.onStepped,
|
||||
})
|
||||
end
|
||||
|
||||
-- Connect RenderSteppedConnector to actions dispatched to the store
|
||||
RenderSteppedConnector = RoactRodux.connect(function(store)
|
||||
return {
|
||||
onStepped = function(delta)
|
||||
store:dispatch({
|
||||
type = "GameStep",
|
||||
delta = delta,
|
||||
inputs = store:getState().playerInputs,
|
||||
})
|
||||
end
|
||||
}
|
||||
end)(RenderSteppedConnector)
|
||||
|
||||
--[[
|
||||
Only renders the given component if the given route is the current one.
|
||||
|
||||
The component is passed as a prop so that we don't worry about its
|
||||
descendants until they're relevant.
|
||||
]]
|
||||
local function PauseRoute(props)
|
||||
-- Injected by RoactRodux
|
||||
local paused = props.paused
|
||||
|
||||
-- Provided by parent component
|
||||
local match = props.match
|
||||
local component = props.component
|
||||
|
||||
if paused == match then
|
||||
return Roact.createElement(component)
|
||||
end
|
||||
end
|
||||
|
||||
PauseRoute = RoactRodux.connect(function(store)
|
||||
local state = store:getState()
|
||||
|
||||
return {
|
||||
paused = state.paused
|
||||
}
|
||||
end)(PauseRoute)
|
||||
|
||||
-- Package everything up in Workspace.
|
||||
local App = Roact.Component:extend("App")
|
||||
|
||||
function App:render()
|
||||
return Roact.createElement("Folder", {}, {
|
||||
Player = Roact.createElement(PlayerShip),
|
||||
PlayerCamera = Roact.createElement(PlayerCamera),
|
||||
GameBackground = Roact.createElement(GameBackground),
|
||||
Asteroids = Roact.createElement(Asteroids),
|
||||
KeyboardInputConnector = Roact.createElement(KeyboardInputConnector),
|
||||
RenderSteppedConnector = Roact.createElement(PauseRoute, {
|
||||
match = false,
|
||||
component = RenderSteppedConnector,
|
||||
}),
|
||||
})
|
||||
end
|
||||
|
||||
-- Package everything up in PlayerGui.
|
||||
local Ui = Roact.Component:extend("Ui")
|
||||
|
||||
function Ui:render()
|
||||
return Roact.createElement("ScreenGui", nil, {
|
||||
PauseText = Roact.createElement(PauseRoute, {
|
||||
match = true,
|
||||
component = PauseText,
|
||||
})
|
||||
})
|
||||
end
|
||||
|
||||
local function main()
|
||||
RunService.Stepped:Wait()
|
||||
|
||||
local store = Rodux.Store.new(reducer)
|
||||
|
||||
local app = Roact.createElement(RoactRodux.StoreProvider, {
|
||||
store = store
|
||||
}, {
|
||||
App = Roact.createElement(App)
|
||||
})
|
||||
|
||||
local ui = Roact.createElement(RoactRodux.StoreProvider, {
|
||||
store = store
|
||||
}, {
|
||||
Ui = Roact.createElement(Ui)
|
||||
})
|
||||
|
||||
Roact.reify(app, Workspace, "Roact-asteroids")
|
||||
Roact.reify(ui, PlayerGui, "Roact-asteroids-ui")
|
||||
end
|
||||
|
||||
return main
|
||||
@@ -0,0 +1,48 @@
|
||||
--[[
|
||||
Roact inherit's React's concept of 'context' used for putting an object
|
||||
into the tree and retrieving it without passing it explicitly.
|
||||
|
||||
This feature should be used sparingly; in most cases, you can use Rodux,
|
||||
which is implemented using context under the hood.
|
||||
|
||||
Context does not update and is only passed when a component is initially
|
||||
constructed; you should use another mechanism to listen for changes if
|
||||
using context.
|
||||
]]
|
||||
|
||||
local Common = script.Parent.Parent
|
||||
local Roact = require(Common.Roact)
|
||||
|
||||
local ContextProvider = Roact.Component:extend("ContextProvider")
|
||||
|
||||
function ContextProvider:init()
|
||||
-- Set self._context to add values to the tree
|
||||
self._context = {
|
||||
someValue = 5
|
||||
}
|
||||
end
|
||||
|
||||
function ContextProvider:render()
|
||||
-- Utility provided by Roact to pull exactly one child out of Children.
|
||||
return Roact.oneChild(self.props[Roact.Children])
|
||||
end
|
||||
|
||||
local ContextReader = Roact.Component:extend("ContextReader")
|
||||
|
||||
function ContextReader:init()
|
||||
-- self._context contains all of the values from our ancestor components
|
||||
print("Context value of", self._context.someValue)
|
||||
end
|
||||
|
||||
function ContextReader:render()
|
||||
-- No need to return anything
|
||||
end
|
||||
|
||||
return function()
|
||||
local element = Roact.createElement(ContextProvider, {
|
||||
}, {
|
||||
SomeChild = Roact.createElement(ContextReader)
|
||||
})
|
||||
|
||||
Roact.reify(element, nil, "SomeName")
|
||||
end
|
||||
@@ -0,0 +1,71 @@
|
||||
--[[
|
||||
Demonstrates use of state updates to re-render the UI.
|
||||
]]
|
||||
|
||||
local CoreGui = game:GetService("CoreGui")
|
||||
|
||||
local Common = script.Parent.Parent
|
||||
local Roact = require(Common.Roact)
|
||||
|
||||
-- A functional component to render the current tick value.
|
||||
local function TickLabel(props)
|
||||
local value = props.value
|
||||
|
||||
return Roact.createElement("TextLabel", {
|
||||
Size = UDim2.new(1, 0, 1, 0),
|
||||
Text = ("Current tick is %d!"):format(value),
|
||||
})
|
||||
end
|
||||
|
||||
local App = Roact.Component:extend("App")
|
||||
|
||||
function App:init()
|
||||
-- State that cannot affect rendering exists directly on the instance.
|
||||
self.running = false
|
||||
|
||||
-- State that the component changes that affects rendering exists on `state`
|
||||
-- You can only assign to `state` directly in the constructor.
|
||||
self.state = {
|
||||
count = 0
|
||||
}
|
||||
end
|
||||
|
||||
function App:render()
|
||||
return Roact.createElement("ScreenGui", {
|
||||
Name = "Roact-demo-counter",
|
||||
}, {
|
||||
Count = Roact.createElement(TickLabel, {
|
||||
value = self.state.count,
|
||||
}),
|
||||
})
|
||||
end
|
||||
|
||||
function App:didMount()
|
||||
-- Use 'didMount' to be notified when a component instance is created
|
||||
-- and added to the Roblox tree.
|
||||
|
||||
spawn(function()
|
||||
self.running = true
|
||||
|
||||
while self.running do
|
||||
-- Use 'setState' to update the component and patch the current
|
||||
-- state with new properties.
|
||||
-- Don't set `state` directly!
|
||||
self:setState({
|
||||
count = self.state.count + 1
|
||||
})
|
||||
|
||||
wait(1)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
function App:willUnmount()
|
||||
-- Use 'willUnmount' to be notified when your component is about to be
|
||||
-- removed from the tree. Do any cleanup here, like terminating a loop!
|
||||
self.running = false
|
||||
end
|
||||
|
||||
return function()
|
||||
Roact.reify(Roact.createElement(App), CoreGui, "Roact-counter")
|
||||
end
|
||||
@@ -0,0 +1,31 @@
|
||||
local CoreGui = game:GetService("CoreGui")
|
||||
|
||||
local Common = script.Parent.Parent
|
||||
|
||||
local Roact = require(Common.Roact)
|
||||
|
||||
local App = Roact.Component:extend("App")
|
||||
|
||||
function App:render()
|
||||
return Roact.createElement("ScreenGui", {
|
||||
}, {
|
||||
Button = Roact.createElement("TextButton", {
|
||||
Size = UDim2.new(0.5, 0, 0.5, 0),
|
||||
Position = UDim2.new(0.5, 0, 0.5, 0),
|
||||
AnchorPoint = Vector2.new(0.5, 0.5),
|
||||
|
||||
-- Attach event listeners using `Roact.Event[eventName]`
|
||||
-- Event listeners get `rbx` as their first parameter
|
||||
-- followed by their normal event arguments.
|
||||
[Roact.Event.MouseButton1Click] = function(rbx)
|
||||
print("The button was clicked!")
|
||||
end
|
||||
}),
|
||||
})
|
||||
end
|
||||
|
||||
return function()
|
||||
local element = Roact.createElement(App)
|
||||
|
||||
Roact.reify(element, CoreGui, "Roact-events")
|
||||
end
|
||||
@@ -0,0 +1,32 @@
|
||||
--[[
|
||||
'Refs' are a concept that let you break out of the Roact paradigm and access
|
||||
Roblox instances directly.
|
||||
|
||||
Pass a callback as a prop using the key [Roact.Ref] to receive
|
||||
the reference.
|
||||
|
||||
When the object is destructed or the ref is replaced in an update, the ref
|
||||
callback will be passed nil.
|
||||
|
||||
This feature is intended to be an escape hatch; it should not be necessary
|
||||
for the majority of work using Roact. In many cases, code using refs can be
|
||||
isolated and exposed with a friendlier API.
|
||||
]]
|
||||
|
||||
local Common = script.Parent.Parent
|
||||
local Roact = require(Common.Roact)
|
||||
|
||||
return function()
|
||||
local currentFrame
|
||||
|
||||
local element = Roact.createElement("Frame", {
|
||||
[Roact.Ref] = function(rbx)
|
||||
-- All properties are already set and the object has been parented
|
||||
currentFrame = rbx
|
||||
end
|
||||
})
|
||||
|
||||
Roact.reify(element, nil, "SomeName")
|
||||
|
||||
print("Have object", currentFrame)
|
||||
end
|
||||
@@ -0,0 +1,83 @@
|
||||
--[[
|
||||
Demonstrates how to use RoactRodux to link Roact and Rodux toegther into
|
||||
a single project.
|
||||
]]
|
||||
|
||||
local CoreGui = game:GetService("CoreGui")
|
||||
|
||||
local Common = script.Parent.Parent
|
||||
local Roact = require(Common.Roact)
|
||||
local Rodux = require(Common.Rodux)
|
||||
local RoactRodux = require(Common.RoactRodux)
|
||||
|
||||
-- React Portion
|
||||
-- This code doesn't know anything about Rodux.
|
||||
-- It can function as an isolated component and should be able to do so.
|
||||
local App = Roact.Component:extend("App")
|
||||
|
||||
function App:render()
|
||||
local count = self.props.count
|
||||
local onClick = self.props.onClick
|
||||
|
||||
return Roact.createElement("ScreenGui", nil, {
|
||||
Label = Roact.createElement("TextButton", {
|
||||
Size = UDim2.new(1, 0, 1, 0),
|
||||
Text = "Count: " .. tostring(count),
|
||||
AutoButtonColor = false,
|
||||
|
||||
[Roact.Event.MouseButton1Click] = onClick,
|
||||
})
|
||||
})
|
||||
end
|
||||
|
||||
-- React-Rodux Portion
|
||||
-- This code ties together Roact and Rodux by generating a wrapper component.
|
||||
-- The wrapper component maps the 'store' to a set of props we can use.
|
||||
-- It also receives (and passes on) 'props' given from the parent component.
|
||||
local connector = RoactRodux.connect(function(store, props)
|
||||
local state = store:getState()
|
||||
|
||||
local function increment()
|
||||
store:dispatch("increment")
|
||||
end
|
||||
|
||||
return {
|
||||
count = state.count,
|
||||
onClick = increment,
|
||||
}
|
||||
end)
|
||||
|
||||
-- In a lot of cases it's useful to preserve the original component
|
||||
-- For this example, we don't need the unwrapped App
|
||||
App = connector(App)
|
||||
|
||||
-- Rodux Portion
|
||||
-- This is a reducer that lets you increment a value.
|
||||
local function reducer(state, action)
|
||||
state = state or {
|
||||
count = 0,
|
||||
}
|
||||
|
||||
if action == "increment" then
|
||||
return {
|
||||
count = state.count + 1
|
||||
}
|
||||
end
|
||||
|
||||
return state
|
||||
end
|
||||
|
||||
-- Setup
|
||||
return function()
|
||||
local store = Rodux.Store.new(reducer)
|
||||
|
||||
-- We wrap our Roact-Rodux app in a `StoreProvider`, which makes sure our
|
||||
-- components know what store they should be connecting to.
|
||||
local app = Roact.createElement(RoactRodux.StoreProvider, {
|
||||
store = store,
|
||||
}, {
|
||||
App = Roact.createElement(App),
|
||||
})
|
||||
|
||||
Roact.reify(app, CoreGui, "Roact-demo-rodux")
|
||||
end
|
||||
@@ -0,0 +1,244 @@
|
||||
--[[
|
||||
Demonstrates creation of a complete routing system using Roact and Rodux.
|
||||
|
||||
This example mimics the philosophy of React Router 4 with dynamic routes:
|
||||
https://reacttraining.com/react-router/web/guides/philosophy
|
||||
]]
|
||||
|
||||
local CoreGui = game:GetService("CoreGui")
|
||||
|
||||
local Common = script.Parent.Parent
|
||||
|
||||
local Immutable = require(Common.Immutable)
|
||||
local Roact = require(Common.Roact)
|
||||
local Rodux = require(Common.Rodux)
|
||||
local RoactRodux = require(Common.RoactRodux)
|
||||
|
||||
-- A component that can be clicked to navigate around the app.
|
||||
local function Link(props)
|
||||
local text = props.text
|
||||
local onClick = props.onClick
|
||||
|
||||
return Roact.createElement("TextButton", {
|
||||
Size = UDim2.new(0, 100, 0, 30),
|
||||
Text = text,
|
||||
|
||||
[Roact.Event.MouseButton1Click] = onClick
|
||||
})
|
||||
end
|
||||
|
||||
Link = RoactRodux.connect(function(store, props)
|
||||
return {
|
||||
onClick = function()
|
||||
store:dispatch({
|
||||
type = "Navigate",
|
||||
location = props.target
|
||||
})
|
||||
end
|
||||
}
|
||||
end)(Link)
|
||||
|
||||
--[[
|
||||
A component that can be clicked to navigate back one step, if available.
|
||||
|
||||
Appears darker if there is no history to navigate backwards to.
|
||||
]]
|
||||
local function BackButton(props)
|
||||
local onClick = props.onClick
|
||||
local enabled = props.enabled
|
||||
|
||||
return Roact.createElement("TextButton", {
|
||||
Size = UDim2.new(0, 30, 0, 30),
|
||||
Text = "<",
|
||||
AutoButtonColor = enabled,
|
||||
BackgroundColor3 = enabled and Color3.new(0.8, 0.8, 0.8) or Color3.new(0.5, 0.5, 0.5),
|
||||
|
||||
[Roact.Event.MouseButton1Click] = onClick,
|
||||
})
|
||||
end
|
||||
|
||||
BackButton = RoactRodux.connect(function(store)
|
||||
local state = store:getState()
|
||||
|
||||
return {
|
||||
onClick = function()
|
||||
store:dispatch({
|
||||
type = "Back"
|
||||
})
|
||||
end,
|
||||
enabled = #state.history > 0,
|
||||
}
|
||||
end)(BackButton)
|
||||
|
||||
--[[
|
||||
Only renders the given component if the given route is the current one.
|
||||
|
||||
The component is passed as a prop so that we don't worry about its
|
||||
descendants until they're relevant.
|
||||
]]
|
||||
local function Route(props)
|
||||
-- Injected by RoactRodux
|
||||
local current = props.current
|
||||
|
||||
-- Provided by parent component
|
||||
local match = props.match
|
||||
local component = props.component
|
||||
|
||||
if current == match then
|
||||
return Roact.createElement(component)
|
||||
end
|
||||
end
|
||||
|
||||
Route = RoactRodux.connect(function(store)
|
||||
local state = store:getState()
|
||||
|
||||
return {
|
||||
current = state.current,
|
||||
}
|
||||
end)(Route)
|
||||
|
||||
--[[
|
||||
A navigation bar that lets us go to one of three locations, or
|
||||
travel backwards.
|
||||
]]
|
||||
local function NavBar()
|
||||
return Roact.createElement("Frame", {
|
||||
Size = UDim2.new(1, 0, 0, 30),
|
||||
Position = UDim2.new(0, 0, 1, 0),
|
||||
BackgroundColor3 = Color3.new(1, 1, 1),
|
||||
}, {
|
||||
Layout = Roact.createElement("UIListLayout", {
|
||||
FillDirection = Enum.FillDirection.Horizontal,
|
||||
}),
|
||||
|
||||
Back = Roact.createElement(BackButton),
|
||||
|
||||
GoHome = Roact.createElement(Link, {
|
||||
text = "Go to Home",
|
||||
target = "home",
|
||||
}),
|
||||
|
||||
GoAbout = Roact.createElement(Link, {
|
||||
text = "Go to About",
|
||||
target = "about",
|
||||
}),
|
||||
|
||||
GoContact = Roact.createElement(Link, {
|
||||
text = "Go to Contact",
|
||||
target = "contact",
|
||||
}),
|
||||
})
|
||||
end
|
||||
|
||||
-- 'Home' page, our default view
|
||||
local function Home()
|
||||
return Roact.createElement("TextLabel", {
|
||||
Size = UDim2.new(1, 0, 1, 0),
|
||||
BackgroundColor3 = Color3.new(0.5, 0.3, 0.7),
|
||||
Text = "Home",
|
||||
TextSize = 30,
|
||||
TextColor3 = Color3.new(0.95, 0.95, 0.95),
|
||||
})
|
||||
end
|
||||
|
||||
-- 'About' page
|
||||
local function About()
|
||||
return Roact.createElement("TextLabel", {
|
||||
Size = UDim2.new(1, 0, 1, 0),
|
||||
BackgroundColor3 = Color3.new(0.5, 0.6, 0.2),
|
||||
Text = "About",
|
||||
TextSize = 30,
|
||||
TextColor3 = Color3.new(0.95, 0.95, 0.95),
|
||||
})
|
||||
end
|
||||
|
||||
-- 'Contact' page
|
||||
local function Contact()
|
||||
return Roact.createElement("TextLabel", {
|
||||
Size = UDim2.new(1, 0, 1, 0),
|
||||
BackgroundColor3 = Color3.new(0.8, 0.3, 0.4),
|
||||
Text = "Contact",
|
||||
TextSize = 30,
|
||||
TextColor3 = Color3.new(0.95, 0.95, 0.95),
|
||||
})
|
||||
end
|
||||
|
||||
--[[
|
||||
The wrapper used to package up all of our components and display them.
|
||||
]]
|
||||
local App = Roact.Component:extend("App")
|
||||
|
||||
function App:render()
|
||||
return Roact.createElement("ScreenGui", {
|
||||
}, {
|
||||
Body = Roact.createElement("Frame", {
|
||||
Size = UDim2.new(0, 600, 0, 400),
|
||||
Position = UDim2.new(0.5, 0, 0.5, 0),
|
||||
AnchorPoint = Vector2.new(0.5, 0.5),
|
||||
BackgroundTransparency = 1,
|
||||
}, {
|
||||
-- These components sort of act like a static routing table.
|
||||
Home = Roact.createElement(Route, {
|
||||
match = "home",
|
||||
component = Home,
|
||||
}),
|
||||
|
||||
About = Roact.createElement(Route, {
|
||||
match = "about",
|
||||
component = About,
|
||||
}),
|
||||
|
||||
Contact = Roact.createElement(Route, {
|
||||
match = "contact",
|
||||
component = Contact,
|
||||
}),
|
||||
|
||||
NavBar = Roact.createElement(NavBar),
|
||||
}),
|
||||
})
|
||||
end
|
||||
|
||||
--[[
|
||||
All of our navigation is described using a Redux reducer.
|
||||
|
||||
This means we can time-travel debug and log actions just like other
|
||||
Redux data.
|
||||
]]
|
||||
local function reducer(state, action)
|
||||
state = state or {
|
||||
current = "home",
|
||||
history = {}
|
||||
}
|
||||
|
||||
if action.type == "Navigate" then
|
||||
return {
|
||||
current = action.location,
|
||||
history = Immutable.Append(state.history, state.current),
|
||||
}
|
||||
elseif action.type == "Back" then
|
||||
local length = #state.history
|
||||
|
||||
if length == 0 then
|
||||
return state
|
||||
end
|
||||
|
||||
return {
|
||||
current = state.history[length],
|
||||
history = Immutable.RemoveFromList(state.history, length),
|
||||
}
|
||||
end
|
||||
|
||||
return state
|
||||
end
|
||||
|
||||
return function()
|
||||
local store = Rodux.Store.new(reducer)
|
||||
|
||||
local element = Roact.createElement(RoactRodux.StoreProvider, {
|
||||
store = store,
|
||||
}, {
|
||||
App = Roact.createElement(App)
|
||||
})
|
||||
|
||||
Roact.reify(element, CoreGui, "Roact-router")
|
||||
end
|
||||
@@ -0,0 +1,63 @@
|
||||
local CoreGui = game:GetService("CoreGui")
|
||||
local RunService = game:GetService("RunService")
|
||||
|
||||
local GRID_SIZE = 50
|
||||
local NODE_SIZE = 10
|
||||
|
||||
local Common = script.Parent.Parent
|
||||
local Roact = require(Common.Roact)
|
||||
|
||||
local Node = Roact.Component:extend("Node")
|
||||
|
||||
function Node:render()
|
||||
local x = self.props.x
|
||||
local y = self.props.y
|
||||
local time = self.props.time
|
||||
|
||||
local n = time + x / NODE_SIZE + y / NODE_SIZE
|
||||
|
||||
return Roact.createElement("Frame", {
|
||||
Size = UDim2.new(0, NODE_SIZE, 0, NODE_SIZE),
|
||||
Position = UDim2.new(0, NODE_SIZE * x, 0, NODE_SIZE * y),
|
||||
BackgroundColor3 = Color3.new(0.5 + 0.5 * math.sin(n), 0.5, 0.5),
|
||||
})
|
||||
end
|
||||
|
||||
local App = Roact.Component:extend("App")
|
||||
|
||||
function App:init()
|
||||
self.state = {
|
||||
time = tick(),
|
||||
}
|
||||
end
|
||||
|
||||
function App:render()
|
||||
local time = self.state.time
|
||||
local nodes = {}
|
||||
|
||||
local n = 0
|
||||
for x = 0, GRID_SIZE - 1 do
|
||||
for y = 0, GRID_SIZE - 1 do
|
||||
n = n + 1
|
||||
nodes[n] = Roact.createElement(Node, {
|
||||
x = x,
|
||||
y = y,
|
||||
time = time,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
return Roact.createElement("ScreenGui", nil, nodes)
|
||||
end
|
||||
|
||||
function App:didMount()
|
||||
RunService.Stepped:Connect(function()
|
||||
self:setState({
|
||||
time = tick(),
|
||||
})
|
||||
end)
|
||||
end
|
||||
|
||||
return function()
|
||||
Roact.reify(Roact.createElement(App), CoreGui)
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
-----------------------------------------------------------------------------
|
||||
--- ---
|
||||
--- Under Migration to CorePackages ---
|
||||
--- ---
|
||||
--- Please put your changes in AppTempCommon. ---
|
||||
--- NOTE: You will have to rebuild for changes to kick in ---
|
||||
-----------------------------------------------------------------------------
|
||||
local CorePackages = game:GetService("CorePackages")
|
||||
return require(CorePackages.AppTempCommon.Common.Functional)
|
||||
@@ -0,0 +1,9 @@
|
||||
-----------------------------------------------------------------------------
|
||||
--- ---
|
||||
--- Under Migration to CorePackages ---
|
||||
--- ---
|
||||
--- Please put your changes in AppTempCommon. ---
|
||||
--- NOTE: You will have to rebuild for changes to kick in ---
|
||||
-----------------------------------------------------------------------------
|
||||
local CorePackages = game:GetService("CorePackages")
|
||||
return require(CorePackages.AppTempCommon.Common.Immutable)
|
||||
@@ -0,0 +1,285 @@
|
||||
--[[
|
||||
A simple observer for errors on the script context.
|
||||
|
||||
By default, sends formatted error reports to the AnalyticsService.
|
||||
]]
|
||||
local ScriptContext = game:GetService("ScriptContext")
|
||||
local RunService = game:GetService("RunService")
|
||||
local Analytics = require(script.Parent.Analytics).new()
|
||||
|
||||
-- flag dependencies
|
||||
local influxSeriesName = settings():GetFVariable("LuaErrorsInfluxSeries")
|
||||
local influxThrottlingPercentage = tonumber(settings():GetFVariable("LuaErrorsInfluxThrottling"))
|
||||
local diagCounterName = settings():GetFVariable("LuaAppsDiagErrorCounter")
|
||||
local isEnabled = settings():GetFFlag("UseNewGoogleAnalyticsImpl2")
|
||||
|
||||
-- defaults
|
||||
local defaultVerboseErrors = false
|
||||
local defaultShouldReportDiag = true
|
||||
local defaultShouldReportGoogleAnalytics = true
|
||||
local defaultShouldReportInflux = false
|
||||
local defaultCurrentApp = "Unknown"
|
||||
local defaultQueuedReportTotalLimit = 30
|
||||
|
||||
|
||||
-- string formatting functions
|
||||
local function createProductName(currentApp)
|
||||
local versionString = RunService:GetRobloxVersion()
|
||||
return string.format("%s-%s", currentApp, versionString)
|
||||
end
|
||||
|
||||
local function convertNewlinesToPipes(stack)
|
||||
local rebuiltStack = ""
|
||||
local first = true
|
||||
for line in stack:gmatch("[^\r\n]+") do
|
||||
if first then
|
||||
rebuiltStack = line
|
||||
first = false
|
||||
else
|
||||
rebuiltStack = rebuiltStack .. " | " .. line
|
||||
end
|
||||
end
|
||||
return rebuiltStack
|
||||
end
|
||||
|
||||
local function removePlayerNameFromStack(stack)
|
||||
stack = string.gsub(stack, "Players%.[^.]+%.", "Players.<Player>.")
|
||||
return stack
|
||||
end
|
||||
|
||||
local function printError(currentApp, message, stack, offendingScript)
|
||||
local outMessages = {
|
||||
"---- Unhandled Error Handler -----",
|
||||
string.format("Current App<%s, %d> : \n%s\n", type(currentApp), #currentApp, currentApp),
|
||||
string.format("Message<%s,%d> :\n%s\n", type(message), #message, message),
|
||||
string.format("Stack<%s,%d> :\n%s", type(stack), #stack, stack),
|
||||
string.format("Script<%s> :\n%s", type(offendingScript), offendingScript:GetFullName()),
|
||||
"----------------------------------"}
|
||||
|
||||
print(table.concat(outMessages, "\n"))
|
||||
end
|
||||
|
||||
-- analytics reporting functions
|
||||
local function reportErrorToGA(currentApp, errorMsg, stack, value)
|
||||
Analytics.GoogleAnalytics:trackEvent(currentApp, errorMsg, stack, value)
|
||||
end
|
||||
|
||||
local function reportErrorToInflux(currentApp, message, stack, offendingScript)
|
||||
local additionalArgs = {
|
||||
app = currentApp,
|
||||
err = message,
|
||||
stack = stack,
|
||||
script = offendingScript:GetFullName()
|
||||
}
|
||||
|
||||
-- fire the error report
|
||||
Analytics.InfluxDb:reportSeries(influxSeriesName, additionalArgs, influxThrottlingPercentage)
|
||||
end
|
||||
|
||||
local function reportErrorToDiag(currentApp)
|
||||
-- these reports may be broken down further based on current app
|
||||
Analytics.Diag:reportCounter(diagCounterName, 1)
|
||||
end
|
||||
|
||||
-- helper queue object
|
||||
local function createErrorQueue()
|
||||
-- NOTE - if error batching other types of reports becomes more important,
|
||||
-- this object can be generalized to work for more errors, not just GA
|
||||
local ErrorQueue = {
|
||||
errors = {},
|
||||
totalErrors = 0,
|
||||
totalKeys = 0,
|
||||
countdown = defaultQueuedReportTotalLimit,
|
||||
shouldCountdown = true,
|
||||
}
|
||||
|
||||
function ErrorQueue:addError(currentApp, message, stack)
|
||||
local key = string.format("%s | %s | %s", currentApp, message, stack)
|
||||
if not self.errors[key] then
|
||||
self.errors[key] = {
|
||||
app = currentApp,
|
||||
message = message,
|
||||
stack = stack,
|
||||
value = 1 }
|
||||
self.totalKeys = self.totalKeys + 1
|
||||
else
|
||||
self.errors[key].value = self.errors[key].value + 1
|
||||
end
|
||||
|
||||
self.totalErrors = self.totalErrors + 1
|
||||
end
|
||||
|
||||
function ErrorQueue:isReadyToReport()
|
||||
-- NOTE - GA has limits on how many reports that it will accept at a time.
|
||||
-- According to : https://developers.google.com/analytics/devguides/config/mgmt/v3/limits-quotas
|
||||
-- the Collection API is limited to 10 queries / second per IP Address
|
||||
return self.totalKeys > 10 or
|
||||
self.totalErrors > defaultQueuedReportTotalLimit or
|
||||
self.countdown <= 0
|
||||
end
|
||||
|
||||
function ErrorQueue:reportAllErrors()
|
||||
-- copy the error queue and instantly clear it out
|
||||
local errors = {}
|
||||
for k, v in pairs(self.errors) do
|
||||
errors[k] = v
|
||||
end
|
||||
|
||||
self.errors = {}
|
||||
self.totalErrors = 0
|
||||
self.totalKeys = 0
|
||||
self.countdown = defaultQueuedReportTotalLimit
|
||||
|
||||
-- report the errors
|
||||
for _, errData in pairs(errors) do
|
||||
reportErrorToGA(errData.app, errData.message, errData.stack, errData.value)
|
||||
end
|
||||
end
|
||||
|
||||
function ErrorQueue:startTimer()
|
||||
spawn(function()
|
||||
while self.shouldCountdown do
|
||||
self.countdown = self.countdown - 1
|
||||
if self:isReadyToReport() then
|
||||
self:reportAllErrors()
|
||||
end
|
||||
wait(1.0)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
function ErrorQueue:stopTimer()
|
||||
self.shouldCountdown = false
|
||||
end
|
||||
|
||||
return ErrorQueue
|
||||
end
|
||||
|
||||
|
||||
|
||||
local LuaErrorReporter = {}
|
||||
LuaErrorReporter.__index = LuaErrorReporter
|
||||
|
||||
-- observedSignal : the Signal to listen for errors on
|
||||
function LuaErrorReporter.new(observedSignal)
|
||||
-- sanitize input
|
||||
if not observedSignal then
|
||||
observedSignal = ScriptContext.Error
|
||||
end
|
||||
|
||||
-- _isInstance : (boolean) simple flag to identify that this object was created with new()
|
||||
-- _verbose : (boolean) when true, prints out debug information before sending error reports
|
||||
-- _shouldReportDiag : (boolean) when true, increments a counter of total errors in Diag
|
||||
-- _shouldReportGoogleAnalytics : (boolean) when true, reports the error to GoogleAnalytics
|
||||
-- _shouldReportInflux : (boolean) when true, reports the error to InfluxDb
|
||||
-- _currentScreen : (string) the name of the screen that is currently presented to the user
|
||||
-- _signalConnectionToken : (RBXScriptConnection) a token issued when connecting to the Error signal
|
||||
-- _reportQueueGA : (ErrorQueue)
|
||||
local instance = {
|
||||
_isInstance = true,
|
||||
_verbose = defaultVerboseErrors,
|
||||
_signalConnectionToken = nil,
|
||||
_shouldReportDiag = defaultShouldReportDiag,
|
||||
_shouldReportGoogleAnalytics = defaultShouldReportGoogleAnalytics,
|
||||
_shouldReportInflux = defaultShouldReportInflux,
|
||||
_currentApp = defaultCurrentApp,
|
||||
_reportQueueGA = createErrorQueue(),
|
||||
}
|
||||
setmetatable(instance, LuaErrorReporter)
|
||||
|
||||
-- connect a listener for errors on the provided Signal.
|
||||
instance._signalConnectionToken = observedSignal:Connect(function(message, stack, offendingScript)
|
||||
|
||||
-- protect against endless chains of errors when actually reporting
|
||||
local success, reportMessage = pcall(function()
|
||||
instance:handleError(message, stack, offendingScript)
|
||||
end)
|
||||
|
||||
if not success then
|
||||
warn(string.format("An error occurred while reporting an error : %s", reportMessage))
|
||||
end
|
||||
end)
|
||||
|
||||
-- the BindToClose function does not play nicely with Studio.
|
||||
if not RunService:IsStudio() then
|
||||
-- BindToClose has about a 30 second timeout before the datamodel will kill any running scripts,
|
||||
-- but this function will only need to fire off, at most, 9 http requests in parallel.
|
||||
-- And since we're not binding any callbacks to these http requests, it's fine.
|
||||
game:BindToClose(function()
|
||||
instance:delete()
|
||||
end)
|
||||
end
|
||||
|
||||
return instance
|
||||
end
|
||||
|
||||
function LuaErrorReporter:delete()
|
||||
-- we're cleaning up this crash observer, disconnect from the Signal
|
||||
self._signalConnectionToken:Disconnect()
|
||||
|
||||
-- when the game closes down, send off all the remaining reports left in the queue
|
||||
self._reportQueueGA:reportAllErrors()
|
||||
self._reportQueueGA.shouldCountdown = false
|
||||
end
|
||||
|
||||
-- appName : (string) the english, human readable name of the current app that is hosting the lua app
|
||||
function LuaErrorReporter:setCurrentApp(appName)
|
||||
if type(appName) ~= "string" then
|
||||
error("appName must be a string")
|
||||
end
|
||||
|
||||
self._currentApp = appName
|
||||
end
|
||||
|
||||
function LuaErrorReporter:startQueueTimers()
|
||||
self._reportQueueGA:startTimer()
|
||||
end
|
||||
|
||||
function LuaErrorReporter:stopQueueTimers()
|
||||
self._reportQueueGA:stopTimer()
|
||||
end
|
||||
|
||||
-- message : (string) the message passed from the error() call
|
||||
-- stack : (string) the stack trace
|
||||
-- offendingScript : (LuaSourceContainer) the specific script that threw the error
|
||||
function LuaErrorReporter:handleError(message, stack, offendingScript)
|
||||
-- NOTE - offendingScript is intended to show where in the workspace the error originated.
|
||||
-- It will not be useful for the Lua Apps as all files originate out of the ***StarterScript.lua
|
||||
|
||||
-- if the fast flag isn't on yet, escape
|
||||
if not isEnabled then
|
||||
return
|
||||
end
|
||||
|
||||
-- make a descriptive name to categorize the errors under- : <currentApp>-<appV
|
||||
local productName = createProductName(self._currentApp)
|
||||
|
||||
-- parse out the error message
|
||||
if self._verbose then
|
||||
printError(productName, message, stack, offendingScript)
|
||||
end
|
||||
|
||||
-- sanitize some inputs
|
||||
local cleanedMessage = removePlayerNameFromStack(message)
|
||||
local cleanedStack = removePlayerNameFromStack(stack)
|
||||
cleanedStack = convertNewlinesToPipes(cleanedStack)
|
||||
|
||||
-- report to the appropriate sources
|
||||
if self._shouldReportGoogleAnalytics then
|
||||
self._reportQueueGA:addError(productName, cleanedMessage, cleanedStack)
|
||||
|
||||
if self._reportQueueGA:isReadyToReport() then
|
||||
self._reportQueueGA:reportAllErrors()
|
||||
end
|
||||
end
|
||||
|
||||
if self._shouldReportInflux then
|
||||
reportErrorToInflux(productName, cleanedMessage, cleanedStack, offendingScript)
|
||||
end
|
||||
|
||||
if self._shouldReportDiag then
|
||||
reportErrorToDiag(productName)
|
||||
end
|
||||
end
|
||||
|
||||
return LuaErrorReporter
|
||||
@@ -0,0 +1,113 @@
|
||||
return function()
|
||||
FIXME("throws libc++abi.dylib: terminating with uncaught exception of type std::runtime_error: Script that implemented this callback has been destroyed")
|
||||
|
||||
-- NOTE : since each Lua Error Reporter object adds an observer to the script context,
|
||||
-- it is important that tests clean up after themselves and call the delete() function
|
||||
local LuaErrorReporter = require(script.Parent.LuaErrorReporter)
|
||||
local Signal = require(script.Parent.Signal)
|
||||
|
||||
-- create some dummy test values
|
||||
local testError = "foo"
|
||||
local testAppName = "testSuite"
|
||||
local testSignal = Signal.new()
|
||||
|
||||
local function createSilentLER()
|
||||
-- NOTE - creating this reporter with the testSignal circumvents testing the functionality
|
||||
-- of observing the ScriptContext.Error signal. It is assumed that the Error signal just works.
|
||||
|
||||
-- NOTE - even if the handleError() function doesn't get overriden on the LuaErrorReporter,
|
||||
-- don't send error reports over the wire for unit tests
|
||||
local ler = LuaErrorReporter.new(testSignal)
|
||||
ler._verbose = false
|
||||
ler._shouldReportInflux = false
|
||||
ler._shouldReportGoogleAnalytics = false
|
||||
ler._shouldReportDiag = false
|
||||
return ler
|
||||
end
|
||||
local function fireTestErrorSignal()
|
||||
-- fire a signal that mimics the structure of the ScriptContext.Error's arguments:
|
||||
-- (message, stackTrace, scriptSource)
|
||||
testSignal:Fire(testError, debug.traceback(), script)
|
||||
|
||||
-- NOTE - if these tests are ever to be modified to once again observe
|
||||
-- the ScriptContext.Error signal, then be mindful that testSignal resolves synchronously
|
||||
-- while ScriptContext.Error does not.
|
||||
end
|
||||
|
||||
describe("new()", function()
|
||||
it("should construct with a custom signal", function()
|
||||
local ler = createSilentLER()
|
||||
expect(ler).to.be.ok()
|
||||
|
||||
ler:delete()
|
||||
end)
|
||||
|
||||
it("should create an object that observes errors", function()
|
||||
local callCounter = 0
|
||||
|
||||
local ler = createSilentLER()
|
||||
function ler:handleError(message, stack, offendingScript)
|
||||
callCounter = callCounter + 1
|
||||
expect(message).to.be.ok()
|
||||
expect(stack).to.be.ok()
|
||||
expect(offendingScript).to.be.ok()
|
||||
end
|
||||
|
||||
fireTestErrorSignal()
|
||||
|
||||
ler:delete()
|
||||
|
||||
expect(callCounter).to.equal(1)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("delete()", function()
|
||||
it("should break the connection to the error context", function()
|
||||
local ler = createSilentLER()
|
||||
function ler:handleError(message, stack, script)
|
||||
error("delete() failed to remove the script context's error observer")
|
||||
end
|
||||
|
||||
ler:delete()
|
||||
|
||||
fireTestErrorSignal()
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("setCurrentApp()", function()
|
||||
it("should not allow the value to be nil", function()
|
||||
local ler = createSilentLER()
|
||||
|
||||
expect(function()
|
||||
ler:setCurrentApp(nil)
|
||||
end).to.throw()
|
||||
|
||||
ler:delete()
|
||||
end)
|
||||
|
||||
it("should allow the value to be set to a string", function()
|
||||
local ler = createSilentLER()
|
||||
|
||||
ler:setCurrentApp(testAppName)
|
||||
expect(ler._currentApp).to.equal(testAppName)
|
||||
|
||||
ler:delete()
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("handleError()", function()
|
||||
it("should be overrideable with a custom function", function()
|
||||
local callCounter = 0
|
||||
|
||||
local ler = createSilentLER()
|
||||
function ler:handleError(messsage, stack, script)
|
||||
callCounter = callCounter + 1
|
||||
end
|
||||
|
||||
fireTestErrorSignal()
|
||||
|
||||
expect(callCounter).to.equal(1)
|
||||
ler:delete()
|
||||
end)
|
||||
end)
|
||||
end
|
||||
@@ -0,0 +1,29 @@
|
||||
--[[
|
||||
Calls a function and throws an error if it attempts to yield.
|
||||
|
||||
Pass any number of arguments to the function after the callback.
|
||||
|
||||
This function supports multiple return; all results returned from the
|
||||
given function will be returned.
|
||||
]]
|
||||
|
||||
local function resultHandler(co, ok, ...)
|
||||
if not ok then
|
||||
local err = (...)
|
||||
error(err, 2)
|
||||
end
|
||||
|
||||
if coroutine.status(co) ~= "dead" then
|
||||
error(debug.traceback(co, "Attempted to yield inside Changed event!"), 2)
|
||||
end
|
||||
|
||||
return ...
|
||||
end
|
||||
|
||||
local function NoYield(callback, ...)
|
||||
local co = coroutine.create(callback)
|
||||
|
||||
return resultHandler(co, coroutine.resume(co, ...))
|
||||
end
|
||||
|
||||
return NoYield
|
||||
@@ -0,0 +1,39 @@
|
||||
return function()
|
||||
local NoYield = require(script.Parent.NoYield)
|
||||
|
||||
it("should call functions normally", function()
|
||||
local callCount = 0
|
||||
|
||||
local function test(a, b)
|
||||
expect(a).to.equal(5)
|
||||
expect(b).to.equal(6)
|
||||
|
||||
callCount = callCount + 1
|
||||
|
||||
return 11, "hello"
|
||||
end
|
||||
|
||||
local a, b = NoYield(test, 5, 6)
|
||||
|
||||
expect(a).to.equal(11)
|
||||
expect(b).to.equal("hello")
|
||||
end)
|
||||
|
||||
it("should throw on yield", function()
|
||||
local preCount = 0
|
||||
local postCount = 0
|
||||
|
||||
local function test()
|
||||
preCount = preCount + 1
|
||||
wait()
|
||||
postCount = postCount + 1
|
||||
end
|
||||
|
||||
expect(function()
|
||||
NoYield(test)
|
||||
end).to.throw()
|
||||
|
||||
expect(preCount).to.equal(1)
|
||||
expect(postCount).to.equal(0)
|
||||
end)
|
||||
end
|
||||
@@ -0,0 +1,71 @@
|
||||
return function()
|
||||
local HttpService = game:GetService("HttpService")
|
||||
|
||||
HACK_NO_XPCALL()
|
||||
|
||||
it("should throw if Url is not a string", function()
|
||||
local options = {
|
||||
Url = true,
|
||||
Method = "GET",
|
||||
}
|
||||
|
||||
expect(function()
|
||||
HttpService:RequestInternal(options)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should throw if method is invalid", function()
|
||||
local options = {
|
||||
Url = "https://google.com",
|
||||
Method = "&&&",
|
||||
}
|
||||
|
||||
expect(function()
|
||||
HttpService:RequestInternal(options)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should not throw if Url and Method are valid", function()
|
||||
local options = {
|
||||
Url = "https://google.com",
|
||||
Method = "GET",
|
||||
}
|
||||
|
||||
expect(HttpService:RequestInternal(options)).to.be.ok()
|
||||
end)
|
||||
|
||||
it("should not throw if CachePolicy, Timeout, and Priority are valid", function()
|
||||
local options = {
|
||||
Url = "https://google.com",
|
||||
Method = "GET",
|
||||
Timeout = 20,
|
||||
Priority = 20,
|
||||
CachePolicy = Enum.HttpCachePolicy.Full,
|
||||
}
|
||||
expect(HttpService:RequestInternal(options)).to.be.ok()
|
||||
end)
|
||||
|
||||
it("should be able to be canceled", function()
|
||||
local options = {
|
||||
Url = "https://google.com",
|
||||
Method = "GET",
|
||||
}
|
||||
|
||||
local request = HttpService:RequestInternal(options)
|
||||
|
||||
local response = nil
|
||||
local success = nil
|
||||
request:Start(function(suc, resp)
|
||||
success = suc
|
||||
response = resp
|
||||
end)
|
||||
request:Cancel()
|
||||
|
||||
while response == nil do
|
||||
wait()
|
||||
end
|
||||
|
||||
expect(success).to.equal(false)
|
||||
expect(response.HttpError).to.equal(Enum.HttpError.Aborted)
|
||||
end)
|
||||
end
|
||||
@@ -0,0 +1,3 @@
|
||||
local CorePackages = game:GetService("CorePackages")
|
||||
|
||||
return require(CorePackages.Roact)
|
||||
@@ -0,0 +1,3 @@
|
||||
local CorePackages = game:GetService("CorePackages")
|
||||
|
||||
return require(CorePackages.RoactRodux)
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
--[[
|
||||
A component that establishes a connection to a Roblox event when it is rendered.
|
||||
]]
|
||||
|
||||
local Roact = require(script.Parent.Parent.Roact)
|
||||
local ExternalEventConnection = Roact.Component:extend("ExternalEventConnection")
|
||||
|
||||
function ExternalEventConnection:init()
|
||||
self.connection = nil
|
||||
end
|
||||
|
||||
--[[
|
||||
Render the child component so that ExternalEventConnections can be nested like so:
|
||||
|
||||
Roact.createElement(ExternalEventConnection, {
|
||||
event = UserInputService.InputBegan,
|
||||
callback = inputBeganCallback,
|
||||
}, {
|
||||
Roact.createElement(ExternalEventConnection, {
|
||||
event = UserInputService.InputEnded,
|
||||
callback = inputChangedCallback,
|
||||
})
|
||||
})
|
||||
]]
|
||||
function ExternalEventConnection:render()
|
||||
return Roact.oneChild(self.props[Roact.Children])
|
||||
end
|
||||
|
||||
function ExternalEventConnection:didMount()
|
||||
local event = self.props.event
|
||||
local callback = self.props.callback
|
||||
|
||||
self.connection = event:Connect(callback)
|
||||
end
|
||||
|
||||
function ExternalEventConnection:didUpdate(oldProps)
|
||||
if self.props.event ~= oldProps.event or self.props.callback ~= oldProps.callback then
|
||||
self.connection:Disconnect()
|
||||
|
||||
self.connection = self.props.event:Connect(self.props.callback)
|
||||
end
|
||||
end
|
||||
|
||||
function ExternalEventConnection:willUnmount()
|
||||
self.connection:Disconnect()
|
||||
|
||||
self.connection = nil
|
||||
end
|
||||
|
||||
return ExternalEventConnection
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
return function ()
|
||||
local Roact = require(script.Parent.Parent.Roact)
|
||||
local ExternalEventConnection = require(script.Parent.ExternalEventConnection)
|
||||
|
||||
it("if mounted, should call the callback when the event is triggered", function()
|
||||
local event = Instance.new("BindableEvent")
|
||||
local count = 0
|
||||
|
||||
local element = Roact.createElement(ExternalEventConnection, {
|
||||
event = event.Event,
|
||||
callback = function()
|
||||
count = count + 1
|
||||
end,
|
||||
})
|
||||
|
||||
local RoactInstance = Roact.reify(element)
|
||||
event:Fire()
|
||||
|
||||
expect(count).to.equal(1)
|
||||
|
||||
Roact.teardown(RoactInstance)
|
||||
event:Fire()
|
||||
|
||||
expect(count).to.equal(1)
|
||||
|
||||
event:Destroy()
|
||||
end)
|
||||
|
||||
it("should handle updating the callback or event", function()
|
||||
local firstEvent = Instance.new("BindableEvent")
|
||||
local secondEvent = Instance.new("BindableEvent")
|
||||
local count = 0
|
||||
local changeState
|
||||
|
||||
local EventContainer = Roact.Component:extend("EventContainer")
|
||||
|
||||
function EventContainer:init()
|
||||
self.state = {
|
||||
event = firstEvent.Event,
|
||||
callback = function()
|
||||
count = count + 1
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
function EventContainer:render()
|
||||
return Roact.createElement(ExternalEventConnection, {
|
||||
event = self.state.event,
|
||||
callback = self.state.callback,
|
||||
})
|
||||
end
|
||||
|
||||
function EventContainer:didMount()
|
||||
changeState = function(newState)
|
||||
self:setState(newState)
|
||||
end
|
||||
end
|
||||
|
||||
function EventContainer:willUnmount()
|
||||
changeState = nil
|
||||
end
|
||||
|
||||
Roact.reify(Roact.createElement(EventContainer))
|
||||
firstEvent:Fire()
|
||||
|
||||
expect(count).to.equal(1)
|
||||
|
||||
changeState({
|
||||
event = secondEvent.Event,
|
||||
})
|
||||
firstEvent:Fire()
|
||||
|
||||
expect(count).to.equal(1)
|
||||
|
||||
secondEvent:Fire()
|
||||
|
||||
expect(count).to.equal(2)
|
||||
|
||||
changeState({
|
||||
callback = function()
|
||||
-- this is intentionally blank
|
||||
end,
|
||||
})
|
||||
secondEvent:Fire()
|
||||
|
||||
expect(count).to.equal(2)
|
||||
firstEvent:Destroy()
|
||||
secondEvent:Destroy()
|
||||
end)
|
||||
end
|
||||
@@ -0,0 +1,104 @@
|
||||
--[[
|
||||
Wraps around Rodux and applies a compatibility patch to deal with API
|
||||
breakages since the last Rodux upgrade.
|
||||
|
||||
Things that have changed:
|
||||
* Store API is now camelCase instead of PascalCase to match standards
|
||||
* Signal API is now camelCase instead of PascalCase (affects store.changed signal)
|
||||
* Thunks are no longer enabled by default
|
||||
|
||||
This file should not be modifying any objects in CorePackages, only wrapping them!
|
||||
]]
|
||||
|
||||
local CorePackages = game:GetService("CorePackages")
|
||||
|
||||
local Logging = require(CorePackages.Logging)
|
||||
|
||||
local Rodux = require(CorePackages.Rodux)
|
||||
local Signal = require(CorePackages.RoduxImpl.Signal)
|
||||
|
||||
local function getWarningMessage(className, funcNameCamelCase)
|
||||
return string.format(
|
||||
"%s:%s() has been deprecated, use %s()\n%s]",
|
||||
className,
|
||||
funcNameCamelCase:sub(1, 1):upper() .. funcNameCamelCase:sub(2),
|
||||
funcNameCamelCase,
|
||||
debug.traceback()
|
||||
)
|
||||
end
|
||||
|
||||
local StoreCompat = {}
|
||||
|
||||
function StoreCompat.new(reducer, initialState)
|
||||
--[[
|
||||
Older consumers of Rodux expect Thunks to be enabled by default, so we
|
||||
introduce the thunk middleware by default here.
|
||||
]]
|
||||
local store = Rodux.Store.new(reducer, initialState, { Rodux.thunkMiddleware })
|
||||
|
||||
--[[
|
||||
Create PascalCase compatibility versions of regular Store methods
|
||||
It's easier to do this here since we'd otherwise have to deal with
|
||||
reassigning the store's metatable
|
||||
]]
|
||||
store.Dispatch = function(...)
|
||||
Logging.warn(getWarningMessage("Store", "dispatch"))
|
||||
return store.dispatch(...)
|
||||
end
|
||||
|
||||
store.GetState = function(...)
|
||||
Logging.warn(getWarningMessage("Store", "getState"))
|
||||
return store.getState(...)
|
||||
end
|
||||
store.Destruct = function(...)
|
||||
Logging.warn(getWarningMessage("Store", "destruct"))
|
||||
return store.destruct(...)
|
||||
end
|
||||
store.Flush = function(...)
|
||||
Logging.warn(getWarningMessage("Store", "flush"))
|
||||
return store.flush(...)
|
||||
end
|
||||
|
||||
-- Store's changed signal also needs a compatibility layer added to it
|
||||
store.changed.connect = function(...)
|
||||
local connection = Signal.connect(...)
|
||||
|
||||
-- 'disconnect' is created for every connection.
|
||||
connection.Disconnect = function(...)
|
||||
Logging.warn(getWarningMessage("Connection", "disconnect"))
|
||||
return connection.disconnect(...)
|
||||
end
|
||||
|
||||
return connection
|
||||
end
|
||||
|
||||
-- Create PascalCase versions of regular Signal methods
|
||||
store.changed.Connect = function(...)
|
||||
Logging.warn(getWarningMessage("Signal", "connect"))
|
||||
return store.changed.connect(...)
|
||||
end
|
||||
|
||||
--[[
|
||||
Note: We deliberately exclude 'Signal.fire' because users
|
||||
should never call it anyways!
|
||||
]]
|
||||
|
||||
-- Provide PascalCase compatibility accessor to store.changed
|
||||
store.Changed = store.changed
|
||||
|
||||
return store
|
||||
end
|
||||
|
||||
--[[
|
||||
Manually recreate Rodux's interface using our augmented version
|
||||
of the Store functionality
|
||||
]]
|
||||
local RoduxCompat = {
|
||||
Store = StoreCompat,
|
||||
createReducer = Rodux.createReducer,
|
||||
combineReducers = Rodux.combineReducers,
|
||||
loggerMiddleware = Rodux.loggerMiddleware,
|
||||
thunkMiddleware = Rodux.thunkMiddleware,
|
||||
}
|
||||
|
||||
return RoduxCompat
|
||||
@@ -0,0 +1,134 @@
|
||||
return function()
|
||||
local Rodux = require(script.Parent.Rodux)
|
||||
|
||||
local CorePackages = game:GetService("CorePackages")
|
||||
local CorePackagesRodux = require(CorePackages.Rodux)
|
||||
|
||||
local Logging = require(CorePackages.Logging)
|
||||
|
||||
local function NoopReducer(state, action)
|
||||
return state
|
||||
end
|
||||
|
||||
describe("Rodux compatibility shim", function()
|
||||
it("should process thunks by default", function()
|
||||
local thunkCallCount = 0
|
||||
local thunk = function(store)
|
||||
thunkCallCount = thunkCallCount + 1
|
||||
end
|
||||
|
||||
local store = Rodux.Store.new(NoopReducer)
|
||||
|
||||
expect(function() store:dispatch(thunk) end).never.to.throw()
|
||||
expect(thunkCallCount).to.equal(1)
|
||||
end)
|
||||
|
||||
it("should have PascalCase versions of Store API that warn", function()
|
||||
local store = Rodux.Store.new(NoopReducer)
|
||||
|
||||
expect(typeof(store.GetState)).to.equal("function")
|
||||
expect(typeof(store.Dispatch)).to.equal("function")
|
||||
expect(typeof(store.Flush)).to.equal("function")
|
||||
|
||||
local logs = Logging.capture(function()
|
||||
store:GetState()
|
||||
store:Dispatch({ type = "Foo" })
|
||||
store:Flush()
|
||||
end)
|
||||
print(unpack(logs.warnings))
|
||||
expect(string.match(
|
||||
logs.warnings[1],
|
||||
"^Store:GetState%(%) has been deprecated, use getState%(%)"
|
||||
)).to.be.ok()
|
||||
expect(string.match(
|
||||
logs.warnings[2],
|
||||
"^Store:Dispatch%(%) has been deprecated, use dispatch%(%)"
|
||||
)).to.be.ok()
|
||||
expect(string.match(
|
||||
logs.warnings[3],
|
||||
"^Store:Flush%(%) has been deprecated, use flush%(%)"
|
||||
)).to.be.ok()
|
||||
end)
|
||||
|
||||
it("should have PascalCase versions of Signal API for 'changed' signal", function()
|
||||
local store = Rodux.Store.new(NoopReducer)
|
||||
|
||||
expect(store.Changed).to.be.ok()
|
||||
expect(typeof(store.Changed.Connect)).to.equal("function")
|
||||
|
||||
local connection
|
||||
local logs = Logging.capture(function()
|
||||
connection = store.Changed:Connect(function()
|
||||
-- do nothing
|
||||
end)
|
||||
end)
|
||||
|
||||
expect(string.match(
|
||||
logs.warnings[1],
|
||||
"^Signal:Connect%(%) has been deprecated, use connect%(%)"
|
||||
)).to.be.ok()
|
||||
|
||||
logs = Logging.capture(function()
|
||||
connection:Disconnect()
|
||||
end)
|
||||
|
||||
expect(string.match(
|
||||
logs.warnings[1],
|
||||
"^Connection:Disconnect%(%) has been deprecated, use disconnect%(%)"
|
||||
)).to.be.ok()
|
||||
end)
|
||||
|
||||
it("should match Rodux's API", function()
|
||||
for name, _ in pairs(CorePackagesRodux) do
|
||||
expect(Rodux[name]).to.be.ok()
|
||||
end
|
||||
|
||||
local corePackagesStore = CorePackagesRodux.Store.new(NoopReducer)
|
||||
local shimStore = Rodux.Store.new(NoopReducer)
|
||||
|
||||
for name, _ in pairs(corePackagesStore) do
|
||||
expect(shimStore[name]).to.be.ok()
|
||||
end
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("CorePackages Rodux version", function()
|
||||
it("should not be overwritten by the compatibility shim", function()
|
||||
expect(Rodux).never.to.equal(CorePackagesRodux)
|
||||
end)
|
||||
|
||||
it("should not have its Store API overwritten by the compatibility shim", function()
|
||||
expect(Rodux.Store).never.to.equal(CorePackagesRodux.Store)
|
||||
end)
|
||||
|
||||
it("should not process thunks by default", function()
|
||||
local thunkCallCount = 0
|
||||
local thunk = function(store)
|
||||
thunkCallCount = thunkCallCount + 1
|
||||
end
|
||||
|
||||
local store = CorePackagesRodux.Store.new(NoopReducer)
|
||||
|
||||
expect(function() store:dispatch(thunk) end).to.throw()
|
||||
expect(thunkCallCount).to.equal(0)
|
||||
end)
|
||||
|
||||
it("should accept and use middleware", function()
|
||||
local middlewareInitialized = false
|
||||
local lastActionProcessed = nil
|
||||
local middleware = function(nextDispatch, store)
|
||||
middlewareInitialized = true
|
||||
return function(action)
|
||||
lastActionProcessed = action.type
|
||||
end
|
||||
end
|
||||
|
||||
local store = CorePackagesRodux.Store.new(NoopReducer, {}, { middleware })
|
||||
|
||||
store:dispatch({ type = "Foo" })
|
||||
|
||||
expect(middlewareInitialized).to.equal(true)
|
||||
expect(lastActionProcessed).to.equal("Foo")
|
||||
end)
|
||||
end)
|
||||
end
|
||||
@@ -0,0 +1,75 @@
|
||||
--[[
|
||||
A limited, simple implementation of a Signal.
|
||||
|
||||
Handlers are fired in order, and (dis)connections are properly handled when
|
||||
executing an event.
|
||||
|
||||
Signal uses Immutable to avoid invalidating the 'Fire' loop iteration.
|
||||
]]
|
||||
|
||||
local Immutable = require(script.Parent.Immutable)
|
||||
|
||||
local Signal = {}
|
||||
|
||||
Signal.__index = Signal
|
||||
|
||||
function Signal.new()
|
||||
local self = {
|
||||
_listeners = {}
|
||||
}
|
||||
|
||||
setmetatable(self, Signal)
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
function Signal:connect(callback)
|
||||
local listener = {
|
||||
callback = callback,
|
||||
isConnected = true,
|
||||
}
|
||||
self._listeners = Immutable.Append(self._listeners, listener)
|
||||
|
||||
local function disconnect()
|
||||
listener.isConnected = false
|
||||
self._listeners = Immutable.RemoveValueFromList(self._listeners, listener)
|
||||
end
|
||||
|
||||
return {
|
||||
Disconnect = function()
|
||||
warn(string.format(
|
||||
"Connection:Disconnect() has been deprecated, use Connection:disconnect()\n%s]",
|
||||
debug.traceback()
|
||||
))
|
||||
disconnect()
|
||||
end,
|
||||
disconnect = disconnect,
|
||||
}
|
||||
end
|
||||
|
||||
function Signal:fire(...)
|
||||
for _, listener in ipairs(self._listeners) do
|
||||
if listener.isConnected then
|
||||
listener.callback(...)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function Signal:Connect(...)
|
||||
warn(string.format(
|
||||
"Signal:Connect() has been deprecated, use Signal:connect()\n%s]",
|
||||
debug.traceback()
|
||||
))
|
||||
return self:connect(...)
|
||||
end
|
||||
|
||||
function Signal:Fire(...)
|
||||
warn(string.format(
|
||||
"Signal:Fire() has been deprecated, use Signal:fire()\n%s]",
|
||||
debug.traceback()
|
||||
))
|
||||
self:fire(...)
|
||||
end
|
||||
|
||||
|
||||
return Signal
|
||||
@@ -0,0 +1,114 @@
|
||||
return function()
|
||||
local Signal = require(script.Parent.Signal)
|
||||
|
||||
it("should construct from nothing", function()
|
||||
local signal = Signal.new()
|
||||
|
||||
expect(signal).to.be.ok()
|
||||
end)
|
||||
|
||||
it("should fire connected callbacks", function()
|
||||
local callCount = 0
|
||||
local value1 = "Hello World"
|
||||
local value2 = 7
|
||||
|
||||
local callback = function(arg1, arg2)
|
||||
expect(arg1).to.equal(value1)
|
||||
expect(arg2).to.equal(value2)
|
||||
callCount = callCount + 1
|
||||
end
|
||||
|
||||
local signal = Signal.new()
|
||||
|
||||
local connection = signal:Connect(callback)
|
||||
signal:Fire(value1, value2)
|
||||
|
||||
expect(callCount).to.equal(1)
|
||||
|
||||
connection:Disconnect()
|
||||
signal:Fire(value1, value2)
|
||||
|
||||
expect(callCount).to.equal(1)
|
||||
end)
|
||||
|
||||
it("should disconnect handlers", function()
|
||||
local callback = function()
|
||||
error("Callback was called after disconnect!")
|
||||
end
|
||||
|
||||
local signal = Signal.new()
|
||||
|
||||
local connection = signal:Connect(callback)
|
||||
connection:Disconnect()
|
||||
|
||||
signal:Fire()
|
||||
end)
|
||||
|
||||
it("should fire handlers in order", function()
|
||||
local signal = Signal.new()
|
||||
local x = 0
|
||||
local y = 0
|
||||
|
||||
local callback1 = function()
|
||||
expect(x).to.equal(0)
|
||||
expect(y).to.equal(0)
|
||||
x = x + 1
|
||||
end
|
||||
|
||||
local callback2 = function()
|
||||
expect(x).to.equal(1)
|
||||
expect(y).to.equal(0)
|
||||
y = y + 1
|
||||
end
|
||||
|
||||
signal:Connect(callback1)
|
||||
signal:Connect(callback2)
|
||||
signal:Fire()
|
||||
|
||||
expect(x).to.equal(1)
|
||||
expect(y).to.equal(1)
|
||||
end)
|
||||
|
||||
it("should continue firing despite mid-event disconnection", function()
|
||||
local signal = Signal.new()
|
||||
local countA = 0
|
||||
local countB = 0
|
||||
|
||||
local connectionA
|
||||
connectionA = signal:Connect(function()
|
||||
connectionA:Disconnect()
|
||||
countA = countA + 1
|
||||
end)
|
||||
|
||||
signal:Connect(function()
|
||||
countB = countB + 1
|
||||
end)
|
||||
|
||||
signal:Fire()
|
||||
|
||||
expect(countA).to.equal(1)
|
||||
expect(countB).to.equal(1)
|
||||
end)
|
||||
|
||||
it("should skip listeners that were disconnected during event evaluation", function()
|
||||
local signal = Signal.new()
|
||||
local countA = 0
|
||||
local countB = 0
|
||||
|
||||
local connectionB
|
||||
|
||||
signal:Connect(function()
|
||||
countA = countA + 1
|
||||
connectionB:Disconnect()
|
||||
end)
|
||||
|
||||
connectionB = signal:Connect(function()
|
||||
countB = countB + 1
|
||||
end)
|
||||
|
||||
signal:Fire()
|
||||
|
||||
expect(countA).to.equal(1)
|
||||
expect(countB).to.equal(0)
|
||||
end)
|
||||
end
|
||||
@@ -0,0 +1,43 @@
|
||||
--[[
|
||||
A 'Symbol' is an opaque marker type that can be used to signify unique
|
||||
statuses. Symbols have the type 'userdata', but when printed to the console,
|
||||
the name of the symbol is shown.
|
||||
]]
|
||||
|
||||
local Symbol = {}
|
||||
|
||||
--[[
|
||||
Creates a Symbol with the given name.
|
||||
|
||||
When printed or coerced to a string, the symbol will turn into the string
|
||||
given as its name.
|
||||
]]
|
||||
function Symbol.named(name)
|
||||
assert(type(name) == "string", "Symbols must be created using a string name!")
|
||||
|
||||
local self = newproxy(true)
|
||||
|
||||
local wrappedName = ("Symbol(%s)"):format(name)
|
||||
|
||||
getmetatable(self).__tostring = function()
|
||||
return wrappedName
|
||||
end
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
--[[
|
||||
Create an unnamed Symbol. Usually, you should create a named Symbol using
|
||||
Symbol.named(name)
|
||||
]]
|
||||
function Symbol.unnamed()
|
||||
local self = newproxy(true)
|
||||
|
||||
getmetatable(self).__tostring = function()
|
||||
return "Unnamed Symbol"
|
||||
end
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
return Symbol
|
||||
@@ -0,0 +1,45 @@
|
||||
return function()
|
||||
local Symbol = require(script.Parent.Symbol)
|
||||
|
||||
describe("named", function()
|
||||
it("should give an opaque object", function()
|
||||
local symbol = Symbol.named("foo")
|
||||
|
||||
expect(symbol).to.be.a("userdata")
|
||||
end)
|
||||
|
||||
it("should coerce to the given name", function()
|
||||
local symbol = Symbol.named("foo")
|
||||
|
||||
expect(tostring(symbol):find("foo")).to.be.ok()
|
||||
end)
|
||||
|
||||
it("should be unique when constructed", function()
|
||||
local symbolA = Symbol.named("abc")
|
||||
local symbolB = Symbol.named("abc")
|
||||
|
||||
expect(symbolA).never.to.equal(symbolB)
|
||||
end)
|
||||
end)
|
||||
|
||||
describe("unnamed", function()
|
||||
it("should give an opaque object", function()
|
||||
local symbol = Symbol.unnamed()
|
||||
|
||||
expect(symbol).to.be.a("userdata")
|
||||
end)
|
||||
|
||||
it("should coerce to some string", function()
|
||||
local symbol = Symbol.unnamed()
|
||||
|
||||
expect(tostring(symbol)).to.be.a("string")
|
||||
end)
|
||||
|
||||
it("should be unique when constructed", function()
|
||||
local symbolA = Symbol.unnamed()
|
||||
local symbolB = Symbol.unnamed()
|
||||
|
||||
expect(symbolA).never.to.equal(symbolB)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
-----------------------------------------------------------------------------
|
||||
--- ---
|
||||
--- Under Migration to CorePackages ---
|
||||
--- ---
|
||||
--- Please put your changes in AppTempCommon. ---
|
||||
--- NOTE: You will have to rebuild for changes to kick in ---
|
||||
-----------------------------------------------------------------------------
|
||||
local CorePackages = game:GetService("CorePackages")
|
||||
return require(CorePackages.AppTempCommon.Common.Text)
|
||||
@@ -0,0 +1,9 @@
|
||||
-----------------------------------------------------------------------------
|
||||
--- ---
|
||||
--- Under Migration to CorePackages ---
|
||||
--- ---
|
||||
--- Please put your changes in AppTempCommon. ---
|
||||
--- NOTE: You will have to rebuild for changes to kick in ---
|
||||
-----------------------------------------------------------------------------
|
||||
local CorePackages = game:GetService("CorePackages")
|
||||
return require(CorePackages.AppTempCommon.Common.memoize)
|
||||
Reference in New Issue
Block a user