├── .gitignore ├── LICENSE ├── Packages └── .robloxrc ├── README.md ├── bin ├── ci.sh └── spec.lua ├── default.project.json ├── foreman.toml ├── roblox.toml ├── rotriever.toml ├── selene.toml ├── src ├── .robloxrc ├── Observable.lua └── init.lua ├── test ├── .robloxrc ├── concat.spec.lua ├── constructor.spec.lua ├── filter.spec.lua ├── flat-map.spec.lua ├── for-each.spec.lua ├── from.spec.lua ├── init.spec.lua ├── map.spec.lua ├── observer-closed.spec.lua ├── observer-complete.spec.lua ├── observer-error.spec.lua ├── observer-next.spec.lua ├── of.spec.lua ├── properties.lua ├── reduce.spec.lua ├── species.spec.lua ├── subscribe.spec.lua └── subscription.spec.lua ├── testez.toml └── tests.project.json /.gitignore: -------------------------------------------------------------------------------- 1 | Packages/* 2 | !Packages/.robloxrc 3 | sourcemap.json 4 | rotriever.lock 5 | *.rbxmx 6 | *.rbxm 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Roblox 4 | 5 | Copyright (c) 2018 zenparsing (Kevin Smith) 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | -------------------------------------------------------------------------------- /Packages/.robloxrc: -------------------------------------------------------------------------------- 1 | { 2 | "language": { 3 | "mode": "nocheck" 4 | }, 5 | "lint": { 6 | "*": "disabled" 7 | } 8 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Coverage Status](https://coveralls.io/repos/github/Roblox/zen-observable-lua/badge.svg?branch=main&t=ZMraRf)](https://coveralls.io/github/Roblox/zen-observable-lua?branch=main) 2 | ![CI](https://github.com/Roblox/zen-observable-lua/workflows/Tests/badge.svg) 3 | 4 | # ZenObservable for Luau 5 | 6 | Upstream Repo: https://github.com/zenparsing/zen-observable 7 | -------------------------------------------------------------------------------- /bin/ci.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -x 4 | 5 | echo "Build project" 6 | rojo build tests.project.json --output model.rbxmx 7 | 8 | echo "Remove .robloxrc from dev dependencies" 9 | find Packages/Dev -name "*.robloxrc" | xargs rm -f 10 | find Packages/_Index -name "*.robloxrc" | xargs rm -f 11 | 12 | echo "Run static analysis" 13 | selene src 14 | roblox-cli analyze tests.project.json 15 | stylua -c src 16 | 17 | echo "Run tests" 18 | roblox-cli run --load.model model.rbxmx --run bin/spec.lua --fastFlags.allOnLuau --fastFlags.overrides "UseDateTimeType3=true" "EnableLoadModule=true" 19 | -------------------------------------------------------------------------------- /bin/spec.lua: -------------------------------------------------------------------------------- 1 | local ProcessService = game:GetService("ProcessService") 2 | local Root = script.Parent.ZenObservableTestModel 3 | 4 | local Packages = Root.Packages 5 | -- Load JestGlobals source into Packages folder so it's next to Roact as expected 6 | local JestGlobals = require(Root.Packages.Dev.JestGlobals) 7 | local TestEZ = JestGlobals.TestEZ 8 | 9 | -- Run all tests, collect results, and report to stdout. 10 | local result = TestEZ.TestBootstrap:run( 11 | { Packages.ZenObservable, Packages.Tests }, 12 | TestEZ.Reporters.TextReporterQuiet 13 | ) 14 | 15 | if result.failureCount == 0 and #result.errors == 0 then 16 | ProcessService:ExitAsync(0) 17 | else 18 | ProcessService:ExitAsync(1) 19 | end 20 | -------------------------------------------------------------------------------- /default.project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ZenObservable", 3 | "tree": { 4 | "$path": "src" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /foreman.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | rotrieve = { source = "roblox/rotriever", version = "0.5.4" } 3 | rojo = { source = "Roblox/rojo-rbx-rojo", version = "7.2.0" } 4 | selene = { source = "Roblox/Kampfkarren-selene", version = "0.20.0" } 5 | stylua = { source = "Roblox/JohnnyMorganz-StyLua", version = "0.14.2" } 6 | -------------------------------------------------------------------------------- /rotriever.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ZenObservable" 3 | version = "0.8.16-roblox.0" 4 | content_root = "src" 5 | authors = [ 6 | "Chris Trześniewski ", 7 | "Ignacio Falk ", 8 | "Matt Hargett ", 9 | "Olivier Trepanier ", 10 | "Paul Doyle ", 11 | "Oluwole Adebiyi", 12 | ] 13 | 14 | [dependencies] 15 | LuauPolyfill = "github.com/roblox/luau-polyfill@1.0.0" 16 | Promise = "github.com/roblox/roblox-lua-promise@3.1.0" 17 | 18 | [dev_dependencies] 19 | JestGlobals = "github.com/roblox/jest-roblox@2.4.1" 20 | -------------------------------------------------------------------------------- /selene.toml: -------------------------------------------------------------------------------- 1 | std = "roblox+testez" 2 | 3 | [config] 4 | empty_if = { comments_count = true } 5 | unused_variable = { ignore_pattern = "result|ok|^_" } 6 | # this comes up when translating nested try/finally scenarios 7 | shadowing = { ignore_pattern = "result|ok|^_" } 8 | # feature request for this config: https://github.com/Kampfkarren/selene/issues/181 9 | # global_usage = { ignore_pattern = "^__" } 10 | 11 | [rules] 12 | # remove this once the feature request here is implemented: https://github.com/Kampfkarren/selene/issues/181 13 | global_usage = "allow" 14 | unused_variable = "allow" 15 | # remove when the Luau type narrowing issues (and the workarounds) are resolved 16 | shadowing = "allow" 17 | 18 | # remove when this issue is fixed: https://github.com/Kampfkarren/selene/issues/179 19 | if_same_then_else = "allow" 20 | -------------------------------------------------------------------------------- /src/.robloxrc: -------------------------------------------------------------------------------- 1 | { 2 | "language": { 3 | "mode": "strict" 4 | }, 5 | "lint": { 6 | "*": "enabled" 7 | } 8 | } -------------------------------------------------------------------------------- /src/Observable.lua: -------------------------------------------------------------------------------- 1 | -- ROBLOX upstream: https://github.com/zenparsing/zen-observable/blob/v0.8.15/src/Observable.js 2 | -- ROBLOX upstream for types: https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/zen-observable/index.d.ts 3 | --!strict 4 | 5 | local srcWorkspace = script.Parent 6 | local rootWorkspace = srcWorkspace.Parent 7 | 8 | local LuauPolyfill = require(rootWorkspace.LuauPolyfill) 9 | local Promise = require(rootWorkspace.Promise) 10 | local instanceOf = LuauPolyfill.instanceof 11 | local Boolean = LuauPolyfill.Boolean 12 | local Error = LuauPolyfill.Error 13 | local setTimeout = LuauPolyfill.setTimeout 14 | local Array = LuauPolyfill.Array 15 | local Symbol = LuauPolyfill.Symbol 16 | type Object = LuauPolyfill.Object 17 | type Array = LuauPolyfill.Array 18 | type Error = LuauPolyfill.Error 19 | 20 | type Promise = LuauPolyfill.Promise & { expect: (self: Promise) -> T } 21 | 22 | type Function = (...any) -> ...any 23 | 24 | -- Predefine variable 25 | -- ROBLOX TODO: strongly type the Observable table once some weird Luau bugs are fixed 26 | local Observable, SubscriptionObserver, notifySubscription, isObservableClass 27 | 28 | -- ROBLOX deviation: type "function" and callable tables need to be checked 29 | local function isCallable(value: any): boolean 30 | if typeof(value) == "function" then 31 | return true 32 | end 33 | if typeof(value) == "table" then 34 | local mt = getmetatable(value) 35 | if mt and rawget(mt, "__call") then 36 | return true 37 | end 38 | if value._isMockFunction then 39 | return true 40 | end 41 | end 42 | return false 43 | end 44 | 45 | local function hasSymbols(): boolean 46 | -- ROBLOX deviation: check for table with __call method 47 | return typeof(Symbol) == "table" and typeof(getmetatable(Symbol)["__call"]) == "function" 48 | end 49 | 50 | local function hasSymbol(name: string): boolean 51 | return hasSymbols() and Boolean.toJSBoolean(Symbol[name]) 52 | end 53 | 54 | local function getSymbol(name: string): string 55 | if hasSymbol(name) then 56 | return Symbol[name] 57 | else 58 | return "@@" .. name 59 | end 60 | end 61 | 62 | if hasSymbols() and not hasSymbol("observable") then 63 | -- ROBLOX deviation START: Luau doesn't allow mutating tables from require in strict mode 64 | (Symbol :: any).observable = Symbol("observable") 65 | -- ROBLOX deviation END 66 | end 67 | 68 | local _SymbolIterator = getSymbol("iterator") 69 | local SymbolObservable = getSymbol("observable") 70 | local SymbolSpecies = getSymbol("species") 71 | 72 | local function getMethod(obj: Object, key): Function | nil 73 | local value = obj[key] 74 | if value == nil then 75 | return nil 76 | end 77 | --ROBLOX deviation START: check for function and callable tables 78 | if not isCallable(value) then 79 | -- ROBLOX deviation: using Error instead of TypeError 80 | error(Error.new(tostring(value) .. " is not a function")) 81 | end 82 | -- ROBLOX deviation END 83 | return value 84 | end 85 | 86 | local function getSpecies(obj: Object) 87 | -- ROBLOX deviation START: obj.constructor not available 88 | local ctor = obj[SymbolSpecies] 89 | if ctor ~= nil then 90 | return ctor 91 | else 92 | return Observable 93 | end 94 | -- ROBLOX deviation END 95 | end 96 | 97 | local function isObservable(x): boolean 98 | return instanceOf(x, Observable) -- SPEC: Brand check 99 | end 100 | 101 | -- ROBLOX deviation START: hostReportError.log, lua functions does not support having other properties, so using setmetatable with __call enables to suppport this 102 | local hostReportError: any 103 | hostReportError = setmetatable({}, { 104 | __call = function(_self, e) 105 | if hostReportError.log then 106 | hostReportError:log(e) 107 | else 108 | setTimeout(function() 109 | error(e) 110 | end, 0) 111 | end 112 | end, 113 | }) 114 | -- ROBLOX deviation END 115 | 116 | local function enqueue(fn) 117 | Promise.delay(0):doneCall(function() 118 | xpcall(fn, function(err) 119 | hostReportError(err) 120 | end) 121 | end) 122 | end 123 | 124 | local function cleanupSubscription(subscription: Subscription): () 125 | local cleanup = subscription._cleanup 126 | if cleanup == nil then 127 | return 128 | end 129 | 130 | subscription._cleanup = nil 131 | 132 | if not cleanup then 133 | return 134 | end 135 | 136 | local ok = true 137 | local err 138 | -- ROBLOX deviation: check for functions and callable tables 139 | if isCallable(cleanup) then 140 | -- ROBLOX FIXME Luau: Luau should narrow based on guard above, and typeof() effect on isCallable parameter 141 | ok, err = pcall(cleanup :: Function) 142 | else 143 | local unsubscribe = getMethod(cleanup :: Object, "unsubscribe") 144 | if unsubscribe then 145 | ok, err = pcall(unsubscribe, cleanup) 146 | end 147 | end 148 | if not ok then 149 | hostReportError(err) 150 | end 151 | end 152 | 153 | local function closeSubscription(subscription: Subscription): () 154 | (subscription :: any)._observer = nil 155 | subscription._queue = nil 156 | subscription._state = "closed" 157 | end 158 | 159 | local function flushSubscription(subscription: Subscription): () 160 | local queue = subscription._queue 161 | if not Boolean.toJSBoolean(queue) then 162 | return 163 | end 164 | 165 | subscription._queue = nil 166 | subscription._state = "ready" 167 | 168 | -- ROBLOX FIXME Luau: Luau doesn't understand toJSBoolean narrowing side-effect 169 | for i = 1, #(queue :: Array), 1 do 170 | notifySubscription(subscription, (queue :: Array)[i].type, (queue :: Array)[i].value) 171 | if subscription._state == "closed" then 172 | break 173 | end 174 | end 175 | end 176 | 177 | function notifySubscription(subscription: Subscription, type, value): () 178 | subscription._state = "running" 179 | local observer = subscription._observer 180 | 181 | local err 182 | local ok, m = pcall(getMethod, observer, type) 183 | if not ok then 184 | hostReportError(m) 185 | return 186 | end 187 | if type == "next" then 188 | if m then 189 | ok, err = pcall(m, observer, value) 190 | end 191 | elseif type == "error" then 192 | closeSubscription(subscription) 193 | if m then 194 | ok, err = pcall(m, observer, value) 195 | else 196 | -- ROBLOX TODO: upstream this optimization, which will retain more stacktrace info 197 | hostReportError(value) 198 | end 199 | elseif type == "complete" then 200 | closeSubscription(subscription) 201 | if m then 202 | ok, err = pcall(m, observer, value) 203 | end 204 | end 205 | if not ok then 206 | hostReportError(err) 207 | end 208 | 209 | if subscription._state == "closed" then 210 | cleanupSubscription(subscription) 211 | elseif subscription._state == "running" then 212 | subscription._state = "ready" 213 | end 214 | end 215 | 216 | local function onNotify(subscription: Subscription, type, value: T?): () 217 | if subscription._state == "closed" then 218 | return 219 | end 220 | if subscription._state == "buffering" then 221 | -- ROBLOX TODO: upstream should technically check this for nil also 222 | assert(subscription._queue, "observable subscription queue nil during onNotify") 223 | table.insert(subscription._queue, { type = type, value = value }) 224 | return 225 | end 226 | if subscription._state ~= "ready" then 227 | subscription._state = "buffering" 228 | 229 | subscription._queue = { { type = type, value = value } } 230 | enqueue(function() 231 | return flushSubscription(subscription) 232 | end) 233 | return 234 | end 235 | notifySubscription(subscription, type, value) 236 | end 237 | 238 | export type Observer = { 239 | start: ((subscription: Subscription) -> any)?, 240 | next: ((self: Observer, value: T) -> ())?, 241 | error: ((self: Observer, errorValue: any) -> ())?, 242 | complete: ((self: Observer) -> ())?, 243 | } 244 | -- ROBLOX deviation: This appears to be a mistake in DefinitelyTyped 245 | export type Subscriber = (SubscriptionObserver) -> () | (() -> ()) -- | Subscription 246 | 247 | export type Subscription = { 248 | closed: boolean, 249 | unsubscribe: (self: Subscription) -> (), 250 | _state: string?, 251 | _queue: Array?, 252 | _cleanup: Function | Object | nil, 253 | -- ROBLOX FIXME Luau: avoid pitfall of recursive type with differing args check, revisit after https://jira.rbx.com/browse/CLI-47160 254 | _observer: Object, 255 | } 256 | 257 | local Subscription = {} 258 | -- ROBLOX FIXME Luau: Luau needs type states and local inference to infer this as any | nil 259 | Subscription.__index = function(t, k): any 260 | if k == "closed" then 261 | return t._state == "closed" 262 | end 263 | if rawget(t, k) then 264 | return rawget(t, k) 265 | end 266 | if rawget(Subscription, k) then 267 | return rawget(Subscription, k) 268 | end 269 | return nil 270 | end 271 | Subscription.__newindex = function(t, k, v): () 272 | if k == "closed" then 273 | error("setting getter-only property 'closed'") 274 | end 275 | rawset(t, k, v) 276 | end 277 | 278 | function Subscription.new(observer: Observer, subscriber: Subscriber): Subscription 279 | local self = (setmetatable({}, Subscription) :: any) :: Subscription 280 | -- ASSERT: observer is an object 281 | -- ASSERT: subscriber is callable 282 | self._cleanup = nil 283 | self._observer = observer 284 | self._queue = nil 285 | self._state = "initializing" 286 | 287 | local subscriptionObserver = SubscriptionObserver.new(self :: any) 288 | 289 | local ok, result = pcall(subscriber :: Function, subscriptionObserver) 290 | 291 | if ok then 292 | self._cleanup = result 293 | else 294 | subscriptionObserver:error(result) 295 | end 296 | 297 | if self._state == "initializing" then 298 | self._state = "ready" 299 | end 300 | 301 | return self 302 | end 303 | 304 | function Subscription:unsubscribe() 305 | if self._state ~= "closed" then 306 | closeSubscription(self) 307 | cleanupSubscription(self) 308 | end 309 | end 310 | 311 | export type SubscriptionObserver = { 312 | closed: boolean, 313 | next: (self: SubscriptionObserver, value: T) -> (), 314 | error: (self: SubscriptionObserver, error: any) -> (), 315 | complete: (self: SubscriptionObserver) -> (), 316 | _subscription: Subscription, 317 | } 318 | 319 | SubscriptionObserver = {} 320 | SubscriptionObserver.__index = function(t, k) 321 | if k == "closed" then 322 | return t._subscription._state == "closed" 323 | end 324 | if rawget(SubscriptionObserver, k) then 325 | return rawget(SubscriptionObserver, k) 326 | end 327 | return rawget(t, k) 328 | end 329 | SubscriptionObserver.__newindex = function(t, k, v) 330 | if k == "closed" then 331 | error("setting getter-only property 'closed'") 332 | end 333 | rawset(t, k, v) 334 | end 335 | 336 | function SubscriptionObserver.new(subscription: Subscription): SubscriptionObserver 337 | local self = ( 338 | setmetatable({ 339 | _subscription = subscription, 340 | }, SubscriptionObserver) :: any 341 | ) :: SubscriptionObserver 342 | return self 343 | end 344 | 345 | function SubscriptionObserver:next(value) 346 | onNotify(self._subscription, "next", value) 347 | end 348 | 349 | function SubscriptionObserver:error(value) 350 | onNotify(self._subscription, "error", value) 351 | end 352 | 353 | function SubscriptionObserver:complete() 354 | onNotify(self._subscription, "complete") 355 | end 356 | 357 | type ObservableLike = { 358 | subscribe: (self: ObservableLike) -> (Subscriber | nil)?, 359 | } 360 | -- ROBLOX FIXME Luau: this is a workaround for the 'recursive type with different args' error, remove this once that's fixed 361 | type _Observable = { 362 | subscribe: ( 363 | self: _Observable, 364 | observer: Observer | (value: T) -> (), 365 | error_: ((error: any) -> ())?, 366 | complete: (() -> ())? 367 | ) -> Subscription, 368 | map: (self: _Observable, fn: ((value: T) -> R)) -> any, 369 | forEach: (self: _Observable, fn: (value: T, cancel: (() -> ())?) -> ...any) -> Promise, 370 | flatMap: (self: _Observable, callback: (value: T) -> ObservableLike) -> any, 371 | concat: (self: _Observable, ...any) -> any, 372 | reduce: ( 373 | self: _Observable, 374 | callback: (previousValue: R, currentValue: T) -> R, 375 | initialValue: R? 376 | ) -> any, 377 | filter: (self: _Observable, callback: (value: T) -> boolean) -> any, 378 | 379 | _subscriber: Subscriber, 380 | } 381 | export type Observable = { 382 | subscribe: ( 383 | self: Observable, 384 | observer: Observer | (value: T) -> (), 385 | error_: ((error: any) -> ())?, 386 | complete: (() -> ())? 387 | ) -> Subscription, 388 | map: (self: Observable, fn: ((value: T) -> R)) -> _Observable, 389 | forEach: (self: Observable, fn: (value: T, cancel: (() -> ())?) -> ...any) -> Promise, 390 | flatMap: (self: Observable, callback: (value: T) -> ObservableLike) -> _Observable, 391 | concat: (self: Observable, ..._Observable) -> _Observable, 392 | reduce: ( 393 | self: Observable, 394 | callback: (previousValue: R, currentValue: T) -> R, 395 | initialValue: R? 396 | ) -> _Observable, 397 | -- ROBLOX TODO Luau: needs extends type expression: filter(callback: (value: T) => value is S): Observable; 398 | filter: (self: Observable, callback: (value: T) -> boolean) -> _Observable, 399 | 400 | _subscriber: Subscriber, 401 | } 402 | 403 | type Observable_Statics = { 404 | new: (subscriber: Subscriber) -> Observable, 405 | from: (C_: any, x_: Observable | ObservableLike | Array) -> Observable, 406 | of: (C_: any, ...R) -> Observable, 407 | } 408 | 409 | Observable = ({} :: any) :: Observable & Observable_Statics; 410 | (Observable :: any).__index = Observable 411 | 412 | -- ROBLOX deviation: adding this method to allow overriding the class of static methods 413 | function isObservableClass(obj: any) 414 | return typeof(obj) == "table" 415 | and obj[SymbolObservable] == (Observable :: any)[SymbolObservable] 416 | and typeof(rawget(obj, "new")) == "function" 417 | end 418 | 419 | function Observable.new(subscriber: Subscriber): Observable 420 | -- ROBLOX deviation: this logic can't/doesn't apply in Lua 421 | -- if not instanceOf(self, Observable) then 422 | -- error("Observable cannot be called as a function") 423 | -- end 424 | 425 | --ROBLOX deviation: check for function and callable tables 426 | if not isCallable(subscriber) then 427 | error("Observable initializer must be a function") 428 | end 429 | 430 | local self = (setmetatable({ 431 | _subscriber = subscriber, 432 | }, Observable) :: any) :: Observable 433 | 434 | return self 435 | end 436 | 437 | function Observable:subscribe( 438 | observer: Observer | (self: Observer, value: T_) -> (), 439 | error_: ((self: Observer, error: any) -> ())?, 440 | complete: ((self: Observer) -> ())? 441 | ): Subscription 442 | if typeof(observer) ~= "table" or observer == nil then 443 | observer = { 444 | start = nil, 445 | next = observer :: (self: Observer, value: T_) -> (), 446 | error = error_, 447 | complete = complete, 448 | } :: Observer 449 | end 450 | 451 | local subscription = Subscription.new(observer :: Observer, self._subscriber) 452 | return subscription 453 | end 454 | 455 | function Observable:forEach(fn: (value: T_, cancel: (() -> ())?) -> ()): Promise 456 | return Promise.new(function(resolve, reject) 457 | --ROBLOX deviation: check for function and callable tables 458 | if not isCallable(fn) then 459 | --ROBLOX deviation: using Error instead of TypeError 460 | reject(Error.new(tostring(fn) .. " is not a function")) 461 | return 462 | end 463 | 464 | --ROBLOX deviation: predefine variable 465 | local subscription 466 | local function done() 467 | subscription:unsubscribe() 468 | resolve() 469 | end 470 | 471 | subscription = self:subscribe({ 472 | next = function(_self, value): () 473 | -- ROBLOX FIXME Luau: CLI-49835, "Function only returns 1 value, 2 are required" 474 | local ok, result = pcall(fn :: Function, value, done) 475 | 476 | if not ok then 477 | reject(result) 478 | subscription:unsubscribe() 479 | end 480 | end, 481 | error = function(_self, e) 482 | reject(e) 483 | end, 484 | complete = function(_self) 485 | resolve() 486 | end, 487 | }) 488 | end) 489 | end 490 | 491 | type T_ = any 492 | function Observable:map(fn: (value: T_) -> R): Observable 493 | --ROBLOX deviation: check for function and callable tables 494 | if not isCallable(fn) then 495 | --ROBLOX deviation: using Error instead of TypeError 496 | error(Error.new(tostring(fn) .. " is not a function")) 497 | end 498 | 499 | local C = getSpecies(self) 500 | 501 | return C.new(function(observer) 502 | return self:subscribe({ 503 | next = function(_self, value): () 504 | --[[ ROBLOX COMMENT: try-catch block conversion ]] 505 | local ok, result = pcall(fn, value) 506 | if not ok then 507 | -- ROBLOX TODO: upstream returns value of error, which is void. upstream the fix. 508 | observer:error(result) 509 | return 510 | end 511 | value = result 512 | observer:next(value) 513 | end, 514 | error = function(_self, e) 515 | observer:error(e) 516 | end, 517 | complete = function(_self) 518 | observer:complete() 519 | end, 520 | }) 521 | end) 522 | end 523 | 524 | type S_ = any 525 | -- ROBLOX TODO Luau: needs 'extends' syntax: filter(callback: (value: T) => value is S): Observable; 526 | function Observable:filter(fn: (value: T_) -> boolean): Observable 527 | --ROBLOX deviation: check for function and callable tables 528 | if not isCallable(fn) then 529 | --ROBLOX deviation: using Error instead of TypeError 530 | error(Error.new(tostring(fn) .. " is not a function")) 531 | end 532 | 533 | local C = getSpecies(self) 534 | 535 | return C.new(function(observer: SubscriptionObserver) 536 | return self:subscribe({ 537 | next = function(_self, value): () 538 | --[[ ROBLOX COMMENT: try-catch block conversion ]] 539 | local ok, result = xpcall(fn, function(e) 540 | -- ROBLOX TODO: error returns void, fix this in upstream 541 | observer:error(e) 542 | end, value) 543 | if ok and not Boolean.toJSBoolean(result) then 544 | return 545 | end 546 | if not ok then 547 | return 548 | end 549 | observer:next(value) 550 | return 551 | end, 552 | error = function(_self, e) 553 | observer:error(e) 554 | end, 555 | complete = function(_self) 556 | observer:complete() 557 | end, 558 | }) 559 | end) 560 | end 561 | 562 | function Observable:reduce(fn: (previousValue: R, currentValue: T_) -> R, ...: R): Observable 563 | local arguments = { fn :: any, ... } 564 | --ROBLOX deviation: check for function and callable tables 565 | if not isCallable(fn) then 566 | --ROBLOX deviation: using Error instead of TypeError 567 | error(Error.new(tostring(fn) .. " is not a function")) 568 | end 569 | 570 | local C = getSpecies(self) 571 | 572 | local hasSeed = #arguments > 1 573 | local hasValue = false 574 | local seed = arguments[2] :: R 575 | local acc = seed 576 | -- ROBLOX TODO: should this be T_? type checker thinks R is incorrect 577 | return C.new(function(observer: SubscriptionObserver) 578 | return self:subscribe({ 579 | next = function(_self, value): () 580 | local first = not hasValue 581 | hasValue = true 582 | if not first or hasSeed then 583 | --[[ ROBLOX COMMENT: try-catch block conversion ]] 584 | local _ok 585 | _ok, acc = xpcall(fn, function(e) 586 | -- ROBLOX TODO: this is a bug in upstream, observer.error returns void and shouldn't use 'return' 587 | observer:error(e) 588 | end, acc, value) 589 | else 590 | acc = value 591 | end 592 | end, 593 | error = function(_self, e) 594 | observer:error(e) 595 | end, 596 | complete = function(_self) 597 | if not hasValue and not hasSeed then 598 | --ROBLOX deviation: using Error instead of TypeError 599 | -- ROBLOX TODO: this is a bug in upstream, observer.error returns void and shouldn't use 'return' 600 | observer:error(Error.new("Cannot reduce an empty sequence")) 601 | end 602 | observer:next(acc) 603 | observer:complete() 604 | end, 605 | }) 606 | end) 607 | end 608 | 609 | function Observable:concat(...: Observable): Observable 610 | local sources = { ... } 611 | 612 | local C = getSpecies(self) 613 | 614 | return C.new(function(observer) 615 | local subscription 616 | local index = 1 -- [[ ROBLOX deviation: index starts from 1 in Lua]] 617 | local function startNext(next: any): () 618 | subscription = next:subscribe({ 619 | next = function(_self, v) 620 | observer:next(v) 621 | end, 622 | error = function(_self, e) 623 | observer:error(e) 624 | end, 625 | complete = function(_self) 626 | if 627 | index == #sources + 1 --[[ ROBLOX deviation, index starts at 1]] 628 | then 629 | subscription = nil 630 | observer:complete() 631 | else 632 | startNext(C.from(sources[(function() 633 | local result = index 634 | index += 1 635 | return result 636 | end)()])) 637 | end 638 | end, 639 | }) 640 | end 641 | 642 | startNext(self) 643 | 644 | return function() 645 | if Boolean.toJSBoolean(subscription) then 646 | -- ROBLOX FIXME Luau: Luau should know non-nil narrowing on first argument of toJSBoolean 647 | (subscription :: any):unsubscribe() 648 | subscription = nil 649 | end 650 | end 651 | end) 652 | end 653 | 654 | function Observable:flatMap(fn: (value: T_) -> ObservableLike): Observable 655 | -- ROBLOX deviation: predefine variable 656 | local completeIfDone 657 | --ROBLOX deviation: check for function and callable tables 658 | if not isCallable(fn) then 659 | --ROBLOX deviation: using Error instead of TypeError 660 | error(Error.new(tostring(fn) .. " is not a function")) 661 | end 662 | 663 | local C = getSpecies(self) 664 | 665 | return C.new(function(observer) 666 | local subscriptions = {} 667 | local outer = self:subscribe({ 668 | next = function(_self, value) 669 | if Boolean.toJSBoolean(fn) then 670 | --[[ ROBLOX COMMENT: try-catch block conversion ]] 671 | local _ok 672 | _ok, value = xpcall(fn, function(e) 673 | -- ROBLOX TODO: this is a bug in upstream, error doesn't return anything 674 | observer:error(e) 675 | end, value) 676 | end 677 | 678 | local inner 679 | inner = C.from(value):subscribe({ 680 | next = function(__self, value) 681 | observer:next(value) 682 | end, 683 | error = function(__self, e) 684 | observer:error(e) 685 | end, 686 | complete = function(__self) 687 | local i = Array.indexOf(subscriptions, inner) 688 | if 689 | i >= 1 --[[ ROBLOX deviation: index start from 1 in Lua ]] 690 | then 691 | Array.splice(subscriptions, i, 1) 692 | end 693 | completeIfDone() 694 | end, 695 | }) 696 | 697 | table.insert(subscriptions, inner) 698 | return nil 699 | end, 700 | -- ROBLOX FIXME Luau: any needed to avoid TypeError: Generic supertype escaping scope 701 | error = function(_self, e: any) 702 | observer:error(e) 703 | end, 704 | complete = function(_self) 705 | completeIfDone() 706 | end, 707 | }) 708 | 709 | function completeIfDone() 710 | if outer.closed and #subscriptions == 0 then 711 | observer:complete() 712 | end 713 | end 714 | 715 | return function() 716 | Array.forEach(subscriptions, function(s) 717 | return s:unsubscribe() 718 | end) 719 | outer:unsubscribe() 720 | end 721 | end) 722 | end 723 | 724 | (Observable :: any)[SymbolObservable] = function(self) 725 | return self 726 | end 727 | 728 | -- ROBLOX FIXME Luau: any needed to avoid TypeError: Generic supertype escaping scope 729 | function Observable.from(C_: any, x_: Observable | ObservableLike | Array): Observable 730 | local C, x 731 | if isObservableClass(C_) then 732 | C = C_ 733 | x = x_ 734 | else 735 | C = Observable 736 | x = C_ 737 | end 738 | 739 | if x == nil then 740 | --ROBLOX deviation: using Error instead of TypeError 741 | error(Error.new(tostring(x) .. " is not an object")) 742 | end 743 | 744 | -- ROBLOX FIXME Luau: Luau doesn't narrow based on guard above, needs type states 745 | local method = getMethod(x :: Object, SymbolObservable) 746 | if method then 747 | local observable = method(x) 748 | 749 | -- if (Object(observable) ~= observable) then 750 | -- --ROBLOX deviation: using Error instead of TypeError 751 | -- error(Error.new(tostring(observable) .. " is not an object")); 752 | -- end 753 | 754 | if isObservable(observable) and observable.new == C.new then 755 | return observable 756 | end 757 | 758 | return C.new(function(observer) 759 | return observable:subscribe(observer) 760 | end) 761 | end 762 | 763 | -- if (hasSymbol('iterator')) { 764 | -- method = getMethod(x, SymbolIterator); 765 | -- if (method) { 766 | -- return new C(observer => { 767 | -- enqueue(() => { 768 | -- if (observer.closed) return; 769 | -- for (let item of method.call(x)) { 770 | -- observer.next(item); 771 | -- if (observer.closed) return; 772 | -- } 773 | -- observer.complete(); 774 | -- }); 775 | -- }); 776 | -- } 777 | -- } 778 | 779 | if Array.isArray(x) then 780 | return Observable.new(function(observer) 781 | enqueue(function() 782 | if observer.closed then 783 | return 784 | end 785 | -- ROBLOX FIXME Luau: Luau doesn't narrow based on guard above, needs type states 786 | for _, item in pairs(x :: Object) do 787 | observer:next(item) 788 | if observer.closed then 789 | return 790 | end 791 | end 792 | 793 | observer:complete() 794 | end) 795 | end) 796 | end 797 | 798 | --ROBLOX deviation: using Error instead of TypeError 799 | error(Error.new(tostring(x) .. " is not observable")) 800 | end 801 | 802 | -- ROBLOX FIXME Luau: any needed to avoid TypeError: Generic supertype escaping scope 803 | function Observable.of(C_: any, ...: R): Observable 804 | local C, items 805 | if isObservableClass(C_) then 806 | C = C_ 807 | items = { ... } 808 | else 809 | C = Observable 810 | items = { C_, ... } 811 | end 812 | 813 | return C.new(function(observer) 814 | enqueue(function() 815 | if observer.closed then 816 | return 817 | end 818 | for _, item in ipairs(items) do 819 | observer:next(item) 820 | if observer.closed then 821 | return 822 | end 823 | end 824 | observer:complete() 825 | end) 826 | end) 827 | end 828 | 829 | -- static get [SymbolSpecies]() { return this } 830 | 831 | -- } ]] 832 | 833 | if hasSymbols() then 834 | -- ROBLOX TODO: TypeError: Type '{ hostReportError: any, symbol: string }' could not be converted into '(a) -> a' 835 | (Observable :: any)[Symbol("extensions")] = { 836 | symbol = SymbolObservable, 837 | hostReportError = hostReportError, 838 | } :: any 839 | end 840 | 841 | return { Observable = Observable } 842 | -------------------------------------------------------------------------------- /src/init.lua: -------------------------------------------------------------------------------- 1 | --!strict 2 | local exports = {} 3 | local ObservableModule = require(script.Observable) 4 | local Observable = ObservableModule.Observable 5 | exports.Observable = Observable 6 | 7 | export type SubscriptionObserver = ObservableModule.SubscriptionObserver 8 | export type Observer = ObservableModule.Observer 9 | export type Subscriber = ObservableModule.Subscriber 10 | export type Subscription = ObservableModule.Subscription 11 | export type Observable = ObservableModule.Observable 12 | 13 | return exports 14 | -------------------------------------------------------------------------------- /test/.robloxrc: -------------------------------------------------------------------------------- 1 | { 2 | "language": { 3 | "mode": "strict" 4 | }, 5 | "lint": { 6 | "*": "enabled" 7 | } 8 | } -------------------------------------------------------------------------------- /test/concat.spec.lua: -------------------------------------------------------------------------------- 1 | -- ROBLOX upstream https://github.com/zenparsing/zen-observable/blob/v0.8.15/test/concat.js 2 | 3 | local rootWorkspace = script.Parent.Parent 4 | 5 | local JestGlobals = require(rootWorkspace.Dev.JestGlobals) 6 | local jestExpect = JestGlobals.expect 7 | 8 | return function() 9 | -- ROBLOX deviation: upstream a global variable is created in the test setup. 10 | -- A local variable is created to avoid using _G.Observable in every test 11 | local Observable 12 | beforeEach(function() 13 | Observable = _G.Observable 14 | end) 15 | describe("concat", function() 16 | it("concatenates the supplied Observable arguments", function() 17 | local list = {} 18 | Observable.from({ 1, 2, 3, 4 }) 19 | :concat(Observable.of(5, 6, 7)) 20 | :forEach(function(x) 21 | table.insert(list, x) 22 | return #list 23 | end) 24 | :expect() 25 | jestExpect(list).toEqual({ 1, 2, 3, 4, 5, 6, 7 }) 26 | end) 27 | 28 | it("can be used multiple times to produce the same results", function() 29 | local list1 = {} 30 | local list2 = {} 31 | local concatenated = Observable.from({ 1, 2, 3, 4 }):concat(Observable.of(5, 6, 7)) 32 | 33 | concatenated 34 | :forEach(function(x) 35 | table.insert(list1, x) 36 | return #list1 37 | end) 38 | :expect() 39 | 40 | concatenated 41 | :forEach(function(x) 42 | table.insert(list2, x) 43 | return #list2 44 | end) 45 | :expect() 46 | 47 | jestExpect(list1).toEqual({ 1, 2, 3, 4, 5, 6, 7 }) 48 | jestExpect(list2).toEqual({ 1, 2, 3, 4, 5, 6, 7 }) 49 | end) 50 | end) 51 | end 52 | -------------------------------------------------------------------------------- /test/constructor.spec.lua: -------------------------------------------------------------------------------- 1 | -- ROBLOX upstream https://github.com/zenparsing/zen-observable/blob/v0.8.15/test/constructor.js 2 | 3 | local rootWorkspace = script.Parent.Parent 4 | 5 | local JestGlobals = require(rootWorkspace.Dev.JestGlobals) 6 | local jestExpect = JestGlobals.expect 7 | 8 | return function() 9 | -- ROBLOX deviation: upstream a global variable is created in the test setup. 10 | -- A local variable is created to avoid using _G.Observable in every test 11 | local Observable 12 | beforeEach(function() 13 | Observable = _G.Observable 14 | end) 15 | describe("constructor", function() 16 | it("throws if called as a function", function() 17 | jestExpect(function() 18 | Observable(function() end) 19 | Observable.__call(function() end) 20 | end).toThrow() 21 | end) 22 | 23 | it("throws if the argument is not callable, argument: table", function() 24 | jestExpect(function() 25 | Observable.new({}) 26 | end).toThrow("Observable initializer must be a function") 27 | end) 28 | 29 | it("throws if the argument is not callable, argument: none", function() 30 | jestExpect(function() 31 | Observable.new() 32 | end).toThrow("Observable initializer must be a function") 33 | end) 34 | 35 | it("throws if the argument is not callable, argument: 1", function() 36 | jestExpect(function() 37 | Observable.new(1) 38 | end).toThrow("Observable initializer must be a function") 39 | end) 40 | 41 | it("throws if the argument is not callable, argument: 'string'", function() 42 | jestExpect(function() 43 | Observable.new("string") 44 | end).toThrow("Observable initializer must be a function") 45 | end) 46 | 47 | it("accepts a function argument", function() 48 | jestExpect(function() 49 | Observable.new(function() end) 50 | end).never.toThrow("Observable initializer must be a function") 51 | end) 52 | 53 | it("is the value of Observable.__index", function() 54 | jestExpect(Observable).toBe(Observable.__index) 55 | end) 56 | 57 | it("does not call the subscriber function", function() 58 | local called = 0 59 | Observable.new(function() 60 | called = called + 1 61 | end) 62 | jestExpect(called).toBe(0) 63 | end) 64 | end) 65 | end 66 | -------------------------------------------------------------------------------- /test/filter.spec.lua: -------------------------------------------------------------------------------- 1 | -- ROBLOX upstream https://github.com/zenparsing/zen-observable/blob/v0.8.15/test/filter.js 2 | --!strict 3 | local rootWorkspace = script.Parent.Parent 4 | 5 | local JestGlobals = require(rootWorkspace.Dev.JestGlobals) 6 | local jestExpect = JestGlobals.expect 7 | 8 | return function() 9 | -- ROBLOX deviation: upstream a global variable is created in the test setup. 10 | -- A local variable is created to avoid using _G.Observable in every test 11 | local Observable 12 | beforeEach(function() 13 | Observable = _G.Observable 14 | end) 15 | describe("filter", function() 16 | it("filters the results using the supplied callback", function() 17 | local list = {} 18 | Observable.from({ 1, 2, 3, 4 }) 19 | :filter(function(x) 20 | return x > 2 21 | end) 22 | :forEach(function(x) 23 | table.insert(list, x) 24 | return #list 25 | end) 26 | :expect() 27 | jestExpect(list).toEqual({ 3, 4 }) 28 | end) 29 | -- ROBLOX TODO: submit this new test upstream 30 | it("errors when given non-function", function() 31 | jestExpect(function() 32 | local NOT_A_FUNCTION = 31337 33 | -- ROBLOX FIXME Luau: type checking should prevent this abuse, but we need recursive type workarounds removed first 34 | Observable.from({ 1, 2, 3, 4 }):filter(NOT_A_FUNCTION):expect() 35 | end).toThrow("31337 is not a function") 36 | end) 37 | end) 38 | end 39 | -------------------------------------------------------------------------------- /test/flat-map.spec.lua: -------------------------------------------------------------------------------- 1 | -- ROBLOX upstream https://github.com/zenparsing/zen-observable/blob/v0.8.15/test/flat-map.js 2 | 3 | local rootWorkspace = script.Parent.Parent 4 | 5 | local JestGlobals = require(rootWorkspace.Dev.JestGlobals) 6 | local jestExpect = JestGlobals.expect 7 | 8 | return function() 9 | -- ROBLOX deviation: upstream a global variable is created in the test setup. 10 | -- A local variable is created to avoid using _G.Observable in every test 11 | local Observable 12 | beforeEach(function() 13 | Observable = _G.Observable 14 | end) 15 | describe("flatMap", function() 16 | it("maps and flattens the results using the supplied callback", function() 17 | local list = {} 18 | Observable.of("a", "b", "c") 19 | :flatMap(function(x) 20 | return (Observable.of(1, 2, 3):map(function(y) 21 | return { x, y } 22 | end)) 23 | end) 24 | :forEach(function(x) 25 | table.insert(list, x) 26 | return #list 27 | end) 28 | :expect() 29 | jestExpect(list).toEqual({ 30 | { "a", 1 :: any }, 31 | { "a", 2 :: any }, 32 | { "a", 3 :: any }, 33 | { "b", 1 :: any }, 34 | { "b", 2 :: any }, 35 | { "b", 3 :: any }, 36 | { "c", 1 :: any }, 37 | { "c", 2 :: any }, 38 | { "c", 3 :: any }, 39 | }) 40 | end) 41 | end) 42 | end 43 | -------------------------------------------------------------------------------- /test/for-each.spec.lua: -------------------------------------------------------------------------------- 1 | -- ROBLOX upstream https://github.com/zenparsing/zen-observable/blob/v0.8.15/test/for-each.js 2 | 3 | local rootWorkspace = script.Parent.Parent 4 | 5 | local JestGlobals = require(rootWorkspace.Dev.JestGlobals) 6 | local jestExpect = JestGlobals.expect 7 | 8 | return function() 9 | -- ROBLOX deviation: upstream a global variable is created in the test setup. 10 | -- A local variable is created to avoid using _G.Observable in every test 11 | local Observable 12 | beforeEach(function() 13 | Observable = _G.Observable 14 | end) 15 | describe("forEach", function() 16 | it("rejects if the argument is not a function", function() 17 | -- ROBLOX deviation START: violates type safety, so we cast it away for this abuse case 18 | local promise = (Observable.of :: any)(1, 2, 3):forEach() 19 | -- ROBLOX deviation END 20 | xpcall(function() 21 | promise:expect() 22 | jestExpect(true).toBe(false) 23 | end, function(err) 24 | jestExpect(err.name).toBe("Error") 25 | end) 26 | end) 27 | 28 | it("rejects if the callback throws", function() 29 | local error_ = {} 30 | --[[ ROBLOX COMMENT: try-catch block conversion ]] 31 | xpcall(function() 32 | Observable.of(1, 2, 3) 33 | :forEach(function(x) 34 | error(error_) 35 | end) 36 | :expect() 37 | jestExpect(true).toBe(false) 38 | end, function(err) 39 | jestExpect(err).toBe(error_) 40 | end) 41 | end) 42 | 43 | it("does not execute callback after callback throws", function() 44 | local calls = {} 45 | --[[ ROBLOX COMMENT: try-catch block conversion ]] 46 | xpcall(function() 47 | Observable.of(1, 2, 3) 48 | :forEach(function(x) 49 | table.insert(calls, x) 50 | error({}) 51 | end) 52 | :expect() 53 | jestExpect(true).toBe(false) 54 | end, function(err) 55 | jestExpect(calls).toBe({ 1 }) 56 | end) 57 | end) 58 | 59 | it("rejects if the producer calls error", function() 60 | local error_ = {} 61 | --[[ ROBLOX COMMENT: try-catch block conversion ]] 62 | xpcall(function() 63 | local observer 64 | local promise = Observable.new(function(x) 65 | observer = x 66 | end):forEach(function() end) 67 | observer:error(error_) 68 | promise:expect() 69 | jestExpect(true).toBe(false) 70 | end, function(err) 71 | jestExpect(err).toBe(error_) 72 | end) 73 | end) 74 | 75 | it("resolves with undefined if the producer calls complete", function() 76 | local observer 77 | local promise = Observable.new(function(x) 78 | observer = x 79 | end):forEach(function() end) 80 | observer:complete() 81 | jestExpect(promise:expect()).toBe(nil) 82 | end) 83 | 84 | it("provides a cancellation function as the second argument", function() 85 | local results = {} 86 | Observable.of(1, 2, 3) 87 | :forEach(function(value, cancel) 88 | table.insert(results, value) 89 | if value > 1 then 90 | -- ROBLOX TODO: cancel isn't supported by definitely-typed, but upstream test should check for undefined at least 91 | -- ROBLOX TODO: upstream tries to return from foreach function, which is incorrect 92 | if cancel ~= nil then 93 | cancel() 94 | end 95 | end 96 | end) 97 | :expect() 98 | jestExpect(results).toEqual({ 1, 2 }) 99 | end) 100 | end) 101 | end 102 | -------------------------------------------------------------------------------- /test/from.spec.lua: -------------------------------------------------------------------------------- 1 | -- ROBLOX upstream https://github.com/zenparsing/zen-observable/blob/v0.8.15/test/from.js 2 | 3 | local rootWorkspace = script.Parent.Parent 4 | 5 | local LuauPolyfill = require(rootWorkspace.LuauPolyfill) 6 | type Array = LuauPolyfill.Array 7 | type Record = { [T]: U } 8 | -- ROBLOX deviation: type system doesn't track mutations on this global 9 | local Symbol = LuauPolyfill.Symbol :: any 10 | 11 | local Promise = require(rootWorkspace.Promise) 12 | 13 | local JestGlobals = require(rootWorkspace.Dev.JestGlobals) 14 | local jestExpect = JestGlobals.expect 15 | 16 | return function() 17 | -- ROBLOX deviation: upstream a global variable is created in the test setup. 18 | -- A local variable is created to avoid using _G.Observable in every test 19 | local Observable 20 | beforeEach(function() 21 | Observable = _G.Observable 22 | end) 23 | describe("from", function() 24 | -- local iterable = {[tostring(Symbol.iterator)] = function(self) 25 | -- error("not implemented") --[[ ROBLOX TODO: Unhandled node for type: YieldExpression ]] 26 | -- --[[ yield 1 ]]; 27 | -- error("not implemented") --[[ ROBLOX TODO: Unhandled node for type: YieldExpression ]] 28 | -- --[[ yield 2 ]]; 29 | -- error("not implemented") --[[ ROBLOX TODO: Unhandled node for type: YieldExpression ]] 30 | -- --[[ yield 3 ]]; 31 | -- end} 32 | -- ROBLOX deviation: prototype not available, we just check that method exists 33 | it("is a method on Observable", function() 34 | jestExpect(function() 35 | Observable.from({ "a-string" }) 36 | end).never.toThrowError() 37 | end) 38 | 39 | it("throws if the argument is null", function() 40 | jestExpect(function() 41 | -- ROBLOX deviation START: violates type safety, so we cast it away for this abuse case 42 | (Observable.from :: any)(nil) 43 | -- ROBLOX deviation END 44 | end).toThrowError() 45 | end) 46 | it("throws if the argument is undefined", function() 47 | jestExpect(function() 48 | -- ROBLOX deviation START: violates type safety, so we cast it away for this abuse case 49 | (Observable.from :: any)(nil) 50 | -- ROBLOX deviation END 51 | end).toThrowError() 52 | end) 53 | 54 | it("throws if the argument is not observable or iterable", function() 55 | jestExpect(function() 56 | -- ROBLOX deviation: table needs key/value to differentiate from empty Array 57 | return Observable.from({ key = "value" }) 58 | end).toThrowError() 59 | end) 60 | describe("observables", function() 61 | it('returns the input if the constructor matches "this"', function() 62 | -- ROBLOX deviation = we need the Obervable symbol to distinguish "this" and use it as "this" 63 | local ctor = { new = function() end, [Symbol.observable] = Observable[Symbol.observable] } 64 | local observable = Observable.new(function() end) 65 | observable.new = ctor.new 66 | jestExpect(Observable.from(ctor, observable)).toEqual(observable) 67 | end) 68 | 69 | -- ROBLOX comment: the obj is considered an instance of Observable on Polyfill implementation 70 | xit("wraps the input if it is not an instance of Observable", function() 71 | local obj = { 72 | new = Observable.new, 73 | [Symbol.observable] = function(self) 74 | return self 75 | end, 76 | } 77 | jestExpect(Observable.from(obj) ~= obj).toBeTruthy() 78 | end) 79 | it("throws if @@observable property is not a method", function() 80 | jestExpect(function() 81 | return Observable.from({ [Symbol.observable] = 1 }) 82 | end).toThrowError() 83 | end) 84 | it("returns an observable wrapping @@observable result", function() 85 | local observer 86 | local cleanupCalled = true 87 | local inner = { 88 | subscribe = function(_self, x) 89 | observer = x 90 | return function() 91 | cleanupCalled = true 92 | end 93 | end, 94 | } 95 | local observable = Observable.from({ 96 | [Symbol.observable] = function(_self) 97 | return inner 98 | end, 99 | }) 100 | observable:subscribe() 101 | jestExpect(typeof(observer.next)).toBe("function") 102 | observer:complete() 103 | jestExpect(cleanupCalled).toBe(true) 104 | end) 105 | end) 106 | describe("iterables", function() 107 | -- it("throws if @@iterator is not a method", function() 108 | -- assert:throws(function() 109 | -- return Observable:from({[tostring(Symbol.iterator)] = 1}) 110 | -- end); 111 | -- end); 112 | -- it("returns an observable wrapping iterables", function() 113 | -- local calls = {} 114 | -- local subscription = Observable:from(iterable):subscribe({next = function(self, v) 115 | -- calls:push({"next", v}); 116 | -- end, complete = function(self) 117 | -- calls:push({"complete"}); 118 | -- end}) 119 | -- assert:deepEqual(calls, {}); 120 | -- error("not implemented") --[[ ROBLOX TODO: Unhandled node for type: AwaitExpression ]] 121 | -- --[[ await null ]]; 122 | -- assert:deepEqual(calls, {{"next", 1}, {"next", 2}, {"next", 3}, {"complete"}}); 123 | -- end); 124 | 125 | -- ROBLOX comment: not present upstream 126 | it("returns an observable wrapping Array", function() 127 | local calls = {} 128 | local _subscription = Observable.from({ 1, 2, 3 }):subscribe({ 129 | next = function(_self, v: number) 130 | table.insert(calls, { "next", v :: any }) 131 | end, 132 | complete = function(_self) 133 | table.insert(calls, { "complete" }) 134 | end, 135 | }) 136 | jestExpect(calls).toEqual({}) 137 | Promise.delay(0):expect() 138 | jestExpect(calls).toEqual({ 139 | { "next", 1 :: any }, 140 | { "next", 2 :: any }, 141 | { "next", 3 :: any }, 142 | { "complete" }, 143 | }) 144 | end) 145 | end) 146 | end) 147 | end 148 | -------------------------------------------------------------------------------- /test/init.spec.lua: -------------------------------------------------------------------------------- 1 | -- ROBLOX upstream https://github.com/zenparsing/zen-observable/blob/v0.8.15/test/setup.js 2 | --!strict 3 | local rootWorkspace = script.Parent.Parent 4 | local LuauPolyfill = require(rootWorkspace.LuauPolyfill) 5 | local Object = LuauPolyfill.Object 6 | local Array = LuauPolyfill.Array 7 | type Object = LuauPolyfill.Object 8 | 9 | local ObservableModule = require(rootWorkspace.ZenObservable) 10 | local Observable_ = ObservableModule.Observable 11 | 12 | -- ROBLOX deviation: used instaead of getOwnPropertySymbols 13 | function getSymbol(obj: Object, name: string): string? 14 | return Array.find(Object.keys(obj), function(key): boolean 15 | return tostring(key) == ("Symbol(%s)"):format(name) 16 | end) 17 | end 18 | 19 | return function() 20 | beforeEach(function() 21 | _G.Observable = Observable_ 22 | _G.hostError = nil 23 | 24 | local extensions = getSymbol(Observable_, "extensions") 25 | -- ROBLOX deviation: no type checker can know the extensions symbol key's shape 26 | local hostReportError = if extensions then (Observable_ :: any)[extensions].hostReportError else nil 27 | -- ROBLOX deviation START: check for nil to avoid nil deref analyze error, this is the test case for CLI-57683 28 | if hostReportError then 29 | hostReportError.log = function(_self, e) 30 | _G.hostError = e 31 | return _G.hostError 32 | end 33 | end 34 | -- ROBLOX deviation END 35 | end) 36 | end 37 | -------------------------------------------------------------------------------- /test/map.spec.lua: -------------------------------------------------------------------------------- 1 | -- ROBLOX upstream https://github.com/zenparsing/zen-observable/blob/v0.8.15/test/map.js 2 | 3 | local rootWorkspace = script.Parent.Parent 4 | 5 | local JestGlobals = require(rootWorkspace.Dev.JestGlobals) 6 | local jestExpect = JestGlobals.expect 7 | 8 | return function() 9 | -- ROBLOX deviation: upstream a global variable is created in the test setup. 10 | -- A local variable is created to avoid using _G.Observable in every test 11 | local Observable 12 | beforeEach(function() 13 | Observable = _G.Observable 14 | end) 15 | describe("map", function() 16 | it("maps the results using the supplied callback", function() 17 | local list = {} 18 | Observable.from({ 1, 2, 3 }) 19 | :map(function(x: number) 20 | return x * 2 21 | end) 22 | :forEach(function(x: number) 23 | table.insert(list, x) 24 | return #list 25 | end) 26 | :expect() 27 | jestExpect(list).toEqual({ 2, 4, 6 }) 28 | end) 29 | end) 30 | end 31 | -------------------------------------------------------------------------------- /test/observer-closed.spec.lua: -------------------------------------------------------------------------------- 1 | -- ROBLOX upstream https://github.com/zenparsing/zen-observable/blob/v0.8.15/test/observer-closed.js 2 | local rootWorkspace = script.Parent.Parent 3 | 4 | local JestGlobals = require(rootWorkspace.Dev.JestGlobals) 5 | local jestExpect = JestGlobals.expect 6 | 7 | local testMethodProperty = require(script.Parent.properties).testMethodProperty 8 | 9 | return function() 10 | -- ROBLOX deviation: upstream a global variable is created in the test setup. 11 | -- A local variable is created to avoid using _G.Observable in every test 12 | local Observable 13 | beforeEach(function() 14 | Observable = _G.Observable 15 | end) 16 | describe("observer.closed", function() 17 | it("is a getter on SubscriptionObserver.prototype", function() 18 | local observer 19 | Observable.new(function(x) 20 | observer = x 21 | end):subscribe() 22 | testMethodProperty( 23 | --ROBLOX deviation, getPrototypeof called in function 24 | observer, 25 | "closed", 26 | { get = true, configurable = true, writable = true, length = 1 } 27 | ) 28 | end) 29 | 30 | it("returns false when the subscription is open", function() 31 | Observable.new(function(observer) 32 | jestExpect(observer.closed).toBe(false) 33 | end):subscribe() 34 | end) 35 | 36 | it("returns true when the subscription is completed", function() 37 | local observer 38 | Observable.new(function(x) 39 | observer = x 40 | end):subscribe() 41 | observer:complete() 42 | jestExpect(observer.closed).toBe(true) 43 | end) 44 | 45 | it("returns true when the subscription is errored", function() 46 | local observer 47 | Observable.new(function(x) 48 | observer = x 49 | end):subscribe(nil, function() end) 50 | -- ROBLOX TODO: upstream test should at least pass in null 51 | observer:error(nil) 52 | jestExpect(observer.closed).toBe(true) 53 | end) 54 | end) 55 | end 56 | -------------------------------------------------------------------------------- /test/observer-complete.spec.lua: -------------------------------------------------------------------------------- 1 | -- ROBLOX upstream https://github.com/zenparsing/zen-observable/blob/v0.8.15/test/observer-complete.js 2 | 3 | local rootWorkspace = script.Parent.Parent 4 | local LuauPolyfill = require(rootWorkspace.LuauPolyfill) 5 | local instanceOf = LuauPolyfill.instanceof 6 | local Error = LuauPolyfill.Error 7 | local Boolean = LuauPolyfill.Boolean 8 | type Object = LuauPolyfill.Object 9 | 10 | local JestGlobals = require(rootWorkspace.Dev.JestGlobals) 11 | local jestExpect = JestGlobals.expect 12 | 13 | local Promise = require(rootWorkspace.Promise) 14 | 15 | local testMethodProperty = require(script.Parent.properties).testMethodProperty 16 | 17 | return function() 18 | -- ROBLOX deviation: upstream a global variable is created in the test setup. 19 | -- A local variable is created to avoid using _G.Observable in every test 20 | local Observable 21 | beforeEach(function() 22 | Observable = _G.Observable 23 | end) 24 | describe("observer.complete", function() 25 | local function getObserver(inner: Object?) 26 | local observer 27 | Observable.new(function(x) 28 | observer = x 29 | end):subscribe(inner) 30 | return observer 31 | end 32 | 33 | it("is a method of SubscriptionObserver", function() 34 | local observer = getObserver() 35 | testMethodProperty( 36 | --ROBLOX deviation, getPrototypeof called in function 37 | observer, 38 | "complete", 39 | { 40 | configurable = true, 41 | writable = true, 42 | length = 1, --[[ ROBLOX deviation: adds self arg ]] 43 | } 44 | ) 45 | end) 46 | 47 | it("does not forward arguments", function() 48 | local args 49 | local observer = getObserver({ 50 | complete = function(_self, ...) 51 | args = { ... } 52 | end, 53 | }) 54 | observer:complete(1) 55 | jestExpect(args).toEqual({}) 56 | end) 57 | 58 | it("does not return a value", function() 59 | local observer = getObserver({ 60 | complete = function(_self) 61 | return 1 62 | end, 63 | }) 64 | jestExpect(observer:complete()).toBe(nil) 65 | end) 66 | 67 | it("does not forward when the subscription is complete", function() 68 | local count = 0 69 | local observer = getObserver({ 70 | complete = function(_self) 71 | count += 1 72 | end, 73 | }) 74 | observer:complete() 75 | observer:complete() 76 | jestExpect(count).toBe(1) 77 | end) 78 | 79 | it("does not forward when the subscription is cancelled", function() 80 | local count = 0 81 | local observer 82 | local subscription = Observable.new(function(x) 83 | observer = x 84 | end):subscribe({ 85 | complete = function(self) 86 | count += 1 87 | end, 88 | }) 89 | subscription:unsubscribe() 90 | observer:complete() 91 | jestExpect(count).toBe(0) 92 | end) 93 | 94 | it("queues if the subscription is not initialized", function() 95 | local completed = false 96 | Observable.new(function(x) 97 | x:complete() 98 | end):subscribe({ 99 | complete = function(self) 100 | completed = true 101 | end, 102 | }) 103 | jestExpect(completed).toBe(false) 104 | Promise.delay(0):expect() 105 | jestExpect(completed).toBe(true) 106 | end) 107 | 108 | it("queues if the observer is running", function() 109 | local observer 110 | local completed = false 111 | Observable.new(function(x) 112 | observer = x 113 | end):subscribe({ 114 | next = function(self) 115 | observer:complete() 116 | end, 117 | complete = function(self) 118 | completed = true 119 | end, 120 | }) 121 | observer:next() 122 | jestExpect(completed).toBe(false) 123 | Promise.delay(0):expect() 124 | jestExpect(completed).toBe(true) 125 | end) 126 | 127 | it("closes the subscription before invoking inner observer", function() 128 | local closed 129 | local observer 130 | observer = getObserver({ 131 | complete = function(self) 132 | closed = observer.closed 133 | end, 134 | }) 135 | observer:complete() 136 | jestExpect(closed).toBe(true) 137 | end) 138 | 139 | it('reports error if "complete" is not a method', function() 140 | local observer = getObserver({ complete = 1 }) 141 | observer:complete() 142 | jestExpect(instanceOf(_G.hostError, Error)).toBeTruthy() 143 | end) 144 | 145 | it('does not report error if "complete" is undefined', function() 146 | local observer = getObserver({ complete = nil }) 147 | observer:complete() 148 | jestExpect(not Boolean.toJSBoolean(_G.hostError)).toBeTruthy() 149 | end) 150 | 151 | it('does not report error if "complete" is null', function() 152 | local observer = getObserver({ complete = nil }) 153 | observer:complete() 154 | jestExpect(not Boolean.toJSBoolean(_G.hostError)).toBeTruthy() 155 | end) 156 | 157 | it('reports error if "complete" throws', function() 158 | local error_ = {} 159 | local observer = getObserver({ 160 | complete = function(self) 161 | error(error_) 162 | end, 163 | }) 164 | observer:complete() 165 | jestExpect(_G.hostError).toEqual(error_) 166 | end) 167 | 168 | it('calls the cleanup method after "complete"', function() 169 | local calls = {} 170 | local observer 171 | Observable.new(function(x) 172 | observer = x 173 | return function() 174 | table.insert(calls, "cleanup") 175 | end 176 | end):subscribe({ 177 | complete = function(_self) 178 | table.insert(calls, "complete") 179 | end, 180 | }) 181 | observer:complete() 182 | jestExpect(calls).toEqual({ "complete", "cleanup" }) 183 | end) 184 | 185 | it('calls the cleanup method if there is no "complete"', function() 186 | local calls = {} 187 | local observer 188 | Observable.new(function(x) 189 | observer = x 190 | return function() 191 | table.insert(calls, "cleanup") 192 | end 193 | end):subscribe({}) 194 | observer:complete() 195 | jestExpect(calls).toEqual({ "cleanup" }) 196 | end) 197 | 198 | it("reports error if the cleanup function throws", function() 199 | local error_ = {} 200 | local observer 201 | Observable.new(function(x) 202 | observer = x 203 | return function() 204 | error(error_) 205 | end 206 | end):subscribe() 207 | observer:complete() 208 | jestExpect(_G.hostError).toEqual(error_) 209 | end) 210 | end) 211 | end 212 | -------------------------------------------------------------------------------- /test/observer-error.spec.lua: -------------------------------------------------------------------------------- 1 | -- ROBLOX upstream https://github.com/zenparsing/zen-observable/blob/v0.8.15/test/observer-error.js 2 | 3 | local rootWorkspace = script.Parent.Parent 4 | local LuauPolyfill = require(rootWorkspace.LuauPolyfill) 5 | local Boolean = LuauPolyfill.Boolean 6 | type Object = LuauPolyfill.Object 7 | 8 | local JestGlobals = require(rootWorkspace.Dev.JestGlobals) 9 | local jestExpect = JestGlobals.expect 10 | 11 | local Promise = require(rootWorkspace.Promise) 12 | 13 | local testMethodProperty = require(script.Parent.properties).testMethodProperty 14 | 15 | return function() 16 | -- ROBLOX deviation: upstream a global variable is created in the test setup. 17 | -- A local variable is created to avoid using _G.Observable in every test 18 | local Observable 19 | beforeEach(function() 20 | Observable = _G.Observable 21 | end) 22 | describe("observer.error", function() 23 | local function getObserver(inner: Object?) 24 | local observer 25 | Observable.new(function(x) 26 | observer = x 27 | end):subscribe(inner) 28 | return observer 29 | end 30 | 31 | it("is a method of SubscriptionObserver", function() 32 | local observer = getObserver() 33 | testMethodProperty( 34 | --ROBLOX deviation, getPrototypeof called in function 35 | observer, 36 | "error", 37 | { 38 | configurable = true, 39 | writable = true, 40 | length = 2, --[[ ROBLOX deviation: adds self arg ]] 41 | } 42 | ) 43 | end) 44 | 45 | it("forwards the argument", function() 46 | local args 47 | local observer = getObserver({ 48 | error = function(_self, ...) 49 | args = { ... } 50 | end, 51 | }) 52 | observer:error(1) 53 | jestExpect(args).toEqual({ 1 }) 54 | end) 55 | 56 | it("does not return a value", function() 57 | local observer = getObserver({ 58 | error = function(_self) 59 | return 1 60 | end, 61 | }) 62 | jestExpect(observer:error()).toBe(nil) 63 | end) 64 | 65 | it("does not throw when the subscription is complete", function() 66 | local observer = getObserver({ error = function(_self) end }) 67 | observer:complete() 68 | observer:error("error") 69 | end) 70 | 71 | it("does not throw when the subscription is cancelled", function() 72 | local observer 73 | local subscription = Observable.new(function(x) 74 | observer = x 75 | end):subscribe({ error = function(_self) end }) 76 | subscription:unsubscribe() 77 | observer:error(1) 78 | jestExpect(not Boolean.toJSBoolean(_G.hostError)).toBeTruthy() 79 | end) 80 | 81 | it("queues if the subscription is not initialized", function() 82 | local error_ 83 | Observable.new(function(x) 84 | x:error_({}) 85 | end):subscribe({ 86 | error = function(_self, err) 87 | error_ = err 88 | end, 89 | }) 90 | jestExpect(error_).toBe(nil) 91 | Promise.delay(0):expect() 92 | jestExpect(error_).toBeTruthy() 93 | end) 94 | 95 | it("queues if the observer is running", function() 96 | local observer 97 | local error_ 98 | Observable.new(function(x) 99 | observer = x 100 | end):subscribe({ 101 | next = function(_self) 102 | observer:error({}) 103 | end, 104 | error = function(_self, e) 105 | error_ = e 106 | end, 107 | }) 108 | observer:next() 109 | jestExpect(not Boolean.toJSBoolean(error_)).toBeTruthy() 110 | Promise.delay(0):expect() 111 | jestExpect(error_).toBeTruthy() 112 | end) 113 | 114 | it("closes the subscription before invoking inner observer", function() 115 | local closed 116 | local observer 117 | observer = getObserver({ 118 | error = function(_self) 119 | closed = observer.closed 120 | end, 121 | }) 122 | observer:error(1) 123 | jestExpect(closed).toBe(true) 124 | end) 125 | 126 | it('reports an error if "error" is not a method', function() 127 | local observer = getObserver({ error = 1 }) 128 | observer:error(1) 129 | jestExpect(_G.hostError).toBeTruthy() 130 | end) 131 | 132 | it('reports an error if "error" is undefined', function() 133 | local error_ = {} 134 | local observer = getObserver({ ["error"] = nil }) 135 | observer:error(error_) 136 | jestExpect(_G.hostError).toEqual(error_) 137 | end) 138 | 139 | it('reports an error if "error" is null', function() 140 | local error_ = {} 141 | local observer = getObserver({ error = nil }) 142 | observer:error(error_) 143 | jestExpect(_G.hostError).toEqual(error_) 144 | end) 145 | 146 | it('reports error if "error" throws', function() 147 | local error_ = {} 148 | local observer = getObserver({ 149 | error = function(self) 150 | error(error_) 151 | end, 152 | }) 153 | observer:error(1) 154 | jestExpect(_G.hostError).toEqual(error_) 155 | end) 156 | 157 | it('calls the cleanup method after "error"', function() 158 | local calls = {} 159 | local observer 160 | Observable.new(function(x) 161 | observer = x 162 | return function() 163 | table.insert(calls, "cleanup") 164 | end 165 | end):subscribe({ 166 | ["error"] = function(_self) 167 | table.insert(calls, "error") 168 | end, 169 | }) 170 | observer:error() 171 | jestExpect(calls).toEqual({ "error", "cleanup" }) 172 | end) 173 | 174 | it('calls the cleanup method if there is no "error"', function() 175 | local calls = {} 176 | local observer 177 | Observable.new(function(x) 178 | observer = x 179 | return function() 180 | table.insert(calls, "cleanup") 181 | end 182 | end):subscribe({}) 183 | local _ok, result, hasReturned = xpcall(function() 184 | observer:error() 185 | end, function(err) end) 186 | if hasReturned then 187 | return result 188 | end 189 | jestExpect(calls).toEqual({ "cleanup" }) 190 | end) 191 | 192 | it("reports error if the cleanup function throws", function() 193 | local error_ = {} 194 | local observer 195 | Observable.new(function(x) 196 | observer = x 197 | return function() 198 | error(error_) 199 | end 200 | end):subscribe() 201 | observer:error(1) 202 | jestExpect(_G.hostError).toEqual(error_) 203 | end) 204 | end) 205 | end 206 | -------------------------------------------------------------------------------- /test/observer-next.spec.lua: -------------------------------------------------------------------------------- 1 | -- ROBLOX upstream https://github.com/zenparsing/zen-observable/blob/v0.8.15/test/observer-next.js 2 | 3 | local rootWorkspace = script.Parent.Parent 4 | local LuauPolyfill = require(rootWorkspace.LuauPolyfill) 5 | local Boolean = LuauPolyfill.Boolean 6 | type Object = LuauPolyfill.Object 7 | 8 | local JestGlobals = require(rootWorkspace.Dev.JestGlobals) 9 | local jestExpect = JestGlobals.expect 10 | 11 | local Promise = require(rootWorkspace.Promise) 12 | 13 | local testMethodProperty = require(script.Parent.properties).testMethodProperty 14 | 15 | return function() 16 | -- ROBLOX deviation: upstream a global variable is created in the test setup. 17 | -- A local variable is created to avoid using _G.Observable in every test 18 | local Observable 19 | beforeEach(function() 20 | Observable = _G.Observable 21 | end) 22 | describe("observer.next", function() 23 | local function getObserver(inner: Object?) 24 | local observer 25 | Observable.new(function(x) 26 | observer = x 27 | end):subscribe(inner) 28 | return observer 29 | end 30 | 31 | it("is a method of SubscriptionObserver", function() 32 | local observer = getObserver() 33 | testMethodProperty( 34 | --ROBLOX deviation, getPrototypeof called in function 35 | observer, 36 | "next", 37 | { 38 | configurable = true, 39 | writable = true, 40 | length = 2, --[[ ROBLOX deviation: adds self arg ]] 41 | } 42 | ) 43 | end) 44 | 45 | it("forwards the first argument", function() 46 | local args 47 | local observer = getObserver({ 48 | next = function(self, ...) 49 | args = { ... } 50 | end, 51 | }) 52 | observer:next(1, 2) 53 | jestExpect(args).toEqual({ 1 }) 54 | end) 55 | 56 | it("does not return a value", function() 57 | local observer = getObserver({ 58 | next = function(self) 59 | return 1 60 | end, 61 | }) 62 | jestExpect(observer:next()).toBe(nil) 63 | end) 64 | 65 | it("does not forward when the subscription is complete", function() 66 | local count = 0 67 | local observer = getObserver({ 68 | next = function(self) 69 | count += 1 70 | end, 71 | }) 72 | observer:complete() 73 | observer:next() 74 | jestExpect(count).toBe(0) 75 | end) 76 | 77 | it("does not forward when the subscription is cancelled", function() 78 | local count = 0 79 | local observer 80 | local subscription = Observable.new(function(x) 81 | observer = x 82 | end):subscribe({ 83 | next = function(self) 84 | count += 1 85 | end, 86 | }) 87 | subscription:unsubscribe() 88 | observer:next() 89 | jestExpect(count).toBe(0) 90 | end) 91 | 92 | it('remains closed if the subscription is cancelled from "next"', function() 93 | local observer 94 | local subscription 95 | subscription = Observable.new(function(x) 96 | observer = x 97 | end):subscribe({ 98 | next = function(self) 99 | subscription:unsubscribe() 100 | end, 101 | }) 102 | observer:next() 103 | jestExpect(observer.closed).toBe(true) 104 | end) 105 | 106 | it("queues if the subscription is not initialized", function() 107 | local values = {} 108 | local observer 109 | Observable.new(function(x) 110 | observer = x 111 | x:next(1) 112 | end):subscribe({ 113 | next = function(self, val) 114 | table.insert(values, val) 115 | if val == 1 then 116 | observer:next(3) 117 | end 118 | end, 119 | }) 120 | observer:next(2) 121 | jestExpect(values).toEqual({}) 122 | Promise.delay(0):expect() 123 | jestExpect(values).toEqual({ 1, 2 }) 124 | Promise.delay(0):expect() 125 | jestExpect(values).toEqual({ 1, 2, 3 }) 126 | end) 127 | 128 | it("drops queue if subscription is closed", function() 129 | local values = {} 130 | local subscription = Observable.new(function(x) 131 | x:next(1) 132 | end):subscribe({ 133 | next = function(self, val) 134 | table.insert(values, val) 135 | end, 136 | }) 137 | jestExpect(values).toEqual({}) 138 | subscription:unsubscribe() 139 | Promise.delay(0):expect() 140 | jestExpect(values).toEqual({}) 141 | end) 142 | 143 | it("queues if the observer is running", function() 144 | local observer 145 | local values = {} 146 | Observable.new(function(x) 147 | observer = x 148 | end):subscribe({ 149 | next = function(self, val) 150 | table.insert(values, val) 151 | if val == 1 then 152 | observer:next(2) 153 | end 154 | end, 155 | }) 156 | observer:next(1) 157 | jestExpect(values).toEqual({ 1 }) 158 | Promise.delay(0):expect() 159 | jestExpect(values).toEqual({ 1, 2 }) 160 | end) 161 | 162 | it('reports error if "next" is not a method', function() 163 | local observer = getObserver({ next = 1 }) 164 | observer:next() 165 | jestExpect(_G.hostError).toBeTruthy() 166 | end) 167 | 168 | it('does not report error if "next" is undefined', function() 169 | local observer = getObserver({ next = nil }) 170 | observer:next() 171 | jestExpect(not Boolean.toJSBoolean(_G.hostError)).toBeTruthy() 172 | end) 173 | 174 | it('does not report error if "next" is null', function() 175 | local observer = getObserver({ next = nil }) 176 | observer:next() 177 | jestExpect(not Boolean.toJSBoolean(_G.hostError)).toBeTruthy() 178 | end) 179 | 180 | it('reports error if "next" throws', function() 181 | local error_ = {} 182 | local observer = getObserver({ 183 | next = function(self) 184 | error(error_) 185 | end, 186 | }) 187 | observer:next() 188 | jestExpect(_G.hostError).toEqual(error_) 189 | end) 190 | 191 | it("does not close the subscription on error", function() 192 | local observer = getObserver({ 193 | next = function(self) 194 | error({}) 195 | end, 196 | }) 197 | observer:next() 198 | jestExpect(observer.closed).toBe(false) 199 | end) 200 | end) 201 | end 202 | -------------------------------------------------------------------------------- /test/of.spec.lua: -------------------------------------------------------------------------------- 1 | -- ROBLOX upstream https://github.com/zenparsing/zen-observable/blob/v0.8.15/test/of.js 2 | 3 | local rootWorkspace = script.Parent.Parent 4 | 5 | local JestGlobals = require(rootWorkspace.Dev.JestGlobals) 6 | local jestExpect = JestGlobals.expect 7 | 8 | local Promise = require(rootWorkspace.Promise) 9 | 10 | return function() 11 | -- ROBLOX deviation: upstream a global variable is created in the test setup. 12 | -- A local variable is created to avoid using _G.Observable in every test 13 | local Observable 14 | beforeEach(function() 15 | Observable = _G.Observable 16 | end) 17 | describe("of", function() 18 | it("is a method on Observable", function() 19 | jestExpect(function() 20 | -- ROBLOX deviation? definitely-typed has of as accepting R[], but this is mixed types. manually annotate. 21 | Observable.of("a-string" :: string | number, 1, 2, "apple", 4, 5, 6, "string") 22 | end).never.toThrowError() 23 | end) 24 | 25 | -- ROBLOX deviation: dropping functionality (not changing this) 26 | -- it("uses the this value if it is a function", function() 27 | -- local usesThis = false 28 | -- Observable.of(function() 29 | -- usesThis = true 30 | -- end) 31 | -- jestExpect(usesThis).toBe(true) 32 | -- end) 33 | 34 | it("uses Observable if the this value is not a function", function() 35 | local result = Observable.of(1, 2, 3, 4) 36 | jestExpect(result.__index).toBe(Observable.__index) 37 | end) 38 | 39 | it("delivers arguments to next in a job", function() 40 | local values = {} 41 | Observable.of(1, 2, 3, 4):subscribe(function(self, v) 42 | table.insert(values, v) 43 | return #values 44 | end) 45 | jestExpect(#values).toEqual(0) 46 | Promise.delay(0):expect() 47 | jestExpect(values).toEqual({ 1, 2, 3, 4 }) 48 | end) 49 | end) 50 | end 51 | -------------------------------------------------------------------------------- /test/properties.lua: -------------------------------------------------------------------------------- 1 | -- ROBLOX upstream https://github.com/zenparsing/zen-observable/blob/v0.8.15/test/properties.js 2 | -- ROBLOX comment: this file contains many deviations to achieve similar checks 3 | local exports = {} 4 | local rootWorkspace = script.Parent.Parent 5 | local LuauPolyfill = require(rootWorkspace.LuauPolyfill) 6 | local Boolean = LuauPolyfill.Boolean 7 | local Array = LuauPolyfill.Array 8 | local Object = LuauPolyfill.Object 9 | type Object = LuauPolyfill.Object 10 | 11 | local JestGlobals = require(rootWorkspace.Dev.JestGlobals) 12 | local jestExpect = JestGlobals.expect 13 | 14 | local function getPrototypeOf(obj) 15 | return getmetatable(obj) 16 | end 17 | 18 | local function testMethodProperty( 19 | object: Object, 20 | key: string, 21 | options: { 22 | enumerable: boolean?, 23 | configurable: boolean?, -- ROBLOX comment: not used 24 | writable: boolean?, -- ROBLOX comment: not used 25 | length: number?, 26 | get: boolean?, 27 | set: boolean?, 28 | } 29 | ) 30 | local desc = object[key] 31 | local proto = getPrototypeOf(object :: any) 32 | local enumerable, _configurable, _writable, length = 33 | options.enumerable :: boolean, options.configurable :: boolean, options.writable :: boolean, options.length 34 | 35 | if options.enumerable == nil then 36 | enumerable = false 37 | end 38 | if options.configurable == nil then 39 | _configurable = false 40 | end 41 | if options.writable == nil then 42 | _writable = false 43 | end 44 | 45 | jestExpect(desc).toBeDefined() 46 | 47 | if Boolean.toJSBoolean(options.get) or Boolean.toJSBoolean(options.set) then 48 | jestExpect(typeof(object.__index)).toBe("function") 49 | jestExpect(typeof(object.__newindex)).toBe("function") 50 | if Boolean.toJSBoolean(options.get) then 51 | jestExpect(function() 52 | (proto.__index :: any)(object, key) 53 | end).never.toThrow() 54 | else 55 | jestExpect(function() 56 | (proto.__index :: any)(object, key) 57 | end).toThrow() 58 | end 59 | if Boolean.toJSBoolean(options.set) then 60 | jestExpect(function() 61 | (proto.__newindex :: any)(object, key) 62 | end).never.toThrow() 63 | else 64 | jestExpect(function() 65 | (proto.__newindex :: any)(object, key) 66 | end).toThrow() 67 | end 68 | else 69 | jestExpect(typeof(desc)).toBe("function") 70 | if length ~= nil then -- ROBLOX deviation: added this check to make sure length is defined 71 | local argumentCount, _variadic = (debug :: any).info(desc, "a") 72 | jestExpect(argumentCount :: number).toBe(length) 73 | end 74 | end 75 | 76 | if enumerable then 77 | jestExpect(Array.some(Object.keys(object), function(k) 78 | return k == key 79 | end)).toBe(true) 80 | end 81 | end 82 | 83 | exports.testMethodProperty = testMethodProperty 84 | 85 | return exports 86 | -------------------------------------------------------------------------------- /test/reduce.spec.lua: -------------------------------------------------------------------------------- 1 | -- ROBLOX upstream https://github.com/zenparsing/zen-observable/blob/v0.8.15/test/reduce.js 2 | 3 | local rootWorkspace = script.Parent.Parent 4 | 5 | local JestGlobals = require(rootWorkspace.Dev.JestGlobals) 6 | local jestExpect = JestGlobals.expect 7 | 8 | return function() 9 | -- ROBLOX deviation: upstream a global variable is created in the test setup. 10 | -- A local variable is created to avoid using _G.Observable in every test 11 | local Observable 12 | beforeEach(function() 13 | Observable = _G.Observable 14 | end) 15 | describe("reduce", function() 16 | it("reduces without a seed", function() 17 | Observable.from({ 1, 2, 3, 4, 5, 6 }) 18 | :reduce(function(a: number, b: number) 19 | return a + b 20 | end) 21 | :forEach(function(x) 22 | jestExpect(x).toBe(21) 23 | end) 24 | :expect() 25 | end) 26 | 27 | it("errors if empty and no seed", function() 28 | local _ok, result, hasReturned = xpcall(function() 29 | Observable.from({}) 30 | .reduce(function(a: number, b: number) 31 | return a + b 32 | end) 33 | :forEach(function() 34 | return nil 35 | end) 36 | :expect() 37 | jestExpect(false).toBeTruthy() 38 | end, function(err) 39 | jestExpect(true).toBeTruthy() 40 | end) 41 | if hasReturned then 42 | return result 43 | end 44 | end) 45 | 46 | it("reduces with a seed", function() 47 | Observable.from({ 1, 2, 3, 4, 5, 6 }) 48 | :reduce(function(a: number, b: number) 49 | return a + b 50 | end, 100) 51 | :forEach(function(x) 52 | jestExpect(x).toBe(121) 53 | end) 54 | :expect() 55 | end) 56 | 57 | it("reduces an empty list with a seed", function() 58 | Observable.from({}) 59 | :reduce(function(a: number, b: number) 60 | return a + b 61 | end, 100) 62 | :forEach(function(x) 63 | jestExpect(x).toBe(100) 64 | end) 65 | :expect() 66 | end) 67 | end) 68 | end 69 | -------------------------------------------------------------------------------- /test/species.spec.lua: -------------------------------------------------------------------------------- 1 | -- ROBLOX upstream https://github.com/zenparsing/zen-observable/blob/v0.8.15/test/species.js 2 | 3 | local rootWorkspace = script.Parent.Parent 4 | local LuauPolyfill = require(rootWorkspace.LuauPolyfill) 5 | local instanceOf = LuauPolyfill.instanceof 6 | -- ROBLOX deviation: type system doesn't track mutations on this global 7 | local Symbol = LuauPolyfill.Symbol :: any 8 | 9 | local JestGlobals = require(rootWorkspace.Dev.JestGlobals) 10 | local jestExpect = JestGlobals.expect 11 | 12 | return function() 13 | -- ROBLOX deviation: upstream a global variable is created in the test setup. 14 | -- A local variable is created to avoid using _G.Observable in every test 15 | local Observable 16 | beforeEach(function() 17 | Observable = _G.Observable 18 | end) 19 | describe("species", function() 20 | local SymbolSpecies = Symbol.species or "@@species" 21 | 22 | it("uses Observable if species is nil", function() 23 | local instance = Observable.new(function() end) 24 | instance[SymbolSpecies] = nil 25 | jestExpect(instanceOf( 26 | instance:map(function(x) 27 | return x 28 | end), 29 | Observable 30 | )).toBeTruthy() 31 | end) 32 | it("uses Observable if species is undefined", function() 33 | local instance = Observable.new(function() end) 34 | instance[SymbolSpecies] = nil 35 | jestExpect(instanceOf( 36 | instance:map(function(x) 37 | return x 38 | end), 39 | Observable 40 | )).toBeTruthy() 41 | end) 42 | 43 | it("uses value of Symbol.species", function() 44 | -- ROBLOX deviation: ctor.new must return {new = ctor.new} to be considered an instanceOf 45 | local ctor 46 | ctor = { 47 | new = function() 48 | return { new = ctor.new } 49 | end, 50 | } 51 | local instance = Observable.new(function() end) 52 | instance[SymbolSpecies] = ctor 53 | jestExpect(instanceOf( 54 | instance:map(function(x) 55 | return x 56 | end), 57 | ctor 58 | )).toBeTruthy() 59 | end) 60 | end) 61 | end 62 | -------------------------------------------------------------------------------- /test/subscribe.spec.lua: -------------------------------------------------------------------------------- 1 | -- ROBLOX upstream https://github.com/zenparsing/zen-observable/blob/v0.8.15/test/subscribe.js 2 | local rootWorkspace = script.Parent.Parent 3 | 4 | local JestGlobals = require(rootWorkspace.Dev.JestGlobals) 5 | local jestExpect = JestGlobals.expect 6 | 7 | local Promise = require(rootWorkspace.Promise) 8 | 9 | local testMethodProperty = require(script.Parent.properties).testMethodProperty 10 | 11 | return function() 12 | -- ROBLOX deviation: upstream a global variable is created in the test setup. 13 | -- A local variable is created to avoid using _G.Observable in every test 14 | local Observable 15 | beforeEach(function() 16 | Observable = _G.Observable 17 | end) 18 | describe("subscribe", function() 19 | it("is a method of Observable.prototype", function() 20 | testMethodProperty( 21 | --ROBLOX deviation, no prototype 22 | Observable, 23 | "subscribe", 24 | { 25 | configurable = true, 26 | writable = true, 27 | length = 4, --[[ in JS, only there's only 1 arg , but the implementation uses arguments to get optional params, in Lua these are explicit ]] 28 | } 29 | ) 30 | end) 31 | 32 | it("accepts an observer argument", function() 33 | local observer 34 | local nextValue 35 | 36 | Observable.new(function(x) 37 | observer = x 38 | end):subscribe({ 39 | next = function(self, v) 40 | nextValue = v 41 | end, 42 | }) 43 | observer:next(1) 44 | jestExpect(nextValue).toBe(1) 45 | end) 46 | 47 | it("accepts a next function argument", function() 48 | local observer 49 | local nextValue 50 | Observable.new(function(x) 51 | observer = x 52 | end):subscribe(function(self, v) 53 | nextValue = v 54 | end) 55 | observer:next(1) 56 | jestExpect(nextValue).toBe(1) 57 | end) 58 | 59 | it("accepts an error function argument", function() 60 | local observer 61 | local errorValue 62 | local error = {} 63 | Observable.new(function(x) 64 | observer = x 65 | end):subscribe(nil, function(self, e) 66 | errorValue = e 67 | end) 68 | observer:error(error) 69 | jestExpect(errorValue).toBe(error) 70 | end) 71 | 72 | it("accepts a complete function argument", function() 73 | local observer 74 | local completed = false 75 | Observable.new(function(x) 76 | observer = x 77 | end):subscribe(nil, nil, function(self) 78 | completed = true 79 | return completed 80 | end) 81 | observer:complete() 82 | jestExpect(completed).toBe(true) 83 | end) 84 | 85 | it("uses function overload if first argument is null", function() 86 | local observer 87 | local completed = false 88 | Observable.new(function(x) 89 | observer = x 90 | end):subscribe(nil, nil, function(self) 91 | completed = true 92 | return completed 93 | end) 94 | observer:complete() 95 | jestExpect(completed).toBe(true) 96 | end) 97 | 98 | it("uses function overload if first argument is a primitive", function() 99 | local observer 100 | local completed = false 101 | Observable.new(function(x) 102 | observer = x 103 | end):subscribe("abc", nil, function(self) 104 | completed = true 105 | return completed 106 | end) 107 | observer:complete() 108 | jestExpect(completed).toBe(true) 109 | end) 110 | 111 | it("enqueues a job to send error if subscriber throws", function() 112 | local anError = {} 113 | local errorValue = nil 114 | Observable.new(function() 115 | error(anError) 116 | end):subscribe({ 117 | error = function(self, e) 118 | errorValue = e 119 | end, 120 | }) 121 | 122 | jestExpect(errorValue).toBe(nil) 123 | Promise.delay(0):expect() 124 | jestExpect(errorValue).toBe(anError) 125 | end) 126 | 127 | it("does not send error if unsubscribed", function() 128 | local anError = {} 129 | local errorValue = nil 130 | local subscription = Observable.new(function() 131 | error(anError) 132 | end):subscribe({ 133 | error = function(self, e) 134 | errorValue = e 135 | end, 136 | }) 137 | 138 | subscription:unsubscribe() 139 | jestExpect(errorValue).toBe(nil) 140 | wait() 141 | jestExpect(errorValue).toBe(nil) 142 | end) 143 | 144 | it("accepts a cleanup function from the subscriber function", function() 145 | local cleanupCalled = false 146 | local subscription = Observable.new(function() 147 | return function() 148 | cleanupCalled = true 149 | end 150 | end):subscribe() 151 | 152 | subscription:unsubscribe() 153 | jestExpect(cleanupCalled).toBe(true) 154 | end) 155 | 156 | it("accepts a subscription table from the subscriber function", function() 157 | local cleanupCalled = false 158 | local subscription = Observable.new(function() 159 | return { 160 | unsubscribe = function(self) 161 | cleanupCalled = true 162 | end, 163 | } 164 | end):subscribe() 165 | subscription:unsubscribe() 166 | jestExpect(cleanupCalled).toBe(true) 167 | end) 168 | end) 169 | end 170 | -------------------------------------------------------------------------------- /test/subscription.spec.lua: -------------------------------------------------------------------------------- 1 | -- ROBLOX upstream https://github.com/zenparsing/zen-observable/blob/v0.8.15/test/subscription.js 2 | 3 | local rootWorkspace = script.Parent.Parent 4 | 5 | type Function = () -> () 6 | local JestGlobals = require(rootWorkspace.Dev.JestGlobals) 7 | local jestExpect = JestGlobals.expect 8 | 9 | local testMethodProperty = require(script.Parent.properties).testMethodProperty 10 | 11 | return function() 12 | -- ROBLOX deviation: upstream a global variable is created in the test setup. 13 | -- A local variable is created to avoid using _G.Observable in every test 14 | local Observable 15 | beforeEach(function() 16 | Observable = _G.Observable 17 | end) 18 | describe("subscription", function() 19 | local function getSubscription(subscriber: Function?) 20 | if subscriber == nil then 21 | subscriber = function() end 22 | end 23 | return Observable.new(subscriber):subscribe() 24 | end 25 | describe("unsubscribe", function() 26 | it("is a method on Subscription.prototype", function() 27 | local subscription = getSubscription() 28 | testMethodProperty( 29 | --ROBLOX deviation, getPrototypeof called in function 30 | subscription, 31 | "unsubscribe", 32 | { 33 | configurable = true, 34 | writable = true, 35 | length = 1, --[[ ROBLOX deviation: adds self arg ]] 36 | } 37 | ) 38 | end) 39 | 40 | it("reports an error if the cleanup function throws", function() 41 | local error_ = {} 42 | local subscription = getSubscription(function() 43 | return function() 44 | error(error_) 45 | end 46 | end) 47 | subscription:unsubscribe() 48 | jestExpect(_G.hostError).toEqual(error_) 49 | end) 50 | end) 51 | 52 | describe("closed", function() 53 | it("is a getter on Subscription.prototype", function() 54 | local subscription = getSubscription() 55 | testMethodProperty( 56 | --ROBLOX deviation, no Object.getPrototypeOf 57 | subscription, 58 | "closed", 59 | { configurable = true, writable = true, get = true } 60 | ) 61 | end) 62 | end) 63 | end) 64 | end 65 | -------------------------------------------------------------------------------- /testez.toml: -------------------------------------------------------------------------------- 1 | [[afterAll.args]] 2 | type = "function" 3 | 4 | [[afterEach.args]] 5 | type = "function" 6 | 7 | [[beforeAll.args]] 8 | type = "function" 9 | 10 | [[beforeEach.args]] 11 | type = "function" 12 | 13 | [[describe.args]] 14 | type = "string" 15 | 16 | [[describe.args]] 17 | type = "function" 18 | 19 | [[describeFOCUS.args]] 20 | type = "string" 21 | 22 | [[describeFOCUS.args]] 23 | type = "function" 24 | 25 | [[describeSKIP.args]] 26 | type = "string" 27 | 28 | [[describeSKIP.args]] 29 | type = "function" 30 | 31 | [[expect.args]] 32 | type = "any" 33 | 34 | [[FIXME.args]] 35 | type = "string" 36 | required = false 37 | 38 | [FOCUS] 39 | args = [] 40 | 41 | [[it.args]] 42 | type = "string" 43 | 44 | [[it.args]] 45 | type = "function" 46 | 47 | [[itFIXME.args]] 48 | type = "string" 49 | 50 | [[itFIXME.args]] 51 | type = "function" 52 | 53 | [[itFOCUS.args]] 54 | type = "string" 55 | 56 | [[itFOCUS.args]] 57 | type = "function" 58 | 59 | [[fit.args]] 60 | type = "string" 61 | 62 | [[fit.args]] 63 | type = "function" 64 | 65 | [[itSKIP.args]] 66 | type = "string" 67 | 68 | [[itSKIP.args]] 69 | type = "function" 70 | 71 | [[xit.args]] 72 | type = "string" 73 | 74 | [[xit.args]] 75 | type = "function" 76 | 77 | [SKIP] 78 | args = [] 79 | 80 | [[xdescribe.args]] 81 | type = "string" 82 | required = true 83 | 84 | [[xdescribe.args]] 85 | type = "function" 86 | required = true 87 | -------------------------------------------------------------------------------- /tests.project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ZenObservableTestModel", 3 | "tree": { 4 | "$className": "Folder", 5 | "Packages": { 6 | "$path": "Packages", 7 | "ZenObservable": { 8 | "$path": "src" 9 | }, 10 | "Tests": { 11 | "$path": "test" 12 | } 13 | } 14 | } 15 | } 16 | --------------------------------------------------------------------------------