From 83c9facecf4a4e5d9b46e43db13ad1d4cee833a5 Mon Sep 17 00:00:00 2001 From: Daniel P H Fox Date: Sat, 1 Feb 2025 21:03:30 +0000 Subject: [PATCH 01/14] Import tiniest --- .../Animation/springCoefficients.spec.luau | 0 .../Spec/Graph/Observer.spec.luau | 0 .../Spec/Graph/change.spec.luau | 0 .../Spec/Graph/evaluate.spec.luau | 0 .../Spec/Instances/Attribute.spec.luau | 0 .../Spec/Instances/AttributeChange.spec.luau | 0 .../Spec/Instances/AttributeOut.spec.luau | 0 .../Spec/Instances/Children.spec.luau | 0 .../Spec/Instances/Hydrate.spec.luau | 0 .../Spec/Instances/New.spec.luau | 0 .../Spec/Instances/OnChange.spec.luau | 0 .../Spec/Instances/OnEvent.spec.luau | 0 .../Spec/Instances/Out.spec.luau | 0 .../Instances/applyInstanceProps.spec.luau | 0 .../Spec/Memory/deriveScope.spec.luau | 0 .../Spec/Memory/doCleanup.spec.luau | 0 .../Spec/Memory/innerScope.spec.luau | 0 .../Spec/Memory/insert.spec.luau | 0 .../Spec/Memory/scoped.spec.luau | 0 .../Spec/State/Computed.spec.luau | 0 .../Spec/State/ForKeys.spec.luau | 0 .../Spec/State/ForPairs.spec.luau | 0 .../Spec/State/ForValues.spec.luau | 0 {test => test-old}/Spec/State/Value.spec.luau | 0 .../Spec/Utility/Contextual.spec.luau | 0 .../Spec/Utility/Safe.spec.luau | 0 .../Spec/Utility/isSimilar.spec.luau | 0 .../Spec/_Integration/DynamicGraphs.spec.lua | 0 {test => test-old}/SpecExternal.luau | 0 {test => test-old}/TestEZ/Context.luau | 0 {test => test-old}/TestEZ/Expectation.luau | 0 .../TestEZ/ExpectationContext.luau | 0 {test => test-old}/TestEZ/LifecycleHooks.luau | 0 .../TestEZ/Reporters/TeamCityReporter.luau | 0 .../TestEZ/Reporters/TextReporter.luau | 0 .../TestEZ/Reporters/TextReporterQuiet.luau | 0 {test => test-old}/TestEZ/TestBootstrap.luau | 0 {test => test-old}/TestEZ/TestEnum.luau | 0 {test => test-old}/TestEZ/TestPlan.luau | 0 {test => test-old}/TestEZ/TestPlanner.luau | 0 {test => test-old}/TestEZ/TestResults.luau | 0 {test => test-old}/TestEZ/TestRunner.luau | 0 {test => test-old}/TestEZ/TestSession.luau | 0 {test => test-old}/TestEZ/init.luau | 0 {test => test-old}/TestVars.luau | 0 {test => test-old}/Util/FiniteTime.luau | 0 {test => test-old}/Util/Graphs.luau | 0 {test => test-old}/init.server.luau | 0 test/lib/tiniest.luau | 239 ++++++++++++++++ test/lib/tiniest_expect.luau | 176 ++++++++++++ test/lib/tiniest_for_lune.luau | 117 ++++++++ test/lib/tiniest_plugin.luau | 41 +++ test/lib/tiniest_pretty.luau | 255 ++++++++++++++++++ test/lib/tiniest_quote.luau | 48 ++++ test/lib/tiniest_snapshot.luau | 186 +++++++++++++ test/lib/tiniest_time.luau | 105 ++++++++ 56 files changed, 1167 insertions(+) rename {test => test-old}/Spec/Animation/springCoefficients.spec.luau (100%) rename {test => test-old}/Spec/Graph/Observer.spec.luau (100%) rename {test => test-old}/Spec/Graph/change.spec.luau (100%) rename {test => test-old}/Spec/Graph/evaluate.spec.luau (100%) rename {test => test-old}/Spec/Instances/Attribute.spec.luau (100%) rename {test => test-old}/Spec/Instances/AttributeChange.spec.luau (100%) rename {test => test-old}/Spec/Instances/AttributeOut.spec.luau (100%) rename {test => test-old}/Spec/Instances/Children.spec.luau (100%) rename {test => test-old}/Spec/Instances/Hydrate.spec.luau (100%) rename {test => test-old}/Spec/Instances/New.spec.luau (100%) rename {test => test-old}/Spec/Instances/OnChange.spec.luau (100%) rename {test => test-old}/Spec/Instances/OnEvent.spec.luau (100%) rename {test => test-old}/Spec/Instances/Out.spec.luau (100%) rename {test => test-old}/Spec/Instances/applyInstanceProps.spec.luau (100%) rename {test => test-old}/Spec/Memory/deriveScope.spec.luau (100%) rename {test => test-old}/Spec/Memory/doCleanup.spec.luau (100%) rename {test => test-old}/Spec/Memory/innerScope.spec.luau (100%) rename {test => test-old}/Spec/Memory/insert.spec.luau (100%) rename {test => test-old}/Spec/Memory/scoped.spec.luau (100%) rename {test => test-old}/Spec/State/Computed.spec.luau (100%) rename {test => test-old}/Spec/State/ForKeys.spec.luau (100%) rename {test => test-old}/Spec/State/ForPairs.spec.luau (100%) rename {test => test-old}/Spec/State/ForValues.spec.luau (100%) rename {test => test-old}/Spec/State/Value.spec.luau (100%) rename {test => test-old}/Spec/Utility/Contextual.spec.luau (100%) rename {test => test-old}/Spec/Utility/Safe.spec.luau (100%) rename {test => test-old}/Spec/Utility/isSimilar.spec.luau (100%) rename {test => test-old}/Spec/_Integration/DynamicGraphs.spec.lua (100%) rename {test => test-old}/SpecExternal.luau (100%) rename {test => test-old}/TestEZ/Context.luau (100%) rename {test => test-old}/TestEZ/Expectation.luau (100%) rename {test => test-old}/TestEZ/ExpectationContext.luau (100%) rename {test => test-old}/TestEZ/LifecycleHooks.luau (100%) rename {test => test-old}/TestEZ/Reporters/TeamCityReporter.luau (100%) rename {test => test-old}/TestEZ/Reporters/TextReporter.luau (100%) rename {test => test-old}/TestEZ/Reporters/TextReporterQuiet.luau (100%) rename {test => test-old}/TestEZ/TestBootstrap.luau (100%) rename {test => test-old}/TestEZ/TestEnum.luau (100%) rename {test => test-old}/TestEZ/TestPlan.luau (100%) rename {test => test-old}/TestEZ/TestPlanner.luau (100%) rename {test => test-old}/TestEZ/TestResults.luau (100%) rename {test => test-old}/TestEZ/TestRunner.luau (100%) rename {test => test-old}/TestEZ/TestSession.luau (100%) rename {test => test-old}/TestEZ/init.luau (100%) rename {test => test-old}/TestVars.luau (100%) rename {test => test-old}/Util/FiniteTime.luau (100%) rename {test => test-old}/Util/Graphs.luau (100%) rename {test => test-old}/init.server.luau (100%) create mode 100644 test/lib/tiniest.luau create mode 100644 test/lib/tiniest_expect.luau create mode 100644 test/lib/tiniest_for_lune.luau create mode 100644 test/lib/tiniest_plugin.luau create mode 100644 test/lib/tiniest_pretty.luau create mode 100644 test/lib/tiniest_quote.luau create mode 100644 test/lib/tiniest_snapshot.luau create mode 100644 test/lib/tiniest_time.luau diff --git a/test/Spec/Animation/springCoefficients.spec.luau b/test-old/Spec/Animation/springCoefficients.spec.luau similarity index 100% rename from test/Spec/Animation/springCoefficients.spec.luau rename to test-old/Spec/Animation/springCoefficients.spec.luau diff --git a/test/Spec/Graph/Observer.spec.luau b/test-old/Spec/Graph/Observer.spec.luau similarity index 100% rename from test/Spec/Graph/Observer.spec.luau rename to test-old/Spec/Graph/Observer.spec.luau diff --git a/test/Spec/Graph/change.spec.luau b/test-old/Spec/Graph/change.spec.luau similarity index 100% rename from test/Spec/Graph/change.spec.luau rename to test-old/Spec/Graph/change.spec.luau diff --git a/test/Spec/Graph/evaluate.spec.luau b/test-old/Spec/Graph/evaluate.spec.luau similarity index 100% rename from test/Spec/Graph/evaluate.spec.luau rename to test-old/Spec/Graph/evaluate.spec.luau diff --git a/test/Spec/Instances/Attribute.spec.luau b/test-old/Spec/Instances/Attribute.spec.luau similarity index 100% rename from test/Spec/Instances/Attribute.spec.luau rename to test-old/Spec/Instances/Attribute.spec.luau diff --git a/test/Spec/Instances/AttributeChange.spec.luau b/test-old/Spec/Instances/AttributeChange.spec.luau similarity index 100% rename from test/Spec/Instances/AttributeChange.spec.luau rename to test-old/Spec/Instances/AttributeChange.spec.luau diff --git a/test/Spec/Instances/AttributeOut.spec.luau b/test-old/Spec/Instances/AttributeOut.spec.luau similarity index 100% rename from test/Spec/Instances/AttributeOut.spec.luau rename to test-old/Spec/Instances/AttributeOut.spec.luau diff --git a/test/Spec/Instances/Children.spec.luau b/test-old/Spec/Instances/Children.spec.luau similarity index 100% rename from test/Spec/Instances/Children.spec.luau rename to test-old/Spec/Instances/Children.spec.luau diff --git a/test/Spec/Instances/Hydrate.spec.luau b/test-old/Spec/Instances/Hydrate.spec.luau similarity index 100% rename from test/Spec/Instances/Hydrate.spec.luau rename to test-old/Spec/Instances/Hydrate.spec.luau diff --git a/test/Spec/Instances/New.spec.luau b/test-old/Spec/Instances/New.spec.luau similarity index 100% rename from test/Spec/Instances/New.spec.luau rename to test-old/Spec/Instances/New.spec.luau diff --git a/test/Spec/Instances/OnChange.spec.luau b/test-old/Spec/Instances/OnChange.spec.luau similarity index 100% rename from test/Spec/Instances/OnChange.spec.luau rename to test-old/Spec/Instances/OnChange.spec.luau diff --git a/test/Spec/Instances/OnEvent.spec.luau b/test-old/Spec/Instances/OnEvent.spec.luau similarity index 100% rename from test/Spec/Instances/OnEvent.spec.luau rename to test-old/Spec/Instances/OnEvent.spec.luau diff --git a/test/Spec/Instances/Out.spec.luau b/test-old/Spec/Instances/Out.spec.luau similarity index 100% rename from test/Spec/Instances/Out.spec.luau rename to test-old/Spec/Instances/Out.spec.luau diff --git a/test/Spec/Instances/applyInstanceProps.spec.luau b/test-old/Spec/Instances/applyInstanceProps.spec.luau similarity index 100% rename from test/Spec/Instances/applyInstanceProps.spec.luau rename to test-old/Spec/Instances/applyInstanceProps.spec.luau diff --git a/test/Spec/Memory/deriveScope.spec.luau b/test-old/Spec/Memory/deriveScope.spec.luau similarity index 100% rename from test/Spec/Memory/deriveScope.spec.luau rename to test-old/Spec/Memory/deriveScope.spec.luau diff --git a/test/Spec/Memory/doCleanup.spec.luau b/test-old/Spec/Memory/doCleanup.spec.luau similarity index 100% rename from test/Spec/Memory/doCleanup.spec.luau rename to test-old/Spec/Memory/doCleanup.spec.luau diff --git a/test/Spec/Memory/innerScope.spec.luau b/test-old/Spec/Memory/innerScope.spec.luau similarity index 100% rename from test/Spec/Memory/innerScope.spec.luau rename to test-old/Spec/Memory/innerScope.spec.luau diff --git a/test/Spec/Memory/insert.spec.luau b/test-old/Spec/Memory/insert.spec.luau similarity index 100% rename from test/Spec/Memory/insert.spec.luau rename to test-old/Spec/Memory/insert.spec.luau diff --git a/test/Spec/Memory/scoped.spec.luau b/test-old/Spec/Memory/scoped.spec.luau similarity index 100% rename from test/Spec/Memory/scoped.spec.luau rename to test-old/Spec/Memory/scoped.spec.luau diff --git a/test/Spec/State/Computed.spec.luau b/test-old/Spec/State/Computed.spec.luau similarity index 100% rename from test/Spec/State/Computed.spec.luau rename to test-old/Spec/State/Computed.spec.luau diff --git a/test/Spec/State/ForKeys.spec.luau b/test-old/Spec/State/ForKeys.spec.luau similarity index 100% rename from test/Spec/State/ForKeys.spec.luau rename to test-old/Spec/State/ForKeys.spec.luau diff --git a/test/Spec/State/ForPairs.spec.luau b/test-old/Spec/State/ForPairs.spec.luau similarity index 100% rename from test/Spec/State/ForPairs.spec.luau rename to test-old/Spec/State/ForPairs.spec.luau diff --git a/test/Spec/State/ForValues.spec.luau b/test-old/Spec/State/ForValues.spec.luau similarity index 100% rename from test/Spec/State/ForValues.spec.luau rename to test-old/Spec/State/ForValues.spec.luau diff --git a/test/Spec/State/Value.spec.luau b/test-old/Spec/State/Value.spec.luau similarity index 100% rename from test/Spec/State/Value.spec.luau rename to test-old/Spec/State/Value.spec.luau diff --git a/test/Spec/Utility/Contextual.spec.luau b/test-old/Spec/Utility/Contextual.spec.luau similarity index 100% rename from test/Spec/Utility/Contextual.spec.luau rename to test-old/Spec/Utility/Contextual.spec.luau diff --git a/test/Spec/Utility/Safe.spec.luau b/test-old/Spec/Utility/Safe.spec.luau similarity index 100% rename from test/Spec/Utility/Safe.spec.luau rename to test-old/Spec/Utility/Safe.spec.luau diff --git a/test/Spec/Utility/isSimilar.spec.luau b/test-old/Spec/Utility/isSimilar.spec.luau similarity index 100% rename from test/Spec/Utility/isSimilar.spec.luau rename to test-old/Spec/Utility/isSimilar.spec.luau diff --git a/test/Spec/_Integration/DynamicGraphs.spec.lua b/test-old/Spec/_Integration/DynamicGraphs.spec.lua similarity index 100% rename from test/Spec/_Integration/DynamicGraphs.spec.lua rename to test-old/Spec/_Integration/DynamicGraphs.spec.lua diff --git a/test/SpecExternal.luau b/test-old/SpecExternal.luau similarity index 100% rename from test/SpecExternal.luau rename to test-old/SpecExternal.luau diff --git a/test/TestEZ/Context.luau b/test-old/TestEZ/Context.luau similarity index 100% rename from test/TestEZ/Context.luau rename to test-old/TestEZ/Context.luau diff --git a/test/TestEZ/Expectation.luau b/test-old/TestEZ/Expectation.luau similarity index 100% rename from test/TestEZ/Expectation.luau rename to test-old/TestEZ/Expectation.luau diff --git a/test/TestEZ/ExpectationContext.luau b/test-old/TestEZ/ExpectationContext.luau similarity index 100% rename from test/TestEZ/ExpectationContext.luau rename to test-old/TestEZ/ExpectationContext.luau diff --git a/test/TestEZ/LifecycleHooks.luau b/test-old/TestEZ/LifecycleHooks.luau similarity index 100% rename from test/TestEZ/LifecycleHooks.luau rename to test-old/TestEZ/LifecycleHooks.luau diff --git a/test/TestEZ/Reporters/TeamCityReporter.luau b/test-old/TestEZ/Reporters/TeamCityReporter.luau similarity index 100% rename from test/TestEZ/Reporters/TeamCityReporter.luau rename to test-old/TestEZ/Reporters/TeamCityReporter.luau diff --git a/test/TestEZ/Reporters/TextReporter.luau b/test-old/TestEZ/Reporters/TextReporter.luau similarity index 100% rename from test/TestEZ/Reporters/TextReporter.luau rename to test-old/TestEZ/Reporters/TextReporter.luau diff --git a/test/TestEZ/Reporters/TextReporterQuiet.luau b/test-old/TestEZ/Reporters/TextReporterQuiet.luau similarity index 100% rename from test/TestEZ/Reporters/TextReporterQuiet.luau rename to test-old/TestEZ/Reporters/TextReporterQuiet.luau diff --git a/test/TestEZ/TestBootstrap.luau b/test-old/TestEZ/TestBootstrap.luau similarity index 100% rename from test/TestEZ/TestBootstrap.luau rename to test-old/TestEZ/TestBootstrap.luau diff --git a/test/TestEZ/TestEnum.luau b/test-old/TestEZ/TestEnum.luau similarity index 100% rename from test/TestEZ/TestEnum.luau rename to test-old/TestEZ/TestEnum.luau diff --git a/test/TestEZ/TestPlan.luau b/test-old/TestEZ/TestPlan.luau similarity index 100% rename from test/TestEZ/TestPlan.luau rename to test-old/TestEZ/TestPlan.luau diff --git a/test/TestEZ/TestPlanner.luau b/test-old/TestEZ/TestPlanner.luau similarity index 100% rename from test/TestEZ/TestPlanner.luau rename to test-old/TestEZ/TestPlanner.luau diff --git a/test/TestEZ/TestResults.luau b/test-old/TestEZ/TestResults.luau similarity index 100% rename from test/TestEZ/TestResults.luau rename to test-old/TestEZ/TestResults.luau diff --git a/test/TestEZ/TestRunner.luau b/test-old/TestEZ/TestRunner.luau similarity index 100% rename from test/TestEZ/TestRunner.luau rename to test-old/TestEZ/TestRunner.luau diff --git a/test/TestEZ/TestSession.luau b/test-old/TestEZ/TestSession.luau similarity index 100% rename from test/TestEZ/TestSession.luau rename to test-old/TestEZ/TestSession.luau diff --git a/test/TestEZ/init.luau b/test-old/TestEZ/init.luau similarity index 100% rename from test/TestEZ/init.luau rename to test-old/TestEZ/init.luau diff --git a/test/TestVars.luau b/test-old/TestVars.luau similarity index 100% rename from test/TestVars.luau rename to test-old/TestVars.luau diff --git a/test/Util/FiniteTime.luau b/test-old/Util/FiniteTime.luau similarity index 100% rename from test/Util/FiniteTime.luau rename to test-old/Util/FiniteTime.luau diff --git a/test/Util/Graphs.luau b/test-old/Util/Graphs.luau similarity index 100% rename from test/Util/Graphs.luau rename to test-old/Util/Graphs.luau diff --git a/test/init.server.luau b/test-old/init.server.luau similarity index 100% rename from test/init.server.luau rename to test-old/init.server.luau diff --git a/test/lib/tiniest.luau b/test/lib/tiniest.luau new file mode 100644 index 000000000..462cff0bf --- /dev/null +++ b/test/lib/tiniest.luau @@ -0,0 +1,239 @@ +-- From dphfox/tiniest, licenced under MIT +--!strict + +local tiniest_plugin = require("tiniest_plugin") + +type Context = DescribeContext | RunContext + +type DescribeContext = { + type: "describe", + labels: {string}, + add_test: (Test) -> () +} + +type RunContext = { + type: "run" +} + +export type ErrorReport = { + type: "tiniest.ErrorReport", + message: string, + trace: string, + code: { + snippet: string, + line: string + }? +} + +export type Test = { + labels: {string}, + run: () -> () +} + +export type RunResult = { + tests: {Test}, + status_tally: { + pass: number, + fail: number + }, + individual: {[Test]: TestRunResult} +} + +export type TestRunResult = PassTestRunResult | FailTestRunResult + +export type PassTestRunResult = { + status: "pass" +} + +export type FailTestRunResult = { + status: "fail", + error: ErrorReport +} + +export type Options = { + plugins: nil | {tiniest_plugin.Plugin} +} + +export type RunOptions = {} + +local function catch_errors(func: any, ...) + local outer_trace + local function handler(message) + local report: ErrorReport + if typeof(message) == "table" and message.type == "tiniest.ErrorReport" then + report = message :: ErrorReport + else + report = { + type = "tiniest.ErrorReport", + message = tostring(message), + trace = debug.traceback(nil, 2) + } + end + local from, to = string.find(report.trace, outer_trace, 1, true) + if from ~= nil and to ~= nil then + report.trace = report.trace:sub(1, from - 1) .. report.trace:sub(to + 1) + end + return report + end + outer_trace = debug.traceback(); return xpcall(func, handler, ...) +end + +local tiniest = {} + +function tiniest.configure( + options: Options +) + + local plugins = tiniest_plugin.configure(options.plugins) + + local get_context, with_root_context, with_inner_context + do + local current_context: Context? = nil + + function get_context(): Context + assert(current_context ~= nil, "This function can only be called from inside of a test suite") + return current_context + end + + function with_root_context( + root_context: Context, + inner: (Context) -> () + ): () + assert(current_context == nil, "This function can't be called from inside of a test suite") + current_context = root_context + local ok, err = catch_errors(inner :: any, root_context) + current_context = nil + if not ok then + error(err) + end + end + + function with_inner_context( + make_context: (Context) -> Context, + inner: (Context) -> () + ): () + local outer_context = get_context() + local inner_context = make_context(outer_context) + current_context = inner_context + local ok, err = catch_errors(inner :: any, inner_context) + current_context = outer_context + if not ok then + error(err) + end + end + end + + local self = {} + + function self.describe( + label: string, + inner: () -> () + ): () + with_inner_context(function(outer_context: Context): Context + assert(outer_context.type == "describe", "This function can only be called outside of tests") + local context = table.clone(outer_context) + context.labels = table.clone(context.labels) + table.insert(context.labels, label) + return context + end, inner) + end + + function self.test( + label: string, + run: () -> () + ): () + local context = get_context() + assert(context.type == "describe", "This function can only be called outside of tests") + local labels = table.clone(context.labels) + table.insert(labels, label) + local test: Test = { + labels = labels, + run = run + } + context.add_test(test) + end + + function self.collect_tests( + inner: () -> () + ): {Test} + local tests = {} + local context: Context = { + type = "describe", + labels = {}, + add_test = function(test) + table.insert(tests, test) + end + } + with_root_context(context, inner) + return tests + end + + function self.run_tests( + tests: {Test}, + run_options: RunOptions + ): RunResult + plugins.notify("before_run", tests, run_options) + + local context: Context = { + type = "run" + } + + local individual = {} + for _, test in tests do + plugins.notify("before_test", test, run_options) + local _, test_run_result = xpcall( + function() + with_root_context(context, test.run) + local pass: TestRunResult = { + status = "pass" + } + return pass + end, + function(message) + if typeof(message) == "table" and message.type == "tiniest.ErrorReport" then + local fail: TestRunResult = { + status = "fail", + error = message :: ErrorReport + } + return fail + else + local error: ErrorReport = { + type = "tiniest.ErrorReport", + message = tostring(message), + trace = debug.traceback() + } + local fail: TestRunResult = { + status = "fail", + error = error + } + return fail + end + end + ) + plugins.notify("after_test", test, test_run_result, run_options) + individual[test] = test_run_result + end + + local status_tally = { + pass = 0, + fail = 0 + } + + for _, result in individual do + status_tally[result.status] += 1 + end + + local result: RunResult = { + tests = tests, + status_tally = status_tally, + individual = individual + } + plugins.notify("after_run", result, run_options) + + return result + end + + return self +end + +return tiniest \ No newline at end of file diff --git a/test/lib/tiniest_expect.luau b/test/lib/tiniest_expect.luau new file mode 100644 index 000000000..be79d2baa --- /dev/null +++ b/test/lib/tiniest_expect.luau @@ -0,0 +1,176 @@ +-- From dphfox/tiniest, licenced under MIT +--!strict + +local tiniest_quote = require("tiniest_quote") + +local tiniest_expect = {} + +type Context = { + target: unknown, + test: string, + params: {unknown} +} + +local context: Context = { + target = nil, + test = "", + params = {} +} + +local function fail( + message: string? +): never + local quoted = {} + for index, param in context.params do + quoted[index] = tiniest_quote(param) + end + local param_list = table.concat(quoted, ",") + error({ + type = "tiniest.ErrorReport", + message = "Expectation not met" .. if message == nil then "" else `, because:\n{message}`, + trace = debug.traceback(nil, 4), + code = { + snippet = `expect({tiniest_quote(context.target)}).{context.test}({param_list})`, + line = debug.info(4, "l") + } + }, 0) +end + +local function check( + condition: boolean, + cause: string +): () + if not condition then + error(`Bad usage of expect().{context.test}()\nCause: {cause}`, 0) + end +end + +local function is_indexable( + x: unknown +): boolean + return type(x) == "table" or type(x) == "userdata" +end + +function tiniest_expect.expect( + a: any +) + local tests = {} + + function tests.exists() + return if a ~= nil then tests else fail() + end + + function tests.never_exists() + return if a == nil then tests else fail() + end + + function tests.is( + b: any + ) + return if a == b then tests else fail() + end + + function tests.never_is( + b: any + ) + return if a ~= b then tests else fail() + end + + function tests.is_true() + check(typeof(a) == "boolean", "expect() value must be boolean") + return if a then tests else fail() + end + + function tests.never_is_true() + check(typeof(a) == "boolean", "expect() value must be boolean") + return if a then tests else fail() + end + + function tests.is_a( + b: string + ) + return if typeof(a) == b then tests else fail() + end + + function tests.never_is_a( + b: string + ) + return if typeof(a) ~= b then tests else fail() + end + + function tests.has_key( + b: any + ) + check(is_indexable(a), "expect() value must be indexable") + check(b ~= nil, "key cannot be nil") + return if a[b] then tests else fail() + end + + function tests.never_has_key( + b: any + ) + check(is_indexable(a), "expect() value must be indexable") + check(b ~= nil, "key cannot be nil") + return if not a[b] then tests else fail(`[{tiniest_quote(b)}] = {tiniest_quote(a[b])}`) + end + + function tests.has_value( + b: any + ) + check(typeof(a) == "table", "expect() value must be table") + check(b ~= nil, "value cannot be nil") + return if table.find(a, b) then tests else fail() + end + + function tests.never_has_value( + b: any + ) + check(typeof(a) == "table", "expect() value must be table") + check(b ~= nil, "value cannot be nil") + local index = table.find(a, b) + return if index ~= nil then tests else fail(`Found at index {index}`) + end + + function tests.fails() + check(typeof(a) == "function", "expect() value must be function") + local ok = pcall(a) + return if not ok then tests else fail() + end + + function tests.never_fails() + check(typeof(a) == "function", "expect() value must be function") + local ok, err = pcall(a) + return if ok then tests else fail(`Failed with {tiniest_quote(err)}`) + end + + function tests.fails_with( + b: string + ) + check(typeof(a) == "function", "expect() value must be function") + local ok, err = pcall(a) + return if not ok and err:lower():match(b:lower()) then tests else + if ok then fail("Did not fail") + else fail(`Failed with {tiniest_quote(err)}`) + end + + function tests.never_fails_with( + b: string + ) + check(typeof(a) == "function", "expect() value must be function") + local ok, err = pcall(a) + return if ok or not err:lower():match(b:lower()) then tests else fail() + end + + for name: any, body: any in tests do + tests[name] = function(...) + context.target = a + context.test = name + context.params = {...} + return body(...) + end + end + + return tests +end + +return tiniest_expect \ No newline at end of file diff --git a/test/lib/tiniest_for_lune.luau b/test/lib/tiniest_for_lune.luau new file mode 100644 index 000000000..ffe052faa --- /dev/null +++ b/test/lib/tiniest_for_lune.luau @@ -0,0 +1,117 @@ +-- From dphfox/tiniest, licenced under MIT +--!strict + +local tiniest_expect = require("tiniest_expect") +local tiniest_time = require("tiniest_time") +local tiniest_snapshot = require("tiniest_snapshot") +local tiniest_pretty = require("tiniest_pretty") +local tiniest = require("tiniest") + +local require: any = require +local fs = require("@lune/fs") +local luau = require("@lune/luau") + +export type Options = { + snapshot_path: string?, + save_snapshots: boolean?, + pretty: nil | { + disable_colour: boolean?, + disable_emoji: boolean?, + disable_unicode: boolean?, + disable_output: nil | { + after_run: boolean? + } + } +} + +local tiniest_for_lune = {} + +function tiniest_for_lune.configure( + options: Options +) + local self = {} + + local function get_path_to_snapshot( + key: string + ): string + assert(options.snapshot_path ~= nil) + return `{options.snapshot_path}/{key}.snap.luau` + end + + local function load_snapshots( + key: string + ): {string}? + local path = get_path_to_snapshot(key) + if not fs.isFile(path) then + return nil + else + local ok, result = pcall(function() + local source = fs.readFile(path) + local bytecode = luau.compile(source) + local loaded = luau.load(bytecode, { + injectGlobals = false + }) + return loaded() + end) + if ok then + return result + else + error("[tiniest_for_lune] Failed to load snapshots from disk: " .. tostring(result), 0) + end + end + end + + local function save_snapshots( + key: string, + snapshots: {string} + ): () + local ok, result = pcall(function() + snapshots = table.clone(snapshots) + for index, snapshot in snapshots do + snapshots[index] = `[====[{snapshot}]====]` + end + fs.writeFile( + get_path_to_snapshot(key), + "-- Auto-generated by dphfox/tiniest. Do not modify!\n" .. + "--!nocheck\n" .. + "return {" .. table.concat(snapshots, ", ") .. "}" + ) + end) + if not ok then + error("[tiniest_for_lune] Failed to save snapshots to disk: " .. tostring(result), 0) + end + end + + self.expect = tiniest_expect.expect + + local tiniest_time = tiniest_time.configure({ + get_timestamp = os.clock + }) + + local tiniest_snapshot = tiniest_snapshot.configure({ + load_snapshots = if options.snapshot_path then load_snapshots else nil, + save_snapshots = if options.save_snapshots then save_snapshots else nil + }) + self.snapshot = tiniest_snapshot.snapshot + + local tiniest_pretty = tiniest_pretty.configure({ + disable_colour = options.pretty and options.pretty.disable_colour, + disable_emoji = options.pretty and options.pretty.disable_emoji, + disable_unicode = options.pretty and options.pretty.disable_unicode, + disable_output = options.pretty and options.pretty.disable_output, + plugins = { tiniest_time :: any, tiniest_snapshot } + }) + self.format_run = tiniest_pretty.format_run + + local tiniest = tiniest.configure({ + plugins = { tiniest_time :: any, tiniest_snapshot, tiniest_pretty } + }) + self.describe = tiniest.describe + self.test = tiniest.test + self.collect_tests = tiniest.collect_tests + self.run_tests = tiniest.run_tests + + return self +end + +return tiniest_for_lune \ No newline at end of file diff --git a/test/lib/tiniest_plugin.luau b/test/lib/tiniest_plugin.luau new file mode 100644 index 000000000..82aa7dbb8 --- /dev/null +++ b/test/lib/tiniest_plugin.luau @@ -0,0 +1,41 @@ +-- From dphfox/tiniest, licenced under MIT +--!strict + +export type Plugin = { + is_tiniest_plugin: true +} + +local tiniest_plugin = {} + +function tiniest_plugin.configure( + plugins: {any}? +) + if plugins ~= nil then + for index, plugin in plugins do + if not plugin.is_tiniest_plugin then + error(`sanity check: plugin #{index} is not a valid plugin`) + end + end + end + + local self = {} + + function self.notify( + method_name: string, + ... + ): () + if plugins == nil then + return + end + for _, plugin in plugins do + local method = plugin[method_name] + if typeof(method) == "function" then + method(...) + end + end + end + + return self +end + +return tiniest_plugin \ No newline at end of file diff --git a/test/lib/tiniest_pretty.luau b/test/lib/tiniest_pretty.luau new file mode 100644 index 000000000..995201be4 --- /dev/null +++ b/test/lib/tiniest_pretty.luau @@ -0,0 +1,255 @@ +-- From dphfox/tiniest, licenced under MIT +--!strict + +local tiniest_plugin = require("tiniest_plugin") +local tiniest = require("tiniest") +type Test = tiniest.Test +type TestRunResult = tiniest.TestRunResult +type RunResult = tiniest.RunResult + +export type Options = { + plugins: nil | {tiniest_plugin.Plugin}, + disable_emoji: boolean?, + disable_unicode: boolean?, + disable_colour: boolean?, + disable_output: nil | { + after_run: boolean? + } +} + +local tiniest_pretty = {} + +function tiniest_pretty.configure( + options: Options +) + if options.disable_unicode then + options.disable_emoji = true + end + + local plugins = tiniest_plugin.configure(options.plugins) + + local function string_len(text: string) + if options.disable_unicode then + return string.len(text) + else + return utf8.len(text) or string.len(text) + end + end + local status_icons = { + pass = if options.disable_emoji then "[PASS]" else "✅", + fail = if options.disable_emoji then "[FAIL]" else "❌" + } + local crumb_trail = if options.disable_unicode then " > " else " ▸ " + local divider = if options.disable_unicode then "=" else "═" + local margin_line = if options.disable_unicode then "|" else "│" + + local paint = {} + do + local function ansi_mode(...) + if options.disable_colour then + return "" + else + return `{string.char(27)}[{table.concat({...}, ";")}m` + end + end + local DIM = ansi_mode("2") + local PASS = ansi_mode("1", "32") + local PASS_DIM = ansi_mode("2", "32") + local FAIL = ansi_mode("1", "31") + local FAIL_DIM = ansi_mode("2", "31") + local TRACE = ansi_mode("1", "34") + local TRACE_DIM = ansi_mode("2", "34") + local NUMBER = ansi_mode("1", "36") + local STRING = ansi_mode("1", "33") + local KEYWORD = ansi_mode("1", "35") + local RESET = ansi_mode("0") + + function paint.dim( + text: string + ): string + return DIM .. text .. RESET + end + + function paint.pass( + text: string + ): string + return PASS .. text .. RESET + end + + function paint.pass_dim( + text: string + ): string + return PASS_DIM .. text .. RESET + end + + function paint.fail( + text: string + ): string + return FAIL .. text .. RESET + end + + function paint.fail_dim( + text: string + ): string + return FAIL_DIM .. text .. RESET + end + + function paint.trace( + text: string + ): string + return TRACE .. text .. RESET + end + + function paint.trace_dim( + text: string + ): string + return TRACE_DIM .. text .. RESET + end + + function paint.number( + text: string + ): string + return NUMBER .. text .. RESET + end + + function paint.string( + text: string + ): string + return STRING .. text .. RESET + end + + function paint.keyword( + text: string + ): string + return KEYWORD .. text .. RESET + end + end + + local LINE_LENGTH = 80 + local full_width_divider = paint.dim(string.rep(divider, LINE_LENGTH)) + local function title( + text: string + ): string + local no_ansi = text:gsub(`{string.char(27)}.-m`, "") + local divider_count = (LINE_LENGTH - string_len(no_ansi) - 2) / 2 + local divider_lhs = string.rep(divider, math.ceil(divider_count)) + local divider_rhs = string.rep(divider, math.floor(divider_count)) + return paint.dim(divider_lhs) .. " " .. text .. " " .. paint.dim(divider_rhs) + end + local function syntax( + snippet: string + ): string + return snippet + :gsub("0?[xXbB]?%d*%.?%d*[eE]?%-?%d+", paint.number) + :gsub("\".-\"", paint.string) + :gsub("\'.-\'", paint.string) + :gsub("function", paint.keyword) + :gsub("end", paint.keyword) + :gsub("true", paint.keyword) + :gsub("false", paint.keyword) + :gsub("nil", paint.keyword) + :gsub("%-%-.-\n", paint.dim) + end + local function indent( + passage: string, + indentation: string + ): string + return passage:gsub("\n", "\n" .. indentation) + end + + local self = {} + self.is_tiniest_plugin = true + + function self.format_run( + run_result: RunResult + ): string + local lines = {} + + local pretty_results = {} + for _, test in run_result.tests do + local result = run_result.individual[test] + local painted_labels = table.clone(test.labels) + for index, label in painted_labels do + local style = result.status .. if index < #painted_labels then "_dim" else "" + painted_labels[index] = paint[style](label) + end + local annotations = "" + local function add_annotation( + annotation: string + ): () + annotations ..= ` - {annotation}` + end + plugins.notify("add_annotations", result, options, add_annotation) + local pretty = { + test = test, + result = result, + crumbs = table.concat(painted_labels, paint[result.status .. "_dim"](crumb_trail)), + icon = status_icons[result.status], + annotations = paint.dim(annotations) + } + table.insert(pretty_results, pretty) + end + + if run_result.status_tally.fail > 0 then + table.insert(lines, title(`Errors from {run_result.status_tally.fail} test(s)`)) + table.insert(lines, "") + for _, pretty in pretty_results do + if pretty.result.status == "pass" then continue end + table.insert(lines, `{pretty.icon} {pretty.crumbs}`) + table.insert(lines, paint.trace(pretty.result.error.message)) + local code = pretty.result.error.code + if code ~= nil then + local num_length = string_len(code.line) + local empty_margin = string.rep(" ", num_length + 1) .. paint.dim(margin_line) .. " " + table.insert(lines, empty_margin) + table.insert(lines, `{paint.dim(`{code.line} {margin_line}`)} {indent(syntax(code.snippet), empty_margin)}`) + table.insert(lines, empty_margin) + end + local trace = pretty.result.error.trace:gsub("\n+$", "") + table.insert(lines, paint.trace_dim(trace)) + table.insert(lines, "") + end + end + + local line_items = {} + local function add_line_items( + key: string, + value: string + ): () + table.insert(line_items, `{paint.dim(key .. ":")} {value}`) + end + plugins.notify("add_line_items", run_result, options, add_line_items) + + table.insert(lines, title(`Status of {#pretty_results} test(s)`)) + table.insert(lines, "") + for _, pretty in pretty_results do + table.insert(lines, `{pretty.icon} {pretty.crumbs}{pretty.annotations}`) + end + table.insert(lines, "") + table.insert(lines, title(`{paint.pass(`{run_result.status_tally.pass} passed`)}, {paint.fail(`{run_result.status_tally.fail} failed`)}`)) + if #line_items > 0 then + table.insert(lines, "") + for _, line_item in line_items do + table.insert(lines, line_item) + end + table.insert(lines, "") + table.insert(lines, full_width_divider) + end + + return table.concat(lines, "\n") + end + + function self.after_run( + run_result: RunResult, + _ + ): () + if options.disable_output and options.disable_output.after_run then + return + end + print(self.format_run(run_result)) + end + + return self +end + +return tiniest_pretty \ No newline at end of file diff --git a/test/lib/tiniest_quote.luau b/test/lib/tiniest_quote.luau new file mode 100644 index 000000000..89730b2e5 --- /dev/null +++ b/test/lib/tiniest_quote.luau @@ -0,0 +1,48 @@ +-- From dphfox/tiniest, licenced under MIT +--!strict + +local function tiniest_quote( + x: unknown, + given_indent_amount: number? +): string + if + type(x) == "nil" or + type(x) == "number" or + type(x) == "boolean" or + type(x) == "userdata" + then + return tostring(x) + elseif type(x) == "string" then + return string.format("%q", x) + elseif type(x) == "function" then + return "function() ... end" + elseif type(x) == "table" then + local outer_indent_amount = given_indent_amount or 0 + local inner_indent_amount = outer_indent_amount + 1 + local outer_indent = string.rep(" ", outer_indent_amount) + local inner_indent = string.rep(" ", inner_indent_amount) + + local tbl: {} = x :: any + local sorted_pairs = {} + for key, value in tbl do + table.insert(sorted_pairs, { + key = tiniest_quote(key, inner_indent_amount), + value = tiniest_quote(value, inner_indent_amount) + }) + end + table.sort(sorted_pairs, function(a, b) + return a.key < b.key + end) + local frozen = table.isfrozen(tbl) + local lines = {if frozen then "table.freeze {" else "{"} + for _, pair in sorted_pairs do + table.insert(lines, `{inner_indent}[{pair.key}] = {pair.value};`) + end + table.insert(lines, outer_indent .. "}") + return table.concat(lines, "\n") + else + return `{type(x)}({tostring(x)})` + end +end + +return tiniest_quote :: (unknown) -> string \ No newline at end of file diff --git a/test/lib/tiniest_snapshot.luau b/test/lib/tiniest_snapshot.luau new file mode 100644 index 000000000..3335783e3 --- /dev/null +++ b/test/lib/tiniest_snapshot.luau @@ -0,0 +1,186 @@ +-- From dphfox/tiniest, licenced under MIT +--!strict + +local tiniest_quote = require("tiniest_quote") +local tiniest = require("tiniest") +type Test = tiniest.Test + +export type TestRunResult = tiniest.TestRunResult & { + num_snapshots_updated: number, + num_snapshots_obsolete: number +} +export type RunResult = tiniest.RunResult & { + num_snapshots_updated: number, + num_snapshots_obsolete: number +} + +export type Options = { + save_snapshots: nil | (key: string, values: {string}) -> (), + load_snapshots: nil | (key: string) -> {string}? +} + +type TestContext = { + key: string, + snapshots: {string}, + next_snapshot_index: number, + num_updated: number +} + +type RunContext = { + num_updated: number, + num_obsolete: number +} + +local tiniest_snapshot = {} + +function tiniest_snapshot.configure( + options: Options +) + + local self = {} + self.is_tiniest_plugin = true + + local function get_test_key( + test: Test + ) + local labels = {} + for _, label in test.labels do + label = label:lower():gsub("[^%w%s]", ""):gsub(" ", "_") + table.insert(labels, label) + end + return table.concat(labels, ".") + end + + local run_context: RunContext? + local test_context: TestContext? + + function self.snapshot( + x: unknown + ): () + if options.load_snapshots == nil then + error("snapshot() is unavailable - snapshots have not been configured", 0) + elseif run_context == nil or test_context == nil then + error("snapshot() can only be used while a test is running with tiniest_snapshot", 0) + end + + local snapshot_index = test_context.next_snapshot_index + test_context.next_snapshot_index += 1 + + local fresh_snapshot = tiniest_quote(x) + local snapshot_on_disk = test_context.snapshots[snapshot_index] + test_context.snapshots[snapshot_index] = fresh_snapshot + + if fresh_snapshot == snapshot_on_disk then + return + end + + if options.save_snapshots then + run_context.num_updated += 1 + test_context.num_updated += 1 + test_context.snapshots[snapshot_index] = fresh_snapshot + elseif snapshot_on_disk == nil then + error({ + type = "tiniest.ErrorReport", + message = "New snapshot() call needs to be saved.\nRun while saving snapshots to save it to disk.", + trace = debug.traceback(nil, 2), + code = { + snippet = `snapshot({fresh_snapshot})`, + line = debug.info(2, "l") + } + }, 0) + else + error({ + type = "tiniest.ErrorReport", + message = "Snapshot does not match", + trace = debug.traceback(nil, 2), + code = { + snippet = `snapshot({fresh_snapshot})\n\n-- snapshot on disk:\nsnapshot({snapshot_on_disk})`, + line = debug.info(2, "l") + } + }, 0) + end + end + + function self.before_run( + _, _ + ): () + run_context = { + num_updated = 0, + num_obsolete = 0 + } + end + + function self.after_run( + original_run_result: tiniest.RunResult, + _ + ): () + assert(run_context ~= nil) + local run_result = original_run_result :: RunResult + run_result.num_snapshots_updated = run_context.num_updated + run_result.num_snapshots_obsolete = run_context.num_obsolete + run_context = nil + end + + function self.before_test( + test: Test, + _ + ): () + assert(run_context ~= nil) + local key = get_test_key(test) + test_context = { + key = key, + snapshots = options.load_snapshots and options.load_snapshots(key) or {}, + next_snapshot_index = 1, + num_updated = 0 + } + end + + function self.after_test( + _, + original_run_result: tiniest.TestRunResult, + _ + ): () + assert(run_context ~= nil) + assert(test_context ~= nil) + local run_result = original_run_result :: TestRunResult + run_result.num_snapshots_updated = test_context.num_updated + run_result.num_snapshots_obsolete = test_context.next_snapshot_index - #test_context.snapshots - 1 + run_context.num_obsolete += run_result.num_snapshots_obsolete + if test_context.num_updated > 0 and options.save_snapshots ~= nil then + options.save_snapshots(test_context.key, test_context.snapshots) + end + test_context = nil + end + + function self.add_annotations( + original_run_result: tiniest.TestRunResult, + _, + add_annotation: (string) -> () + ) + local run_result = original_run_result :: TestRunResult + if run_result.num_snapshots_updated > 0 then + add_annotation(`{run_result.num_snapshots_updated} snapshot(s) updated`) + end + if run_result.num_snapshots_obsolete > 0 then + add_annotation(`{run_result.num_snapshots_obsolete} snapshot(s) obsolete`) + end + end + + function self.add_line_items( + original_run_result: tiniest.RunResult, + _, + add_line_item: (string, string) -> () + ) + local run_result = original_run_result :: RunResult + if run_result.num_snapshots_updated > 0 then + add_line_item("Updated snapshots", tostring(run_result.num_snapshots_updated)) + end + if run_result.num_snapshots_obsolete > 0 then + add_line_item("Obsolete snapshots", tostring(run_result.num_snapshots_obsolete)) + end + end + + return self +end + +return tiniest_snapshot \ No newline at end of file diff --git a/test/lib/tiniest_time.luau b/test/lib/tiniest_time.luau new file mode 100644 index 000000000..8de53b4a1 --- /dev/null +++ b/test/lib/tiniest_time.luau @@ -0,0 +1,105 @@ +-- From dphfox/tiniest, licenced under MIT +--!strict + +local tiniest = require("tiniest") +local tiniest_pretty = require("tiniest_pretty") +type Test = tiniest.Test + +export type TestRunResult = tiniest.TestRunResult & { + duration: number +} +export type RunResult = tiniest.RunResult & { + duration: number +} + +export type Options = { + get_timestamp: () -> number +} + +local tiniest_time = {} + +local function format_duration( + seconds: number, + options: tiniest_pretty.Options +): string + local SECOND = 1 + local MILLISECOND = SECOND / 1000 + local MICROSECOND = MILLISECOND / 1000 + local suffix = { + micro = if options.disable_unicode then "u" else "µ", + milli = "m", + } + + if seconds < 100 * MICROSECOND then + return `{math.ceil(seconds / MICROSECOND * 100) / 100}{suffix.micro}s` + elseif seconds < 100 * MILLISECOND then + return `{math.ceil(seconds / MILLISECOND * 100) / 100}{suffix.milli}s` + else + return `{math.ceil(seconds * 100) / 100}s` + end +end + +function tiniest_time.configure( + options: Options +) + + local self = {} + self.is_tiniest_plugin = true + + local start_times = {} + + function self.before_run( + tests: {Test}, + _ + ): () + start_times[tests] = options.get_timestamp() + end + + function self.after_run( + original_run_result: tiniest.RunResult, + _ + ): () + local run_result = original_run_result :: RunResult + run_result.duration = options.get_timestamp() - start_times[run_result.tests] + start_times[run_result.tests] = nil + end + + function self.before_test( + test: Test, + _ + ): () + start_times[test] = options.get_timestamp() + end + + function self.after_test( + test: Test, + original_run_result: tiniest.TestRunResult, + _ + ): () + local run_result = original_run_result :: TestRunResult + run_result.duration = options.get_timestamp() - start_times[test] + start_times[test] = nil + end + + function self.add_annotations( + original_run_result: tiniest.TestRunResult, + options: tiniest_pretty.Options, + add_annotation: (string) -> () + ) + local run_result = original_run_result :: TestRunResult + add_annotation(format_duration(run_result.duration, options)) + end + + function self.add_line_items( + original_run_result: tiniest.RunResult, + options: tiniest_pretty.Options, + add_line_item: (string, string) -> () + ) + local run_result = original_run_result :: RunResult + add_line_item("Time to run", format_duration(run_result.duration, options)) + end + + return self +end + +return tiniest_time \ No newline at end of file From b7a64e67ede395f9c8eadb7917641c3c4a6a81e8 Mon Sep 17 00:00:00 2001 From: Daniel P H Fox Date: Sat, 1 Feb 2025 21:27:21 +0000 Subject: [PATCH 02/14] Tiniest in Roblox --- test/lib/tiniest.luau | 2 +- test/lib/tiniest_expect.luau | 2 +- test/lib/tiniest_for_lune.luau | 117 ------------------- test/lib/tiniest_for_roblox.luau | 54 +++++++++ test/lib/tiniest_pretty.luau | 4 +- test/lib/tiniest_snapshot.luau | 186 ------------------------------- test/lib/tiniest_time.luau | 4 +- test/test_main.server.luau | 16 +++ 8 files changed, 76 insertions(+), 309 deletions(-) delete mode 100644 test/lib/tiniest_for_lune.luau create mode 100644 test/lib/tiniest_for_roblox.luau delete mode 100644 test/lib/tiniest_snapshot.luau create mode 100644 test/test_main.server.luau diff --git a/test/lib/tiniest.luau b/test/lib/tiniest.luau index 462cff0bf..7e93de611 100644 --- a/test/lib/tiniest.luau +++ b/test/lib/tiniest.luau @@ -1,7 +1,7 @@ -- From dphfox/tiniest, licenced under MIT --!strict -local tiniest_plugin = require("tiniest_plugin") +local tiniest_plugin = require("./tiniest_plugin") type Context = DescribeContext | RunContext diff --git a/test/lib/tiniest_expect.luau b/test/lib/tiniest_expect.luau index be79d2baa..030d9464d 100644 --- a/test/lib/tiniest_expect.luau +++ b/test/lib/tiniest_expect.luau @@ -1,7 +1,7 @@ -- From dphfox/tiniest, licenced under MIT --!strict -local tiniest_quote = require("tiniest_quote") +local tiniest_quote = require("./tiniest_quote") local tiniest_expect = {} diff --git a/test/lib/tiniest_for_lune.luau b/test/lib/tiniest_for_lune.luau deleted file mode 100644 index ffe052faa..000000000 --- a/test/lib/tiniest_for_lune.luau +++ /dev/null @@ -1,117 +0,0 @@ --- From dphfox/tiniest, licenced under MIT ---!strict - -local tiniest_expect = require("tiniest_expect") -local tiniest_time = require("tiniest_time") -local tiniest_snapshot = require("tiniest_snapshot") -local tiniest_pretty = require("tiniest_pretty") -local tiniest = require("tiniest") - -local require: any = require -local fs = require("@lune/fs") -local luau = require("@lune/luau") - -export type Options = { - snapshot_path: string?, - save_snapshots: boolean?, - pretty: nil | { - disable_colour: boolean?, - disable_emoji: boolean?, - disable_unicode: boolean?, - disable_output: nil | { - after_run: boolean? - } - } -} - -local tiniest_for_lune = {} - -function tiniest_for_lune.configure( - options: Options -) - local self = {} - - local function get_path_to_snapshot( - key: string - ): string - assert(options.snapshot_path ~= nil) - return `{options.snapshot_path}/{key}.snap.luau` - end - - local function load_snapshots( - key: string - ): {string}? - local path = get_path_to_snapshot(key) - if not fs.isFile(path) then - return nil - else - local ok, result = pcall(function() - local source = fs.readFile(path) - local bytecode = luau.compile(source) - local loaded = luau.load(bytecode, { - injectGlobals = false - }) - return loaded() - end) - if ok then - return result - else - error("[tiniest_for_lune] Failed to load snapshots from disk: " .. tostring(result), 0) - end - end - end - - local function save_snapshots( - key: string, - snapshots: {string} - ): () - local ok, result = pcall(function() - snapshots = table.clone(snapshots) - for index, snapshot in snapshots do - snapshots[index] = `[====[{snapshot}]====]` - end - fs.writeFile( - get_path_to_snapshot(key), - "-- Auto-generated by dphfox/tiniest. Do not modify!\n" .. - "--!nocheck\n" .. - "return {" .. table.concat(snapshots, ", ") .. "}" - ) - end) - if not ok then - error("[tiniest_for_lune] Failed to save snapshots to disk: " .. tostring(result), 0) - end - end - - self.expect = tiniest_expect.expect - - local tiniest_time = tiniest_time.configure({ - get_timestamp = os.clock - }) - - local tiniest_snapshot = tiniest_snapshot.configure({ - load_snapshots = if options.snapshot_path then load_snapshots else nil, - save_snapshots = if options.save_snapshots then save_snapshots else nil - }) - self.snapshot = tiniest_snapshot.snapshot - - local tiniest_pretty = tiniest_pretty.configure({ - disable_colour = options.pretty and options.pretty.disable_colour, - disable_emoji = options.pretty and options.pretty.disable_emoji, - disable_unicode = options.pretty and options.pretty.disable_unicode, - disable_output = options.pretty and options.pretty.disable_output, - plugins = { tiniest_time :: any, tiniest_snapshot } - }) - self.format_run = tiniest_pretty.format_run - - local tiniest = tiniest.configure({ - plugins = { tiniest_time :: any, tiniest_snapshot, tiniest_pretty } - }) - self.describe = tiniest.describe - self.test = tiniest.test - self.collect_tests = tiniest.collect_tests - self.run_tests = tiniest.run_tests - - return self -end - -return tiniest_for_lune \ No newline at end of file diff --git a/test/lib/tiniest_for_roblox.luau b/test/lib/tiniest_for_roblox.luau new file mode 100644 index 000000000..f5437dfcb --- /dev/null +++ b/test/lib/tiniest_for_roblox.luau @@ -0,0 +1,54 @@ +-- From dphfox/tiniest, licenced under MIT +--!strict + +local tiniest_expect = require("./tiniest_expect") +local tiniest_time = require("./tiniest_time") +local tiniest_pretty = require("./tiniest_pretty") +local tiniest = require("./tiniest") + +export type Options = { + snapshot_path: string?, + save_snapshots: boolean?, + pretty: nil | { + disable_emoji: boolean?, + disable_unicode: boolean?, + disable_output: nil | { + after_run: boolean? + } + } +} + +local tiniest_for_roblox = {} + +function tiniest_for_roblox.configure( + options: Options +) + local self = {} + + self.expect = tiniest_expect.expect + + local tiniest_time = tiniest_time.configure({ + get_timestamp = os.clock + }) + + local tiniest_pretty = tiniest_pretty.configure({ + disable_colour = true, + disable_emoji = options.pretty and options.pretty.disable_emoji, + disable_unicode = options.pretty and options.pretty.disable_unicode, + disable_output = options.pretty and options.pretty.disable_output, + plugins = { tiniest_time :: any } + }) + self.format_run = tiniest_pretty.format_run + + local tiniest = tiniest.configure({ + plugins = { tiniest_time :: any, tiniest_pretty } + }) + self.describe = tiniest.describe + self.test = tiniest.test + self.collect_tests = tiniest.collect_tests + self.run_tests = tiniest.run_tests + + return self +end + +return tiniest_for_roblox \ No newline at end of file diff --git a/test/lib/tiniest_pretty.luau b/test/lib/tiniest_pretty.luau index 995201be4..ca01862e9 100644 --- a/test/lib/tiniest_pretty.luau +++ b/test/lib/tiniest_pretty.luau @@ -1,8 +1,8 @@ -- From dphfox/tiniest, licenced under MIT --!strict -local tiniest_plugin = require("tiniest_plugin") -local tiniest = require("tiniest") +local tiniest_plugin = require("./tiniest_plugin") +local tiniest = require("./tiniest") type Test = tiniest.Test type TestRunResult = tiniest.TestRunResult type RunResult = tiniest.RunResult diff --git a/test/lib/tiniest_snapshot.luau b/test/lib/tiniest_snapshot.luau deleted file mode 100644 index 3335783e3..000000000 --- a/test/lib/tiniest_snapshot.luau +++ /dev/null @@ -1,186 +0,0 @@ --- From dphfox/tiniest, licenced under MIT ---!strict - -local tiniest_quote = require("tiniest_quote") -local tiniest = require("tiniest") -type Test = tiniest.Test - -export type TestRunResult = tiniest.TestRunResult & { - num_snapshots_updated: number, - num_snapshots_obsolete: number -} -export type RunResult = tiniest.RunResult & { - num_snapshots_updated: number, - num_snapshots_obsolete: number -} - -export type Options = { - save_snapshots: nil | (key: string, values: {string}) -> (), - load_snapshots: nil | (key: string) -> {string}? -} - -type TestContext = { - key: string, - snapshots: {string}, - next_snapshot_index: number, - num_updated: number -} - -type RunContext = { - num_updated: number, - num_obsolete: number -} - -local tiniest_snapshot = {} - -function tiniest_snapshot.configure( - options: Options -) - - local self = {} - self.is_tiniest_plugin = true - - local function get_test_key( - test: Test - ) - local labels = {} - for _, label in test.labels do - label = label:lower():gsub("[^%w%s]", ""):gsub(" ", "_") - table.insert(labels, label) - end - return table.concat(labels, ".") - end - - local run_context: RunContext? - local test_context: TestContext? - - function self.snapshot( - x: unknown - ): () - if options.load_snapshots == nil then - error("snapshot() is unavailable - snapshots have not been configured", 0) - elseif run_context == nil or test_context == nil then - error("snapshot() can only be used while a test is running with tiniest_snapshot", 0) - end - - local snapshot_index = test_context.next_snapshot_index - test_context.next_snapshot_index += 1 - - local fresh_snapshot = tiniest_quote(x) - local snapshot_on_disk = test_context.snapshots[snapshot_index] - test_context.snapshots[snapshot_index] = fresh_snapshot - - if fresh_snapshot == snapshot_on_disk then - return - end - - if options.save_snapshots then - run_context.num_updated += 1 - test_context.num_updated += 1 - test_context.snapshots[snapshot_index] = fresh_snapshot - elseif snapshot_on_disk == nil then - error({ - type = "tiniest.ErrorReport", - message = "New snapshot() call needs to be saved.\nRun while saving snapshots to save it to disk.", - trace = debug.traceback(nil, 2), - code = { - snippet = `snapshot({fresh_snapshot})`, - line = debug.info(2, "l") - } - }, 0) - else - error({ - type = "tiniest.ErrorReport", - message = "Snapshot does not match", - trace = debug.traceback(nil, 2), - code = { - snippet = `snapshot({fresh_snapshot})\n\n-- snapshot on disk:\nsnapshot({snapshot_on_disk})`, - line = debug.info(2, "l") - } - }, 0) - end - end - - function self.before_run( - _, _ - ): () - run_context = { - num_updated = 0, - num_obsolete = 0 - } - end - - function self.after_run( - original_run_result: tiniest.RunResult, - _ - ): () - assert(run_context ~= nil) - local run_result = original_run_result :: RunResult - run_result.num_snapshots_updated = run_context.num_updated - run_result.num_snapshots_obsolete = run_context.num_obsolete - run_context = nil - end - - function self.before_test( - test: Test, - _ - ): () - assert(run_context ~= nil) - local key = get_test_key(test) - test_context = { - key = key, - snapshots = options.load_snapshots and options.load_snapshots(key) or {}, - next_snapshot_index = 1, - num_updated = 0 - } - end - - function self.after_test( - _, - original_run_result: tiniest.TestRunResult, - _ - ): () - assert(run_context ~= nil) - assert(test_context ~= nil) - local run_result = original_run_result :: TestRunResult - run_result.num_snapshots_updated = test_context.num_updated - run_result.num_snapshots_obsolete = test_context.next_snapshot_index - #test_context.snapshots - 1 - run_context.num_obsolete += run_result.num_snapshots_obsolete - if test_context.num_updated > 0 and options.save_snapshots ~= nil then - options.save_snapshots(test_context.key, test_context.snapshots) - end - test_context = nil - end - - function self.add_annotations( - original_run_result: tiniest.TestRunResult, - _, - add_annotation: (string) -> () - ) - local run_result = original_run_result :: TestRunResult - if run_result.num_snapshots_updated > 0 then - add_annotation(`{run_result.num_snapshots_updated} snapshot(s) updated`) - end - if run_result.num_snapshots_obsolete > 0 then - add_annotation(`{run_result.num_snapshots_obsolete} snapshot(s) obsolete`) - end - end - - function self.add_line_items( - original_run_result: tiniest.RunResult, - _, - add_line_item: (string, string) -> () - ) - local run_result = original_run_result :: RunResult - if run_result.num_snapshots_updated > 0 then - add_line_item("Updated snapshots", tostring(run_result.num_snapshots_updated)) - end - if run_result.num_snapshots_obsolete > 0 then - add_line_item("Obsolete snapshots", tostring(run_result.num_snapshots_obsolete)) - end - end - - return self -end - -return tiniest_snapshot \ No newline at end of file diff --git a/test/lib/tiniest_time.luau b/test/lib/tiniest_time.luau index 8de53b4a1..172ef058d 100644 --- a/test/lib/tiniest_time.luau +++ b/test/lib/tiniest_time.luau @@ -1,8 +1,8 @@ -- From dphfox/tiniest, licenced under MIT --!strict -local tiniest = require("tiniest") -local tiniest_pretty = require("tiniest_pretty") +local tiniest = require("./tiniest") +local tiniest_pretty = require("./tiniest_pretty") type Test = tiniest.Test export type TestRunResult = tiniest.TestRunResult & { diff --git a/test/test_main.server.luau b/test/test_main.server.luau new file mode 100644 index 000000000..ba576a066 --- /dev/null +++ b/test/test_main.server.luau @@ -0,0 +1,16 @@ +--!strict + +local tiniest = require("./lib/tiniest_for_roblox").configure({}) + +local tests = tiniest.collect_tests(function() + local describe = tiniest.describe + local test = tiniest.test + local expect = tiniest.expect + + describe("something cool", function() + test("it works", function() + expect(2 + 2).is(5) + end) + end) +end) +tiniest.run_tests(tests, {}) \ No newline at end of file From 6829e66e0c0efa0e03cea0dcbf634ae4f52d19a0 Mon Sep 17 00:00:00 2001 From: Daniel P H Fox Date: Sun, 2 Feb 2025 01:57:00 +0000 Subject: [PATCH 03/14] Basic test harness --- test-runner.project.json | 12 +++---- test/fusion/Memory/doCleanup.spec.luau | 23 +++++++++++++ test/lib/tiniest.luau | 4 +-- test/lib/tiniest_for_roblox.luau | 45 ++++++++++++++++++++++++++ test/test_main.server.luau | 15 +++------ 5 files changed, 78 insertions(+), 21 deletions(-) create mode 100644 test/fusion/Memory/doCleanup.spec.luau diff --git a/test-runner.project.json b/test-runner.project.json index 04881787d..137605c72 100644 --- a/test-runner.project.json +++ b/test-runner.project.json @@ -5,15 +5,11 @@ ], "tree": { "$className": "DataModel", - "ReplicatedStorage": { - "$className": "ReplicatedStorage", - "Fusion": { - "$path": "default.project.json" - } - }, "ServerScriptService": { - "$className": "ServerScriptService", - "FusionTest": { + "src": { + "$path": "src" + }, + "test": { "$path": "test" } } diff --git a/test/fusion/Memory/doCleanup.spec.luau b/test/fusion/Memory/doCleanup.spec.luau new file mode 100644 index 000000000..4c06a3c90 --- /dev/null +++ b/test/fusion/Memory/doCleanup.spec.luau @@ -0,0 +1,23 @@ +--!strict + +local tiniest = require("../../lib/tiniest_for_roblox") + +local doCleanup = require("../../../src/Memory/doCleanup") + +return function(tiniest: tiniest.Configured): () + local describe = tiniest.describe + local test = tiniest.test + local expect = tiniest.expect + + describe("data types", function() + test("instance", function() + local instance = Instance.new("Folder") + local watch = instance.AncestryChanged:Connect(function() end) + + doCleanup(instance) + + expect(watch.Connected).is(false) + end) + end) + +end \ No newline at end of file diff --git a/test/lib/tiniest.luau b/test/lib/tiniest.luau index 7e93de611..f14dc969c 100644 --- a/test/lib/tiniest.luau +++ b/test/lib/tiniest.luau @@ -104,7 +104,7 @@ function tiniest.configure( local ok, err = catch_errors(inner :: any, root_context) current_context = nil if not ok then - error(err) + error(err.message, 0) end end @@ -118,7 +118,7 @@ function tiniest.configure( local ok, err = catch_errors(inner :: any, inner_context) current_context = outer_context if not ok then - error(err) + error(err.message, 0) end end end diff --git a/test/lib/tiniest_for_roblox.luau b/test/lib/tiniest_for_roblox.luau index f5437dfcb..e7efa0d47 100644 --- a/test/lib/tiniest_for_roblox.luau +++ b/test/lib/tiniest_for_roblox.luau @@ -18,6 +18,10 @@ export type Options = { } } +export type CollectOptions = { + file_name_pattern: string? +} + local tiniest_for_roblox = {} function tiniest_for_roblox.configure( @@ -48,7 +52,48 @@ function tiniest_for_roblox.configure( self.collect_tests = tiniest.collect_tests self.run_tests = tiniest.run_tests + function self.collect_tests_from_hierarchy( + ancestor: Instance, + declared_collect_options: CollectOptions? + ) + local collect_options: CollectOptions = declared_collect_options or {} + local file_name_pattern = collect_options.file_name_pattern or "%.spec$" + local function discover( + ancestor: Instance + ): () + self.describe(ancestor.Name, function() + if ancestor:IsA("ModuleScript") and ancestor.Name:match(file_name_pattern) then + local requireOk, module = pcall(require, ancestor) + if not requireOk then + error(`Error while requiring {ancestor:GetFullName()}`, 0) + elseif typeof(module) ~= "function" then + error(`Did not get a function from {ancestor:GetFullName()}`, 0) + end + local collectOk, err = pcall(module, self) + if not collectOk then + error(err, 0) + end + end + local children = ancestor:GetChildren() + if #children > 0 then + table.sort(children, function(a, b) + return a.Name < b.Name + end) + for _, child in children do + discover(child) + end + end + end) + + end + return self.collect_tests(function() + discover(ancestor) + end) + end + return self end +export type Configured = typeof(tiniest_for_roblox.configure({})) + return tiniest_for_roblox \ No newline at end of file diff --git a/test/test_main.server.luau b/test/test_main.server.luau index ba576a066..069b2c3a3 100644 --- a/test/test_main.server.luau +++ b/test/test_main.server.luau @@ -2,15 +2,8 @@ local tiniest = require("./lib/tiniest_for_roblox").configure({}) -local tests = tiniest.collect_tests(function() - local describe = tiniest.describe - local test = tiniest.test - local expect = tiniest.expect - - describe("something cool", function() - test("it works", function() - expect(2 + 2).is(5) - end) - end) -end) +local test_root = (script :: any).Parent.fusion +print("Collecting tests...") +local tests = tiniest.collect_tests_from_hierarchy(test_root) +print("Running tests...") tiniest.run_tests(tests, {}) \ No newline at end of file From 2b9b16288c5940e27f521dc67a21a1a1cc20e260 Mon Sep 17 00:00:00 2001 From: Daniel P H Fox Date: Sun, 2 Feb 2025 02:12:44 +0000 Subject: [PATCH 04/14] doCleanup.spec.luau --- test/fusion/Memory/doCleanup.spec.luau | 64 ++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 5 deletions(-) diff --git a/test/fusion/Memory/doCleanup.spec.luau b/test/fusion/Memory/doCleanup.spec.luau index 4c06a3c90..46936df19 100644 --- a/test/fusion/Memory/doCleanup.spec.luau +++ b/test/fusion/Memory/doCleanup.spec.luau @@ -9,14 +9,68 @@ return function(tiniest: tiniest.Configured): () local test = tiniest.test local expect = tiniest.expect - describe("data types", function() + test("callback", function() + local run = false + doCleanup(function() + run = true + end) + expect(run).is(true) + end) + + describe("methods", function() + for _, method in {"destroy", "Destroy"} do + test("destroy", function() + local run = false + doCleanup({ + destroy = function(self) + expect(self).exists() + run = true + end + }) + expect(run).is(true) + end) + end + end) + + test("scope", function() + local runs = {} + local scope = {} + scope[3] = function() + table.insert(runs, 3) + end + scope[1] = function() + table.insert(runs, 1) + end + scope[2] = function() + table.insert(runs, 2) + end + doCleanup(scope) + expect(runs[1]).is(3) + expect(runs[2]).is(2) + expect(runs[3]).is(1) + end) + + test("recursion", function() + local function foo() + doCleanup(foo) + end + expect(foo).fails_with("destroyedTwice") + end) + + describe("roblox", function() test("instance", function() local instance = Instance.new("Folder") - local watch = instance.AncestryChanged:Connect(function() end) - doCleanup(instance) - - expect(watch.Connected).is(false) + expect(function() + instance.Parent = instance + end).fails_with("locked") + end) + + test("connection", function() + local instance = Instance.new("Folder") + local connection = instance.AncestryChanged:Connect(function() end) + doCleanup(instance) + expect(connection.Connected).is(false) end) end) From 8eb5531937804d5f64b5f2a498409c686880e00b Mon Sep 17 00:00:00 2001 From: Daniel P H Fox Date: Sun, 2 Feb 2025 02:12:44 +0000 Subject: [PATCH 05/14] doCleanup.spec.luau --- test-old/Spec/Memory/doCleanup.spec.luau | 147 ----------------------- test/fusion/Memory/doCleanup.spec.luau | 64 +++++++++- 2 files changed, 59 insertions(+), 152 deletions(-) delete mode 100644 test-old/Spec/Memory/doCleanup.spec.luau diff --git a/test-old/Spec/Memory/doCleanup.spec.luau b/test-old/Spec/Memory/doCleanup.spec.luau deleted file mode 100644 index 0df2ceb73..000000000 --- a/test-old/Spec/Memory/doCleanup.spec.luau +++ /dev/null @@ -1,147 +0,0 @@ ---!strict ---!nolint LocalUnused -local task = nil -- Disable usage of Roblox's task scheduler - -local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Fusion = ReplicatedStorage.Fusion - -local New = require(Fusion.Instances.New) -local doCleanup = require(Fusion.Memory.doCleanup) - -return function() - local it = getfenv().it - - it("should destroy instances", function() - local expect = getfenv().expect - - local instance = New({}, "Folder") {} - -- one of the only reliable ways to test for proper destruction - local conn = instance.AncestryChanged:Connect(function() end) - - doCleanup(instance) - - expect(conn.Connected).to.equal(false) - end) - - it("should disconnect connections", function() - local expect = getfenv().expect - - local instance = New({}, "Folder") {} - local conn = instance.AncestryChanged:Connect(function() end) - - doCleanup(conn) - - expect(conn.Connected).to.equal(false) - end) - - it("should invoke callbacks", function() - local expect = getfenv().expect - - local didRun = false - - doCleanup(function() - didRun = true - end) - - expect(didRun).to.equal(true) - end) - - it("should invoke :destroy() methods", function() - local expect = getfenv().expect - - local didRun = false - - doCleanup({ - destroy = function() - didRun = true - end - }) - - expect(didRun).to.equal(true) - end) - - it("should invoke :Destroy() methods", function() - local expect = getfenv().expect - - local didRun = false - - doCleanup({ - Destroy = function() - didRun = true - end - }) - - expect(didRun).to.equal(true) - end) - - it("should clean up contents of arrays", function() - local expect = getfenv().expect - - local numRuns = 0 - - local function doRun() - numRuns += 1 - end - - local arr = {doRun, doRun, doRun} - - doCleanup(arr) - - expect(numRuns).to.equal(3) - expect(rawget(arr, 3)).to.equal(nil) - expect(rawget(arr, 2)).to.equal(nil) - expect(rawget(arr, 1)).to.equal(nil) - end) - - it("should clean up contents of nested arrays", function() - local expect = getfenv().expect - - local numRuns = 0 - - local function doRun() - numRuns += 1 - end - - doCleanup({{doRun :: any, {doRun :: any, {doRun}}}}) - - expect(numRuns).to.equal(3) - end) - - it("should clean up contents of arrays in reverse order", function() - local expect = getfenv().expect - - local runs = {} - - local tasks = {} - - tasks[3] = function() - table.insert(runs, 3) - end - - tasks[1] = function() - table.insert(runs, 1) - end - - tasks[2] = function() - table.insert(runs, 2) - end - - doCleanup(tasks) - - expect(runs[1]).to.equal(3) - expect(runs[2]).to.equal(2) - expect(runs[3]).to.equal(1) - end) - - it("should throw for recursive cleanups", function() - local expect = getfenv().expect - - local function cleanupFunc() - doCleanup(cleanupFunc) - end - - expect(function() - doCleanup(cleanupFunc) - end).to.throw("destroyedTwice") - end) -end \ No newline at end of file diff --git a/test/fusion/Memory/doCleanup.spec.luau b/test/fusion/Memory/doCleanup.spec.luau index 4c06a3c90..46936df19 100644 --- a/test/fusion/Memory/doCleanup.spec.luau +++ b/test/fusion/Memory/doCleanup.spec.luau @@ -9,14 +9,68 @@ return function(tiniest: tiniest.Configured): () local test = tiniest.test local expect = tiniest.expect - describe("data types", function() + test("callback", function() + local run = false + doCleanup(function() + run = true + end) + expect(run).is(true) + end) + + describe("methods", function() + for _, method in {"destroy", "Destroy"} do + test("destroy", function() + local run = false + doCleanup({ + destroy = function(self) + expect(self).exists() + run = true + end + }) + expect(run).is(true) + end) + end + end) + + test("scope", function() + local runs = {} + local scope = {} + scope[3] = function() + table.insert(runs, 3) + end + scope[1] = function() + table.insert(runs, 1) + end + scope[2] = function() + table.insert(runs, 2) + end + doCleanup(scope) + expect(runs[1]).is(3) + expect(runs[2]).is(2) + expect(runs[3]).is(1) + end) + + test("recursion", function() + local function foo() + doCleanup(foo) + end + expect(foo).fails_with("destroyedTwice") + end) + + describe("roblox", function() test("instance", function() local instance = Instance.new("Folder") - local watch = instance.AncestryChanged:Connect(function() end) - doCleanup(instance) - - expect(watch.Connected).is(false) + expect(function() + instance.Parent = instance + end).fails_with("locked") + end) + + test("connection", function() + local instance = Instance.new("Folder") + local connection = instance.AncestryChanged:Connect(function() end) + doCleanup(instance) + expect(connection.Connected).is(false) end) end) From 85fce203314b50193dda088ddf67375776d431c8 Mon Sep 17 00:00:00 2001 From: Daniel P H Fox Date: Sun, 2 Feb 2025 02:14:19 +0000 Subject: [PATCH 06/14] Remove TestEZ --- test-old/TestEZ/Context.luau | 26 -- test-old/TestEZ/Expectation.luau | 311 ------------------ test-old/TestEZ/ExpectationContext.luau | 38 --- test-old/TestEZ/LifecycleHooks.luau | 89 ----- .../TestEZ/Reporters/TeamCityReporter.luau | 104 ------ test-old/TestEZ/Reporters/TextReporter.luau | 106 ------ .../TestEZ/Reporters/TextReporterQuiet.luau | 97 ------ test-old/TestEZ/TestBootstrap.luau | 147 --------- test-old/TestEZ/TestEnum.luau | 28 -- test-old/TestEZ/TestPlan.luau | 304 ----------------- test-old/TestEZ/TestPlanner.luau | 40 --- test-old/TestEZ/TestResults.luau | 112 ------- test-old/TestEZ/TestRunner.luau | 188 ----------- test-old/TestEZ/TestSession.luau | 243 -------------- test-old/TestEZ/init.luau | 40 --- 15 files changed, 1873 deletions(-) delete mode 100644 test-old/TestEZ/Context.luau delete mode 100644 test-old/TestEZ/Expectation.luau delete mode 100644 test-old/TestEZ/ExpectationContext.luau delete mode 100644 test-old/TestEZ/LifecycleHooks.luau delete mode 100644 test-old/TestEZ/Reporters/TeamCityReporter.luau delete mode 100644 test-old/TestEZ/Reporters/TextReporter.luau delete mode 100644 test-old/TestEZ/Reporters/TextReporterQuiet.luau delete mode 100644 test-old/TestEZ/TestBootstrap.luau delete mode 100644 test-old/TestEZ/TestEnum.luau delete mode 100644 test-old/TestEZ/TestPlan.luau delete mode 100644 test-old/TestEZ/TestPlanner.luau delete mode 100644 test-old/TestEZ/TestResults.luau delete mode 100644 test-old/TestEZ/TestRunner.luau delete mode 100644 test-old/TestEZ/TestSession.luau delete mode 100644 test-old/TestEZ/init.luau diff --git a/test-old/TestEZ/Context.luau b/test-old/TestEZ/Context.luau deleted file mode 100644 index efd4993a5..000000000 --- a/test-old/TestEZ/Context.luau +++ /dev/null @@ -1,26 +0,0 @@ ---[[ - The Context object implements a write-once key-value store. It also allows - for a new Context object to inherit the entries from an existing one. -]] -local Context = {} - -function Context.new(parent) - local meta = {} - local index = {} - meta.__index = index - - if parent then - for key, value in pairs(getmetatable(parent).__index) do - index[key] = value - end - end - - function meta.__newindex(_obj, key, value) - assert(index[key] == nil, string.format("Cannot reassign %s in context", tostring(key))) - index[key] = value - end - - return setmetatable({}, meta) -end - -return Context diff --git a/test-old/TestEZ/Expectation.luau b/test-old/TestEZ/Expectation.luau deleted file mode 100644 index 96dc2c79e..000000000 --- a/test-old/TestEZ/Expectation.luau +++ /dev/null @@ -1,311 +0,0 @@ ---[[ - Allows creation of expectation statements designed for behavior-driven - testing (BDD). See Chai (JS) or RSpec (Ruby) for examples of other BDD - frameworks. - - The Expectation class is exposed to tests as a function called `expect`: - - expect(5).to.equal(5) - expect(foo()).to.be.ok() - - Expectations can be negated using .never: - - expect(true).never.to.equal(false) - - Expectations throw errors when their conditions are not met. -]] - -local Expectation = {} - ---[[ - These keys don't do anything except make expectations read more cleanly -]] -local SELF_KEYS = { - to = true, - be = true, - been = true, - have = true, - was = true, - at = true, -} - ---[[ - These keys invert the condition expressed by the Expectation. -]] -local NEGATION_KEYS = { - never = true, -} - ---[[ - Extension of Lua's 'assert' that lets you specify an error level. -]] -local function assertLevel(condition, message, level) - message = message or "Assertion failed!" - level = level or 1 - - if not condition then - error(message, level + 1) - end -end - ---[[ - Returns a version of the given method that can be called with either . or : -]] -local function bindSelf(self, method) - return function(firstArg, ...) - if firstArg == self then - return method(self, ...) - else - return method(self, firstArg, ...) - end - end -end - -local function formatMessage(result, trueMessage, falseMessage) - if result then - return trueMessage - else - return falseMessage - end -end - ---[[ - Create a new expectation -]] -function Expectation.new(value) - local self = { - value = value, - successCondition = true, - condition = false, - matchers = {}, - _boundMatchers = {}, - } - - setmetatable(self, Expectation) - - self.a = bindSelf(self, self.a) - self.an = self.a - self.ok = bindSelf(self, self.ok) - self.equal = bindSelf(self, self.equal) - self.throw = bindSelf(self, self.throw) - self.near = bindSelf(self, self.near) - - return self -end - -function Expectation.checkMatcherNameCollisions(name) - if SELF_KEYS[name] or NEGATION_KEYS[name] or Expectation[name] then - return false - end - - return true -end - -function Expectation:extend(matchers) - self.matchers = matchers or {} - - for name, implementation in pairs(self.matchers) do - self._boundMatchers[name] = bindSelf(self, function(_self, ...) - local result = implementation(self.value, ...) - local pass = result.pass == self.successCondition - - assertLevel(pass, result.message, 3) - self:_resetModifiers() - return self - end) - end - - return self -end - -function Expectation.__index(self, key) - -- Keys that don't do anything except improve readability - if SELF_KEYS[key] then - return self - end - - -- Invert your assertion - if NEGATION_KEYS[key] then - local newExpectation = Expectation.new(self.value):extend(self.matchers) - newExpectation.successCondition = not self.successCondition - - return newExpectation - end - - if self._boundMatchers[key] then - return self._boundMatchers[key] - end - - -- Fall back to methods provided by Expectation - return Expectation[key] -end - ---[[ - Called by expectation terminators to reset modifiers in a statement. - - This makes chains like: - - expect(5) - .never.to.equal(6) - .to.equal(5) - - Work as expected. -]] -function Expectation:_resetModifiers() - self.successCondition = true -end - ---[[ - Assert that the expectation value is the given type. - - expect(5).to.be.a("number") -]] -function Expectation:a(typeName) - local result = (type(self.value) == typeName) == self.successCondition - - local message = formatMessage(self.successCondition, - ("Expected value of type %q, got value %q of type %s"):format( - typeName, - tostring(self.value), - type(self.value) - ), - ("Expected value not of type %q, got value %q of type %s"):format( - typeName, - tostring(self.value), - type(self.value) - ) - ) - - assertLevel(result, message, 3) - self:_resetModifiers() - - return self -end - --- Make alias public on class -Expectation.an = Expectation.a - ---[[ - Assert that our expectation value is truthy -]] -function Expectation:ok() - local result = (self.value ~= nil) == self.successCondition - - local message = formatMessage(self.successCondition, - ("Expected value %q to be non-nil"):format( - tostring(self.value) - ), - ("Expected value %q to be nil"):format( - tostring(self.value) - ) - ) - - assertLevel(result, message, 3) - self:_resetModifiers() - - return self -end - ---[[ - Assert that our expectation value is equal to another value -]] -function Expectation:equal(otherValue) - local result = (self.value == otherValue) == self.successCondition - - local message = formatMessage(self.successCondition, - ("Expected value %q (%s), got %q (%s) instead"):format( - tostring(otherValue), - type(otherValue), - tostring(self.value), - type(self.value) - ), - ("Expected anything but value %q (%s)"):format( - tostring(otherValue), - type(otherValue) - ) - ) - - assertLevel(result, message, 3) - self:_resetModifiers() - - return self -end - ---[[ - Assert that our expectation value is equal to another value within some - inclusive limit. -]] -function Expectation:near(otherValue, limit) - assert(type(self.value) == "number", "Expectation value must be a number to use 'near'") - assert(type(otherValue) == "number", "otherValue must be a number") - assert(type(limit) == "number" or limit == nil, "limit must be a number or nil") - - limit = limit or 1e-7 - - local result = (math.abs(self.value - otherValue) <= limit) == self.successCondition - - local message = formatMessage(self.successCondition, - ("Expected value to be near %f (within %f) but got %f instead"):format( - otherValue, - limit, - self.value - ), - ("Expected value to not be near %f (within %f) but got %f instead"):format( - otherValue, - limit, - self.value - ) - ) - - assertLevel(result, message, 3) - self:_resetModifiers() - - return self -end - ---[[ - Assert that our functoid expectation value throws an error when called. - An optional error message can be passed to assert that the error message - contains the given value. -]] -function Expectation:throw(messageSubstring) - local ok, err = pcall(self.value) - local result = ok ~= self.successCondition - - if messageSubstring and not ok then - if self.successCondition then - result = err:find(messageSubstring, 1, true) ~= nil - else - result = err:find(messageSubstring, 1, true) == nil - end - end - - local message - - if messageSubstring then - message = formatMessage(self.successCondition, - ("Expected function to throw an error containing %q, but it %s"):format( - messageSubstring, - err and ("threw: %s"):format(err) or "did not throw." - ), - ("Expected function to never throw an error containing %q, but it threw: %s"):format( - messageSubstring, - tostring(err) - ) - ) - else - message = formatMessage(self.successCondition, - "Expected function to throw an error, but it did not throw.", - ("Expected function to succeed, but it threw an error: %s"):format( - tostring(err) - ) - ) - end - - assertLevel(result, message, 3) - self:_resetModifiers() - - return self -end - -return Expectation diff --git a/test-old/TestEZ/ExpectationContext.luau b/test-old/TestEZ/ExpectationContext.luau deleted file mode 100644 index b55f53c3f..000000000 --- a/test-old/TestEZ/ExpectationContext.luau +++ /dev/null @@ -1,38 +0,0 @@ -local Expectation = require(script.Parent.Expectation) -local checkMatcherNameCollisions = Expectation.checkMatcherNameCollisions - -local function copy(t) - local result = {} - - for key, value in pairs(t) do - result[key] = value - end - - return result -end - -local ExpectationContext = {} -ExpectationContext.__index = ExpectationContext - -function ExpectationContext.new(parent) - local self = { - _extensions = parent and copy(parent._extensions) or {}, - } - - return setmetatable(self, ExpectationContext) -end - -function ExpectationContext:startExpectationChain(...) - return Expectation.new(...):extend(self._extensions) -end - -function ExpectationContext:extend(config) - for key, value in pairs(config) do - assert(self._extensions[key] == nil, string.format("Cannot reassign %q in expect.extend", key)) - assert(checkMatcherNameCollisions(key), string.format("Cannot overwrite matcher %q; it already exists", key)) - - self._extensions[key] = value - end -end - -return ExpectationContext diff --git a/test-old/TestEZ/LifecycleHooks.luau b/test-old/TestEZ/LifecycleHooks.luau deleted file mode 100644 index c60b497ba..000000000 --- a/test-old/TestEZ/LifecycleHooks.luau +++ /dev/null @@ -1,89 +0,0 @@ -local TestEnum = require(script.Parent.TestEnum) - -local LifecycleHooks = {} -LifecycleHooks.__index = LifecycleHooks - -function LifecycleHooks.new() - local self = { - _stack = {}, - } - return setmetatable(self, LifecycleHooks) -end - ---[[ - Returns an array of `beforeEach` hooks in FIFO order -]] -function LifecycleHooks:getBeforeEachHooks() - local key = TestEnum.NodeType.BeforeEach - local hooks = {} - - for _, level in ipairs(self._stack) do - for _, hook in ipairs(level[key]) do - table.insert(hooks, hook) - end - end - - return hooks -end - ---[[ - Returns an array of `afterEach` hooks in FILO order -]] -function LifecycleHooks:getAfterEachHooks() - local key = TestEnum.NodeType.AfterEach - local hooks = {} - - for _, level in ipairs(self._stack) do - for _, hook in ipairs(level[key]) do - table.insert(hooks, 1, hook) - end - end - - return hooks -end - ---[[ - Pushes uncalled beforeAll and afterAll hooks back up the stack -]] -function LifecycleHooks:popHooks() - table.remove(self._stack, #self._stack) -end - -function LifecycleHooks:pushHooksFrom(planNode) - assert(planNode ~= nil) - - table.insert(self._stack, { - [TestEnum.NodeType.BeforeAll] = self:_getHooksOfType(planNode.children, TestEnum.NodeType.BeforeAll), - [TestEnum.NodeType.AfterAll] = self:_getHooksOfType(planNode.children, TestEnum.NodeType.AfterAll), - [TestEnum.NodeType.BeforeEach] = self:_getHooksOfType(planNode.children, TestEnum.NodeType.BeforeEach), - [TestEnum.NodeType.AfterEach] = self:_getHooksOfType(planNode.children, TestEnum.NodeType.AfterEach), - }) -end - ---[[ - Get the beforeAll hooks from the current level. -]] -function LifecycleHooks:getBeforeAllHooks() - return self._stack[#self._stack][TestEnum.NodeType.BeforeAll] -end - ---[[ - Get the afterAll hooks from the current level. -]] -function LifecycleHooks:getAfterAllHooks() - return self._stack[#self._stack][TestEnum.NodeType.AfterAll] -end - -function LifecycleHooks:_getHooksOfType(nodes, key) - local hooks = {} - - for _, node in ipairs(nodes) do - if node.type == key then - table.insert(hooks, node.callback) - end - end - - return hooks -end - -return LifecycleHooks diff --git a/test-old/TestEZ/Reporters/TeamCityReporter.luau b/test-old/TestEZ/Reporters/TeamCityReporter.luau deleted file mode 100644 index 19c669db4..000000000 --- a/test-old/TestEZ/Reporters/TeamCityReporter.luau +++ /dev/null @@ -1,104 +0,0 @@ -local TestService = game:GetService("TestService") - -local TestEnum = require(script.Parent.Parent.TestEnum) - -local TeamCityReporter = {} - -local function teamCityEscape(str) - str = string.gsub(str, "([]|'[])","|%1") - str = string.gsub(str, "\r", "|r") - str = string.gsub(str, "\n", "|n") - return str -end - -local function teamCityEnterSuite(suiteName) - return string.format("##teamcity[testSuiteStarted name='%s']", teamCityEscape(suiteName)) -end - -local function teamCityLeaveSuite(suiteName) - return string.format("##teamcity[testSuiteFinished name='%s']", teamCityEscape(suiteName)) -end - -local function teamCityEnterCase(caseName) - return string.format("##teamcity[testStarted name='%s']", teamCityEscape(caseName)) -end - -local function teamCityLeaveCase(caseName) - return string.format("##teamcity[testFinished name='%s']", teamCityEscape(caseName)) -end - -local function teamCityFailCase(caseName, errorMessage) - return string.format("##teamcity[testFailed name='%s' message='%s']", - teamCityEscape(caseName), teamCityEscape(errorMessage)) -end - -local function reportNode(node, buffer, level) - buffer = buffer or {} - level = level or 0 - if node.status == TestEnum.TestStatus.Skipped then - return buffer - end - if node.planNode.type == TestEnum.NodeType.Describe then - table.insert(buffer, teamCityEnterSuite(node.planNode.phrase)) - for _, child in ipairs(node.children) do - reportNode(child, buffer, level + 1) - end - table.insert(buffer, teamCityLeaveSuite(node.planNode.phrase)) - return nil - else - table.insert(buffer, teamCityEnterCase(node.planNode.phrase)) - if node.status == TestEnum.TestStatus.Failure then - table.insert(buffer, teamCityFailCase(node.planNode.phrase, table.concat(node.errors,"\n"))) - end - table.insert(buffer, teamCityLeaveCase(node.planNode.phrase)) - return nil - end -end - -local function reportRoot(node) - local buffer = {} - - for _, child in ipairs(node.children) do - reportNode(child, buffer, 0) - end - - return buffer -end - -local function report(root) - local buffer = reportRoot(root) - - return table.concat(buffer, "\n") -end - -function TeamCityReporter.report(results) - local resultBuffer = { - "Test results:", - report(results), - ("%d passed, %d failed, %d skipped"):format( - results.successCount, - results.failureCount, - results.skippedCount - ) - } - - print(table.concat(resultBuffer, "\n")) - - if results.failureCount > 0 then - print(("%d test nodes reported failures."):format(results.failureCount)) - end - - if #results.errors > 0 then - print("Errors reported by tests:") - print("") - - for _, message in ipairs(results.errors) do - TestService:Error(message) - - -- Insert a blank line after each error - print("") - end - end -end - -return TeamCityReporter \ No newline at end of file diff --git a/test-old/TestEZ/Reporters/TextReporter.luau b/test-old/TestEZ/Reporters/TextReporter.luau deleted file mode 100644 index e40d858a0..000000000 --- a/test-old/TestEZ/Reporters/TextReporter.luau +++ /dev/null @@ -1,106 +0,0 @@ ---[[ - The TextReporter uses the results from a completed test to output text to - standard output and TestService. -]] - -local TestService = game:GetService("TestService") - -local TestEnum = require(script.Parent.Parent.TestEnum) - -local INDENT = (" "):rep(3) -local STATUS_SYMBOLS = { - [TestEnum.TestStatus.Success] = "+", - [TestEnum.TestStatus.Failure] = "-", - [TestEnum.TestStatus.Skipped] = "~" -} -local UNKNOWN_STATUS_SYMBOL = "?" - -local TextReporter = {} - -local function compareNodes(a, b) - return a.planNode.phrase:lower() < b.planNode.phrase:lower() -end - -local function reportNode(node, buffer, level) - buffer = buffer or {} - level = level or 0 - - if node.status == TestEnum.TestStatus.Skipped then - return buffer - end - - local line - - if node.status then - local symbol = STATUS_SYMBOLS[node.status] or UNKNOWN_STATUS_SYMBOL - - line = ("%s[%s] %s"):format( - INDENT:rep(level), - symbol, - node.planNode.phrase - ) - else - line = ("%s%s"):format( - INDENT:rep(level), - node.planNode.phrase - ) - end - - table.insert(buffer, line) - table.sort(node.children, compareNodes) - - for _, child in ipairs(node.children) do - reportNode(child, buffer, level + 1) - end - - return buffer -end - -local function reportRoot(node) - local buffer = {} - table.sort(node.children, compareNodes) - - for _, child in ipairs(node.children) do - reportNode(child, buffer, 0) - end - - return buffer -end - -local function report(root) - local buffer = reportRoot(root) - - return table.concat(buffer, "\n") -end - -function TextReporter.report(results) - local resultBuffer = { - "Test results:", - report(results), - ("%d passed, %d failed, %d skipped"):format( - results.successCount, - results.failureCount, - results.skippedCount - ) - } - - print(table.concat(resultBuffer, "\n")) - - if results.failureCount > 0 then - print(("%d test nodes reported failures."):format(results.failureCount)) - end - - if #results.errors > 0 then - print("Errors reported by tests:") - print("") - - for _, message in ipairs(results.errors) do - TestService:Error(message) - - -- Insert a blank line after each error - print("") - end - end -end - -return TextReporter \ No newline at end of file diff --git a/test-old/TestEZ/Reporters/TextReporterQuiet.luau b/test-old/TestEZ/Reporters/TextReporterQuiet.luau deleted file mode 100644 index cbbb1b4a3..000000000 --- a/test-old/TestEZ/Reporters/TextReporterQuiet.luau +++ /dev/null @@ -1,97 +0,0 @@ ---[[ - Copy of TextReporter that doesn't output successful tests. - - This should be temporary, it's just a workaround to make CI environments - happy in the short-term. -]] - -local TestService = game:GetService("TestService") - -local TestEnum = require(script.Parent.Parent.TestEnum) - -local INDENT = (" "):rep(3) -local STATUS_SYMBOLS = { - [TestEnum.TestStatus.Success] = "+", - [TestEnum.TestStatus.Failure] = "-", - [TestEnum.TestStatus.Skipped] = "~" -} -local UNKNOWN_STATUS_SYMBOL = "?" - -local TextReporterQuiet = {} - -local function reportNode(node, buffer, level) - buffer = buffer or {} - level = level or 0 - - if node.status == TestEnum.TestStatus.Skipped then - return buffer - end - - local line - - if node.status ~= TestEnum.TestStatus.Success then - local symbol = STATUS_SYMBOLS[node.status] or UNKNOWN_STATUS_SYMBOL - - line = ("%s[%s] %s"):format( - INDENT:rep(level), - symbol, - node.planNode.phrase - ) - end - - table.insert(buffer, line) - - for _, child in ipairs(node.children) do - reportNode(child, buffer, level + 1) - end - - return buffer -end - -local function reportRoot(node) - local buffer = {} - - for _, child in ipairs(node.children) do - reportNode(child, buffer, 0) - end - - return buffer -end - -local function report(root) - local buffer = reportRoot(root) - - return table.concat(buffer, "\n") -end - -function TextReporterQuiet.report(results) - local resultBuffer = { - "Test results:", - report(results), - ("%d passed, %d failed, %d skipped"):format( - results.successCount, - results.failureCount, - results.skippedCount - ) - } - - print(table.concat(resultBuffer, "\n")) - - if results.failureCount > 0 then - print(("%d test nodes reported failures."):format(results.failureCount)) - end - - if #results.errors > 0 then - print("Errors reported by tests:") - print("") - - for _, message in ipairs(results.errors) do - TestService:Error(message) - - -- Insert a blank line after each error - print("") - end - end -end - -return TextReporterQuiet \ No newline at end of file diff --git a/test-old/TestEZ/TestBootstrap.luau b/test-old/TestEZ/TestBootstrap.luau deleted file mode 100644 index e3641a515..000000000 --- a/test-old/TestEZ/TestBootstrap.luau +++ /dev/null @@ -1,147 +0,0 @@ ---[[ - Provides an interface to quickly run and report tests from a given object. -]] - -local TestPlanner = require(script.Parent.TestPlanner) -local TestRunner = require(script.Parent.TestRunner) -local TextReporter = require(script.Parent.Reporters.TextReporter) - -local TestBootstrap = {} - -local function stripSpecSuffix(name) - return (name:gsub("%.spec$", "")) -end -local function isSpecScript(aScript) - return aScript:IsA("ModuleScript") and aScript.Name:match("%.spec$") -end - -local function getPath(module, root) - root = root or game - - local path = {} - local last = module - - if last.Name == "init.spec" then - -- Use the directory's node for init.spec files. - last = last.Parent - end - - while last ~= nil and last ~= root do - table.insert(path, stripSpecSuffix(last.Name)) - last = last.Parent - end - table.insert(path, stripSpecSuffix(root.Name)) - - return path -end - -local function toStringPath(tablePath) - local stringPath = "" - local first = true - for _, element in ipairs(tablePath) do - if first then - stringPath = element - first = false - else - stringPath = element .. " " .. stringPath - end - end - return stringPath -end - -function TestBootstrap:getModulesImpl(root, modules, current) - modules = modules or {} - current = current or root - - if isSpecScript(current) then - local method = require(current) - local path = getPath(current, root) - local pathString = toStringPath(path) - - table.insert(modules, { - method = method, - path = path, - pathStringForSorting = pathString:lower() - }) - end -end - ---[[ - Find all the ModuleScripts in this tree that are tests. -]] -function TestBootstrap:getModules(root) - local modules = {} - - self:getModulesImpl(root, modules) - - for _, child in ipairs(root:GetDescendants()) do - self:getModulesImpl(root, modules, child) - end - - return modules -end - ---[[ - Runs all test and reports the results using the given test reporter. - - If no reporter is specified, a reasonable default is provided. - - This function demonstrates the expected workflow with this testing system: - 1. Locate test modules - 2. Generate test plan - 3. Run test plan - 4. Report test results - - This means we could hypothetically present a GUI to the developer that shows - the test plan before we execute it, allowing them to toggle specific tests - before they're run, but after they've been identified! -]] -function TestBootstrap:run(roots, reporter, otherOptions) - reporter = reporter or TextReporter - - otherOptions = otherOptions or {} - local showTimingInfo = otherOptions["showTimingInfo"] or false - local testNamePattern = otherOptions["testNamePattern"] - local extraEnvironment = otherOptions["extraEnvironment"] or {} - - if type(roots) ~= "table" then - error(("Bad argument #1 to TestBootstrap:run. Expected table, got %s"):format(typeof(roots)), 2) - end - - local startTime = tick() - - local modules = {} - for _, subRoot in ipairs(roots) do - local newModules = self:getModules(subRoot) - - for _, newModule in ipairs(newModules) do - table.insert(modules, newModule) - end - end - - local afterModules = tick() - - local plan = TestPlanner.createPlan(modules, testNamePattern, extraEnvironment) - local afterPlan = tick() - - local results = TestRunner.runPlan(plan) - local afterRun = tick() - - reporter.report(results) - local afterReport = tick() - - if showTimingInfo then - local timing = { - ("Took %f seconds to locate test modules"):format(afterModules - startTime), - ("Took %f seconds to create test plan"):format(afterPlan - afterModules), - ("Took %f seconds to run tests"):format(afterRun - afterPlan), - ("Took %f seconds to report tests"):format(afterReport - afterRun), - } - - print(table.concat(timing, "\n")) - end - - return results -end - -return TestBootstrap \ No newline at end of file diff --git a/test-old/TestEZ/TestEnum.luau b/test-old/TestEZ/TestEnum.luau deleted file mode 100644 index d8d31b71e..000000000 --- a/test-old/TestEZ/TestEnum.luau +++ /dev/null @@ -1,28 +0,0 @@ ---[[ - Constants used throughout the testing framework. -]] - -local TestEnum = {} - -TestEnum.TestStatus = { - Success = "Success", - Failure = "Failure", - Skipped = "Skipped" -} - -TestEnum.NodeType = { - Describe = "Describe", - It = "It", - BeforeAll = "BeforeAll", - AfterAll = "AfterAll", - BeforeEach = "BeforeEach", - AfterEach = "AfterEach" -} - -TestEnum.NodeModifier = { - None = "None", - Skip = "Skip", - Focus = "Focus" -} - -return TestEnum \ No newline at end of file diff --git a/test-old/TestEZ/TestPlan.luau b/test-old/TestEZ/TestPlan.luau deleted file mode 100644 index 5537f56cb..000000000 --- a/test-old/TestEZ/TestPlan.luau +++ /dev/null @@ -1,304 +0,0 @@ ---[[ - Represents a tree of tests that have been loaded but not necessarily - executed yet. - - TestPlan objects are produced by TestPlanner. -]] - -local TestEnum = require(script.Parent.TestEnum) -local Expectation = require(script.Parent.Expectation) - -local function newEnvironment(currentNode, extraEnvironment) - local env = {} - - if extraEnvironment then - if type(extraEnvironment) ~= "table" then - error(("Bad argument #2 to newEnvironment. Expected table, got %s"):format( - typeof(extraEnvironment)), 2) - end - - for key, value in pairs(extraEnvironment) do - env[key] = value - end - end - - local function addChild(phrase, callback, nodeType, nodeModifier) - local node = currentNode:addChild(phrase, nodeType, nodeModifier) - node.callback = callback - if nodeType == TestEnum.NodeType.Describe then - node:expand() - end - return node - end - - function env.describeFOCUS(phrase, callback) - addChild(phrase, callback, TestEnum.NodeType.Describe, TestEnum.NodeModifier.Focus) - end - - function env.describeSKIP(phrase, callback) - addChild(phrase, callback, TestEnum.NodeType.Describe, TestEnum.NodeModifier.Skip) - end - - function env.describe(phrase, callback, nodeModifier) - addChild(phrase, callback, TestEnum.NodeType.Describe, TestEnum.NodeModifier.None) - end - - function env.itFOCUS(phrase, callback) - addChild(phrase, callback, TestEnum.NodeType.It, TestEnum.NodeModifier.Focus) - end - - function env.itSKIP(phrase, callback) - addChild(phrase, callback, TestEnum.NodeType.It, TestEnum.NodeModifier.Skip) - end - - function env.itFIXME(phrase, callback) - local node = addChild(phrase, callback, TestEnum.NodeType.It, TestEnum.NodeModifier.Skip) - warn("FIXME: broken test", node:getFullName()) - end - - function env.it(phrase, callback, nodeModifier) - addChild(phrase, callback, TestEnum.NodeType.It, TestEnum.NodeModifier.None) - end - - -- Incrementing counter used to ensure that beforeAll, afterAll, beforeEach, afterEach have unique phrases - local lifecyclePhaseId = 0 - - local lifecycleHooks = { - [TestEnum.NodeType.BeforeAll] = "beforeAll", - [TestEnum.NodeType.AfterAll] = "afterAll", - [TestEnum.NodeType.BeforeEach] = "beforeEach", - [TestEnum.NodeType.AfterEach] = "afterEach" - } - - for nodeType, name in pairs(lifecycleHooks) do - env[name] = function(callback) - addChild(name .. "_" .. tostring(lifecyclePhaseId), callback, nodeType, TestEnum.NodeModifier.None) - lifecyclePhaseId = lifecyclePhaseId + 1 - end - end - - function env.FIXME(optionalMessage) - warn("FIXME: broken test", currentNode:getFullName(), optionalMessage or "") - - currentNode.modifier = TestEnum.NodeModifier.Skip - end - - function env.FOCUS() - currentNode.modifier = TestEnum.NodeModifier.Focus - end - - function env.SKIP() - currentNode.modifier = TestEnum.NodeModifier.Skip - end - - --[[ - This function is deprecated. Calling it is a no-op beyond generating a - warning. - ]] - function env.HACK_NO_XPCALL() - warn("HACK_NO_XPCALL is deprecated. It is now safe to yield in an " .. - "xpcall, so this is no longer necessary. It can be safely deleted.") - end - - env.fit = env.itFOCUS - env.xit = env.itSKIP - env.fdescribe = env.describeFOCUS - env.xdescribe = env.describeSKIP - - env.expect = setmetatable({ - extend = function(...) - error("Cannot call \"expect.extend\" from within a \"describe\" node.") - end, - }, { - __call = function(_self, ...) - return Expectation.new(...) - end, - }) - - return env -end - -local TestNode = {} -TestNode.__index = TestNode - ---[[ - Create a new test node. A pointer to the test plan, a phrase to describe it - and the type of node it is are required. The modifier is optional and will - be None if left blank. -]] -function TestNode.new(plan, phrase, nodeType, nodeModifier) - nodeModifier = nodeModifier or TestEnum.NodeModifier.None - - local node = { - plan = plan, - phrase = phrase, - type = nodeType, - modifier = nodeModifier, - children = {}, - callback = nil, - parent = nil, - } - - node.environment = newEnvironment(node, plan.extraEnvironment) - return setmetatable(node, TestNode) -end - -local function getModifier(name, pattern, modifier) - if pattern and (modifier == nil or modifier == TestEnum.NodeModifier.None) then - if name:match(pattern) then - return TestEnum.NodeModifier.Focus - else - return TestEnum.NodeModifier.Skip - end - end - return modifier -end - -function TestNode:addChild(phrase, nodeType, nodeModifier) - if nodeType == TestEnum.NodeType.It then - for _, child in pairs(self.children) do - if child.phrase == phrase then - error("Duplicate it block found: " .. child:getFullName()) - end - end - end - - local childName = self:getFullName() .. " " .. phrase - nodeModifier = getModifier(childName, self.plan.testNamePattern, nodeModifier) - local child = TestNode.new(self.plan, phrase, nodeType, nodeModifier) - child.parent = self - table.insert(self.children, child) - return child -end - ---[[ - Join the names of all the nodes back to the parent. -]] -function TestNode:getFullName() - if self.parent then - local parentPhrase = self.parent:getFullName() - if parentPhrase then - return parentPhrase .. " " .. self.phrase - end - end - return self.phrase -end - ---[[ - Expand a node by setting its callback environment and then calling it. Any - further it and describe calls within the callback will be added to the tree. -]] -function TestNode:expand() - local originalEnv = getfenv(self.callback) - local callbackEnv = setmetatable({}, { __index = originalEnv }) - for key, value in pairs(self.environment) do - callbackEnv[key] = value - end - -- Copy 'script' directly to new env to make Studio debugger happy. - -- Studio debugger does not look into __index, because of security reasons - callbackEnv.script = originalEnv.script - setfenv(self.callback, callbackEnv) - - local success, result = xpcall(self.callback, function(message) - return debug.traceback(tostring(message), 2) - end) - - if not success then - self.loadError = result - end -end - -local TestPlan = {} -TestPlan.__index = TestPlan - ---[[ - Create a new, empty TestPlan. -]] -function TestPlan.new(testNamePattern, extraEnvironment) - local plan = { - children = {}, - testNamePattern = testNamePattern, - extraEnvironment = extraEnvironment, - } - - return setmetatable(plan, TestPlan) -end - ---[[ - Add a new child under the test plan's root node. -]] -function TestPlan:addChild(phrase, nodeType, nodeModifier) - nodeModifier = getModifier(phrase, self.testNamePattern, nodeModifier) - local child = TestNode.new(self, phrase, nodeType, nodeModifier) - table.insert(self.children, child) - return child -end - ---[[ - Add a new describe node with the given method as a callback. Generates or - reuses all the describe nodes along the path. -]] -function TestPlan:addRoot(path, method) - local curNode = self - for i = #path, 1, -1 do - local nextNode = nil - - for _, child in ipairs(curNode.children) do - if child.phrase == path[i] then - nextNode = child - break - end - end - - if nextNode == nil then - nextNode = curNode:addChild(path[i], TestEnum.NodeType.Describe) - end - - curNode = nextNode - end - - curNode.callback = method - curNode:expand() -end - ---[[ - Calls the given callback on all nodes in the tree, traversed depth-first. -]] -function TestPlan:visitAllNodes(callback, root, level) - root = root or self - level = level or 0 - - for _, child in ipairs(root.children) do - callback(child, level) - - self:visitAllNodes(callback, child, level + 1) - end -end - ---[[ - Visualizes the test plan in a simple format, suitable for debugging the test - plan's structure. -]] -function TestPlan:visualize() - local buffer = {} - self:visitAllNodes(function(node, level) - table.insert(buffer, (" "):rep(3 * level) .. node.phrase) - end) - return table.concat(buffer, "\n") -end - ---[[ - Gets a list of all nodes in the tree for which the given callback returns - true. -]] -function TestPlan:findNodes(callback) - local results = {} - self:visitAllNodes(function(node) - if callback(node) then - table.insert(results, node) - end - end) - return results -end - -return TestPlan diff --git a/test-old/TestEZ/TestPlanner.luau b/test-old/TestEZ/TestPlanner.luau deleted file mode 100644 index 6612ff59d..000000000 --- a/test-old/TestEZ/TestPlanner.luau +++ /dev/null @@ -1,40 +0,0 @@ ---[[ - Turns a series of specification functions into a test plan. - - Uses a TestPlanBuilder to keep track of the state of the tree being built. -]] -local TestPlan = require(script.Parent.TestPlan) - -local TestPlanner = {} - ---[[ - Create a new TestPlan from a list of specification functions. - - These functions should call a combination of `describe` and `it` (and their - variants), which will be turned into a test plan to be executed. - - Parameters: - - modulesList - list of tables describing test modules { - method, -- specification function described above - path, -- array of parent entires, first element is the leaf that owns `method` - pathStringForSorting -- a string representation of `path`, used for sorting of the test plan - } - - testNamePattern - Only tests matching this Lua pattern string will run. Pass empty or nil to run all tests - - extraEnvironment - Lua table holding additional functions and variables to be injected into the specification - function during execution -]] -function TestPlanner.createPlan(modulesList, testNamePattern, extraEnvironment) - local plan = TestPlan.new(testNamePattern, extraEnvironment) - - table.sort(modulesList, function(a, b) - return a.pathStringForSorting < b.pathStringForSorting - end) - - for _, module in ipairs(modulesList) do - plan:addRoot(module.path, module.method) - end - - return plan -end - -return TestPlanner \ No newline at end of file diff --git a/test-old/TestEZ/TestResults.luau b/test-old/TestEZ/TestResults.luau deleted file mode 100644 index c39c8297b..000000000 --- a/test-old/TestEZ/TestResults.luau +++ /dev/null @@ -1,112 +0,0 @@ ---[[ - Represents a tree of test results. - - Each node in the tree corresponds directly to a node in a corresponding - TestPlan, accessible via the 'planNode' field. - - TestResults objects are produced by TestRunner using TestSession as state. -]] - -local TestEnum = require(script.Parent.TestEnum) - -local STATUS_SYMBOLS = { - [TestEnum.TestStatus.Success] = "+", - [TestEnum.TestStatus.Failure] = "-", - [TestEnum.TestStatus.Skipped] = "~" -} - -local TestResults = {} - -TestResults.__index = TestResults - ---[[ - Create a new TestResults tree that's linked to the given TestPlan. -]] -function TestResults.new(plan) - local self = { - successCount = 0, - failureCount = 0, - skippedCount = 0, - planNode = plan, - children = {}, - errors = {} - } - - setmetatable(self, TestResults) - - return self -end - ---[[ - Create a new result node that can be inserted into a TestResult tree. -]] -function TestResults.createNode(planNode) - local node = { - planNode = planNode, - children = {}, - errors = {}, - status = nil - } - - return node -end - ---[[ - Visit all test result nodes, depth-first. -]] -function TestResults:visitAllNodes(callback, root) - root = root or self - - for _, child in ipairs(root.children) do - callback(child) - - self:visitAllNodes(callback, child) - end -end - ---[[ - Creates a debug visualization of the test results. -]] -function TestResults:visualize(root, level) - root = root or self - level = level or 0 - - local buffer = {} - - for _, child in ipairs(root.children) do - if child.planNode.type == TestEnum.NodeType.It then - local symbol = STATUS_SYMBOLS[child.status] or "?" - local str = ("%s[%s] %s"):format( - (" "):rep(3 * level), - symbol, - child.planNode.phrase - ) - - if child.messages and #child.messages > 0 then - str = str .. "\n " .. (" "):rep(3 * level) .. table.concat(child.messages, "\n " .. (" "):rep(3 * level)) - end - - table.insert(buffer, str) - else - local str = ("%s%s"):format( - (" "):rep(3 * level), - child.planNode.phrase or "" - ) - - if child.status then - str = str .. (" (%s)"):format(child.status) - end - - table.insert(buffer, str) - - if #child.children > 0 then - local text = self:visualize(child, level + 1) - table.insert(buffer, text) - end - end - end - - return table.concat(buffer, "\n") -end - -return TestResults \ No newline at end of file diff --git a/test-old/TestEZ/TestRunner.luau b/test-old/TestEZ/TestRunner.luau deleted file mode 100644 index 2ccff8185..000000000 --- a/test-old/TestEZ/TestRunner.luau +++ /dev/null @@ -1,188 +0,0 @@ ---[[ - Contains the logic to run a test plan and gather test results from it. - - TestRunner accepts a TestPlan object, executes the planned tests, and - produces a TestResults object. While the tests are running, the system's - state is contained inside a TestSession object. -]] - -local TestEnum = require(script.Parent.TestEnum) -local TestSession = require(script.Parent.TestSession) -local LifecycleHooks = require(script.Parent.LifecycleHooks) - -local RUNNING_GLOBAL = "__TESTEZ_RUNNING_TEST__" - -local TestRunner = { - environment = {} -} - -local function wrapExpectContextWithPublicApi(expectationContext) - return setmetatable({ - extend = function(...) - expectationContext:extend(...) - end, - }, { - __call = function(_self, ...) - return expectationContext:startExpectationChain(...) - end, - }) -end - ---[[ - Runs the given TestPlan and returns a TestResults object representing the - results of the run. -]] -function TestRunner.runPlan(plan) - local session = TestSession.new(plan) - local lifecycleHooks = LifecycleHooks.new() - - local exclusiveNodes = plan:findNodes(function(node) - return node.modifier == TestEnum.NodeModifier.Focus - end) - - session.hasFocusNodes = #exclusiveNodes > 0 - - TestRunner.runPlanNode(session, plan, lifecycleHooks) - - return session:finalize() -end - ---[[ - Run the given test plan node and its descendants, using the given test - session to store all of the results. -]] -function TestRunner.runPlanNode(session, planNode, lifecycleHooks) - local function runCallback(callback, messagePrefix) - local success = true - local errorMessage - -- Any code can check RUNNING_GLOBAL to fork behavior based on - -- whether a test is running. We use this to avoid accessing - -- protected APIs; it's a workaround that will go away someday. - _G[RUNNING_GLOBAL] = true - - messagePrefix = messagePrefix or "" - - local testEnvironment = getfenv(callback) - - for key, value in pairs(TestRunner.environment) do - testEnvironment[key] = value - end - - testEnvironment.fail = function(message) - if message == nil then - message = "fail() was called." - end - - success = false - errorMessage = messagePrefix .. debug.traceback(tostring(message), 2) - end - - testEnvironment.expect = wrapExpectContextWithPublicApi(session:getExpectationContext()) - - local context = session:getContext() - - local nodeSuccess, nodeResult = xpcall( - function() - callback(context) - end, - function(message) - return messagePrefix .. debug.traceback(tostring(message), 2) - end - ) - - -- If a node threw an error, we prefer to use that message over - -- one created by fail() if it was set. - if not nodeSuccess then - success = false - errorMessage = nodeResult - end - - _G[RUNNING_GLOBAL] = nil - - return success, errorMessage - end - - local function runNode(childPlanNode) - -- Errors can be set either via `error` propagating upwards or - -- by a test calling fail([message]). - - for _, hook in ipairs(lifecycleHooks:getBeforeEachHooks()) do - local success, errorMessage = runCallback(hook, "beforeEach hook: ") - if not success then - return false, errorMessage - end - end - - local testSuccess, testErrorMessage = runCallback(childPlanNode.callback) - - for _, hook in ipairs(lifecycleHooks:getAfterEachHooks()) do - local success, errorMessage = runCallback(hook, "afterEach hook: ") - if not success then - if not testSuccess then - return false, testErrorMessage .. "\nWhile cleaning up the failed test another error was found:\n" .. errorMessage - end - return false, errorMessage - end - end - - if not testSuccess then - return false, testErrorMessage - end - - return true, nil - end - - lifecycleHooks:pushHooksFrom(planNode) - - local halt = false - for _, hook in ipairs(lifecycleHooks:getBeforeAllHooks()) do - local success, errorMessage = runCallback(hook, "beforeAll hook: ") - if not success then - session:addDummyError("beforeAll", errorMessage) - halt = true - end - end - - if not halt then - for _, childPlanNode in ipairs(planNode.children) do - if childPlanNode.type == TestEnum.NodeType.It then - session:pushNode(childPlanNode) - if session:shouldSkip() then - session:setSkipped() - else - local success, errorMessage = runNode(childPlanNode) - - if success then - session:setSuccess() - else - session:setError(errorMessage) - end - end - session:popNode() - elseif childPlanNode.type == TestEnum.NodeType.Describe then - session:pushNode(childPlanNode) - TestRunner.runPlanNode(session, childPlanNode, lifecycleHooks) - - -- Did we have an error trying build a test plan? - if childPlanNode.loadError then - local message = "Error during planning: " .. childPlanNode.loadError - session:setError(message) - else - session:setStatusFromChildren() - end - session:popNode() - end - end - end - - for _, hook in ipairs(lifecycleHooks:getAfterAllHooks()) do - local success, errorMessage = runCallback(hook, "afterAll hook: ") - if not success then - session:addDummyError("afterAll", errorMessage) - end - end - - lifecycleHooks:popHooks() -end - -return TestRunner diff --git a/test-old/TestEZ/TestSession.luau b/test-old/TestEZ/TestSession.luau deleted file mode 100644 index 285e11c3c..000000000 --- a/test-old/TestEZ/TestSession.luau +++ /dev/null @@ -1,243 +0,0 @@ ---[[ - Represents the state relevant while executing a test plan. - - Used by TestRunner to produce a TestResults object. - - Uses the same tree building structure as TestPlanBuilder; TestSession keeps - track of a stack of nodes that represent the current path through the tree. -]] - -local TestEnum = require(script.Parent.TestEnum) -local TestResults = require(script.Parent.TestResults) -local Context = require(script.Parent.Context) -local ExpectationContext = require(script.Parent.ExpectationContext) - -local TestSession = {} - -TestSession.__index = TestSession - ---[[ - Create a TestSession related to the given TestPlan. - - The resulting TestResults object will be linked to this TestPlan. -]] -function TestSession.new(plan) - local self = { - results = TestResults.new(plan), - nodeStack = {}, - contextStack = {}, - expectationContextStack = {}, - hasFocusNodes = false - } - - setmetatable(self, TestSession) - - return self -end - ---[[ - Calculate success, failure, and skipped test counts in the tree at the - current point in the execution. -]] -function TestSession:calculateTotals() - local results = self.results - - results.successCount = 0 - results.failureCount = 0 - results.skippedCount = 0 - - results:visitAllNodes(function(node) - local status = node.status - local nodeType = node.planNode.type - - if nodeType == TestEnum.NodeType.It then - if status == TestEnum.TestStatus.Success then - results.successCount = results.successCount + 1 - elseif status == TestEnum.TestStatus.Failure then - results.failureCount = results.failureCount + 1 - elseif status == TestEnum.TestStatus.Skipped then - results.skippedCount = results.skippedCount + 1 - end - end - end) -end - ---[[ - Gathers all of the errors reported by tests and puts them at the top level - of the TestResults object. -]] -function TestSession:gatherErrors() - local results = self.results - - results.errors = {} - - results:visitAllNodes(function(node) - if #node.errors > 0 then - for _, message in ipairs(node.errors) do - table.insert(results.errors, message) - end - end - end) -end - ---[[ - Calculates test totals, verifies the tree is valid, and returns results. -]] -function TestSession:finalize() - if #self.nodeStack ~= 0 then - error("Cannot finalize TestResults with nodes still on the stack!", 2) - end - - self:calculateTotals() - self:gatherErrors() - - return self.results -end - ---[[ - Create a new test result node and push it onto the navigation stack. -]] -function TestSession:pushNode(planNode) - local node = TestResults.createNode(planNode) - local lastNode = self.nodeStack[#self.nodeStack] or self.results - table.insert(lastNode.children, node) - table.insert(self.nodeStack, node) - - local lastContext = self.contextStack[#self.contextStack] - local context = Context.new(lastContext) - table.insert(self.contextStack, context) - - local lastExpectationContext = self.expectationContextStack[#self.expectationContextStack] - local expectationContext = ExpectationContext.new(lastExpectationContext) - table.insert(self.expectationContextStack, expectationContext) -end - ---[[ - Pops a node off of the navigation stack. -]] -function TestSession:popNode() - assert(#self.nodeStack > 0, "Tried to pop from an empty node stack!") - table.remove(self.nodeStack, #self.nodeStack) - table.remove(self.contextStack, #self.contextStack) - table.remove(self.expectationContextStack, #self.expectationContextStack) -end - ---[[ - Gets the Context object for the current node. -]] -function TestSession:getContext() - assert(#self.contextStack > 0, "Tried to get context from an empty stack!") - return self.contextStack[#self.contextStack] -end - - -function TestSession:getExpectationContext() - assert(#self.expectationContextStack > 0, "Tried to get expectationContext from an empty stack!") - return self.expectationContextStack[#self.expectationContextStack] -end - ---[[ - Tells whether the current test we're in should be skipped. -]] -function TestSession:shouldSkip() - -- If our test tree had any exclusive tests, then normal tests are skipped! - if self.hasFocusNodes then - for i = #self.nodeStack, 1, -1 do - local node = self.nodeStack[i] - - -- Skipped tests are still skipped - if node.planNode.modifier == TestEnum.NodeModifier.Skip then - return true - end - - -- Focused tests are the only ones that aren't skipped - if node.planNode.modifier == TestEnum.NodeModifier.Focus then - return false - end - end - - return true - else - for i = #self.nodeStack, 1, -1 do - local node = self.nodeStack[i] - - if node.planNode.modifier == TestEnum.NodeModifier.Skip then - return true - end - end - end - - return false -end - ---[[ - Set the current node's status to Success. -]] -function TestSession:setSuccess() - assert(#self.nodeStack > 0, "Attempting to set success status on empty stack") - self.nodeStack[#self.nodeStack].status = TestEnum.TestStatus.Success -end - ---[[ - Set the current node's status to Skipped. -]] -function TestSession:setSkipped() - assert(#self.nodeStack > 0, "Attempting to set skipped status on empty stack") - self.nodeStack[#self.nodeStack].status = TestEnum.TestStatus.Skipped -end - ---[[ - Set the current node's status to Failure and adds a message to its list of - errors. -]] -function TestSession:setError(message) - assert(#self.nodeStack > 0, "Attempting to set error status on empty stack") - local last = self.nodeStack[#self.nodeStack] - last.status = TestEnum.TestStatus.Failure - table.insert(last.errors, message) -end - ---[[ - Add a dummy child node to the current node to hold the given error. This - allows an otherwise empty describe node to report an error in a more natural - way. -]] -function TestSession:addDummyError(phrase, message) - self:pushNode({type = TestEnum.NodeType.It, phrase = phrase}) - self:setError(message) - self:popNode() - self.nodeStack[#self.nodeStack].status = TestEnum.TestStatus.Failure -end - ---[[ - Set the current node's status based on that of its children. If all children - are skipped, mark it as skipped. If any are fails, mark it as failed. - Otherwise, mark it as success. -]] -function TestSession:setStatusFromChildren() - assert(#self.nodeStack > 0, "Attempting to set status from children on empty stack") - - local last = self.nodeStack[#self.nodeStack] - local status = TestEnum.TestStatus.Success - local skipped = true - - -- If all children were skipped, then we were skipped - -- If any child failed, then we failed! - for _, child in ipairs(last.children) do - if child.status ~= TestEnum.TestStatus.Skipped then - skipped = false - - if child.status == TestEnum.TestStatus.Failure then - status = TestEnum.TestStatus.Failure - end - end - end - - if skipped then - status = TestEnum.TestStatus.Skipped - end - - last.status = status -end - -return TestSession diff --git a/test-old/TestEZ/init.luau b/test-old/TestEZ/init.luau deleted file mode 100644 index 9c702a1ad..000000000 --- a/test-old/TestEZ/init.luau +++ /dev/null @@ -1,40 +0,0 @@ -local Expectation = require(script.Expectation) -local TestBootstrap = require(script.TestBootstrap) -local TestEnum = require(script.TestEnum) -local TestPlan = require(script.TestPlan) -local TestPlanner = require(script.TestPlanner) -local TestResults = require(script.TestResults) -local TestRunner = require(script.TestRunner) -local TestSession = require(script.TestSession) -local TextReporter = require(script.Reporters.TextReporter) -local TextReporterQuiet = require(script.Reporters.TextReporterQuiet) -local TeamCityReporter = require(script.Reporters.TeamCityReporter) - -local function run(testRoot, callback) - local modules = TestBootstrap:getModules(testRoot) - local plan = TestPlanner.createPlan(modules) - local results = TestRunner.runPlan(plan) - - callback(results) -end - -local TestEZ = { - run = run, - - Expectation = Expectation, - TestBootstrap = TestBootstrap, - TestEnum = TestEnum, - TestPlan = TestPlan, - TestPlanner = TestPlanner, - TestResults = TestResults, - TestRunner = TestRunner, - TestSession = TestSession, - - Reporters = { - TextReporter = TextReporter, - TextReporterQuiet = TextReporterQuiet, - TeamCityReporter = TeamCityReporter, - }, -} - -return TestEZ \ No newline at end of file From 1091426c894073e89592868381de474d560c385e Mon Sep 17 00:00:00 2001 From: Daniel P H Fox Date: Sun, 2 Feb 2025 02:34:09 +0000 Subject: [PATCH 07/14] Safe.spec.luau --- test/fusion/Utility/Safe.spec.luau | 52 ++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 test/fusion/Utility/Safe.spec.luau diff --git a/test/fusion/Utility/Safe.spec.luau b/test/fusion/Utility/Safe.spec.luau new file mode 100644 index 000000000..d62cd71ca --- /dev/null +++ b/test/fusion/Utility/Safe.spec.luau @@ -0,0 +1,52 @@ +--!strict + +local tiniest = require("../../lib/tiniest_for_roblox") + +local Safe = require("../../../src/Utility/Safe") + +return function(tiniest: tiniest.Configured): () + local describe = tiniest.describe + local test = tiniest.test + local expect = tiniest.expect + + test("success", function() + expect( + Safe({ + try = function() + return "foo" + end, + fallback = function() + return "bar" + end + }) + ).is("foo") + end) + + test("failure", function() + expect( + Safe({ + try = function() + error("garb", 0) + return "foo" + end, + fallback = function() + return "bar" + end + }) + ).is("bar") + end) + + test("error reporting", function() + Safe({ + try = function() + error("garb", 0) + return nil + end, + fallback = function(err) + expect(err).is("garb") + return nil + end + }) + end) + +end \ No newline at end of file From 218ddec189fbfdde3fb0e2206631adeb7c510c1c Mon Sep 17 00:00:00 2001 From: Daniel P H Fox Date: Sun, 2 Feb 2025 02:34:09 +0000 Subject: [PATCH 08/14] Safe.spec.luau --- test-old/Spec/Utility/Safe.spec.luau | 59 ---------------------------- test/fusion/Utility/Safe.spec.luau | 51 ++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 59 deletions(-) delete mode 100644 test-old/Spec/Utility/Safe.spec.luau create mode 100644 test/fusion/Utility/Safe.spec.luau diff --git a/test-old/Spec/Utility/Safe.spec.luau b/test-old/Spec/Utility/Safe.spec.luau deleted file mode 100644 index 056fbf157..000000000 --- a/test-old/Spec/Utility/Safe.spec.luau +++ /dev/null @@ -1,59 +0,0 @@ ---!strict ---!nolint LocalUnused -local task = nil -- Disable usage of Roblox's task scheduler - -local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Fusion = ReplicatedStorage.Fusion - -local Safe = require(Fusion.Utility.Safe) - -return function() - local it = getfenv().it - - it("returns values from try() on success", function() - local expect = getfenv().expect - - expect( - Safe { - try = function() - return "foo" - end, - fallback = function() - return "bar" - end - } - ).to.equal("foo") - end) - - it("returns values from fallback() on error", function() - local expect = getfenv().expect - - expect( - Safe { - try = function() - error("garb", 0) - return "foo" - end, - fallback = function() - return "bar" - end - } - ).to.equal("bar") - end) - - it("passes the error on to fallback()", function() - local expect = getfenv().expect - - expect( - Safe { - try = function() - error("garb", 0) - return "foo" - end, - fallback = function(err) - return "bar" .. tostring(err) - end - } - ).to.equal("bargarb") - end) -end \ No newline at end of file diff --git a/test/fusion/Utility/Safe.spec.luau b/test/fusion/Utility/Safe.spec.luau new file mode 100644 index 000000000..650c81183 --- /dev/null +++ b/test/fusion/Utility/Safe.spec.luau @@ -0,0 +1,51 @@ +--!strict + +local tiniest = require("../../lib/tiniest_for_roblox") + +local Safe = require("../../../src/Utility/Safe") + +return function(tiniest: tiniest.Configured): () + local test = tiniest.test + local expect = tiniest.expect + + test("success", function() + expect( + Safe({ + try = function() + return "foo" + end, + fallback = function() + return "bar" + end + }) + ).is("foo") + end) + + test("failure", function() + expect( + Safe({ + try = function() + error("garb", 0) + return "foo" + end, + fallback = function() + return "bar" + end + }) + ).is("bar") + end) + + test("error reporting", function() + Safe({ + try = function() + error("garb", 0) + return nil + end, + fallback = function(err) + expect(err).is("garb") + return nil + end + }) + end) + +end \ No newline at end of file From 39ab7cbe1788d764e8004514e59a8bb0789f04d8 Mon Sep 17 00:00:00 2001 From: Daniel P H Fox Date: Mon, 3 Feb 2025 10:23:24 +0000 Subject: [PATCH 09/14] isSimilar.spec.luau --- test-old/Spec/Utility/isSimilar.spec.luau | 279 ---------------------- test/fusion/Utility/isSimilar.spec.luau | 167 +++++++++++++ 2 files changed, 167 insertions(+), 279 deletions(-) delete mode 100644 test-old/Spec/Utility/isSimilar.spec.luau create mode 100644 test/fusion/Utility/isSimilar.spec.luau diff --git a/test-old/Spec/Utility/isSimilar.spec.luau b/test-old/Spec/Utility/isSimilar.spec.luau deleted file mode 100644 index 1e8d7565d..000000000 --- a/test-old/Spec/Utility/isSimilar.spec.luau +++ /dev/null @@ -1,279 +0,0 @@ ---!strict ---!nolint LocalUnused ---!nolint LocalShadow -local task = nil -- Disable usage of Roblox's task scheduler - -local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Fusion = ReplicatedStorage.Fusion - -local isSimilar = require(Fusion.Utility.isSimilar) - -return function() - local it = getfenv().it - - it("should return similar for identical values", function() - local expect = getfenv().expect - - local value = 123 - - expect(isSimilar(value, value)).to.equal(true) - end) - - it("should return non-similar for different values", function() - local expect = getfenv().expect - - local value1 = 123 - local value2 = 321 - - expect(isSimilar(value1, value2)).to.equal(false) - end) - - it("should return similar for any NaN values", function() - local expect = getfenv().expect - - local nan1 = 0 / 0 - local nan2 = math.huge / math.huge - - expect(isSimilar(nan1, nan1)).to.equal(true) - expect(isSimilar(nan1, nan2)).to.equal(true) - end) - - it("should return non-similar for mutable tables", function() - local expect = getfenv().expect - - local initialTable = { foo = 123, bar = "hello" } - local similarTable = { foo = 123, bar = "hello" } - local differentTable = { foo = 321, bar = "world" } - - expect(isSimilar(initialTable, initialTable)).to.equal(false) - expect(isSimilar(initialTable, similarTable)).to.equal(false) - expect(isSimilar(initialTable, differentTable)).to.equal(false) - end) - - it("should return similar for equal frozen tables", function() - local expect = getfenv().expect - - local initialTable = table.freeze { foo = 123, bar = "hello" } - local similarTable = table.freeze { foo = 123, bar = "hello" } - local differentTable = table.freeze { foo = 321, bar = "world" } - - expect(isSimilar(initialTable, initialTable)).to.equal(true) - expect(isSimilar(initialTable, similarTable)).to.equal(false) - expect(isSimilar(initialTable, differentTable)).to.equal(false) - end) - - it("should return similar for normal userdatas", function() - local expect = getfenv().expect - - local userdata1 = newproxy(false) - local userdata2 = newproxy(false) - local userdata3 = newproxy(true) - local userdata4 = newproxy(true) - - expect(isSimilar(userdata1, userdata1)).to.equal(true) - expect(isSimilar(userdata1, userdata2)).to.equal(false) - expect(isSimilar(userdata1, userdata3)).to.equal(false) - expect(isSimilar(userdata1, userdata4)).to.equal(false) - - expect(isSimilar(userdata2, userdata1)).to.equal(false) - expect(isSimilar(userdata2, userdata2)).to.equal(true) - expect(isSimilar(userdata2, userdata3)).to.equal(false) - expect(isSimilar(userdata2, userdata4)).to.equal(false) - - expect(isSimilar(userdata3, userdata1)).to.equal(false) - expect(isSimilar(userdata3, userdata2)).to.equal(false) - expect(isSimilar(userdata3, userdata3)).to.equal(true) - expect(isSimilar(userdata3, userdata4)).to.equal(false) - - expect(isSimilar(userdata4, userdata1)).to.equal(false) - expect(isSimilar(userdata4, userdata2)).to.equal(false) - expect(isSimilar(userdata4, userdata3)).to.equal(false) - expect(isSimilar(userdata4, userdata4)).to.equal(true) - end) - - it("should respect __eq for userdatas", function() - local expect = getfenv().expect - - local expectedOutput = true - local original = newproxy(true) - getmetatable(original).__eq = function() - return expectedOutput - end - local sameEq = newproxy(true) - getmetatable(sameEq).__eq = getmetatable(original).__eq - local diffEq = newproxy(true) - getmetatable(diffEq).__eq = function() - return expectedOutput - end - - expect(isSimilar(original, diffEq)).to.equal(false) - expect(isSimilar(sameEq, diffEq)).to.equal(false) - expect(isSimilar(diffEq, original)).to.equal(false) - expect(isSimilar(diffEq, sameEq)).to.equal(false) - expectedOutput = true - expect(isSimilar(original, original)).to.equal(expectedOutput) - expect(isSimilar(original, sameEq)).to.equal(expectedOutput) - expect(isSimilar(sameEq, original)).to.equal(expectedOutput) - expect(isSimilar(sameEq, sameEq)).to.equal(expectedOutput) - expect(isSimilar(diffEq, diffEq)).to.equal(expectedOutput) - expectedOutput = false - expect(isSimilar(original, original)).to.equal(expectedOutput) - expect(isSimilar(original, sameEq)).to.equal(expectedOutput) - expect(isSimilar(sameEq, original)).to.equal(expectedOutput) - expect(isSimilar(sameEq, sameEq)).to.equal(expectedOutput) - expect(isSimilar(diffEq, diffEq)).to.equal(expectedOutput) - end) - - it("should respect __eq for mutable tables", function() - local expect = getfenv().expect - - local expectedOutput - local original = setmetatable({}, { - __eq = function(a, b) - return expectedOutput - end - }) - local sameEq = setmetatable({}, { - __eq = getmetatable(original).__eq - }) - local diffEq = setmetatable({}, { - __eq = function(a, b) - return expectedOutput - end - }) - - expect(isSimilar(original, diffEq)).to.equal(false) - expect(isSimilar(sameEq, diffEq)).to.equal(false) - expect(isSimilar(diffEq, original)).to.equal(false) - expect(isSimilar(diffEq, sameEq)).to.equal(false) - expectedOutput = true - expect(isSimilar(original, original)).to.equal(expectedOutput) - expect(isSimilar(original, sameEq)).to.equal(expectedOutput) - expect(isSimilar(sameEq, original)).to.equal(expectedOutput) - expect(isSimilar(sameEq, sameEq)).to.equal(expectedOutput) - expect(isSimilar(diffEq, diffEq)).to.equal(expectedOutput) - expectedOutput = false - expect(isSimilar(original, original)).to.equal(expectedOutput) - expect(isSimilar(original, sameEq)).to.equal(expectedOutput) - expect(isSimilar(sameEq, original)).to.equal(expectedOutput) - expect(isSimilar(sameEq, sameEq)).to.equal(expectedOutput) - expect(isSimilar(diffEq, diffEq)).to.equal(expectedOutput) - end) - - it("should respect __eq even when the metatable is locked opaquely", function() - local expect = getfenv().expect - - local expectedOutput - local function eq() - return expectedOutput - end - local original: any = setmetatable({}, { - __eq = eq, - __metatable = "This metatable is locked" - }) - local sameEq: any = setmetatable({}, { - __eq = eq, - __metatable = "This metatable is locked" - }) - local diffEq: any = setmetatable({}, { - __eq = function() - return expectedOutput - end, - __metatable = "This metatable is locked" - }) - - expect(isSimilar(original, diffEq)).to.equal(false) - expect(isSimilar(sameEq, diffEq)).to.equal(false) - expect(isSimilar(diffEq, original)).to.equal(false) - expect(isSimilar(diffEq, sameEq)).to.equal(false) - expectedOutput = true - expect(isSimilar(original, original)).to.equal(expectedOutput) - expect(isSimilar(original, sameEq)).to.equal(expectedOutput) - expect(isSimilar(sameEq, original)).to.equal(expectedOutput) - expect(isSimilar(sameEq, sameEq)).to.equal(expectedOutput) - expect(isSimilar(diffEq, diffEq)).to.equal(expectedOutput) - expectedOutput = false - expect(isSimilar(original, original)).to.equal(expectedOutput) - expect(isSimilar(original, sameEq)).to.equal(expectedOutput) - expect(isSimilar(sameEq, original)).to.equal(expectedOutput) - expect(isSimilar(sameEq, sameEq)).to.equal(expectedOutput) - expect(isSimilar(diffEq, diffEq)).to.equal(expectedOutput) - end) - - it("should respect __eq for frozen tables", function() - local expect = getfenv().expect - - local expectedOutput = true - local immutable1 = table.freeze(setmetatable({}, { - __eq = function(a, b) - return expectedOutput - end - })) - local immutable1b = table.freeze(setmetatable({}, getmetatable(immutable1))) - local immutable2 = table.freeze(setmetatable({}, { - __eq = function(a, b) - return expectedOutput - end - })) - - expect(isSimilar(immutable1, immutable2)).to.equal(false) - expect(isSimilar(immutable1b, immutable2)).to.equal(false) - expect(isSimilar(immutable2, immutable1)).to.equal(false) - expect(isSimilar(immutable2, immutable1b)).to.equal(false) - expect(isSimilar(immutable1, immutable1)).to.equal(expectedOutput) - expect(isSimilar(immutable1, immutable1b)).to.equal(expectedOutput) - expect(isSimilar(immutable1b, immutable1)).to.equal(expectedOutput) - expect(isSimilar(immutable1b, immutable1b)).to.equal(expectedOutput) - expect(isSimilar(immutable2, immutable2)).to.equal(expectedOutput) - expectedOutput = not expectedOutput - expect(isSimilar(immutable1, immutable1)).to.equal(expectedOutput) - expect(isSimilar(immutable1, immutable1b)).to.equal(expectedOutput) - expect(isSimilar(immutable1b, immutable1)).to.equal(expectedOutput) - expect(isSimilar(immutable1b, immutable1b)).to.equal(expectedOutput) - expect(isSimilar(immutable2, immutable2)).to.equal(expectedOutput) - end) - - it("should use equality for Roblox data types", function() - local expect = getfenv().expect - - local a, b = Vector2.new(10, 10), Vector2.new(10, 10) - expect(isSimilar(a, a)).to.equal(true) - expect(isSimilar(a, b)).to.equal(true) - expect(isSimilar(b, a)).to.equal(true) - expect(isSimilar(b, b)).to.equal(true) - - local a, b = Vector3.new(10, 10, 10), Vector3.new(10, 10, 10) - expect(isSimilar(a, a)).to.equal(true) - expect(isSimilar(a, b)).to.equal(true) - expect(isSimilar(b, a)).to.equal(true) - expect(isSimilar(b, b)).to.equal(true) - - local a, b = CFrame.new(10, 10, 10), CFrame.new(10, 10, 10) - expect(isSimilar(a, a)).to.equal(true) - expect(isSimilar(a, b)).to.equal(true) - expect(isSimilar(b, a)).to.equal(true) - expect(isSimilar(b, b)).to.equal(true) - end) - - it("should correctly handle NaN for Roblox data types", function() - local expect = getfenv().expect - - local a, b = Vector2.new(0/0, 0/0), Vector2.new(0/0, 0/0) - expect(isSimilar(a, a)).to.equal(true) - expect(isSimilar(a, b)).to.equal(true) - expect(isSimilar(b, a)).to.equal(true) - expect(isSimilar(b, b)).to.equal(true) - - local a, b = Vector3.new(0/0, 0/0, 0/0), Vector3.new(0/0, 0/0, 0/0) - expect(isSimilar(a, a)).to.equal(true) - expect(isSimilar(a, b)).to.equal(true) - expect(isSimilar(b, a)).to.equal(true) - expect(isSimilar(b, b)).to.equal(true) - - local a, b = CFrame.new(0/0, 0/0, 0/0), CFrame.new(0/0, 0/0, 0/0) - expect(isSimilar(a, a)).to.equal(true) - expect(isSimilar(a, b)).to.equal(true) - expect(isSimilar(b, a)).to.equal(true) - expect(isSimilar(b, b)).to.equal(true) - end) -end \ No newline at end of file diff --git a/test/fusion/Utility/isSimilar.spec.luau b/test/fusion/Utility/isSimilar.spec.luau new file mode 100644 index 000000000..933c1d179 --- /dev/null +++ b/test/fusion/Utility/isSimilar.spec.luau @@ -0,0 +1,167 @@ +--!strict + +local tiniest = require("../../lib/tiniest_for_roblox") + +local isSimilar = require("../../../src/Utility/isSimilar") + +return function(tiniest: tiniest.Configured): () + local describe = tiniest.describe + local test = tiniest.test + local expect = tiniest.expect + + local function resultOf(a, b) + local forward = isSimilar(a, b) + local reverse = isSimilar(b, a) + expect(forward).is(reverse) + return forward + end + + test("identical", function() + expect(resultOf(123, 123)).is(true) + end) + + test("different", function() + expect(resultOf(123, 321)).is(false) + end) + + test("nan", function() + expect(resultOf(0 / 0, math.huge / math.huge)).is(true) + end) + + test("mutable table", function() + local self = { foo = "bar" } + expect(resultOf(self, self)).is(false) + end) + + test("frozen table", function() + local self = table.freeze { foo = "bar" } + local other = table.freeze { foo = "bar" } + expect(resultOf(self, self)).is(true) + expect(resultOf(self, other)).is(false) + end) + + describe("userdata", function() + test("no metatable", function() + local self = newproxy(false) + local other = newproxy(false) + expect(resultOf(self, self)).is(true) + expect(resultOf(self, other)).is(false) + end) + + test("default metatable", function() + local self = newproxy(true) + local other = newproxy(true) + expect(resultOf(self, self)).is(true) + expect(resultOf(self, other)).is(false) + end) + end) + + describe("__eq", function() + + local function runEqTest(constructor: any, ...) + local output + + local original, sameEq, diffEq = constructor(...), constructor(...), constructor(...) + local original_mt = getmetatable(original) + local sameEq_mt = getmetatable(sameEq) + local diffEq_mt = getmetatable(diffEq) + original_mt.__eq = function() + return output + end + sameEq_mt.__eq = original_mt.__eq + diffEq_mt.__eq = function() + return output + end + original_mt.__metatable = "This metatable is locked" + sameEq_mt.__metatable = "This metatable is locked" + diffEq_mt.__metatable = "This metatable is locked" + + expect(resultOf(original, diffEq)).is(false) + expect(resultOf(sameEq, diffEq)).is(false) + output = true + expect(resultOf(original, original)).is(output) + expect(resultOf(original, sameEq)).is(output) + expect(resultOf(sameEq, diffEq)).is(false) + output = false + expect(resultOf(original, original)).is(output) + expect(resultOf(original, sameEq)).is(output) + expect(resultOf(sameEq, diffEq)).is(false) + end + + test("userdata", function() + runEqTest(newproxy, true) + end) + + test("mutable tables", function() + runEqTest(function() + return setmetatable({}, {}) + end) + end) + + test("frozen tables", function() + runEqTest(function() + return table.freeze(setmetatable({}, {})) + end) + end) + end) + + describe("roblox", function() + test("vector2", function() + local a, b = Vector2.new(10, 10), Vector2.new(10, 10) + expect(resultOf(a, a)).is(true) + expect(resultOf(a, b)).is(true) + + local c = Vector2.new(20, 20) + expect(resultOf(a, c)).is(false) + end) + + test("vector3", function() + local a, b = Vector3.new(10, 10, 10), Vector3.new(10, 10, 10) + expect(resultOf(a, a)).is(true) + expect(resultOf(a, b)).is(true) + expect(resultOf(a, b)).is(true) + + local c = Vector3.new(20, 20, 20) + expect(resultOf(a, c)).is(false) + end) + + test("cframe", function() + local a, b = CFrame.new(10, 10, 10), CFrame.new(10, 10, 10) + expect(resultOf(a, a)).is(true) + expect(resultOf(a, b)).is(true) + + local c = CFrame.new(20, 20, 20) + expect(resultOf(a, c)).is(false) + end) + + describe("nan", function() + test("vector2", function() + local a, b = Vector2.new(0 / 0, 0 / 0), Vector2.new(math.huge / math.huge, math.huge / math.huge) + expect(resultOf(a, a)).is(true) + expect(resultOf(a, b)).is(true) + + local c = Vector2.new(20, 20) + expect(resultOf(a, c)).is(false) + end) + + test("vector3", function() + local a, b = Vector3.new(0 / 0, 0 / 0, 0 / 0), Vector3.new(math.huge / math.huge, math.huge / math.huge, math.huge / math.huge) + expect(resultOf(a, a)).is(true) + expect(resultOf(a, b)).is(true) + expect(resultOf(a, b)).is(true) + + local c = Vector3.new(20, 20, 20) + expect(resultOf(a, c)).is(false) + end) + + test("cframe", function() + local a, b = CFrame.new(0 / 0, 0 / 0, 0 / 0), CFrame.new(math.huge / math.huge, math.huge / math.huge, math.huge / math.huge) + expect(resultOf(a, a)).is(true) + expect(resultOf(a, b)).is(true) + + local c = CFrame.new(20, 20, 20) + expect(resultOf(a, c)).is(false) + end) + end) + end) +end \ No newline at end of file From f176e03a848774dbe21c11ae562a6625cc9d7ca0 Mon Sep 17 00:00:00 2001 From: Daniel P H Fox Date: Mon, 3 Feb 2025 10:29:06 +0000 Subject: [PATCH 10/14] Contextual.spec.luau --- test-old/Spec/Utility/Contextual.spec.luau | 100 --------------------- test/fusion/Utility/Contextual.spec.luau | 69 ++++++++++++++ 2 files changed, 69 insertions(+), 100 deletions(-) delete mode 100644 test-old/Spec/Utility/Contextual.spec.luau create mode 100644 test/fusion/Utility/Contextual.spec.luau diff --git a/test-old/Spec/Utility/Contextual.spec.luau b/test-old/Spec/Utility/Contextual.spec.luau deleted file mode 100644 index 5dff28a27..000000000 --- a/test-old/Spec/Utility/Contextual.spec.luau +++ /dev/null @@ -1,100 +0,0 @@ ---!strict ---!nolint LocalUnused -local task = nil -- Disable usage of Roblox's task scheduler - -local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Fusion = ReplicatedStorage.Fusion - -local Contextual = require(Fusion.Utility.Contextual) - -return function() - local it = getfenv().it - - it("should construct a Contextual object", function() - local expect = getfenv().expect - - local ctx = Contextual(nil) - - expect(ctx).to.be.a("table") - expect(ctx.type).to.equal("Contextual") - end) - - it("should provide its default value", function() - local expect = getfenv().expect - - local ctx = Contextual("foo") - - expect(ctx:now()).to.equal("foo") - end) - - it("should correctly scope temporary values", function() - local expect = getfenv().expect - - local ctx = Contextual("foo") - - expect(ctx:now()).to.equal("foo") - - ctx:is("bar"):during(function() - expect(ctx:now()).to.equal("bar") - - ctx:is("baz"):during(function() - expect(ctx:now()).to.equal("baz") - return nil - end) - - expect(ctx:now()).to.equal("bar") - return nil - end) - - expect(ctx:now()).to.equal("foo") - end) - - it("should allow for argument passing", function() - local expect = getfenv().expect - - local ctx = Contextual("foo") - - local function test(a, b, c, d) - expect(a).to.equal("a") - expect(b).to.equal("b") - expect(c).to.equal("c") - expect(d).to.equal("d") - return nil - end - - ctx:is("bar"):during(test, "a", "b", "c", "d") - end) - - it("should not interfere across coroutines", function() - local expect = getfenv().expect - - local ctx = Contextual("foo") - - local coro1 = coroutine.create(function() - ctx:is("bar"):during(function() - expect(ctx:now()).to.equal("bar") - coroutine.yield() - expect(ctx:now()).to.equal("bar") - return nil - end) - end) - - local coro2 = coroutine.create(function() - ctx:is("baz"):during(function() - expect(ctx:now()).to.equal("baz") - coroutine.yield() - expect(ctx:now()).to.equal("baz") - return nil - end) - end) - - coroutine.resume(coro1) - expect(ctx:now()).to.equal("foo") - coroutine.resume(coro2) - expect(ctx:now()).to.equal("foo") - coroutine.resume(coro1) - expect(ctx:now()).to.equal("foo") - coroutine.resume(coro2) - expect(ctx:now()).to.equal("foo") - end) -end \ No newline at end of file diff --git a/test/fusion/Utility/Contextual.spec.luau b/test/fusion/Utility/Contextual.spec.luau new file mode 100644 index 000000000..eeca9545d --- /dev/null +++ b/test/fusion/Utility/Contextual.spec.luau @@ -0,0 +1,69 @@ +--!strict + +local tiniest = require("../../lib/tiniest_for_roblox") + +local Contextual = require("../../../src/Utility/Contextual") + +return function(tiniest: tiniest.Configured): () + local test = tiniest.test + local expect = tiniest.expect + + test("default value", function() + local context = Contextual("foo") + expect(context:now()).is("foo") + end) + + test("time-based value", function() + local context = Contextual("foo") + expect(context:now()).is("foo") + context:is("bar"):during(function() + expect(context:now()).is("bar") + context:is("baz"):during(function() + expect(context:now()).is("baz") + return nil + end) + expect(context:now()).is("bar") + return nil + end) + expect(context:now()).is("foo") + end) + + test("arguments", function() + local context = Contextual("foo") + context:is("bar"):during(function(a, b, c, d) + expect(a).is("a") + expect(b).is("b") + expect(c).is("c") + expect(d).is("d") + return nil + end, "a", "b", "c", "d") + end) + + test("coroutine safety", function() + local context = Contextual("foo") + local coro1 = coroutine.create(function() + context:is("bar"):during(function() + expect(context:now()).is("bar") + coroutine.yield() + expect(context:now()).is("bar") + return nil + end) + end) + local coro2 = coroutine.create(function() + context:is("baz"):during(function() + expect(context:now()).is("baz") + coroutine.yield() + expect(context:now()).is("baz") + return nil + end) + end) + coroutine.resume(coro1) + expect(context:now()).is("foo") + coroutine.resume(coro2) + expect(context:now()).is("foo") + coroutine.resume(coro1) + expect(context:now()).is("foo") + coroutine.resume(coro2) + expect(context:now()).is("foo") + end) +end \ No newline at end of file From 4466ef3e49d09b7cd2e2855c2a37c6f4684d1da8 Mon Sep 17 00:00:00 2001 From: Daniel P H Fox Date: Mon, 3 Feb 2025 10:39:09 +0000 Subject: [PATCH 11/14] insert.spec.luau --- src/Memory/insert.luau | 2 +- test-old/Spec/Memory/insert.spec.luau | 80 --------------------------- test/fusion/Memory/insert.spec.luau | 39 +++++++++++++ 3 files changed, 40 insertions(+), 81 deletions(-) delete mode 100644 test-old/Spec/Memory/insert.spec.luau create mode 100644 test/fusion/Memory/insert.spec.luau diff --git a/src/Memory/insert.luau b/src/Memory/insert.luau index 282dfba6d..5e2acd435 100644 --- a/src/Memory/insert.luau +++ b/src/Memory/insert.luau @@ -14,7 +14,7 @@ local function insert( ...: Tasks... ): Tasks... for index = 1, select("#", ...) do - table.insert(scope, select(index, ...)) + table.insert(scope, (select(index, ...))) end return ... end diff --git a/test-old/Spec/Memory/insert.spec.luau b/test-old/Spec/Memory/insert.spec.luau deleted file mode 100644 index 87c44e6cd..000000000 --- a/test-old/Spec/Memory/insert.spec.luau +++ /dev/null @@ -1,80 +0,0 @@ ---!strict ---!nolint LocalUnused -local task = nil -- Disable usage of Roblox's task scheduler - -local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Fusion = ReplicatedStorage.Fusion -local scoped = require(Fusion.Memory.scoped) -local doCleanup = require(Fusion.Memory.doCleanup) -local insert = require(Fusion.Memory.insert) - -return function() - local it = getfenv().it - - it("should accept zero tasks", function() - local expect = getfenv().expect - - local scope = scoped() - insert(scope) - - expect(#scope).to.equal(0) - end) - - it("should accept single tasks", function() - local expect = getfenv().expect - - local isDestroyed = false - local scope = scoped() - insert(scope, function() - isDestroyed = true - end) - - expect(#scope).to.equal(1) - doCleanup(scope) - - expect(isDestroyed).to.equal(true) - end) - - it("should accept multiple tasks", function() - local expect = getfenv().expect - - local counter = 0 - local scope = scoped() - - insert(scope, function() - counter += 1 - end) - insert(scope, function() - counter += 1 - end) - insert(scope, { - function() - counter += 1 - end - }) - - expect(#scope).to.equal(3) - doCleanup(scope) - expect(counter).to.equal(3) - end) - - it("should return the given tasks", function() - local expect = getfenv().expect - - local counter = 0 - local function onDestroy() - counter += 1 - end - local function onDestroy2() - counter += 2 - end - local scope = scoped() - - local returnedDestroy, returnedDestroy2 = insert(scope, onDestroy, onDestroy2) - expect(returnedDestroy).to.equal(onDestroy) - expect(returnedDestroy2).to.equal(onDestroy2) - expect(#scope).to.equal(2) - doCleanup(scope) - expect(counter).to.equal(3) - end) -end diff --git a/test/fusion/Memory/insert.spec.luau b/test/fusion/Memory/insert.spec.luau new file mode 100644 index 000000000..fe994b818 --- /dev/null +++ b/test/fusion/Memory/insert.spec.luau @@ -0,0 +1,39 @@ +--!strict + +local tiniest = require("../../lib/tiniest_for_roblox") + +local insert = require("../../../src/Memory/insert") + +return function(tiniest: tiniest.Configured): () + local test = tiniest.test + local expect = tiniest.expect + + test("nothing", function() + local list: any = {1, 2, 3} + insert(list) + expect(list[3]).is(3) + expect(list[4]).never_exists() + end) + + test("single", function() + local list: any = {1, 2, 3} + local a = insert(list, 4) + expect(a).is(4) + expect(list[3]).is(3) + expect(list[4]).is(4) + expect(list[5]).never_exists() + end) + + test("bulk", function() + local list: any = {1, 2, 3} + local a, b, c = insert(list, 4, 5, 6) + expect(a).is(4) + expect(b).is(5) + expect(c).is(6) + expect(list[3]).is(3) + expect(list[4]).is(4) + expect(list[5]).is(5) + expect(list[6]).is(6) + expect(list[7]).never_exists() + end) +end \ No newline at end of file From 81ab691e5d151e06b79e9079deefa3afa5c3a5bf Mon Sep 17 00:00:00 2001 From: Daniel P H Fox Date: Mon, 3 Feb 2025 10:48:08 +0000 Subject: [PATCH 12/14] scoped.spec.luau --- test-old/Spec/Memory/scoped.spec.luau | 90 --------------------------- test/fusion/Memory/scoped.spec.luau | 49 +++++++++++++++ 2 files changed, 49 insertions(+), 90 deletions(-) delete mode 100644 test-old/Spec/Memory/scoped.spec.luau create mode 100644 test/fusion/Memory/scoped.spec.luau diff --git a/test-old/Spec/Memory/scoped.spec.luau b/test-old/Spec/Memory/scoped.spec.luau deleted file mode 100644 index 617ed86ff..000000000 --- a/test-old/Spec/Memory/scoped.spec.luau +++ /dev/null @@ -1,90 +0,0 @@ ---!strict ---!nolint LocalUnused -local task = nil -- Disable usage of Roblox's task scheduler - -local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Fusion = ReplicatedStorage.Fusion - -local scoped = require(Fusion.Memory.scoped) - -return function() - local it = getfenv().it - - it("should accept zero arguments", function() - local expect = getfenv().expect - - local scope = scoped() - - expect(scope).to.be.a("table") - expect(#scope).to.equal(0) - end) - - it("should accept single arguments", function() - local expect = getfenv().expect - - local original = {foo = "FOO", bar = "BAR", baz = "BAZ"} - local scope = scoped(original) - - expect(scope).to.be.a("table") - expect(scope).to.never.equal(original) - for key, value in original do - expect((scope :: any)[key]).to.equal(value) - end - end) - - it("should merge two arguments", function() - local expect = getfenv().expect - - local originalA = {foo = "FOO", bar = "BAR", baz = "BAZ"} - local originalB = {frob = "FROB", garb = "GARB", grok = "GROK"} - local scope = scoped(originalA, originalB) - - expect(scope).to.be.a("table") - for _, original in {originalA :: any, originalB} do - expect(scope).to.never.equal(original) - for key, value in original do - expect((scope :: any)[key]).to.equal(value) - end - for key, value in original do - expect((scope :: any)[key]).to.equal(value) - end - for key, value in original do - expect((scope :: any)[key]).to.equal(value) - end - end - end) - - it("should merge three arguments", function() - local expect = getfenv().expect - - local originalA = {foo = "FOO", bar = "BAR", baz = "BAZ"} - local originalB = {frob = "FROB", garb = "GARB", grok = "GROK"} - local originalC = {grep = "GREP", bork = "BORK", grum = "GRUM"} - local scope = scoped(originalA, originalB, originalC) - - expect(scope).to.be.a("table") - for _, original in {originalA :: any, originalB, originalC} do - expect(scope).to.never.equal(original) - for key, value in original do - expect((scope :: any)[key]).to.equal(value) - end - for key, value in original do - expect((scope :: any)[key]).to.equal(value) - end - for key, value in original do - expect((scope :: any)[key]).to.equal(value) - end - end - end) - - it("should error on collision", function() - local expect = getfenv().expect - - expect(function() - local originalA = {foo = "FOO", bar = "BAR", baz = "BAZ"} - local originalB = {frob = "FROB", garb = "GARB", grok = "GROK"} - local originalC = {grep = "GREP", grok = "GROK", grum = "GRUM"} - scoped(originalA, originalB, originalC) - end).to.throw("mergeConflict") - end) -end \ No newline at end of file diff --git a/test/fusion/Memory/scoped.spec.luau b/test/fusion/Memory/scoped.spec.luau new file mode 100644 index 000000000..00b2bec8a --- /dev/null +++ b/test/fusion/Memory/scoped.spec.luau @@ -0,0 +1,49 @@ +--!strict + +local tiniest = require("../../lib/tiniest_for_roblox") + +local scoped = require("../../../src/Memory/scoped") + +return function(tiniest: tiniest.Configured): () + local describe = tiniest.describe + local test = tiniest.test + local expect = tiniest.expect + + test("basic", function() + local scope = scoped() + expect(scope).is_a("table") + expect((next(scope))).never_exists() + end) + + describe("associated table", function() + test("single", function() + local assoc = { foo = {}, bar = {} } + local scope = scoped(assoc) + expect(scope).never_is(assoc) + expect(scope.foo).is(assoc.foo) + expect(scope.bar).is(assoc.bar) + end) + + test("multiple", function() + local assoc1 = { foo = {} } + local assoc2 = { bar = {} } + local assoc3 = { baz = {} } + local scope = scoped(assoc1, assoc2, assoc3) + expect(scope).never_is(assoc1) + expect(scope).never_is(assoc2) + expect(scope).never_is(assoc3) + expect(scope.foo).is(assoc1.foo) + expect(scope.bar).is(assoc2.bar) + expect(scope.baz).is(assoc3.baz) + end) + + test("collision", function() + local assoc1 = { foo = {} } + local assoc2 = { foo = {} } + local assoc3 = { foo = {} } + expect(function() + scoped(assoc1, assoc2, assoc3) + end).fails_with("mergeConflict") + end) + end) +end \ No newline at end of file From e558706d94b62056292d8e97561e3e37b1aa2a93 Mon Sep 17 00:00:00 2001 From: Daniel P H Fox Date: Mon, 3 Feb 2025 10:59:18 +0000 Subject: [PATCH 13/14] DynamicGraphs.spec.luau --- .../Spec/_Integration/DynamicGraphs.spec.lua | 47 ------------------- .../_Integration/DynamicGraphs.spec.luau | 34 ++++++++++++++ 2 files changed, 34 insertions(+), 47 deletions(-) delete mode 100644 test-old/Spec/_Integration/DynamicGraphs.spec.lua create mode 100644 test/fusion/_Integration/DynamicGraphs.spec.luau diff --git a/test-old/Spec/_Integration/DynamicGraphs.spec.lua b/test-old/Spec/_Integration/DynamicGraphs.spec.lua deleted file mode 100644 index ac6b00e4e..000000000 --- a/test-old/Spec/_Integration/DynamicGraphs.spec.lua +++ /dev/null @@ -1,47 +0,0 @@ ---!strict ---!nolint LocalUnused ---!nolint LocalShadow -local task = nil -- Disable usage of Roblox's task scheduler - -local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Fusion = require(ReplicatedStorage.Fusion) -local scoped, peek = Fusion.scoped, Fusion.peek - -return function() - local describe = getfenv().describe - - describe("regression tests", function() - local it = getfenv().it - - it("re-entrant Observers do not block eager updates", function() - local expect = getfenv().expect - - local scope = scoped(Fusion) - - local count = 0 - local unrelatedValue = scope:Value(count) - local trigger = scope:Value(false) - - local o1 = scope:Observer(trigger) - o1:onChange(function() - count += 1 - unrelatedValue:set(count) - end) - - local numFires = 0 - local o2 = scope:Observer(trigger) - o2:onChange(function() - numFires += 1 - end) - - trigger:set(true) - expect(numFires).to.equal(1) - trigger:set(false) - expect(numFires).to.equal(2) - trigger:set(true) - expect(numFires).to.equal(3) - - scope:doCleanup() - end) - end) -end \ No newline at end of file diff --git a/test/fusion/_Integration/DynamicGraphs.spec.luau b/test/fusion/_Integration/DynamicGraphs.spec.luau new file mode 100644 index 000000000..8286a2bde --- /dev/null +++ b/test/fusion/_Integration/DynamicGraphs.spec.luau @@ -0,0 +1,34 @@ +--!strict + +local tiniest = require("../../lib/tiniest_for_roblox") + +local Fusion = require("../../../src") + +return function(tiniest: tiniest.Configured): () + local describe = tiniest.describe + local test = tiniest.test + local expect = tiniest.expect + + describe("regression", function() + test("eager update that causes sub-update shouldn't block other eager updates", function() + local scope = Fusion:scoped() + local count = 0 + local unrelatedValue = scope:Value(count) + local trigger = scope:Value(false) + scope:Observer(trigger):onChange(function() + count += 1 + unrelatedValue:set(count) + end) + local numFires = 0 + scope:Observer(trigger):onChange(function() + numFires += 1 + end) + trigger:set(true) + expect(numFires).is(1) + trigger:set(false) + expect(numFires).is(2) + trigger:set(true) + expect(numFires).is(3) + end) + end) +end \ No newline at end of file From 66deaaa9e7bc72885d890b521528a71d6deb34ca Mon Sep 17 00:00:00 2001 From: Daniel P H Fox Date: Mon, 3 Feb 2025 10:59:36 +0000 Subject: [PATCH 14/14] add cleanup call --- test/fusion/_Integration/DynamicGraphs.spec.luau | 1 + 1 file changed, 1 insertion(+) diff --git a/test/fusion/_Integration/DynamicGraphs.spec.luau b/test/fusion/_Integration/DynamicGraphs.spec.luau index 8286a2bde..1165b9045 100644 --- a/test/fusion/_Integration/DynamicGraphs.spec.luau +++ b/test/fusion/_Integration/DynamicGraphs.spec.luau @@ -29,6 +29,7 @@ return function(tiniest: tiniest.Configured): () expect(numFires).is(2) trigger:set(true) expect(numFires).is(3) + scope:doCleanup() end) end) end \ No newline at end of file