This commit is contained in:
lx
2026-07-06 14:01:11 -04:00
commit c4f97d729d
16468 changed files with 935321 additions and 0 deletions
@@ -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 message = (...)
error(debug.traceback(co, message), 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,56 @@
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 testMethod()
preCount = preCount + 1
wait()
postCount = postCount + 1
end
local ok, err = pcall(NoYield, testMethod)
expect(preCount).to.equal(1)
expect(postCount).to.equal(0)
expect(ok).to.equal(false)
expect(err:find("Attempted to yield inside changed event!")).to.be.ok()
expect(err:find("NoYield.spec")).to.be.ok()
end)
it("should propagate error messages", function()
local count = 0
local function test()
count = count + 1
error("foo")
end
local ok, err = pcall(NoYield, test)
expect(ok).to.equal(false)
expect(err:find("foo")).to.be.ok()
expect(err:find("NoYield.spec")).to.be.ok()
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.
]]
local function immutableAppend(list, ...)
local new = {}
local len = #list
for key = 1, len do
new[key] = list[key]
end
for i = 1, select("#", ...) do
new[len + i] = select(i, ...)
end
return new
end
local function immutableRemoveValue(list, removeValue)
local new = {}
for i = 1, #list do
if list[i] ~= removeValue then
table.insert(new, list[i])
end
end
return new
end
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,
disconnected = false,
}
self._listeners = immutableAppend(self._listeners, listener)
local function disconnect()
listener.disconnected = true
self._listeners = immutableRemoveValue(self._listeners, listener)
end
return {
disconnect = disconnect
}
end
function Signal:fire(...)
for _, listener in ipairs(self._listeners) do
if not listener.disconnected then
listener.callback(...)
end
end
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,131 @@
local RunService = game:GetService("RunService")
local Signal = require(script.Parent.Signal)
local NoYield = require(script.Parent.NoYield)
local Store = {}
-- This value is exposed as a private value so that the test code can stay in
-- sync with what event we listen to for dispatching the Changed event.
-- It may not be Heartbeat in the future.
Store._flushEvent = RunService.Heartbeat
Store.__index = Store
--[[
Create a new Store whose state is transformed by the given reducer function.
Each time an action is dispatched to the store, the new state of the store
is given by:
state = reducer(state, action)
Reducers do not mutate the state object, so the original state is still
valid.
]]
function Store.new(reducer, initialState, middlewares)
assert(typeof(reducer) == "function", "Bad argument #1 to Store.new, expected function.")
assert(middlewares == nil or typeof(middlewares) == "table", "Bad argument #3 to Store.new, expected nil or table.")
local self = {}
self._reducer = reducer
self._state = reducer(initialState, {
type = "@@INIT",
})
self._lastState = self._state
self._mutatedSinceFlush = false
self._connections = {}
self.changed = Signal.new()
setmetatable(self, Store)
local connection = self._flushEvent:Connect(function()
self:flush()
end)
table.insert(self._connections, connection)
if middlewares then
local unboundDispatch = self.dispatch
local dispatch = function(...)
return unboundDispatch(self, ...)
end
for i = #middlewares, 1, -1 do
local middleware = middlewares[i]
dispatch = middleware(dispatch, self)
end
self.dispatch = function(self, ...)
return dispatch(...)
end
end
return self
end
--[[
Get the current state of the Store. Do not mutate this!
]]
function Store:getState()
return self._state
end
--[[
Dispatch an action to the store. This allows the store's reducer to mutate
the state of the application by creating a new copy of the state.
Listeners on the changed event of the store are notified when the state
changes, but not necessarily on every Dispatch.
]]
function Store:dispatch(action)
if typeof(action) == "table" then
if action.type == nil then
error("action does not have a type field", 2)
end
self._state = self._reducer(self._state, action)
self._mutatedSinceFlush = true
else
error(("actions of type %q are not permitted"):format(typeof(action)), 2)
end
end
--[[
Marks the store as deleted, disconnecting any outstanding connections.
]]
function Store:destruct()
for _, connection in ipairs(self._connections) do
connection:Disconnect()
end
self._connections = nil
end
--[[
Flush all pending actions since the last change event was dispatched.
]]
function Store:flush()
if not self._mutatedSinceFlush then
return
end
self._mutatedSinceFlush = false
-- On self.changed:fire(), further actions may be immediately dispatched, in
-- which case self._lastState will be set to the most recent self._state,
-- unless we cache this value first
local state = self._state
-- If a changed listener yields, *very* surprising bugs can ensue.
-- Because of that, changed listeners cannot yield.
NoYield(function()
self.changed:fire(state, self._lastState)
end)
self._lastState = state
end
return Store
@@ -0,0 +1,342 @@
return function()
local Store = require(script.Parent.Store)
describe("new", function()
it("should instantiate with a reducer", function()
local store = Store.new(function(state, action)
return "hello, world"
end)
expect(store).to.be.ok()
expect(store:getState()).to.equal("hello, world")
store:destruct()
end)
it("should instantiate with a reducer and an initial state", function()
local store = Store.new(function(state, action)
return state
end, "initial state")
expect(store).to.be.ok()
expect(store:getState()).to.equal("initial state")
store:destruct()
end)
it("should instantiate with a reducer, initial state, and middlewares", function()
local store = Store.new(function(state, action)
return state
end, "initial state", {})
expect(store).to.be.ok()
expect(store:getState()).to.equal("initial state")
store:destruct()
end)
it("should modify the dispatch method when middlewares are passed", function()
local middlewareInstantiateCount = 0
local middlewareInvokeCount = 0
local passedDispatch
local passedStore
local passedAction
local function reducer(state, action)
if action.type == "test" then
return "test state"
end
return state
end
local function testMiddleware(nextDispatch, store)
middlewareInstantiateCount = middlewareInstantiateCount + 1
passedDispatch = nextDispatch
passedStore = store
return function(action)
middlewareInvokeCount = middlewareInvokeCount + 1
passedAction = action
nextDispatch(action)
end
end
local store = Store.new(reducer, "initial state", { testMiddleware })
expect(middlewareInstantiateCount).to.equal(1)
expect(middlewareInvokeCount).to.equal(0)
expect(passedDispatch).to.be.a("function")
expect(passedStore).to.equal(store)
store:dispatch({
type = "test",
})
expect(middlewareInstantiateCount).to.equal(1)
expect(middlewareInvokeCount).to.equal(1)
expect(passedAction.type).to.equal("test")
store:flush()
expect(store:getState()).to.equal("test state")
store:destruct()
end)
it("should execute middleware left-to-right", function()
local events = {}
local function reducer(state)
return state
end
local function middlewareA(nextDispatch, store)
table.insert(events, "instantiate a")
return function(action)
table.insert(events, "execute a")
return nextDispatch(action)
end
end
local function middlewareB(nextDispatch, store)
table.insert(events, "instantiate b")
return function(action)
table.insert(events, "execute b")
return nextDispatch(action)
end
end
local store = Store.new(reducer, 5, { middlewareA, middlewareB })
expect(#events).to.equal(2)
expect(events[1]).to.equal("instantiate b")
expect(events[2]).to.equal("instantiate a")
store:dispatch({
type = "test",
})
expect(#events).to.equal(4)
expect(events[3]).to.equal("execute a")
expect(events[4]).to.equal("execute b")
end)
it("should send an initial action with a 'type' field", function()
local lastAction
local callCount = 0
local store = Store.new(function(state, action)
lastAction = action
callCount = callCount + 1
return state
end)
expect(callCount).to.equal(1)
expect(lastAction).to.be.a("table")
expect(lastAction.type).to.be.ok()
store:destruct()
end)
end)
describe("getState", function()
it("should get the current state", function()
local store = Store.new(function(state, action)
return "foo"
end)
local state = store:getState()
expect(state).to.equal("foo")
store:destruct()
end)
end)
describe("dispatch", function()
it("should be sent through the reducer", function()
local store = Store.new(function(state, action)
state = state or "foo"
if action.type == "act" then
return "bar"
end
return state
end)
expect(store).to.be.ok()
expect(store:getState()).to.equal("foo")
store:dispatch({
type = "act",
})
store:flush()
expect(store:getState()).to.equal("bar")
store:destruct()
end)
it("should trigger the changed event after a flush", function()
local store = Store.new(function(state, action)
state = state or 0
if action.type == "increment" then
return state + 1
end
return state
end)
local callCount = 0
store.changed:connect(function(state, oldState)
expect(oldState).to.equal(0)
expect(state).to.equal(1)
callCount = callCount + 1
end)
store:dispatch({
type = "increment",
})
store:flush()
expect(callCount).to.equal(1)
store:destruct()
end)
it("should handle actions dispatched within the changed event", function()
local store = Store.new(function(state, action)
state = state or {
value = 0,
}
if action.type == "increment" then
return {
value = state.value + 1,
}
elseif action.type == "decrement" then
return {
value = state.value - 1,
}
end
return state
end)
local changeCount = 0
store.changed:connect(function(state, oldState)
expect(state).never.to.equal(oldState)
if state.value > 0 then
store:dispatch({
type = "decrement",
})
end
changeCount = changeCount + 1
end)
store:dispatch({
type = "increment",
})
store:flush()
store:flush()
expect(changeCount).to.equal(2)
store:destruct()
end)
it("should prevent yielding from changed handler", function()
local preCount = 0
local postCount = 0
local store = Store.new(function(state, action)
state = state or 0
return state + 1
end)
store.changed:connect(function(state, oldState)
preCount = preCount + 1
wait()
postCount = postCount + 1
end)
store:dispatch({
type = "increment",
})
expect(function()
store:flush()
end).to.throw()
expect(preCount).to.equal(1)
expect(postCount).to.equal(0)
store:destruct()
end)
it("should throw if an action is dispatched without a type field", function()
local store = Store.new(function(state, action)
return state
end)
expect(function()
store:dispatch({})
end).to.throw()
store:destruct()
end)
it("should throw if the action is not a function or table", function()
local store = Store.new(function(state, action)
return state
end)
expect(function()
store:dispatch(1)
end).to.throw()
store:destruct()
end)
end)
describe("flush", function()
it("should not fire a changed event if there were no dispatches", function()
local store = Store.new(function()
end)
local count = 0
store.changed:connect(function()
count = count + 1
end)
store:flush()
expect(count).to.equal(0)
store:dispatch({
type = "increment",
})
store:flush()
expect(count).to.equal(1)
store:flush()
expect(count).to.equal(1)
store:destruct()
end)
end)
end
@@ -0,0 +1,22 @@
--[[
Create a composite reducer from a map of keys and sub-reducers.
]]
local function combineReducers(map)
return function(state, action)
-- If state is nil, substitute it with a blank table.
if state == nil then
state = {}
end
local newState = {}
for key, reducer in pairs(map) do
-- Each reducer gets its own state, not the entire state table
newState[key] = reducer(state[key], action)
end
return newState
end
end
return combineReducers
@@ -0,0 +1,52 @@
return function()
local combineReducers = require(script.Parent.combineReducers)
it("should invoke each sub-reducer for every action", function()
local aCount = 0
local bCount = 0
local reducer = combineReducers({
a = function(state, action)
aCount = aCount + 1
end,
b = function(state, action)
bCount = bCount + 1
end,
})
-- Mock reducer invocation
reducer({}, {})
expect(aCount).to.equal(1)
expect(bCount).to.equal(1)
end)
it("should assign each sub-reducer's value to the new state", function()
local reducer = combineReducers({
a = function(state, action)
return (state or 0) + 1
end,
b = function(state, action)
return (state or 0) + 3
end,
})
local newState = reducer({}, {})
expect(newState.a).to.equal(1)
expect(newState.b).to.equal(3)
end)
it("should not throw when state is nil", function()
local reducer = combineReducers({
a = function(state, action)
return (state or 0) + 1
end,
b = function(state, action)
return (state or 0) + 3
end,
})
expect(function()
reducer(nil, {})
end).to.never.throw()
end)
end
@@ -0,0 +1,15 @@
return function(initialState, handlers)
return function(state, action)
if state == nil then
state = initialState
end
local handler = handlers[action.type]
if handler then
return handler(state, action)
end
return state
end
end
@@ -0,0 +1,106 @@
return function()
local createReducer = require(script.Parent.createReducer)
it("should handle actions", function()
local reducer = createReducer({
a = 0,
b = 0,
}, {
a = function(state, action)
return {
a = state.a + 1,
b = state.b,
}
end,
b = function(state, action)
return {
a = state.a,
b = state.b + 2,
}
end,
})
local newState = reducer({
a = 0,
b = 0,
}, {
type = "a",
})
expect(newState.a).to.equal(1)
newState = reducer(newState, {
type = "b",
})
expect(newState.b).to.equal(2)
end)
it("should return the initial state if the state is nil", function()
local reducer = createReducer({
a = 0,
b = 0,
-- We don't care about the actions here
}, {})
local newState = reducer(nil, {})
expect(newState).to.be.ok()
expect(newState.a).to.equal(0)
expect(newState.b).to.equal(0)
end)
it("should still run action handlers if the state is nil", function()
local callCount = 0
local reducer = createReducer(0, {
foo = function(state, action)
callCount = callCount + 1
return nil
end
})
expect(callCount).to.equal(0)
local newState = reducer(nil, {
type = "foo",
})
expect(callCount).to.equal(1)
expect(newState).to.equal(nil)
newState = reducer(newState, {
type = "foo",
})
expect(callCount).to.equal(2)
expect(newState).to.equal(nil)
end)
it("should return the same state if the action is not handled", function()
local initialState = {
a = 0,
b = 0,
}
local reducer = createReducer(initialState, {
a = function(state, action)
return {
a = state.a + 1,
b = state.b,
}
end,
b = function(state, action)
return {
a = state.a,
b = state.b + 2,
}
end,
})
local newState = reducer(initialState, {
type = "c",
})
expect(newState).to.equal(initialState)
end)
end
@@ -0,0 +1,13 @@
local Store = require(script.Store)
local createReducer = require(script.createReducer)
local combineReducers = require(script.combineReducers)
local loggerMiddleware = require(script.loggerMiddleware)
local thunkMiddleware = require(script.thunkMiddleware)
return {
Store = Store,
createReducer = createReducer,
combineReducers = combineReducers,
loggerMiddleware = loggerMiddleware.middleware,
thunkMiddleware = thunkMiddleware,
}
@@ -0,0 +1,9 @@
return function()
describe("Rodux", function()
it("should load", function()
local Rodux = require(script.Parent)
expect(Rodux.Store).to.be.ok()
end)
end)
end
@@ -0,0 +1,55 @@
local indent = " "
local function prettyPrint(value, indentLevel)
indentLevel = indentLevel or 0
local output = {}
if typeof(value) == "table" then
table.insert(output, "{\n")
for key, value in pairs(value) do
table.insert(output, indent:rep(indentLevel + 1))
table.insert(output, tostring(key))
table.insert(output, " = ")
table.insert(output, prettyPrint(value, indentLevel + 1))
table.insert(output, "\n")
end
table.insert(output, indent:rep(indentLevel))
table.insert(output, "}")
elseif typeof(value) == "string" then
table.insert(output, string.format("%q", value))
table.insert(output, " (string)")
else
table.insert(output, tostring(value))
table.insert(output, " (")
table.insert(output, typeof(value))
table.insert(output, ")")
end
return table.concat(output, "")
end
-- We want to be able to override outputFunction in tests, so the shape of this
-- module is kind of unconventional.
--
-- We fix it this weird shape in init.lua.
local loggerMiddleware = {
outputFunction = print,
}
function loggerMiddleware.middleware(nextDispatch, store)
return function(action)
local result = nextDispatch(action)
loggerMiddleware.outputFunction(("Action dispatched: %s\nState changed to: %s"):format(
prettyPrint(action),
prettyPrint(store:getState())
))
return result
end
end
return loggerMiddleware
@@ -0,0 +1,39 @@
return function()
local Store = require(script.Parent.Store)
local loggerMiddleware = require(script.Parent.loggerMiddleware)
it("should print whenever an action is dispatched", function()
local outputCount = 0
local outputMessage
local function reducer(state, action)
return state
end
local store = Store.new(reducer, {
fooValue = 12345,
barValue = {
bazValue = "hiBaz",
},
}, { loggerMiddleware.middleware })
loggerMiddleware.outputFunction = function(message)
outputCount = outputCount + 1
outputMessage = message
end
store:dispatch({
type = "testActionType",
})
expect(outputCount).to.equal(1)
expect(outputMessage:find("testActionType")).to.be.ok()
expect(outputMessage:find("fooValue")).to.be.ok()
expect(outputMessage:find("12345")).to.be.ok()
expect(outputMessage:find("barValue")).to.be.ok()
expect(outputMessage:find("bazValue")).to.be.ok()
expect(outputMessage:find("hiBaz")).to.be.ok()
loggerMiddleware.outputFunction = print
end)
end
@@ -0,0 +1,17 @@
--[[
A middleware that allows for functions to be dispatched.
Functions will receive a single argument, the store itself.
This middleware consumes the function; middleware further down the chain
will not receive it.
]]
local function thunkMiddleware(nextDispatch, store)
return function(action)
if typeof(action) == "function" then
return action(store)
else
return nextDispatch(action)
end
end
end
return thunkMiddleware
@@ -0,0 +1,58 @@
return function()
local Store = require(script.Parent.Store)
local thunkMiddleware = require(script.Parent.thunkMiddleware)
it("should dispatch thunks", function()
local function reducer(state, action)
return state
end
local store = Store.new(reducer, {}, { thunkMiddleware })
local thunkCount = 0
local function thunk(store)
thunkCount = thunkCount + 1
end
store:dispatch(thunk)
expect(thunkCount).to.equal(1)
end)
it("should allow normal actions to pass through", function()
local reducerCount = 0
local function reducer(state, action)
reducerCount = reducerCount + 1
return state
end
local store = Store.new(reducer, {}, { thunkMiddleware })
store:dispatch({
type = "test",
})
-- Reducer will be invoked twice:
-- Once when creating the store (@@INIT action)
-- Once when the test action is dispatched
expect(reducerCount).to.equal(2)
end)
it("should return the value from the thunk", function()
local function reducer(state, action)
return state
end
local store = Store.new(reducer, {}, { thunkMiddleware })
local thunkValue = "test"
local function thunk(store)
return thunkValue
end
local result = store:dispatch(thunk)
expect(result).to.equal(thunkValue)
end)
end