From 72ae35423d6825deae5442897dc11f23d38fd458 Mon Sep 17 00:00:00 2001 From: 1hitsong <3330318+1hitsong@users.noreply.github.com> Date: Tue, 20 Dec 2022 07:10:22 -0500 Subject: [PATCH 01/14] Add Test Framework --- source/Main.brs | 18 + source/testFramework/UnitTestFramework.brs | 2867 ++++++++++++++++++++ source/tests/Test__Main.brs | 44 + 3 files changed, 2929 insertions(+) create mode 100644 source/testFramework/UnitTestFramework.brs create mode 100644 source/tests/Test__Main.brs diff --git a/source/Main.brs b/source/Main.brs index 1e3e2024..52e565e0 100644 --- a/source/Main.brs +++ b/source/Main.brs @@ -1,5 +1,23 @@ sub Main (args as dynamic) as void + appInfo = CreateObject("roAppInfo") + + ' http://{Roku IP}:8060/launch/dev?RunTests=true + if appInfo.IsDev() and args.RunTests = "true" and TF_Utils__IsFunction(TestRunner) + Runner = TestRunner() + + Runner.SetFunctions([ + TestSuite__Main + ]) + + Runner.Logger.SetVerbosity(1) + Runner.Logger.SetEcho(false) + Runner.Logger.SetJUnit(false) + Runner.SetFailFast(true) + + Runner.Run() + end if + ' The main function that runs when the application is launched. m.screen = CreateObject("roSGScreen") diff --git a/source/testFramework/UnitTestFramework.brs b/source/testFramework/UnitTestFramework.brs new file mode 100644 index 00000000..8989edef --- /dev/null +++ b/source/testFramework/UnitTestFramework.brs @@ -0,0 +1,2867 @@ +'***************************************************************** +'* Roku Unit Testing Framework +'* Automating test suites for Roku channels. +'* +'* Build Version: 2.1.1 +'* Build Date: 05/06/2019 +'* +'* Public Documentation is avaliable on GitHub: +'* https://github.com/rokudev/unit-testing-framework +'* +'***************************************************************** +'***************************************************************** +'* Copyright Roku 2011-2019 +'* All Rights Reserved +'***************************************************************** + +' Functions in this file: + +' BaseTestSuite +' BTS__AddTest +' BTS__CreateTest +' BTS__Fail +' BTS__AssertFalse +' BTS__AssertTrue +' BTS__AssertEqual +' BTS__AssertNotEqual +' BTS__AssertInvalid +' BTS__AssertNotInvalid +' BTS__AssertAAHasKey +' BTS__AssertAANotHasKey +' BTS__AssertAAHasKeys +' BTS__AssertAANotHasKeys +' BTS__AssertArrayContains +' BTS__AssertArrayNotContains +' BTS__AssertArrayContainsSubset +' BTS__AssertArrayNotContainsSubset +' BTS__AssertArrayCount +' BTS__AssertArrayNotCount +' BTS__AssertEmpty +' BTS__AssertNotEmpty + +' ---------------------------------------------------------------- +' Main function. Create BaseTestSuite object. + +' @return A BaseTestSuite object. +' ---------------------------------------------------------------- +function BaseTestSuite() + this = {} + this.Name = "BaseTestSuite" + this.SKIP_TEST_MESSAGE_PREFIX = "SKIP_TEST_MESSAGE_PREFIX__" + ' Test Cases methods + this.testCases = [] + this.IS_NEW_APPROACH = false + this.addTest = BTS__AddTest + this.createTest = BTS__CreateTest + this.StorePerformanceData = BTS__StorePerformanceData + + ' Assertion methods which determine test failure or skipping + this.skip = BTS__Skip + this.fail = BTS__Fail + this.assertFalse = BTS__AssertFalse + this.assertTrue = BTS__AssertTrue + this.assertEqual = BTS__AssertEqual + this.assertNotEqual = BTS__AssertNotEqual + this.assertInvalid = BTS__AssertInvalid + this.assertNotInvalid = BTS__AssertNotInvalid + this.assertAAHasKey = BTS__AssertAAHasKey + this.assertAANotHasKey = BTS__AssertAANotHasKey + this.assertAAHasKeys = BTS__AssertAAHasKeys + this.assertAANotHasKeys = BTS__AssertAANotHasKeys + this.assertArrayContains = BTS__AssertArrayContains + this.assertArrayNotContains = BTS__AssertArrayNotContains + this.assertArrayContainsSubset = BTS__AssertArrayContainsSubset + this.assertArrayNotContainsSubset = BTS__AssertArrayNotContainsSubset + this.assertArrayCount = BTS__AssertArrayCount + this.assertArrayNotCount = BTS__AssertArrayNotCount + this.assertEmpty = BTS__AssertEmpty + this.assertNotEmpty = BTS__AssertNotEmpty + + ' Type Comparison Functionality + this.eqValues = TF_Utils__EqValues + this.eqAssocArrays = TF_Utils__EqAssocArray + this.eqArrays = TF_Utils__EqArray + this.baseComparator = TF_Utils__BaseComparator + + return this +end function + +' ---------------------------------------------------------------- +' Add a test to a suite's test cases array. + +' @param name (string) A test name. +' @param func (object) A pointer to test function. +' @param setup (object) A pointer to setup function. +' @param teardown (object) A pointer to teardown function. +' @param arg (dynamic) A test function arguments. +' @param hasArgs (boolean) True if test function has parameters. +' @param skip (boolean) Skip test run. +' ---------------------------------------------------------------- +sub BTS__AddTest(name as string, func as object, setup = invalid as object, teardown = invalid as object, arg = invalid as dynamic, hasArgs = false as boolean, skip = false as boolean) + m.testCases.Push(m.createTest(name, func, setup, teardown, arg, hasArgs, skip)) +end sub + +' ---------------------------------------------------------------- +' Create a test object. + +' @param name (string) A test name. +' @param func (object) A pointer to test function. +' @param setup (object) A pointer to setup function. +' @param teardown (object) A pointer to teardown function. +' @param arg (dynamic) A test function arguments. +' @param hasArgs (boolean) True if test function has parameters. +' @param skip (boolean) Skip test run. +' +' @return TestCase object. +' ---------------------------------------------------------------- +function BTS__CreateTest(name as string, func as object, setup = invalid as object, teardown = invalid as object, arg = invalid as dynamic, hasArgs = false as boolean, skip = false as boolean) as object + return { + Name: name + Func: func + SetUp: setup + TearDown: teardown + + perfData: {} + + hasArguments: hasArgs + arg: arg + + skip: skip + } +end function + +'---------------------------------------------------------------- +' Store performance data to current test instance. +' +' @param name (string) A property name. +' @param value (Object) A value of data. +'---------------------------------------------------------------- +sub BTS__StorePerformanceData(name as string, value as object) + timestamp = StrI(CreateObject("roDateTime").AsSeconds()) + m.testInstance.perfData.Append({ + name: { + "value": value + "timestamp": timestamp + } + }) + ' print performance data to console + ? "PERF_DATA: " + m.testInstance.Name + ": " + timestamp + ": " + name + "|" + TF_Utils__AsString(value) +end sub + +' ---------------------------------------------------------------- +' Assertion methods which determine test failure or skipping +' ---------------------------------------------------------------- + +' ---------------------------------------------------------------- +' Should be used to skip test cases. To skip test you must return the result of this method invocation. + +' @param message (string) Optional skip message. +' Default value: "". + +' @return A skip message, with a specific prefix added, in order to runner know that this test should be skipped. +' ---------------------------------------------------------------- +function BTS__Skip(message = "" as string) as string + ' add prefix so we know that this test is skipped, but not failed + return m.SKIP_TEST_MESSAGE_PREFIX + message +end function + +' ---------------------------------------------------------------- +' Fail immediately, with the given message + +' @param msg (string) An error message. +' Default value: "Error". + +' @return An error message. +' ---------------------------------------------------------------- +function BTS__Fail(msg = "Error" as string) as string + return msg +end function + +' ---------------------------------------------------------------- +' Fail the test if the expression is true. + +' @param expr (dynamic) An expression to evaluate. +' @param msg (string) An error message. +' Default value: "Expression evaluates to true" + +' @return An error message. +' ---------------------------------------------------------------- +function BTS__AssertFalse(expr as dynamic, msg = "Expression evaluates to true" as string) as string + if not TF_Utils__IsBoolean(expr) or expr + return BTS__Fail(msg) + end if + return "" +end function + +' ---------------------------------------------------------------- +' Fail the test unless the expression is true. + +' @param expr (dynamic) An expression to evaluate. +' @param msg (string) An error message. +' Default value: "Expression evaluates to false" + +' @return An error message. +' ---------------------------------------------------------------- +function BTS__AssertTrue(expr as dynamic, msg = "Expression evaluates to false" as string) as string + if not TF_Utils__IsBoolean(expr) or not expr then + return msg + end if + return "" +end function + +' ---------------------------------------------------------------- +' Fail if the two objects are unequal as determined by the '<>' operator. + +' @param first (dynamic) A first object to compare. +' @param second (dynamic) A second object to compare. +' @param msg (string) An error message. +' Default value: "" + +' @return An error message. +' ---------------------------------------------------------------- +function BTS__AssertEqual(first as dynamic, second as dynamic, msg = "" as string) as string + if not TF_Utils__EqValues(first, second) + if msg = "" + first_as_string = TF_Utils__AsString(first) + second_as_string = TF_Utils__AsString(second) + msg = first_as_string + " != " + second_as_string + end if + return msg + end if + return "" +end function + +' ---------------------------------------------------------------- +' Fail if the two objects are equal as determined by the '=' operator. + +' @param first (dynamic) A first object to compare. +' @param second (dynamic) A second object to compare. +' @param msg (string) An error message. +' Default value: "" + +' @return An error message. +' ---------------------------------------------------------------- +function BTS__AssertNotEqual(first as dynamic, second as dynamic, msg = "" as string) as string + if TF_Utils__EqValues(first, second) + if msg = "" + first_as_string = TF_Utils__AsString(first) + second_as_string = TF_Utils__AsString(second) + msg = first_as_string + " == " + second_as_string + end if + return msg + end if + return "" +end function + +' ---------------------------------------------------------------- +' Fail if the value is not invalid. + +' @param value (dynamic) A value to check. +' @param msg (string) An error message. +' Default value: "" + +' @return An error message. +' ---------------------------------------------------------------- +function BTS__AssertInvalid(value as dynamic, msg = "" as string) as string + if TF_Utils__IsValid(value) + if msg = "" + expr_as_string = TF_Utils__AsString(value) + msg = expr_as_string + " <> Invalid" + end if + return msg + end if + return "" +end function + +' ---------------------------------------------------------------- +' Fail if the value is invalid. + +' @param value (dynamic) A value to check. +' @param msg (string) An error message. +' Default value: "" + +' @return An error message. +' ---------------------------------------------------------------- +function BTS__AssertNotInvalid(value as dynamic, msg = "" as string) as string + if not TF_Utils__IsValid(value) + if msg = "" + if LCase(Type(value)) = "" then value = invalid + expr_as_string = TF_Utils__AsString(value) + msg = expr_as_string + " = Invalid" + end if + return msg + end if + return "" +end function + +' ---------------------------------------------------------------- +' Fail if the array doesn't have the key. + +' @param array (dynamic) A target array. +' @param key (string) A key name. +' @param msg (string) An error message. +' Default value: "" + +' @return An error message. +' ---------------------------------------------------------------- +function BTS__AssertAAHasKey(array as dynamic, key as dynamic, msg = "" as string) as string + if not TF_Utils__IsString(key) + return "Key value has invalid type." + end if + + if TF_Utils__IsAssociativeArray(array) + if not array.DoesExist(key) + if msg = "" + msg = "Array doesn't have the '" + key + "' key." + end if + return msg + end if + else + msg = "Input value is not an Associative Array." + return msg + end if + + return "" +end function + +' ---------------------------------------------------------------- +' Fail if the array has the key. + +' @param array (dynamic) A target array. +' @param key (string) A key name. +' @param msg (string) An error message. +' Default value: "" + +' @return An error message. +' ---------------------------------------------------------------- +function BTS__AssertAANotHasKey(array as dynamic, key as dynamic, msg = "" as string) as string + if not TF_Utils__IsString(key) + return "Key value has invalid type." + end if + + if TF_Utils__IsAssociativeArray(array) + if array.DoesExist(key) + if msg = "" + msg = "Array has the '" + key + "' key." + end if + return msg + end if + else + msg = "Input value is not an Associative Array." + return msg + end if + + return "" +end function + +' ---------------------------------------------------------------- +' Fail if the array doesn't have the keys list. + +' @param array (dynamic) A target associative array. +' @param keys (object) A key names array. +' @param msg (string) An error message. +' Default value: "" + +' @return An error message. +' ---------------------------------------------------------------- +function BTS__AssertAAHasKeys(array as dynamic, keys as object, msg = "" as string) as string + if not TF_Utils__IsAssociativeArray(array) + return "Input value is not an Associative Array." + end if + + if not TF_Utils__IsArray(keys) or keys.Count() = 0 + return "Keys value is not an Array or is empty." + end if + + if TF_Utils__IsAssociativeArray(array) and TF_Utils__IsArray(keys) + for each key in keys + if not TF_Utils__IsString(key) + return "Key value has invalid type." + end if + + if not array.DoesExist(key) + if msg = "" + msg = "Array doesn't have the '" + key + "' key." + end if + + return msg + end if + end for + else + msg = "Input value is not an Associative Array." + return msg + end if + + return "" +end function + +' ---------------------------------------------------------------- +' Fail if the array has the keys list. + +' @param array (dynamic) A target associative array. +' @param keys (object) A key names array. +' @param msg (string) An error message. +' Default value: "" + +' @return An error message. +' ---------------------------------------------------------------- +function BTS__AssertAANotHasKeys(array as dynamic, keys as object, msg = "" as string) as string + if not TF_Utils__IsAssociativeArray(array) + return "Input value is not an Associative Array." + end if + + if not TF_Utils__IsArray(keys) or keys.Count() = 0 + return "Keys value is not an Array or is empty." + end if + + if TF_Utils__IsAssociativeArray(array) and TF_Utils__IsArray(keys) + for each key in keys + if not TF_Utils__IsString(key) + return "Key value has invalid type." + end if + + if array.DoesExist(key) + if msg = "" + msg = "Array has the '" + key + "' key." + end if + return msg + end if + end for + else + msg = "Input value is not an Associative Array." + return msg + end if + return "" +end function + +' ---------------------------------------------------------------- +' Fail if the array doesn't have the item. + +' @param array (dynamic) A target array. +' @param value (dynamic) A value to check. +' @param key (object) A key name for associative array. +' @param msg (string) An error message. +' Default value: "" + +' @return An error message. +' ---------------------------------------------------------------- +function BTS__AssertArrayContains(array as dynamic, value as dynamic, key = invalid as dynamic, msg = "" as string) as string + if key <> invalid and not TF_Utils__IsString(key) + return "Key value has invalid type." + end if + + if TF_Utils__IsAssociativeArray(array) or TF_Utils__IsArray(array) + if not TF_Utils__ArrayContains(array, value, key) + msg = "Array doesn't have the '" + TF_Utils__AsString(value) + "' value." + + return msg + end if + else + msg = "Input value is not an Array." + + return msg + end if + + return "" +end function + +' ---------------------------------------------------------------- +' Fail if the array has the item. + +' @param array (dynamic) A target array. +' @param value (dynamic) A value to check. +' @param key (object) A key name for associative array. +' @param msg (string) An error message. +' Default value: "" + +' @return An error message. +' ---------------------------------------------------------------- +function BTS__AssertArrayNotContains(array as dynamic, value as dynamic, key = invalid as dynamic, msg = "" as string) as string + if key <> invalid and not TF_Utils__IsString(key) + return "Key value has invalid type." + end if + + if TF_Utils__IsAssociativeArray(array) or TF_Utils__IsArray(array) + if TF_Utils__ArrayContains(array, value, key) + msg = "Array has the '" + TF_Utils__AsString(value) + "' value." + + return msg + end if + else + msg = "Input value is not an Array." + + return msg + end if + + return "" +end function + +' ---------------------------------------------------------------- +' Fail if the array doesn't have the item subset. + +' @param array (dynamic) A target array. +' @param subset (dynamic) An items array to check. +' @param msg (string) An error message. +' Default value: "" + +' @return An error message. +' ---------------------------------------------------------------- +function BTS__AssertArrayContainsSubset(array as dynamic, subset as dynamic, msg = "" as string) as string + if (TF_Utils__IsAssociativeArray(array) and TF_Utils__IsAssociativeArray(subset)) or (TF_Utils__IsArray(array) and TF_Utils__IsArray(subset)) + isAA = TF_Utils__IsAssociativeArray(subset) + for each item in subset + key = invalid + value = item + if isAA + key = item + value = subset[key] + end if + + if not TF_Utils__ArrayContains(array, value, key) + msg = "Array doesn't have the '" + TF_Utils__AsString(value) + "' value." + + return msg + end if + end for + else + msg = "Input value is not an Array." + + return msg + end if + + return "" +end function + +' ---------------------------------------------------------------- +' Fail if the array have the item from subset. + +' @param array (dynamic) A target array. +' @param subset (dynamic) A items array to check. +' @param msg (string) An error message. +' Default value: "" + +' @return An error message. +' ---------------------------------------------------------------- +function BTS__AssertArrayNotContainsSubset(array as dynamic, subset as dynamic, msg = "" as string) as string + if (TF_Utils__IsAssociativeArray(array) and TF_Utils__IsAssociativeArray(subset)) or (TF_Utils__IsArray(array) and TF_Utils__IsArray(subset)) + isAA = TF_Utils__IsAssociativeArray(subset) + for each item in subset + key = invalid + value = item + if isAA + key = item + value = subset[key] + end if + + if TF_Utils__ArrayContains(array, value, key) + msg = "Array has the '" + TF_Utils__AsString(value) + "' value." + + return msg + end if + end for + else + msg = "Input value is not an Array." + + return msg + end if + + return "" +end function + +' ---------------------------------------------------------------- +' Fail if the array items count <> expected count + +' @param array (dynamic) A target array. +' @param count (integer) An expected array items count. +' @param msg (string) An error message. +' Default value: "" + +' @return An error message. +' ---------------------------------------------------------------- +function BTS__AssertArrayCount(array as dynamic, count as dynamic, msg = "" as string) as string + if not TF_Utils__IsInteger(count) + return "Count value should be an integer." + end if + + if TF_Utils__IsAssociativeArray(array) or TF_Utils__IsArray(array) + if array.Count() <> count + msg = "Array items count <> " + TF_Utils__AsString(count) + "." + + return msg + end if + else + msg = "Input value is not an Array." + + return msg + end if + + return "" +end function + +' ---------------------------------------------------------------- +' Fail if the array items count = expected count. + +' @param array (dynamic) A target array. +' @param count (integer) An expected array items count. +' @param msg (string) An error message. +' Default value: "" + +' @return An error message. +' ---------------------------------------------------------------- +function BTS__AssertArrayNotCount(array as dynamic, count as dynamic, msg = "" as string) as string + if not TF_Utils__IsInteger(count) + return "Count value should be an integer." + end if + + if TF_Utils__IsAssociativeArray(array) or TF_Utils__IsArray(array) + if array.Count() = count + msg = "Array items count = " + TF_Utils__AsString(count) + "." + + return msg + end if + else + msg = "Input value is not an Array." + + return msg + end if + + return "" +end function + +' ---------------------------------------------------------------- +' Fail if the item is not empty array or string. + +' @param item (dynamic) An array or string to check. +' @param msg (string) An error message. +' Default value: "" + +' @return An error message. +' ---------------------------------------------------------------- +function BTS__AssertEmpty(item as dynamic, msg = "" as string) as string + if TF_Utils__IsAssociativeArray(item) or TF_Utils__IsArray(item) + if item.Count() > 0 + msg = "Array is not empty." + + return msg + end if + else if TF_Utils__IsString(item) + if Len(item) <> 0 + msg = "Input value is not empty." + + return msg + end if + else + msg = "Input value is not an Array, AssociativeArray or String." + + return msg + end if + + return "" +end function + +' ---------------------------------------------------------------- +' Fail if the item is empty array or string. + +' @param item (dynamic) An array or string to check. +' @param msg (string) An error message. +' Default value: "" + +' @return An error message. +' ---------------------------------------------------------------- +function BTS__AssertNotEmpty(item as dynamic, msg = "" as string) as string + if TF_Utils__IsAssociativeArray(item) or TF_Utils__IsArray(item) + if item.Count() = 0 + msg = "Array is empty." + + return msg + end if + else if TF_Utils__IsString(item) + if Len(item) = 0 + msg = "Input value is empty." + + return msg + end if + else + msg = "Input value is not an Array, AssociativeArray or String." + + return msg + end if + + return "" +end function + +'***************************************************************** +'* Copyright Roku 2011-2019 +'* All Rights Reserved +'***************************************************************** + +' Functions in this file: +' ItemGenerator +' IG_GetItem +' IG_GetAssocArray +' IG_GetArray +' IG_GetSimpleType +' IG_GetBoolean +' IG_GetInteger +' IG_GetFloat +' IG_GetString + +' ---------------------------------------------------------------- +' Main function to generate object according to specified scheme. + +' @param scheme (object) A scheme with desired object structure. Can be +' any simple type, array of types or associative array in form +' { propertyName1 : "propertyType1" +' propertyName2 : "propertyType2" +' ... +' propertyNameN : "propertyTypeN" } + +' @return An object according to specified scheme or invalid, +' if scheme is not valid. +' ---------------------------------------------------------------- +function ItemGenerator(scheme as object) as object + this = {} + + this.getItem = IG_GetItem + this.getAssocArray = IG_GetAssocArray + this.getArray = IG_GetArray + this.getSimpleType = IG_GetSimpleType + this.getInteger = IG_GetInteger + this.getFloat = IG_GetFloat + this.getString = IG_GetString + this.getBoolean = IG_GetBoolean + + if not TF_Utils__IsValid(scheme) + return invalid + end if + + return this.getItem(scheme) +end function + +' TODO: Create IG_GetInvalidItem function with random type fields + +' ---------------------------------------------------------------- +' Generate object according to specified scheme. + +' @param scheme (object) A scheme with desired object structure. +' Can be any simple type, array of types or associative array. + +' @return An object according to specified scheme or invalid, +' if scheme is not one of simple type, array or +' associative array. +' ---------------------------------------------------------------- +function IG_GetItem(scheme as object) as object + item = invalid + + if TF_Utils__IsAssociativeArray(scheme) + item = IG_GetAssocArray(scheme) + else if TF_Utils__IsArray(scheme) + item = IG_GetArray(scheme) + else if TF_Utils__IsString(scheme) + item = IG_GetSimpleType(LCase(scheme)) + end if + + return item +end function + +' ---------------------------------------------------------------- +' Generates associative array according to specified scheme. + +' @param scheme (object) An associative array with desired +' object structure in form +' { propertyName1 : "propertyType1" +' propertyName2 : "propertyType2" +' ... +' propertyNameN : "propertyTypeN" } + +' @return An associative array according to specified scheme. +' ---------------------------------------------------------------- +function IG_GetAssocArray(scheme as object) as object + item = {} + + for each key in scheme + if not item.DoesExist(key) + item[key] = IG_GetItem(scheme[key]) + end if + end for + + return item +end function + +' ---------------------------------------------------------------- +' Generates array according to specified scheme. + +' @param scheme (object) An array with desired object types. + +' @return An array according to specified scheme. +' ---------------------------------------------------------------- +function IG_GetArray(scheme as object) as object + item = [] + + for each key in scheme + item.Push(IG_GetItem(key)) + end for + + return item +end function + +' ---------------------------------------------------------------- +' Generates random value of specified type. + +' @param typeStr (string) A name of desired object type. + +' @return A simple type object or invalid if type is not supported. +' ---------------------------------------------------------------- +function IG_GetSimpleType(typeStr as string) as object + item = invalid + + if typeStr = "integer" or typeStr = "int" or typeStr = "roint" + item = IG_GetInteger() + else if typeStr = "float" or typeStr = "rofloat" + item = IG_GetFloat() + else if typeStr = "string" or typeStr = "rostring" + item = IG_GetString(10) + else if typeStr = "boolean" or typeStr = "roboolean" + item = IG_GetBoolean() + end if + + return item +end function + +' ---------------------------------------------------------------- +' Generates random boolean value. + +' @return A random boolean value. +' ---------------------------------------------------------------- +function IG_GetBoolean() as boolean + return TF_Utils__AsBoolean(Rnd(2) \ Rnd(2)) +end function + +' ---------------------------------------------------------------- +' Generates random integer value from 1 to specified seed value. + +' @param seed (integer) A seed value for Rnd function. +' Default value: 100. + +' @return A random integer value. +' ---------------------------------------------------------------- +function IG_GetInteger(seed = 100 as integer) as integer + return Rnd(seed) +end function + +' ---------------------------------------------------------------- +' Generates random float value. + +' @return A random float value. +' ---------------------------------------------------------------- +function IG_GetFloat() as float + return Rnd(0) +end function + +' ---------------------------------------------------------------- +' Generates random string with specified length. + +' @param seed (integer) A string length. + +' @return A random string value or empty string if seed is 0. +' ---------------------------------------------------------------- +function IG_GetString(seed as integer) as string + item = "" + if seed > 0 + stringLength = Rnd(seed) + + for i = 0 to stringLength + chType = Rnd(3) + + if chType = 1 ' Chr(48-57) - numbers + chNumber = 47 + Rnd(10) + else if chType = 2 ' Chr(65-90) - Uppercase Letters + chNumber = 64 + Rnd(26) + else ' Chr(97-122) - Lowercase Letters + chNumber = 96 + Rnd(26) + end if + + item = item + Chr(chNumber) + end for + end if + + return item +end function +'***************************************************************** +'* Copyright Roku 2011-2019 +'* All Rights Reserved +'***************************************************************** + +' Functions in this file: +' Logger +' Logger__SetVerbosity +' Logger__SetEcho +' Logger__SetServerURL +' Logger__PrintStatistic +' Logger__SendToServer +' Logger__CreateTotalStatistic +' Logger__CreateSuiteStatistic +' Logger__CreateTestStatistic +' Logger__AppendSuiteStatistic +' Logger__AppendTestStatistic +' Logger__PrintSuiteStatistic +' Logger__PrintTestStatistic +' Logger__PrintStart +' Logger__PrintEnd +' Logger__PrintSuiteSetUp +' Logger__PrintSuiteStart +' Logger__PrintSuiteEnd +' Logger__PrintSuiteTearDown +' Logger__PrintTestSetUp +' Logger__PrintTestStart +' Logger__PrintTestEnd +' Logger__PrintTestTearDown + +' ---------------------------------------------------------------- +' Main function. Create Logger object. + +' @return A Logger object. +' ---------------------------------------------------------------- +function Logger() as object + this = {} + + this.verbosityLevel = { + basic: 0 + normal: 1 + verboseFailed: 2 + verbose: 3 + } + + ' Internal properties + this.verbosity = this.verbosityLevel.normal + this.echoEnabled = false + this.serverURL = "" + this.jUnitEnabled = false + + ' Interface + this.SetVerbosity = Logger__SetVerbosity + this.SetEcho = Logger__SetEcho + this.SetJUnit = Logger__SetJUnit + this.SetServer = Logger__SetServer + this.SetServerURL = Logger__SetServerURL ' Deprecated. Use Logger__SetServer instead. + this.PrintStatistic = Logger__PrintStatistic + this.SendToServer = Logger__SendToServer + + this.CreateTotalStatistic = Logger__CreateTotalStatistic + this.CreateSuiteStatistic = Logger__CreateSuiteStatistic + this.CreateTestStatistic = Logger__CreateTestStatistic + this.AppendSuiteStatistic = Logger__AppendSuiteStatistic + this.AppendTestStatistic = Logger__AppendTestStatistic + + ' Internal functions + this.PrintSuiteStatistic = Logger__PrintSuiteStatistic + this.PrintTestStatistic = Logger__PrintTestStatistic + this.PrintStart = Logger__PrintStart + this.PrintEnd = Logger__PrintEnd + this.PrintSuiteSetUp = Logger__PrintSuiteSetUp + this.PrintSuiteStart = Logger__PrintSuiteStart + this.PrintSuiteEnd = Logger__PrintSuiteEnd + this.PrintSuiteTearDown = Logger__PrintSuiteTearDown + this.PrintTestSetUp = Logger__PrintTestSetUp + this.PrintTestStart = Logger__PrintTestStart + this.PrintTestEnd = Logger__PrintTestEnd + this.PrintTestTearDown = Logger__PrintTestTearDown + this.PrintJUnitFormat = Logger__PrintJUnitFormat + + return this +end function + +' ---------------------------------------------------------------- +' Set logging verbosity parameter. + +' @param verbosity (integer) A verbosity level. +' Posible values: +' 0 - basic +' 1 - normal +' 2 - verbose failed tests +' 3 - verbose +' Default level: 1 +' ---------------------------------------------------------------- +sub Logger__SetVerbosity(verbosity = m.verbosityLevel.normal as integer) + if verbosity >= m.verbosityLevel.basic and verbosity <= m.verbosityLevel.verbose + m.verbosity = verbosity + end if +end sub + +' ---------------------------------------------------------------- +' Set logging echo parameter. + +' @param enable (boolean) A echo trigger. +' Posible values: true or false +' Default value: false +' ---------------------------------------------------------------- +sub Logger__SetEcho(enable = false as boolean) + m.echoEnabled = enable +end sub + +' ---------------------------------------------------------------- +' Set logging JUnit output parameter. + +' @param enable (boolean) A JUnit output trigger. +' Posible values: true or false +' Default value: false +' ---------------------------------------------------------------- +sub Logger__SetJUnit(enable = false as boolean) + m.jUnitEnabled = enable +end sub + +' ---------------------------------------------------------------- +' Set storage server parameters. + +' @param url (string) Storage server host. +' Default value: "" +' @param port (string) Storage server port. +' Default value: "" +' ---------------------------------------------------------------- +sub Logger__SetServer(host = "" as string, port = "" as string) + if TF_Utils__IsNotEmptyString(host) + if TF_Utils__IsNotEmptyString(port) + m.serverURL = "http://" + host + ":" + port + else + m.serverURL = "http://" + host + end if + end if +end sub + +' ---------------------------------------------------------------- +' Set storage server URL parameter. + +' @param url (string) A storage server URL. +' Default value: "" +' ---------------------------------------------------------------- +sub Logger__SetServerURL(url = "" as string) + ? "This function is deprecated. Please use Logger__SetServer(host, port)" +end sub + +'---------------------------------------------------------------- +' Send test results as a POST json payload. +' +' @param statObj (object) stats of the test run. +' Default value: invalid +' ---------------------------------------------------------------- +sub Logger__SendToServer(statObj as object) + if TF_Utils__IsNotEmptyString(m.serverURL) and TF_Utils__IsValid(statObj) + ? "***" + ? "*** Sending statsObj to server: "; m.serverURL + + request = CreateObject("roUrlTransfer") + request.SetUrl(m.serverURL) + statString = FormatJson(statObj) + + ? "*** Response: "; request.postFromString(statString) + ? "***" + ? "******************************************************************" + end if +end sub + +' ---------------------------------------------------------------- +' Print statistic object with specified verbosity. + +' @param statObj (object) A statistic object to print. +' ---------------------------------------------------------------- +sub Logger__PrintStatistic(statObj as object) + if not m.echoEnabled + m.PrintStart() + + if m.verbosity = m.verbosityLevel.normal or m.verbosity = m.verbosityLevel.verboseFailed + for each testSuite in statObj.Suites + for each testCase in testSuite.Tests + if m.verbosity = m.verbosityLevel.verboseFailed and testCase.result = "Fail" + m.printTestStatistic(testCase) + else + ? "*** "; testSuite.Name; ": "; testCase.Name; " - "; testCase.Result + end if + end for + end for + else if m.verbosity = m.verbosityLevel.verbose + for each testSuite in statObj.Suites + m.PrintSuiteStatistic(testSuite) + end for + end if + end if + + ? "***" + ? "*** Total = "; TF_Utils__AsString(statObj.Total); " ; Passed = "; statObj.Correct; " ; Failed = "; statObj.Fail; " ; Skipped = "; statObj.skipped; " ; Crashes = "; statObj.Crash; + ? "*** Time spent: "; statObj.Time; "ms" + ? "***" + + m.PrintEnd() + + m.SendToServer(statObj) + + if m.jUnitEnabled + m.printJUnitFormat(statObj) + end if +end sub + +' ---------------------------------------------------------------- +' Create an empty statistic object for totals in output log. + +' @return An empty statistic object. +' ---------------------------------------------------------------- +function Logger__CreateTotalStatistic() as object + statTotalItem = { + Suites: [] + Time: 0 + Total: 0 + Correct: 0 + Fail: 0 + Skipped: 0 + Crash: 0 + } + + if m.echoEnabled + m.PrintStart() + end if + + return statTotalItem +end function + +' ---------------------------------------------------------------- +' Create an empty statistic object for test suite with specified name. + +' @param name (string) A test suite name for statistic object. + +' @return An empty statistic object for test suite. +' ---------------------------------------------------------------- +function Logger__CreateSuiteStatistic(name as string) as object + statSuiteItem = { + Name: name + Tests: [] + Time: 0 + Total: 0 + Correct: 0 + Fail: 0 + Skipped: 0 + Crash: 0 + } + + if m.echoEnabled + if m.verbosity = m.verbosityLevel.verbose + m.PrintSuiteStart(name) + end if + end if + + return statSuiteItem +end function + +' ---------------------------------------------------------------- +' Create statistic object for test with specified name. + +' @param name (string) A test name. +' @param result (string) A result of test running. +' Posible values: "Success", "Fail". +' Default value: "Success" +' @param time (integer) A test running time. +' Default value: 0 +' @param errorCode (integer) An error code for failed test. +' Posible values: +' 252 (&hFC) : ERR_NORMAL_END +' 226 (&hE2) : ERR_VALUE_RETURN +' 233 (&hE9) : ERR_USE_OF_UNINIT_VAR +' 020 (&h14) : ERR_DIV_ZERO +' 024 (&h18) : ERR_TM +' 244 (&hF4) : ERR_RO2 +' 236 (&hEC) : ERR_RO4 +' 002 (&h02) : ERR_SYNTAX +' 241 (&hF1) : ERR_WRONG_NUM_PARAM +' Default value: 0 +' @param errorMessage (string) An error message for failed test. + +' @return A statistic object for test. +' ---------------------------------------------------------------- +function Logger__CreateTestStatistic(name as string, result = "Success" as string, time = 0 as integer, errorCode = 0 as integer, errorMessage = "" as string, isInit = false as boolean) as object + statTestItem = { + Name: name + Result: result + Time: time + PerfData: {} + Error: { + Code: errorCode + Message: errorMessage + } + } + + if m.echoEnabled and not isInit + if m.verbosity = m.verbosityLevel.verbose + m.PrintTestStart(name) + end if + end if + + return statTestItem +end function + +' ---------------------------------------------------------------- +' Append test statistic to test suite statistic. + +' @param statSuiteObj (object) A target test suite object. +' @param statTestObj (object) A test statistic to append. +' ---------------------------------------------------------------- +sub Logger__AppendTestStatistic(statSuiteObj as object, statTestObj as object) + if TF_Utils__IsAssociativeArray(statSuiteObj) and TF_Utils__IsAssociativeArray(statTestObj) + statSuiteObj.Tests.Push(statTestObj) + + if TF_Utils__IsInteger(statTestObj.time) + statSuiteObj.Time = statSuiteObj.Time + statTestObj.Time + end if + + statSuiteObj.Total = statSuiteObj.Total + 1 + + if LCase(statTestObj.Result) = "success" + statSuiteObj.Correct = statSuiteObj.Correct + 1 + else if LCase(statTestObj.result) = "fail" + statSuiteObj.Fail = statSuiteObj.Fail + 1 + else if LCase(statTestObj.result) = "skipped" + statSuiteObj.skipped++ + else + statSuiteObj.crash = statSuiteObj.crash + 1 + end if + + if m.echoEnabled + if m.verbosity = m.verbosityLevel.normal + ? "*** "; statSuiteObj.Name; ": "; statTestObj.Name; " - "; statTestObj.Result + else if m.verbosity = m.verbosityLevel.verbose + m.PrintTestStatistic(statTestObj) + end if + end if + end if +end sub + +' ---------------------------------------------------------------- +' Append suite statistic to total statistic object. + +' @param statTotalObj (object) A target total statistic object. +' @param statSuiteObj (object) A test suite statistic object to append. +' ---------------------------------------------------------------- +sub Logger__AppendSuiteStatistic(statTotalObj as object, statSuiteObj as object) + if TF_Utils__IsAssociativeArray(statTotalObj) and TF_Utils__IsAssociativeArray(statSuiteObj) + statTotalObj.Suites.Push(statSuiteObj) + statTotalObj.Time = statTotalObj.Time + statSuiteObj.Time + + if TF_Utils__IsInteger(statSuiteObj.Total) + statTotalObj.Total = statTotalObj.Total + statSuiteObj.Total + end if + + if TF_Utils__IsInteger(statSuiteObj.Correct) + statTotalObj.Correct = statTotalObj.Correct + statSuiteObj.Correct + end if + + if TF_Utils__IsInteger(statSuiteObj.Fail) + statTotalObj.Fail = statTotalObj.Fail + statSuiteObj.Fail + end if + + if TF_Utils__IsInteger(statSuiteObj.skipped) + statTotalObj.skipped += statSuiteObj.skipped + end if + + if TF_Utils__IsInteger(statSuiteObj.Crash) + statTotalObj.Crash = statTotalObj.Crash + statSuiteObj.Crash + end if + + if m.echoEnabled + if m.verbosity = m.verbosityLevel.verbose + m.PrintSuiteStatistic(statSuiteObj) + end if + end if + end if +end sub + +' ---------------------------------------------------------------- +' Print test suite statistic. + +' @param statSuiteObj (object) A target test suite object to print. +' ---------------------------------------------------------------- +sub Logger__PrintSuiteStatistic(statSuiteObj as object) + if not m.echoEnabled + m.PrintSuiteStart(statSuiteObj.Name) + + for each testCase in statSuiteObj.Tests + m.PrintTestStatistic(testCase) + end for + end if + + ? "===" + ? "=== Total = "; TF_Utils__AsString(statSuiteObj.Total); " ; Passed = "; statSuiteObj.Correct; " ; Failed = "; statSuiteObj.Fail; " ; Skipped = "; statSuiteObj.skipped; " ; Crashes = "; statSuiteObj.Crash; + ? " Time spent: "; statSuiteObj.Time; "ms" + ? "===" + + m.PrintSuiteEnd(statSuiteObj.Name) +end sub + +' ---------------------------------------------------------------- +' Print test statistic. + +' @param statTestObj (object) A target test object to print. +' ---------------------------------------------------------------- +sub Logger__PrintTestStatistic(statTestObj as object) + if not m.echoEnabled + m.PrintTestStart(statTestObj.Name) + end if + + ? "--- Result: "; statTestObj.Result + ? "--- Time: "; statTestObj.Time + + if LCase(statTestObj.result) = "skipped" + if Len(statTestObj.message) > 0 + ? "--- Message: "; statTestObj.message + end if + else if LCase(statTestObj.Result) <> "success" + ? "--- Error Code: "; statTestObj.Error.Code + ? "--- Error Message: "; statTestObj.Error.Message + end if + + m.PrintTestEnd(statTestObj.Name) +end sub + +' ---------------------------------------------------------------- +' Print testting start message. +' ---------------------------------------------------------------- +sub Logger__PrintStart() + ? "" + ? "******************************************************************" + ? "******************************************************************" + ? "************* Start testing *************" + ? "******************************************************************" +end sub + +' ---------------------------------------------------------------- +' Print testing end message. +' ---------------------------------------------------------------- +sub Logger__PrintEnd() + ? "******************************************************************" + ? "************* End testing *************" + ? "******************************************************************" + ? "******************************************************************" + ? "" +end sub + +' ---------------------------------------------------------------- +' Print test suite SetUp message. +' ---------------------------------------------------------------- +sub Logger__PrintSuiteSetUp(sName as string) + if m.verbosity = m.verbosityLevel.verbose + ? "=================================================================" + ? "=== SetUp "; sName; " suite." + ? "=================================================================" + end if +end sub + +' ---------------------------------------------------------------- +' Print test suite start message. +' ---------------------------------------------------------------- +sub Logger__PrintSuiteStart(sName as string) + ? "=================================================================" + ? "=== Start "; sName; " suite:" + ? "===" +end sub + +' ---------------------------------------------------------------- +' Print test suite end message. +' ---------------------------------------------------------------- +sub Logger__PrintSuiteEnd(sName as string) + ? "===" + ? "=== End "; sName; " suite." + ? "=================================================================" +end sub + +' ---------------------------------------------------------------- +' Print test suite TearDown message. +' ---------------------------------------------------------------- +sub Logger__PrintSuiteTearDown(sName as string) + if m.verbosity = m.verbosityLevel.verbose + ? "=================================================================" + ? "=== TearDown "; sName; " suite." + ? "=================================================================" + end if +end sub + +' ---------------------------------------------------------------- +' Print test setUp message. +' ---------------------------------------------------------------- +sub Logger__PrintTestSetUp(tName as string) + if m.verbosity = m.verbosityLevel.verbose + ? "----------------------------------------------------------------" + ? "--- SetUp "; tName; " test." + ? "----------------------------------------------------------------" + end if +end sub + +' ---------------------------------------------------------------- +' Print test start message. +' ---------------------------------------------------------------- +sub Logger__PrintTestStart(tName as string) + ? "----------------------------------------------------------------" + ? "--- Start "; tName; " test:" + ? "---" +end sub + +' ---------------------------------------------------------------- +' Print test end message. +' ---------------------------------------------------------------- +sub Logger__PrintTestEnd(tName as string) + ? "---" + ? "--- End "; tName; " test." + ? "----------------------------------------------------------------" +end sub + +' ---------------------------------------------------------------- +' Print test TearDown message. +' ---------------------------------------------------------------- +sub Logger__PrintTestTearDown(tName as string) + if m.verbosity = m.verbosityLevel.verbose + ? "----------------------------------------------------------------" + ? "--- TearDown "; tName; " test." + ? "----------------------------------------------------------------" + end if +end sub + +sub Logger__PrintJUnitFormat(statObj as object) + ' TODO finish report + xml = CreateObject("roXMLElement") + xml.SetName("testsuites") + for each testSuiteAA in statObj.suites + testSuite = xml.AddElement("testsuite") + ' name="FeatureManagerTest" time="13.923" tests="2" errors="0" skipped="0" failures="0" + testSuite.AddAttribute("name", testSuiteAA.name) + testSuite.AddAttribute("time", testSuiteAA.time.toStr()) + testSuite.AddAttribute("tests", testSuiteAA.Tests.count().toStr()) + + skippedNum = 0 + failedNum = 0 + for each testAA in testSuiteAA.Tests + test = testSuite.AddElement("testcase") + test.AddAttribute("name", testAA.name) + test.AddAttribute("time", testAA.time.toStr()) + + if LCase(testAA.result) = "skipped" then + test.AddElement("skipped") + skippedNum++ + else if LCase(testAA.Result) <> "success" + failure = test.AddElement("failure") + failure.AddAttribute("message", testAA.error.message) + failure.AddAttribute("type", testAA.error.code.tostr()) + failedNum++ + end if + end for + testSuite.AddAttribute("errors", failedNum.tostr()) + testSuite.AddAttribute("skipped", skippedNum.tostr()) + end for + ? xml.GenXML(true) +end sub +'***************************************************************** +'* Copyright Roku 2011-2019 +'* All Rights Reserved +'***************************************************************** + +' Functions in this file: +' TestRunner +' TestRunner__Run +' TestRunner__SetTestsDirectory +' TestRunner__SetTestFilePrefix +' TestRunner__SetTestSuitePrefix +' TestRunner__SetTestSuiteName +' TestRunner__SetTestCaseName +' TestRunner__SetFailFast +' TestRunner__GetTestSuitesList +' TestRunner__GetTestSuiteNamesList +' TestRunner__GetTestFilesList +' TestRunner__GetTestNodesList +' TestFramework__RunNodeTests + +' ---------------------------------------------------------------- +' Main function. Create TestRunner object. + +' @return A TestRunner object. +' ---------------------------------------------------------------- +function TestRunner() as object + this = {} + GetGlobalAA().globalErrorsList = [] + this.isNodeMode = GetGlobalAA().top <> invalid + this.Logger = Logger() + + ' Internal properties + this.SKIP_TEST_MESSAGE_PREFIX = "SKIP_TEST_MESSAGE_PREFIX__" + this.nodesTestDirectory = "pkg:/components/tests" + if this.isNodeMode + this.testsDirectory = this.nodesTestDirectory + this.testFilePrefix = m.top.subtype() + else + this.testsDirectory = "pkg:/source/tests" + this.testFilePrefix = "Test__" + end if + this.testSuitePrefix = "TestSuite__" + this.testSuiteName = "" + this.testCaseName = "" + this.failFast = false + + ' Interface + this.Run = TestRunner__Run + this.SetTestsDirectory = TestRunner__SetTestsDirectory + this.SetTestFilePrefix = TestRunner__SetTestFilePrefix + this.SetTestSuitePrefix = TestRunner__SetTestSuitePrefix + this.SetTestSuiteName = TestRunner__SetTestSuiteName ' Obsolete, will be removed in next versions + this.SetTestCaseName = TestRunner__SetTestCaseName ' Obsolete, will be removed in next versions + this.SetFailFast = TestRunner__SetFailFast + this.SetFunctions = TestRunner__SetFunctions + this.SetIncludeFilter = TestRunner__SetIncludeFilter + this.SetExcludeFilter = TestRunner__SetExcludeFilter + + ' Internal functions + this.GetTestFilesList = TestRunner__GetTestFilesList + this.GetTestSuitesList = TestRunner__GetTestSuitesList + this.GetTestNodesList = TestRunner__GetTestNodesList + this.GetTestSuiteNamesList = TestRunner__GetTestSuiteNamesList + this.GetIncludeFilter = TestRunner__GetIncludeFilter + this.GetExcludeFilter = TestRunner__GetExcludeFilter + + return this +end function + +' ---------------------------------------------------------------- +' Run main test loop. + +' @param statObj (object, optional) statistic object to be used in tests +' @param testSuiteNamesList (array, optional) array of test suite function names to be used in tests + +' @return Statistic object if run in node mode, invalid otherwise +' ---------------------------------------------------------------- +function TestRunner__Run(statObj = m.Logger.CreateTotalStatistic() as object, testSuiteNamesList = [] as object) as object + alltestCount = 0 + totalStatObj = statObj + testSuitesList = m.GetTestSuitesList(testSuiteNamesList) + + globalErrorsList = GetGlobalAA().globalErrorsList + for each testSuite in testSuitesList + testCases = testSuite.testCases + testCount = testCases.Count() + alltestCount = alltestCount + testCount + + IS_NEW_APPROACH = testSuite.IS_NEW_APPROACH + ' create dedicated env for each test, so that they will have not global m and don't rely on m.that is set in another suite + env = {} + + if TF_Utils__IsFunction(testSuite.SetUp) + m.Logger.PrintSuiteSetUp(testSuite.Name) + if IS_NEW_APPROACH then + env.functionToCall = testSuite.SetUp + env.functionToCall() + else + testSuite.SetUp() + end if + end if + + suiteStatObj = m.Logger.CreateSuiteStatistic(testSuite.Name) + ' Initiate empty test statistics object to print results if no tests was run + testStatObj = m.Logger.CreateTestStatistic("", "Success", 0, 0, "", true) + for each testCase in testCases + ' clear all existing errors + globalErrorsList.clear() + + if m.testCaseName = "" or (m.testCaseName <> "" and LCase(testCase.Name) = LCase(m.testCaseName)) + skipTest = TF_Utils__AsBoolean(testCase.skip) + + if TF_Utils__IsFunction(testCase.SetUp) and not skipTest + m.Logger.PrintTestSetUp(testCase.Name) + if IS_NEW_APPROACH then + env.functionToCall = testCase.SetUp + env.functionToCall() + else + testCase.SetUp() + end if + end if + + testTimer = CreateObject("roTimespan") + testStatObj = m.Logger.CreateTestStatistic(testCase.Name) + + if skipTest + runResult = m.SKIP_TEST_MESSAGE_PREFIX + "Test was skipped according to specified filters" + else + testSuite.testInstance = testCase + testSuite.testCase = testCase.Func + + runResult = "" + if IS_NEW_APPROACH then + env.functionToCall = testCase.Func + + if GetInterface(env.functionToCall, "ifFunction") <> invalid + if testCase.hasArguments then + env.functionToCall(testCase.arg) + else + env.functionToCall() + end if + else + UTF_fail("Failed to execute test """ + testCase.Name + """ function pointer not found") + end if + else + runResult = testSuite.testCase() + end if + end if + + if TF_Utils__IsFunction(testCase.TearDown) and not skipTest + m.Logger.PrintTestTearDown(testCase.Name) + if IS_NEW_APPROACH then + env.functionToCall = testCase.TearDown + env.functionToCall() + else + testCase.TearDown() + end if + end if + + if IS_NEW_APPROACH then + if globalErrorsList.count() > 0 + for each error in globalErrorsList + runResult += error + Chr(10) + string(10, "-") + Chr(10) + end for + end if + end if + + if runResult <> "" + if InStr(0, runResult, m.SKIP_TEST_MESSAGE_PREFIX) = 1 + testStatObj.result = "Skipped" + testStatObj.message = runResult.Mid(Len(m.SKIP_TEST_MESSAGE_PREFIX)) ' remove prefix from the message + else + testStatObj.Result = "Fail" + testStatObj.Error.Code = 1 + testStatObj.Error.Message = runResult + end if + else + testStatObj.Result = "Success" + end if + + testStatObj.Time = testTimer.TotalMilliseconds() + m.Logger.AppendTestStatistic(suiteStatObj, testStatObj) + + if testStatObj.Result = "Fail" and m.failFast + suiteStatObj.Result = "Fail" + exit for + end if + end if + end for + + m.Logger.AppendSuiteStatistic(totalStatObj, suiteStatObj) + + if TF_Utils__IsFunction(testSuite.TearDown) + m.Logger.PrintSuiteTearDown(testSuite.Name) + testSuite.TearDown() + end if + + if suiteStatObj.Result = "Fail" and m.failFast + exit for + end if + end for + + gthis = GetGlobalAA() + msg = "" + if gthis.notFoundFunctionPointerList <> invalid then + msg = Chr(10) + string(40, "---") + Chr(10) + if m.isNodeMode + fileNamesString = "" + + for each testSuiteObject in testSuiteNamesList + if GetInterface(testSuiteObject, "ifString") <> invalid then + fileNamesString += testSuiteObject + ".brs, " + else if GetInterface(testSuiteObject, "ifAssociativeArray") <> invalid then + if testSuiteObject.filePath <> invalid then + fileNamesString += testSuiteObject.filePath + ", " + end if + end if + end for + + msg += Chr(10) + "Create this function below in one of these files" + msg += Chr(10) + fileNamesString + Chr(10) + + msg += Chr(10) + "sub init()" + end if + msg += Chr(10) + "Runner.SetFunctions([" + Chr(10) + " testCase" + Chr(10) + "])" + msg += Chr(10) + "For example we think this might resolve your issue" + msg += Chr(10) + "Runner = TestRunner()" + msg += Chr(10) + "Runner.SetFunctions([" + + tmpMap = {} + for each functionName in gthis.notFoundFunctionPointerList + if tmpMap[functionName] = invalid then + tmpMap[functionName] = "" + msg += Chr(10) + " " + functionName + end if + end for + + msg += Chr(10) + "])" + if m.isNodeMode then + msg += Chr(10) + "end sub" + else + msg += Chr(10) + "Runner.Run()" + end if + end if + + if m.isNodeMode + if msg.Len() > 0 then + if totalStatObj.notFoundFunctionsMessage = invalid then totalStatObj.notFoundFunctionsMessage = "" + totalStatObj.notFoundFunctionsMessage += msg + end if + return totalStatObj + else + testNodes = m.getTestNodesList() + for each testNodeName in testNodes + testNode = CreateObject("roSGNode", testNodeName) + if testNode <> invalid + testSuiteNamesList = m.GetTestSuiteNamesList(testNodeName) + if CreateObject("roSGScreen").CreateScene(testNodeName) <> invalid + ? "WARNING: Test cases cannot be run in main scene." + for each testSuiteName in testSuiteNamesList + suiteStatObj = m.Logger.CreateSuiteStatistic(testSuiteName) + suiteStatObj.fail = 1 + suiteStatObj.total = 1 + m.Logger.AppendSuiteStatistic(totalStatObj, suiteStatObj) + end for + else + params = [m, totalStatObj, testSuiteNamesList, m.GetIncludeFilter(), m.GetExcludeFilter()] + tmp = testNode.callFunc("TestFramework__RunNodeTests", params) + if tmp <> invalid then + totalStatObj = tmp + end if + end if + end if + end for + + m.Logger.PrintStatistic(totalStatObj) + end if + + if msg.Len() > 0 or totalStatObj.notFoundFunctionsMessage <> invalid then + title = "" + title += Chr(10) + "NOTE: If some your tests haven't been executed this might be due to outdated list of functions" + title += Chr(10) + "To resolve this issue please execute" + Chr(10) + Chr(10) + + title += msg + + if totalStatObj.notFoundFunctionsMessage <> invalid then + title += totalStatObj.notFoundFunctionsMessage + end if + ? title + end if +end function + +' ---------------------------------------------------------------- +' Set testsDirectory property. +' ---------------------------------------------------------------- +sub TestRunner__SetTestsDirectory(testsDirectory as string) + m.testsDirectory = testsDirectory +end sub + +' ---------------------------------------------------------------- +' Set testFilePrefix property. +' ---------------------------------------------------------------- +sub TestRunner__SetTestFilePrefix(testFilePrefix as string) + m.testFilePrefix = testFilePrefix +end sub + +' ---------------------------------------------------------------- +' Set testSuitePrefix property. +' ---------------------------------------------------------------- +sub TestRunner__SetTestSuitePrefix(testSuitePrefix as string) + m.testSuitePrefix = testSuitePrefix +end sub + +' ---------------------------------------------------------------- +' Set testSuiteName property. +' ---------------------------------------------------------------- +sub TestRunner__SetTestSuiteName(testSuiteName as string) + m.testSuiteName = testSuiteName +end sub + +' ---------------------------------------------------------------- +' Set testCaseName property. +' ---------------------------------------------------------------- +sub TestRunner__SetTestCaseName(testCaseName as string) + m.testCaseName = testCaseName +end sub + +' ---------------------------------------------------------------- +' Set failFast property. +' ---------------------------------------------------------------- +sub TestRunner__SetFailFast(failFast = false as boolean) + m.failFast = failFast +end sub + +' ---------------------------------------------------------------- +' Builds an array of test suite objects. + +' @param testSuiteNamesList (string, optional) array of names of test suite functions. If not passed, scans all test files for test suites + +' @return An array of test suites. +' ---------------------------------------------------------------- +function TestRunner__GetTestSuitesList(testSuiteNamesList = [] as object) as object + result = [] + + if testSuiteNamesList.count() > 0 + for each value in testSuiteNamesList + if TF_Utils__IsString(value) then + tmpTestSuiteFunction = TestFramework__getFunctionPointer(value) + if tmpTestSuiteFunction <> invalid then + testSuite = tmpTestSuiteFunction() + + if TF_Utils__IsAssociativeArray(testSuite) + result.Push(testSuite) + end if + end if + ' also we can get AA that will give source code and filePath + ' Please be aware this is executed in render thread + else if GetInterface(value, "ifAssociativeArray") <> invalid then + ' try to use new approach + testSuite = ScanFileForNewTests(value.code, value.filePath) + if testSuite <> invalid then + result.push(testSuite) + end if + else if GetInterface(value, "ifFunction") <> invalid then + result.Push(value) + end if + end for + else + testSuiteRegex = CreateObject("roRegex", "^(function|sub)\s(" + m.testSuitePrefix + m.testSuiteName + "[0-9a-z\_]*)\s*\(", "i") + testFilesList = m.GetTestFilesList() + + for each filePath in testFilesList + code = TF_Utils__AsString(ReadAsciiFile(filePath)) + + if code <> "" + foundTestSuite = false + for each line in code.Tokenize(Chr(10)) + line.Trim() + + if testSuiteRegex.IsMatch(line) + testSuite = invalid + functionName = testSuiteRegex.Match(line).Peek() + + tmpTestSuiteFunction = TestFramework__getFunctionPointer(functionName) + if tmpTestSuiteFunction <> invalid then + testSuite = tmpTestSuiteFunction() + + if TF_Utils__IsAssociativeArray(testSuite) + result.Push(testSuite) + foundTestSuite = true + else + ' TODO check if we need this + ' using new mode + ' testSuite = ScanFileForNewTests(code, filePath) + + ' exit for + end if + end if + end if + end for + if not foundTestSuite then + testSuite = ScanFileForNewTests(code, filePath) + if testSuite <> invalid then + result.push(testSuite) + end if + end if + end if + end for + end if + + return result +end function + +function ScanFileForNewTests(souceCode, filePath) + foundAnyTest = false + testSuite = BaseTestSuite() + + allowedAnnotationsRegex = CreateObject("roRegex", "^'\s*@(test|beforeall|beforeeach|afterall|aftereach|repeatedtest|parameterizedtest|methodsource|ignore)\s*|\n", "i") + voidFunctionRegex = CreateObject("roRegex", "^(function|sub)\s([a-z0-9A-Z_]*)\(\)", "i") + anyArgsFunctionRegex = CreateObject("roRegex", "^(function|sub)\s([a-z0-9A-Z_]*)\(", "i") + + processors = { + testSuite: testSuite + filePath: filePath + currentLine: "" + annotations: {} + + functionName: "" + + tests: [] + + beforeEachFunc: invalid + beforeAllFunc: invalid + + AfterEachFunc: invalid + AfterAllFunc: invalid + + isParameterizedTest: false + MethodForArguments: "" + executedParametrizedAdding: false + + test: sub() + skipTest = m.doSkipTest(m.functionName) + funcPointer = m.getFunctionPointer(m.functionName) + m.tests.push({ name: m.functionName, pointer: funcPointer, skip: skipTest }) + end sub + + repeatedtest: sub() + allowedAnnotationsRegex = CreateObject("roRegex", "^'\s*@(repeatedtest)\((\d*)\)", "i") + annotationLine = m.annotations["repeatedtest"].line + if allowedAnnotationsRegex.IsMatch(annotationLine) + groups = allowedAnnotationsRegex.Match(annotationLine) + numberOfLoops = groups[2] + if numberOfLoops <> invalid and TF_Utils__AsInteger(numberOfLoops) > 0 then + numberOfLoops = TF_Utils__AsInteger(numberOfLoops) + funcPointer = m.getFunctionPointer(m.functionName) + for index = 1 to numberOfLoops + skipTest = m.doSkipTest(m.functionName) + text = " " + index.tostr() + " of " + numberOfLoops.tostr() + m.tests.push({ name: m.functionName + text, pointer: funcPointer, skip: skipTest }) + end for + end if + else + ? "WARNING: Wrong format of repeatedTest(numberOfRuns) "annotationLine + end if + end sub + + parameterizedTest: sub() + m.processParameterizedTests() + end sub + + methodSource: sub() + m.processParameterizedTests() + end sub + + processParameterizedTests: sub() + ' add test if it was not added already + if not m.executedParametrizedAdding + if m.annotations.methodSource <> invalid and m.annotations.parameterizedTest <> invalid then + methodAnottation = m.annotations.methodSource.line + + allowedAnnotationsRegex = CreateObject("roRegex", "^'\s*@(methodsource)\(" + Chr(34) + "([A-Za-z0-9_]*)" + Chr(34) + "\)", "i") + + if allowedAnnotationsRegex.IsMatch(methodAnottation) + groups = allowedAnnotationsRegex.Match(methodAnottation) + providerFunction = groups[2] + + providerFunctionPointer = m.getFunctionPointer(providerFunction) + + if providerFunctionPointer <> invalid then + funcPointer = m.getFunctionPointer(m.functionName) + + args = providerFunctionPointer() + + index = 1 + for each arg in args + skipTest = m.doSkipTest(m.functionName) + text = " " + index.tostr() + " of " + args.count().tostr() + m.tests.push({ name: m.functionName + text, pointer: funcPointer, arg: arg, hasArgs: true, skip: skipTest }) + index++ + end for + else + ? "WARNING: Cannot find function [" providerFunction "]" + end if + end if + else + ? "WARNING: Wrong format of @ParameterizedTest \n @MethodSource(providerFunctionName)" + ? "m.executedParametrizedAdding = "m.executedParametrizedAdding + ? "m.annotations.methodSource = "m.annotations.methodSource + ? "m.annotations.parameterizedTest = "m.annotations.parameterizedTest + ? "" + end if + end if + end sub + + beforeEach: sub() + m.beforeEachFunc = m.getFunctionPointer(m.functionName) + end sub + + beforeAll: sub() + m.beforeAllFunc = m.getFunctionPointer(m.functionName) + end sub + + AfterEach: sub() + m.AfterEachFunc = m.getFunctionPointer(m.functionName) + end sub + + AfterAll: sub() + m.AfterAllFunc = m.getFunctionPointer(m.functionName) + end sub + + ignore: sub() + funcPointer = m.getFunctionPointer(m.functionName) + m.tests.push({ name: m.functionName, pointer: funcPointer, skip: true }) + end sub + + doSkipTest: function(name as string) + includeFilter = [] + excludeFilter = [] + + gthis = GetGlobalAA() + if gthis.IncludeFilter <> invalid then includeFilter.append(gthis.IncludeFilter) + if gthis.ExcludeFilter <> invalid then excludeFilter.append(gthis.ExcludeFilter) + + ' apply test filters + skipTest = false + ' skip test if it is found in exclude filter + for each testName in excludeFilter + if TF_Utils__IsNotEmptyString(testName) and LCase(testName.Trim()) = LCase(name.Trim()) + skipTest = true + exit for + end if + end for + + ' skip test if it is not found in include filter + if not skipTest and includeFilter.Count() > 0 + foundInIncludeFilter = false + + for each testName in includeFilter + if TF_Utils__IsNotEmptyString(testName) and LCase(testName) = LCase(name) + foundInIncludeFilter = true + exit for + end if + end for + + skipTest = not foundInIncludeFilter + end if + + return skipTest + end function + + buildTests: sub() + testSuite = m.testSuite + testSuite.Name = m.filePath + if m.beforeAllFunc <> invalid then testSuite.SetUp = m.beforeAllFunc + if m.AfterAllFunc <> invalid then testSuite.TearDown = m.AfterAllFunc + testSuite.IS_NEW_APPROACH = true + + for each test in m.tests + ' Add tests to suite's tests collection + arg = invalid + hasArgs = false + if test.hasArgs <> invalid then + arg = test.arg + hasArgs = true + end if + + testSuite.addTest(test.name, test.pointer, m.beforeEachFunc, m.AfterEachFunc, arg, hasArgs, test.skip) + end for + end sub + + getFunctionPointer: TestFramework__getFunctionPointer + } + + currentAnottations = [] + index = 0 + + for each line in souceCode.Tokenize(Chr(10)) + line = line.Trim() + if line <> "" ' skipping empty lines + if allowedAnnotationsRegex.IsMatch(line) + groups = allowedAnnotationsRegex.Match(line) + anottationType = groups[1] + if anottationType <> invalid and processors[anottationType] <> invalid then + currentAnottations.push(anottationType) + processors.annotations[anottationType] = { line: line, lineIndex: index } + end if + else + if currentAnottations.count() > 0 then + isParametrized = anyArgsFunctionRegex.IsMatch(line) + properMap = { parameterizedtest: "", methodsource: "" } + for each availableAnottation in currentAnottations + isParametrized = isParametrized or properMap[availableAnottation] <> invalid + end for + + if voidFunctionRegex.IsMatch(line) or isParametrized then + groups = voidFunctionRegex.Match(line) + + if isParametrized then + groups = anyArgsFunctionRegex.Match(line) + end if + if groups[2] <> invalid then + processors.functionName = groups[2] + processors.currentLine = line + + ' process all handlers + if isParametrized then processors.executedParametrizedAdding = false + for each availableAnottation in currentAnottations + processors[availableAnottation]() + if isParametrized then processors.executedParametrizedAdding = true + end for + currentAnottations = [] + processors.annotations = {} + foundAnyTest = true + end if + else + ' invalidating annotation + ' TODO print message here that we skipped annotation + ? "WARNING: annotation " currentAnottations " isparametrized=" isParametrized " skipped at line " index ":[" line "]" + processors.annotations = {} + currentAnottations = [] + end if + end if + end if + end if + index++ + end for + + processors.buildTests() + + if not foundAnyTest then + testSuite = invalid + end if + return testSuite +end function + +function TestFramework__getFunctionPointer(functionName as string) as dynamic + result = invalid + + gthis = GetGlobalAA() + if gthis.FunctionsList <> invalid then + for each value in gthis.FunctionsList + if Type(value) <> "" and LCase(Type(value)) <> "" and GetInterface(value, "ifFunction") <> invalid and LCase(value.tostr()) = "function: " + LCase(functionName) then + result = value + exit for + end if + end for + end if + + if LCase(Type(result)) = "" then result = invalid + if result = invalid then + if gthis.notFoundFunctionPointerList = invalid then gthis.notFoundFunctionPointerList = [] + gthis.notFoundFunctionPointerList.push(functionName) + end if + return result +end function + +sub TestRunner__SetFunctions(listOfFunctions as dynamic) + gthis = GetGlobalAA() + + if gthis.FunctionsList = invalid then + gthis.FunctionsList = [] + end if + gthis.FunctionsList.append(listOfFunctions) +end sub + +sub TestRunner__SetIncludeFilter(listOfFunctions as dynamic) + gthis = GetGlobalAA() + + if gthis.IncludeFilter = invalid + gthis.IncludeFilter = [] + end if + + if TF_Utils__IsArray(listOfFunctions) + gthis.IncludeFilter.Append(listOfFunctions) + else if TF_Utils__IsNotEmptyString(listOfFunctions) + gthis.IncludeFilter.Append(listOfFunctions.Split(",")) + else + ? "WARNING: Could not parse input parameters for Include Filter. Filter wont be applied." + end if +end sub + +function TestRunner__GetIncludeFilter() + gthis = GetGlobalAA() + + if gthis.IncludeFilter = invalid + gthis.IncludeFilter = [] + end if + + return gthis.IncludeFilter +end function + +sub TestRunner__SetExcludeFilter(listOfFunctions as dynamic) + gthis = GetGlobalAA() + + if gthis.ExcludeFilter = invalid + gthis.ExcludeFilter = [] + end if + + if TF_Utils__IsArray(listOfFunctions) + gthis.ExcludeFilter.Append(listOfFunctions) + else if TF_Utils__IsNotEmptyString(listOfFunctions) + gthis.ExcludeFilter.Append(listOfFunctions.Split(",")) + else + ? "WARNING: Could not parse input parameters for Exclude Filter. Filter wont be applied." + end if +end sub + +function TestRunner__GetExcludeFilter() + gthis = GetGlobalAA() + + if gthis.ExcludeFilter = invalid + gthis.ExcludeFilter = [] + end if + + return gthis.ExcludeFilter +end function + +' ---------------------------------------------------------------- +' Scans all test files for test suite function names for a given test node. + +' @param testNodeName (string) name of a test node, test suites for which are needed + +' @return An array of test suite names. +' ---------------------------------------------------------------- +function TestRunner__GetTestSuiteNamesList(testNodeName as string) as object + result = [] + testSuiteRegex = CreateObject("roRegex", "^(function|sub)\s(" + m.testSuitePrefix + m.testSuiteName + "[0-9a-z\_]*)\s*\(", "i") + testFilesList = m.GetTestFilesList(m.nodesTestDirectory, testNodeName) + + for each filePath in testFilesList + code = TF_Utils__AsString(ReadAsciiFile(filePath)) + + if code <> "" + foundTestSuite = false + for each line in code.Tokenize(Chr(10)) + line.Trim() + + if testSuiteRegex.IsMatch(line) + functionName = testSuiteRegex.Match(line).Peek() + result.Push(functionName) + foundTestSuite = true + end if + end for + + if not foundTestSuite then + ' we cannot scan for new tests as we are not in proper scope + ' so we need to pass some data so this can be executed in render thread + result.push({ filePath: filePath, code: code }) + end if + end if + end for + + return result +end function + +' ---------------------------------------------------------------- +' Scan testsDirectory and all subdirectories for test files. + +' @param testsDirectory (string, optional) A target directory with test files. +' @param testFilePrefix (string, optional) prefix, used by test files + +' @return An array of test files. +' ---------------------------------------------------------------- +function TestRunner__GetTestFilesList(testsDirectory = m.testsDirectory as string, testFilePrefix = m.testFilePrefix as string) as object + result = [] + testsFileRegex = CreateObject("roRegex", "^(" + testFilePrefix + ")[0-9a-z\_]*\.brs$", "i") + + if testsDirectory <> "" + fileSystem = CreateObject("roFileSystem") + + if m.isNodeMode + ? string(2, Chr(10)) + ? string(10, "!!!") + ? "Note if you crash here this means that we are in render thread and searching for tests" + ? "Problem is that file naming is wrong" + ? "check brs file name they should match pattern ""Test_ExactComponentName_anything.brs""" + ? "In this case we were looking for "testFilePrefix + ? string(10, "!!!") string(2, Chr(10)) + end if + listing = fileSystem.GetDirectoryListing(testsDirectory) + + for each item in listing + itemPath = testsDirectory + "/" + item + itemStat = fileSystem.Stat(itemPath) + + if itemStat.type = "directory" then + result.Append(m.getTestFilesList(itemPath, testFilePrefix)) + else if testsFileRegex.IsMatch(item) then + result.Push(itemPath) + end if + end for + end if + + return result +end function + +' ---------------------------------------------------------------- +' Scan nodesTestDirectory and all subdirectories for test nodes. + +' @param nodesTestDirectory (string, optional) A target directory with test nodes. + +' @return An array of test node names. +' ---------------------------------------------------------------- +function TestRunner__GetTestNodesList(testsDirectory = m.nodesTestDirectory as string) as object + result = [] + testsFileRegex = CreateObject("roRegex", "^(" + m.testFilePrefix + ")[0-9a-z\_]*\.xml$", "i") + + if testsDirectory <> "" + fileSystem = CreateObject("roFileSystem") + listing = fileSystem.GetDirectoryListing(testsDirectory) + + for each item in listing + itemPath = testsDirectory + "/" + item + itemStat = fileSystem.Stat(itemPath) + + if itemStat.type = "directory" then + result.Append(m.getTestNodesList(itemPath)) + else if testsFileRegex.IsMatch(item) then + result.Push(item.replace(".xml", "")) + end if + end for + end if + + return result +end function + +' ---------------------------------------------------------------- +' Creates and runs test runner. Should be used ONLY within a node. + +' @param params (array) parameters, passed from main thread, used to setup new test runner + +' @return statistic object. +' ---------------------------------------------------------------- +function TestFramework__RunNodeTests(params as object) as object + this = params[0] + + statObj = params[1] + testSuiteNamesList = params[2] + + Runner = TestRunner() + + Runner.SetTestSuitePrefix(this.testSuitePrefix) + Runner.SetTestFilePrefix(this.testFilePrefix) + Runner.SetTestSuiteName(this.testSuiteName) + Runner.SetTestCaseName(this.testCaseName) + Runner.SetFailFast(this.failFast) + + Runner.SetIncludeFilter(params[3]) + Runner.SetExcludeFilter(params[4]) + + return Runner.Run(statObj, testSuiteNamesList) +end function +function UTF_skip(msg = "") + return UTF_PushErrorMessage(BTS__Skip(msg)) +end function + +function UTF_fail(msg = "") + return UTF_PushErrorMessage(BTS__Fail(msg)) +end function + +function UTF_assertFalse(expr, msg = "Expression evaluates to true") + return UTF_PushErrorMessage(BTS__AssertFalse(expr, msg)) +end function + +function UTF_assertTrue(expr, msg = "Expression evaluates to false") + return UTF_PushErrorMessage(BTS__AssertTrue(expr, msg)) +end function + +function UTF_assertEqual(first, second, msg = "") + return UTF_PushErrorMessage(BTS__AssertEqual(first, second, msg)) +end function + +function UTF_assertNotEqual(first, second, msg = "") + return UTF_PushErrorMessage(BTS__AssertNotEqual(first, second, msg)) +end function + +function UTF_assertInvalid(value, msg = "") + return UTF_PushErrorMessage(BTS__AssertInvalid(value, msg)) +end function + +function UTF_assertNotInvalid(value, msg = "") + return UTF_PushErrorMessage(BTS__AssertNotInvalid(value, msg)) +end function + +function UTF_assertAAHasKey(array, key, msg = "") + return UTF_PushErrorMessage(BTS__AssertAAHasKey(array, key, msg)) +end function + +function UTF_assertAANotHasKey(array, key, msg = "") + return UTF_PushErrorMessage(BTS__AssertAANotHasKey(array, key, msg)) +end function + +function UTF_assertAAHasKeys(array, keys, msg = "") + return UTF_PushErrorMessage(BTS__AssertAAHasKeys(array, keys, msg)) +end function + +function UTF_assertAANotHasKeys(array, keys, msg = "") + return UTF_PushErrorMessage(BTS__AssertAANotHasKeys(array, keys, msg)) +end function + +function UTF_assertArrayContains(array, value, key = invalid, msg = "") + return UTF_PushErrorMessage(BTS__AssertArrayContains(array, value, key, msg)) +end function + +function UTF_assertArrayNotContains(array, value, key = invalid, msg = "") + return UTF_PushErrorMessage(BTS__AssertArrayNotContains(array, value, key, msg)) +end function + +function UTF_assertArrayContainsSubset(array, subset, msg = "") + return UTF_PushErrorMessage(BTS__AssertArrayContainsSubset(array, subset, msg)) +end function + +function UTF_assertArrayNotContainsSubset(array, subset, msg = "") + return UTF_PushErrorMessage(BTS__AssertArrayNotContainsSubset(array, subset, msg)) +end function + +function UTF_assertArrayCount(array, count, msg = "") + return UTF_PushErrorMessage(BTS__AssertArrayCount(array, count, msg)) +end function + +function UTF_assertArrayNotCount(array, count, msg = "") + return UTF_PushErrorMessage(BTS__AssertArrayNotCount(array, count, msg)) +end function + +function UTF_assertEmpty(item, msg = "") + return UTF_PushErrorMessage(BTS__AssertEmpty(item, msg)) +end function + +function UTF_assertNotEmpty(item, msg = "") + return UTF_PushErrorMessage(BTS__AssertNotEmpty(item, msg)) +end function + +function UTF_PushErrorMessage(message as string) as boolean + result = Len(message) <= 0 + if not result then + m.globalErrorsList.push(message) + end if + + return result +end function'***************************************************************** +'* Copyright Roku 2011-2019 +'* All Rights Reserved +'***************************************************************** +' Common framework utility functions +' ***************************************************************** + +' ************************************************* +' TF_Utils__IsXmlElement - check if value contains XMLElement interface +' @param value As Dynamic +' @return As Boolean - true if value contains XMLElement interface, else return false +' ************************************************* +function TF_Utils__IsXmlElement(value as dynamic) as boolean + return TF_Utils__IsValid(value) and GetInterface(value, "ifXMLElement") <> invalid +end function + +' ************************************************* +' TF_Utils__IsFunction - check if value contains Function interface +' @param value As Dynamic +' @return As Boolean - true if value contains Function interface, else return false +' ************************************************* +function TF_Utils__IsFunction(value as dynamic) as boolean + return TF_Utils__IsValid(value) and GetInterface(value, "ifFunction") <> invalid +end function + +' ************************************************* +' TF_Utils__IsBoolean - check if value contains Boolean interface +' @param value As Dynamic +' @return As Boolean - true if value contains Boolean interface, else return false +' ************************************************* +function TF_Utils__IsBoolean(value as dynamic) as boolean + return TF_Utils__IsValid(value) and GetInterface(value, "ifBoolean") <> invalid +end function + +' ************************************************* +' TF_Utils__IsInteger - check if value type equals Integer +' @param value As Dynamic +' @return As Boolean - true if value type equals Integer, else return false +' ************************************************* +function TF_Utils__IsInteger(value as dynamic) as boolean + return TF_Utils__IsValid(value) and GetInterface(value, "ifInt") <> invalid and (Type(value) = "roInt" or Type(value) = "roInteger" or Type(value) = "Integer") +end function + +' ************************************************* +' TF_Utils__IsFloat - check if value contains Float interface +' @param value As Dynamic +' @return As Boolean - true if value contains Float interface, else return false +' ************************************************* +function TF_Utils__IsFloat(value as dynamic) as boolean + return TF_Utils__IsValid(value) and GetInterface(value, "ifFloat") <> invalid +end function + +' ************************************************* +' TF_Utils__IsDouble - check if value contains Double interface +' @param value As Dynamic +' @return As Boolean - true if value contains Double interface, else return false +' ************************************************* +function TF_Utils__IsDouble(value as dynamic) as boolean + return TF_Utils__IsValid(value) and GetInterface(value, "ifDouble") <> invalid +end function + +' ************************************************* +' TF_Utils__IsLongInteger - check if value contains LongInteger interface +' @param value As Dynamic +' @return As Boolean - true if value contains LongInteger interface, else return false +' ************************************************* +function TF_Utils__IsLongInteger(value as dynamic) as boolean + return TF_Utils__IsValid(value) and GetInterface(value, "ifLongInt") <> invalid +end function + +' ************************************************* +' TF_Utils__IsNumber - check if value contains LongInteger or Integer or Double or Float interface +' @param value As Dynamic +' @return As Boolean - true if value is number, else return false +' ************************************************* +function TF_Utils__IsNumber(value as dynamic) as boolean + return TF_Utils__IsLongInteger(value) or TF_Utils__IsDouble(value) or TF_Utils__IsInteger(value) or TF_Utils__IsFloat(value) +end function + +' ************************************************* +' TF_Utils__IsList - check if value contains List interface +' @param value As Dynamic +' @return As Boolean - true if value contains List interface, else return false +' ************************************************* +function TF_Utils__IsList(value as dynamic) as boolean + return TF_Utils__IsValid(value) and GetInterface(value, "ifList") <> invalid +end function + +' ************************************************* +' TF_Utils__IsArray - check if value contains Array interface +' @param value As Dynamic +' @return As Boolean - true if value contains Array interface, else return false +' ************************************************* +function TF_Utils__IsArray(value as dynamic) as boolean + return TF_Utils__IsValid(value) and GetInterface(value, "ifArray") <> invalid +end function + +' ************************************************* +' TF_Utils__IsAssociativeArray - check if value contains AssociativeArray interface +' @param value As Dynamic +' @return As Boolean - true if value contains AssociativeArray interface, else return false +' ************************************************* +function TF_Utils__IsAssociativeArray(value as dynamic) as boolean + return TF_Utils__IsValid(value) and GetInterface(value, "ifAssociativeArray") <> invalid +end function + +' ************************************************* +' TF_Utils__IsSGNode - check if value contains SGNodeChildren interface +' @param value As Dynamic +' @return As Boolean - true if value contains SGNodeChildren interface, else return false +' ************************************************* +function TF_Utils__IsSGNode(value as dynamic) as boolean + return TF_Utils__IsValid(value) and GetInterface(value, "ifSGNodeChildren") <> invalid +end function + +' ************************************************* +' TF_Utils__IsString - check if value contains String interface +' @param value As Dynamic +' @return As Boolean - true if value contains String interface, else return false +' ************************************************* +function TF_Utils__IsString(value as dynamic) as boolean + return TF_Utils__IsValid(value) and GetInterface(value, "ifString") <> invalid +end function + +' ************************************************* +' TF_Utils__IsNotEmptyString - check if value contains String interface and length more 0 +' @param value As Dynamic +' @return As Boolean - true if value contains String interface and length more 0, else return false +' ************************************************* +function TF_Utils__IsNotEmptyString(value as dynamic) as boolean + return TF_Utils__IsString(value) and Len(value) > 0 +end function + +' ************************************************* +' TF_Utils__IsDateTime - check if value contains DateTime interface +' @param value As Dynamic +' @return As Boolean - true if value contains DateTime interface, else return false +' ************************************************* +function TF_Utils__IsDateTime(value as dynamic) as boolean + return TF_Utils__IsValid(value) and (GetInterface(value, "ifDateTime") <> invalid or Type(value) = "roDateTime") +end function + +' ************************************************* +' TF_Utils__IsValid - check if value initialized and not equal invalid +' @param value As Dynamic +' @return As Boolean - true if value initialized and not equal invalid, else return false +' ************************************************* +function TF_Utils__IsValid(value as dynamic) as boolean + return Type(value) <> "" and value <> invalid +end function + +' ************************************************* +' TF_Utils__ValidStr - return value if his contains String interface else return empty string +' @param value As Object +' @return As String - value if his contains String interface else return empty string +' ************************************************* +function TF_Utils__ValidStr(obj as object) as string + if obj <> invalid and GetInterface(obj, "ifString") <> invalid + return obj + else + return "" + end if +end function + +' ************************************************* +' TF_Utils__AsString - convert input to String if this possible, else return empty string +' @param input As Dynamic +' @return As String - return converted string +' ************************************************* +function TF_Utils__AsString(input as dynamic) as string + if TF_Utils__IsValid(input) = false + return "" + else if TF_Utils__IsString(input) + return input + else if TF_Utils__IsInteger(input) or TF_Utils__IsLongInteger(input) or TF_Utils__IsBoolean(input) + return input.ToStr() + else if TF_Utils__IsFloat(input) or TF_Utils__IsDouble(input) + return Str(input).Trim() + else + return "" + end if +end function + +' ************************************************* +' TF_Utils__AsInteger - convert input to Integer if this possible, else return 0 +' @param input As Dynamic +' @return As Integer - return converted Integer +' ************************************************* +function TF_Utils__AsInteger(input as dynamic) as integer + if TF_Utils__IsValid(input) = false + return 0 + else if TF_Utils__IsString(input) + return input.ToInt() + else if TF_Utils__IsInteger(input) + return input + else if TF_Utils__IsFloat(input) or TF_Utils__IsDouble(input) or TF_Utils__IsLongInteger(input) + return Int(input) + else + return 0 + end if +end function + +' ************************************************* +' TF_Utils__AsLongInteger - convert input to LongInteger if this possible, else return 0 +' @param input As Dynamic +' @return As Integer - return converted LongInteger +' ************************************************* +function TF_Utils__AsLongInteger(input as dynamic) as longinteger + if TF_Utils__IsValid(input) = false + return 0 + else if TF_Utils__IsString(input) + return TF_Utils__AsInteger(input) + else if TF_Utils__IsLongInteger(input) or TF_Utils__IsFloat(input) or TF_Utils__IsDouble(input) or TF_Utils__IsInteger(input) + return input + else + return 0 + end if +end function + +' ************************************************* +' TF_Utils__AsFloat - convert input to Float if this possible, else return 0.0 +' @param input As Dynamic +' @return As Float - return converted Float +' ************************************************* +function TF_Utils__AsFloat(input as dynamic) as float + if TF_Utils__IsValid(input) = false + return 0.0 + else if TF_Utils__IsString(input) + return input.ToFloat() + else if TF_Utils__IsInteger(input) + return (input / 1) + else if TF_Utils__IsFloat(input) or TF_Utils__IsDouble(input) or TF_Utils__IsLongInteger(input) + return input + else + return 0.0 + end if +end function + +' ************************************************* +' TF_Utils__AsDouble - convert input to Double if this possible, else return 0.0 +' @param input As Dynamic +' @return As Float - return converted Double +' ************************************************* +function TF_Utils__AsDouble(input as dynamic) as double + if TF_Utils__IsValid(input) = false + return 0.0 + else if TF_Utils__IsString(input) + return TF_Utils__AsFloat(input) + else if TF_Utils__IsInteger(input) or TF_Utils__IsLongInteger(input) or TF_Utils__IsFloat(input) or TF_Utils__IsDouble(input) + return input + else + return 0.0 + end if +end function + +' ************************************************* +' TF_Utils__AsBoolean - convert input to Boolean if this possible, else return False +' @param input As Dynamic +' @return As Boolean +' ************************************************* +function TF_Utils__AsBoolean(input as dynamic) as boolean + if TF_Utils__IsValid(input) = false + return false + else if TF_Utils__IsString(input) + return LCase(input) = "true" + else if TF_Utils__IsInteger(input) or TF_Utils__IsFloat(input) + return input <> 0 + else if TF_Utils__IsBoolean(input) + return input + else + return false + end if +end function + +' ************************************************* +' TF_Utils__AsArray - if type of value equals array return value, else return array with one element [value] +' @param value As Object +' @return As Object - roArray +' ************************************************* +function TF_Utils__AsArray(value as object) as object + if TF_Utils__IsValid(value) + if not TF_Utils__IsArray(value) + return [value] + else + return value + end if + end if + return [] +end function + +' ===================== +' Strings +' ===================== + +' ************************************************* +' TF_Utils__IsNullOrEmpty - check if value is invalid or empty +' @param value As Dynamic +' @return As Boolean - true if value is null or empty string, else return false +' ************************************************* +function TF_Utils__IsNullOrEmpty(value as dynamic) as boolean + if TF_Utils__IsString(value) + return Len(value) = 0 + else + return not TF_Utils__IsValid(value) + end if +end function + +' ===================== +' Arrays +' ===================== + +' ************************************************* +' TF_Utils__FindElementIndexInArray - find an element index in array +' @param array As Object +' @param value As Object +' @param compareAttribute As Dynamic +' @param caseSensitive As Boolean +' @return As Integer - element index if array contains a value, else return -1 +' ************************************************* +function TF_Utils__FindElementIndexInArray(array as object, value as object, compareAttribute = invalid as dynamic, caseSensitive = false as boolean) as integer + if TF_Utils__IsArray(array) + for i = 0 to TF_Utils__AsArray(array).Count() - 1 + compareValue = array[i] + + if compareAttribute <> invalid and TF_Utils__IsAssociativeArray(compareValue) and compareValue.DoesExist(compareAttribute) + compareValue = compareValue.LookupCI(compareAttribute) + end if + + if TF_Utils__IsString(compareValue) and TF_Utils__IsString(value) and not caseSensitive + if LCase(compareValue) = LCase(value) + return i + end if + else if TF_Utils__BaseComparator(compareValue, value) + return i + end if + + item = array[i] + next + end if + + return -1 +end function + +' ************************************************* +' TF_Utils__ArrayContains - check if array contains specified value +' @param array As Object +' @param value As Object +' @param compareAttribute As Dynamic +' @return As Boolean - true if array contains a value, else return false +' ************************************************* +function TF_Utils__ArrayContains(array as object, value as object, compareAttribute = invalid as dynamic) as boolean + return (TF_Utils__FindElementIndexInArray(array, value, compareAttribute) > -1) +end function + +' ---------------------------------------------------------------- +' Type Comparison Functionality +' ---------------------------------------------------------------- + +' ---------------------------------------------------------------- +' Compare two arbitrary values to each other. + +' @param Value1 (dynamic) A first item to compare. +' @param Value2 (dynamic) A second item to compare. +' @param comparator (Function, optional) Function, to compare 2 values. Should take in 2 parameters and return either true or false. + +' @return True if values are equal or False in other case. +' ---------------------------------------------------------------- +function TF_Utils__EqValues(Value1 as dynamic, Value2 as dynamic, comparator = invalid as object) as boolean + if comparator = invalid + return TF_Utils__BaseComparator(value1, value2) + else + return comparator(value1, value2) + end if +end function + +' ---------------------------------------------------------------- +' Base comparator for comparing two values. + +' @param Value1 (dynamic) A first item to compare. +' @param Value2 (dynamic) A second item to compare. + +' @return True if values are equal or False in other case. +function TF_Utils__BaseComparator(value1 as dynamic, value2 as dynamic) as boolean + value1Type = Type(value1) + value2Type = Type(value2) + + if (value1Type = "roList" or value1Type = "roArray") and (value2Type = "roList" or value2Type = "roArray") + return TF_Utils__EqArray(value1, value2) + else if value1Type = "roAssociativeArray" and value2Type = "roAssociativeArray" + return TF_Utils__EqAssocArray(value1, value2) + else if Type(box(value1), 3) = Type(box(value2), 3) + return value1 = value2 + else + return false + end if +end function + +' ---------------------------------------------------------------- +' Compare two roAssociativeArray objects for equality. + +' @param Value1 (object) A first associative array. +' @param Value2 (object) A second associative array. + +' @return True if arrays are equal or False in other case. +' ---------------------------------------------------------------- +function TF_Utils__EqAssocArray(Value1 as object, Value2 as object) as boolean + l1 = Value1.Count() + l2 = Value2.Count() + + if not l1 = l2 + return false + else + for each k in Value1 + if not Value2.DoesExist(k) + return false + else + v1 = Value1[k] + v2 = Value2[k] + if not TF_Utils__EqValues(v1, v2) + return false + end if + end if + end for + return true + end if +end function + +' ---------------------------------------------------------------- +' Compare two roArray objects for equality. + +' @param Value1 (object) A first array. +' @param Value2 (object) A second array. + +' @return True if arrays are equal or False in other case. +' ---------------------------------------------------------------- +function TF_Utils__EqArray(Value1 as object, Value2 as object) as boolean + l1 = Value1.Count() + l2 = Value2.Count() + + if not l1 = l2 + return false + else + for i = 0 to l1 - 1 + v1 = Value1[i] + v2 = Value2[i] + if not TF_Utils__EqValues(v1, v2) then + return false + end if + end for + return true + end if +end function diff --git a/source/tests/Test__Main.brs b/source/tests/Test__Main.brs new file mode 100644 index 00000000..4c29c89a --- /dev/null +++ b/source/tests/Test__Main.brs @@ -0,0 +1,44 @@ +function TestSuite__Main() as object + + ' Inherite your test suite from BaseTestSuite + this = BaseTestSuite() + + ' Test suite name for log statistics + this.Name = "MainTestSuite" + + this.SetUp = MainTestSuite__SetUp + this.TearDown = MainTestSuite__TearDown + + ' Add tests to suite's tests collection + this.addTest("CheckDataCount", TestCase__Main_CheckDataCount) + + return this +end function + +'---------------------------------------------------------------- +' This function called immediately before running tests of current suite. +' This function called to prepare all data for testing. +'---------------------------------------------------------------- +sub MainTestSuite__SetUp() + ' Target testing object. To avoid the object creation in each test + ' we create instance of target object here and use it in tests as m.targetTestObject. + m.mainData = [1, 2, 3, 4, 5] +end sub + +'---------------------------------------------------------------- +' This function called immediately after running tests of current suite. +' This function called to clean or remove all data for testing. +'---------------------------------------------------------------- +sub MainTestSuite__TearDown() + ' Remove all the test data + m.Delete("mainData") +end sub + +'---------------------------------------------------------------- +' Check if data has an expected amount of items +' +' @return An empty string if test is success or error message if not. +'---------------------------------------------------------------- +function TestCase__Main_CheckDataCount() as string + return m.assertArrayCount(m.mainData, 5) +end function From ae0d12f3c66402f1ba5922ac78119297fed2eae9 Mon Sep 17 00:00:00 2001 From: 1hitsong <3330318+1hitsong@users.noreply.github.com> Date: Tue, 20 Dec 2022 08:33:28 -0500 Subject: [PATCH 02/14] Add some Misc tests --- source/Main.brs | 4 +- source/tests/Test__Main.brs | 44 -------------------- source/tests/Test__Misc.brs | 81 +++++++++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 46 deletions(-) delete mode 100644 source/tests/Test__Main.brs create mode 100644 source/tests/Test__Misc.brs diff --git a/source/Main.brs b/source/Main.brs index 52e565e0..33ba5e35 100644 --- a/source/Main.brs +++ b/source/Main.brs @@ -2,12 +2,12 @@ sub Main (args as dynamic) as void appInfo = CreateObject("roAppInfo") - ' http://{Roku IP}:8060/launch/dev?RunTests=true if appInfo.IsDev() and args.RunTests = "true" and TF_Utils__IsFunction(TestRunner) + ' POST to {ROKU ADDRESS}:8060/launch/dev?RunTests=true Runner = TestRunner() Runner.SetFunctions([ - TestSuite__Main + TestSuite__Misc ]) Runner.Logger.SetVerbosity(1) diff --git a/source/tests/Test__Main.brs b/source/tests/Test__Main.brs deleted file mode 100644 index 4c29c89a..00000000 --- a/source/tests/Test__Main.brs +++ /dev/null @@ -1,44 +0,0 @@ -function TestSuite__Main() as object - - ' Inherite your test suite from BaseTestSuite - this = BaseTestSuite() - - ' Test suite name for log statistics - this.Name = "MainTestSuite" - - this.SetUp = MainTestSuite__SetUp - this.TearDown = MainTestSuite__TearDown - - ' Add tests to suite's tests collection - this.addTest("CheckDataCount", TestCase__Main_CheckDataCount) - - return this -end function - -'---------------------------------------------------------------- -' This function called immediately before running tests of current suite. -' This function called to prepare all data for testing. -'---------------------------------------------------------------- -sub MainTestSuite__SetUp() - ' Target testing object. To avoid the object creation in each test - ' we create instance of target object here and use it in tests as m.targetTestObject. - m.mainData = [1, 2, 3, 4, 5] -end sub - -'---------------------------------------------------------------- -' This function called immediately after running tests of current suite. -' This function called to clean or remove all data for testing. -'---------------------------------------------------------------- -sub MainTestSuite__TearDown() - ' Remove all the test data - m.Delete("mainData") -end sub - -'---------------------------------------------------------------- -' Check if data has an expected amount of items -' -' @return An empty string if test is success or error message if not. -'---------------------------------------------------------------- -function TestCase__Main_CheckDataCount() as string - return m.assertArrayCount(m.mainData, 5) -end function diff --git a/source/tests/Test__Misc.brs b/source/tests/Test__Misc.brs new file mode 100644 index 00000000..922c0699 --- /dev/null +++ b/source/tests/Test__Misc.brs @@ -0,0 +1,81 @@ +function TestSuite__Misc() as object + + ' Inherite test suite from BaseTestSuite + this = BaseTestSuite() + + ' Test suite name for log statistics + this.Name = "MiscTestSuite" + + this.SetUp = MiscTestSuite__SetUp + this.TearDown = MiscTestSuite__TearDown + + ' Add tests to suite's tests collection + this.addTest("IsValid() true", TestCase__Misc_IsValid_True) + this.addTest("IsValid() false", TestCase__Misc_IsValid_False) + this.addTest("RoundNumber() Floor", TestCase__Misc_RoundNumber_Floor) + this.addTest("RoundNumber() Ceiling", TestCase__Misc_RoundNumber_Ceiling) + + return this +end function + +'---------------------------------------------------------------- +' This function called immediately before running tests of current suite. +'---------------------------------------------------------------- +sub MiscTestSuite__SetUp() +end sub + +'---------------------------------------------------------------- +' This function called immediately after running tests of current suite. +'---------------------------------------------------------------- +sub MiscTestSuite__TearDown() +end sub + +'---------------------------------------------------------------- +' Check if isValid() properly identifies valid items +' +' @return An empty string if test is success or error message if not. +'---------------------------------------------------------------- +function TestCase__Misc_IsValid_True() as string + returnResults = "" + testData = [1, 2, [3, 4], { "key": invalid }, [1, 2, 3], CreateObject("roAppInfo")] + + for each testItem in testData + returnResults = returnResults + m.AssertTrue(isValid(testItem)) + end for + + return m.AssertEmpty(returnResults) +end function + +'---------------------------------------------------------------- +' Check if isValid() properly identifies invalid items +' +' @return An empty string if test is success or error message if not. +'---------------------------------------------------------------- +function TestCase__Misc_IsValid_False() as string + returnResults = "" + testData = [invalid, CreateObject("nothing")] + + for each testItem in testData + returnResults = m.AssertFalse(isValid(testItem)) + end for + + return m.AssertEmpty(returnResults) +end function + +'---------------------------------------------------------------- +' Check if roundNumber() properly rounds down +' +' @return An empty string if test is success or error message if not. +'---------------------------------------------------------------- +function TestCase__Misc_RoundNumber_Floor() as string + return m.AssertEqual(roundNumber(9.4), 9) +end function + +'---------------------------------------------------------------- +' Check if roundNumber() properly rounds up +' +' @return An empty string if test is success or error message if not. +'---------------------------------------------------------------- +function TestCase__Misc_RoundNumber_Ceiling() as string + return m.AssertEqual(roundNumber(9.6), 10) +end function From 538d915909a19a0e2558739a066da20c484e34c9 Mon Sep 17 00:00:00 2001 From: 1hitsong <3330318+1hitsong@users.noreply.github.com> Date: Thu, 22 Dec 2022 10:08:08 -0500 Subject: [PATCH 03/14] Ignore validation/formatting on testFramework and tests --- bsconfig.json | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bsconfig.json b/bsconfig.json index ca83c58c..4045d2b8 100644 --- a/bsconfig.json +++ b/bsconfig.json @@ -8,8 +8,12 @@ "locale/**/*.*", "settings/*.*" ], - "plugins": [ "@rokucommunity/bslint" ], + "plugins": [ + "@rokucommunity/bslint" + ], "diagnosticFilters": [ - "**/roku_modules/**/*" + "**/roku_modules/**/*", + "**/testFramework/*", + "**/tests/*" ] } \ No newline at end of file From 7c72385ba406abda034f09a8140eb59ca0360482 Mon Sep 17 00:00:00 2001 From: sevenrats Date: Sun, 15 Jan 2023 19:49:29 -0500 Subject: [PATCH 04/14] improve settings menu --- components/ItemGrid/MovieLibraryView.brs | 2 +- settings/settings.json | 201 +++++++++++------------ 2 files changed, 94 insertions(+), 109 deletions(-) diff --git a/components/ItemGrid/MovieLibraryView.brs b/components/ItemGrid/MovieLibraryView.brs index 05323786..cda2e3e5 100644 --- a/components/ItemGrid/MovieLibraryView.brs +++ b/components/ItemGrid/MovieLibraryView.brs @@ -196,7 +196,7 @@ sub loadInitialItems() m.itemGrid.numRows = "3" m.selectedMovieOverview.visible = false m.infoGroup.visible = false - m.top.showItemTitles = get_user_setting("itemgrid.movieGridTitles") + m.top.showItemTitles = get_user_setting("itemgrid.alwaysShowTitles") if LCase(m.top.showItemTitles) = "hidealways" m.itemGrid.itemSize = "[230, 315]" m.itemGrid.rowHeights = "[315]" diff --git a/settings/settings.json b/settings/settings.json index 8dabaf52..12696a4b 100644 --- a/settings/settings.json +++ b/settings/settings.json @@ -66,21 +66,21 @@ } ] }, - { - "title": "Show What's New Popup", - "description": "Show What's New popup when Jellyfin is updated to a new version.", - "settingName": "load.allowwhatsnew", - "type": "bool", - "default": "true" - }, { "title": "User Interface", "description": "Settings relating to how the application looks.", "children": [ { - "title": "Home Page", - "description": "Options for Home page.", + "title": "General", + "description": "Settings relating to the appearance of the Home screen and the program in general.", "children": [ + { + "title": "Use Splashscreen as Home Background", + "description": "Use generated splashscreen image as Jellyfin's screensaver background. Jellyfin will need to be closed and reopened for change to take effect.", + "settingName": "ui.screensaver.splashBackground", + "type": "bool", + "default": "false" + }, { "title": "Max Days Next Up", "description": "Set the maximum amount of days a show should stay in the 'Next Up' list without watching it.", @@ -94,66 +94,14 @@ "settingName": "ui.home.splashBackground", "type": "bool", "default": "false" - } - ] - }, - { - "title": "Details Page", - "description": "Options for Details pages.", - "children": [ - { - "title": "Hide Taglines", - "description": "Hides tagline text on details pages.", - "settingName": "ui.details.hidetagline", - "type": "bool", - "default": "false" - } - ] - }, - { - "title": "TV Shows", - "description": "Options for TV Shows.", - "children": [ - { - "title": "Blur Unwatched Episodes", - "description": "If enabled, images of unwatched episodes will be blurred.", - "settingName": "ui.tvshows.blurunwatched", - "type": "bool", - "default": "false" }, { - "title": "Skip Details for Single Seasons", - "description": "If enabled, selecting a TV series with only one season will go straight to the episode list rather than the show details and season list.", - "settingName": "ui.tvshows.goStraightToEpisodeListing", + "title": "Show What's New Popup", + "description": "Show What's New popup when Jellyfin is updated to a new version.", + "settingName": "load.allowwhatsnew", "type": "bool", - "default": "false" + "default": "true" }, - { - "title":"Disable Community Rating for Episodes", - "description": "If enabled, the star and community rating for episodes of a TV show will be removed. This is to prevent spoilers of an upcoming good/bad episode.", - "settingName": "ui.tvshows.disableCommunityRating", - "type":"bool", - "default":"false" - } - ] - }, - { - "title": "Screensaver", - "description": "Options for Jellyfin's screensaver.", - "children": [ - { - "title": "Use Splashscreen as Screensaver Background", - "description": "Use generated splashscreen image as Jellyfin's screensaver background. Jellyfin will need to be closed and reopened for change to take effect.", - "settingName": "ui.screensaver.splashBackground", - "type": "bool", - "default": "false" - } - ] - }, - { - "title": "Design Elements", - "description": "Options that alter the design of Jellyfin.", - "children": [ { "title": "Hide Clock", "description": "Hides all clocks in Jellyfin. Jellyfin will need to be closed and reopened for change to take effect.", @@ -164,67 +112,104 @@ ] }, { - "title": "Media Grid", - "description": "Media Grid options.", + "title": "Libraries", + "description": "Setting related to the appearance of Library pages.", "children": [ { - "title": "Movie Library Default View", - "description": "Default view for Movie Libraries.", - "settingName": "itemgrid.movieDefaultView", - "type": "radio", - "default": "movies", - "options": [ + "title": "General", + "description": "Settings relating to the appearance of pages in all Libraries.", + "children": [ { - "title": "Movies (Presentation)", - "id": "Movies" + "title": "Grid View Settings", + "description": "Settings that apply when Grid views are enabled.", + "children": [ + { + "title": "Item Count", + "description": "Show item count in the library and index of selected item.", + "settingName": "itemgrid.showItemCount", + "type": "bool", + "default": "false" + }, + { + "title": "Item Titles", + "description": "Always show the titles below the poster images. (If disabled, the title will be shown under the highlighted item only).", + "settingName": "itemgrid.alwaysShowTitles", + "type": "bool", + "default": "false" + } + ] }, { - "title": "Movies (Grid)", - "id": "MoviesGrid" + "title": "Presentation View Settings", + "description": "Settings that apply when Presentation views are enabled.", + "children": [ + { + "title": "Return to Top", + "description": "Use the replay button to slowly animate to the first item in the folder. (If disabled, the folder will reset to the first item immediately).", + "settingName": "itemgrid.reset", + "type": "bool", + "default": "true" + } + ] + }, + { + "title": "Hide Taglines", + "description": "Hides tagline text on details pages.", + "settingName": "ui.details.hidetagline", + "type": "bool", + "default": "false" } ] }, { - "title": "Movie Library Grid Titles", - "description": "Select when to show titles.", - "settingName": "itemgrid.movieGridTitles", - "type": "radio", - "default": "showonhover", - "options": [ + "title": "TV Shows", + "description": "Settings relating to the appearance of pages in TV Libraries.", + "children": [ { - "title": "Show On Hover", - "id": "showonhover" + "title": "Blur Unwatched Episodes", + "description": "If enabled, images of unwatched episodes will be blurred.", + "settingName": "ui.tvshows.blurunwatched", + "type": "bool", + "default": "false" }, { - "title": "Always Show", - "id": "showalways" + "title": "Skip Details for Single Seasons", + "description": "If enabled, selecting a TV series with only one season will go straight to the episode list rather than the show details and season list.", + "settingName": "ui.tvshows.goStraightToEpisodeListing", + "type": "bool", + "default": "false" }, { - "title": "Always Hide", - "id": "hidealways" + "title": "Disable Community Rating for Episodes", + "description": "If enabled, the star and community rating for episodes of a TV show will be removed. This is to prevent spoilers of an upcoming good/bad episode.", + "settingName": "ui.tvshows.disableCommunityRating", + "type": "bool", + "default": "false" } ] }, { - "title": "Item Count", - "description": "Show item count in the library and index of selected item.", - "settingName": "itemgrid.showItemCount", - "type": "bool", - "default": "false" - }, - { - "title": "Item Titles", - "description": "Always show the titles below the poster images. (If disabled, the title will be shown under the highlighted item only).", - "settingName": "itemgrid.alwaysShowTitles", - "type": "bool", - "default": "false" - }, - { - "title": "Return to Top", - "description": "Use the replay button to slowly animate to the first item in the folder. (If disabled, the folder will reset to the first item immediately).", - "settingName": "itemgrid.reset", - "type": "bool", - "default": "true" + "title": "Movies", + "description": "Settings relating to the appearance of pages in Movie Libraries.", + "children": [ + { + "title": "Movie Library Default View", + "description": "Default view for Movie Libraries.", + "settingName": "itemgrid.movieDefaultView", + "type": "radio", + "default": "movies", + "options": [ + { + "title": "Movies (Presentation)", + "id": "Movies" + }, + { + "title": "Movies (Grid)", + "id": "MoviesGrid" + } + ] + } + ] } ] } From 24aeca4261c8704d6e46f74059fc84372561d76f Mon Sep 17 00:00:00 2001 From: sevenrats Date: Sun, 15 Jan 2023 19:55:52 -0500 Subject: [PATCH 05/14] elliminate new redundancy --- settings/settings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/settings/settings.json b/settings/settings.json index 12696a4b..9e09e13c 100644 --- a/settings/settings.json +++ b/settings/settings.json @@ -193,7 +193,7 @@ "description": "Settings relating to the appearance of pages in Movie Libraries.", "children": [ { - "title": "Movie Library Default View", + "title": "Default View", "description": "Default view for Movie Libraries.", "settingName": "itemgrid.movieDefaultView", "type": "radio", From 53958fa8ccaef4f4ccd86e6d164b40d6a6f461a9 Mon Sep 17 00:00:00 2001 From: sevenrats Date: Sun, 15 Jan 2023 20:08:19 -0500 Subject: [PATCH 06/14] unbreak grid view setting --- components/ItemGrid/GridItem.brs | 2 +- components/ItemGrid/MovieLibraryView.brs | 2 +- settings/settings.json | 22 ++++++++++++++++++---- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/components/ItemGrid/GridItem.brs b/components/ItemGrid/GridItem.brs index 162db0b9..3d167109 100644 --- a/components/ItemGrid/GridItem.brs +++ b/components/ItemGrid/GridItem.brs @@ -13,7 +13,7 @@ sub init() m.itemText.translation = [0, m.itemPoster.height + 7] - m.alwaysShowTitles = get_user_setting("itemgrid.alwaysShowTitles") = "true" + m.alwaysShowTitles = get_user_setting("itemgrid.gridTitles") = "showalways" m.itemText.visible = m.alwaysShowTitles ' Add some padding space when Item Titles are always showing diff --git a/components/ItemGrid/MovieLibraryView.brs b/components/ItemGrid/MovieLibraryView.brs index cda2e3e5..af22211d 100644 --- a/components/ItemGrid/MovieLibraryView.brs +++ b/components/ItemGrid/MovieLibraryView.brs @@ -196,7 +196,7 @@ sub loadInitialItems() m.itemGrid.numRows = "3" m.selectedMovieOverview.visible = false m.infoGroup.visible = false - m.top.showItemTitles = get_user_setting("itemgrid.alwaysShowTitles") + m.top.showItemTitles = get_user_setting("itemgrid.gridTitles") if LCase(m.top.showItemTitles) = "hidealways" m.itemGrid.itemSize = "[230, 315]" m.itemGrid.rowHeights = "[315]" diff --git a/settings/settings.json b/settings/settings.json index 9e09e13c..f746fe0f 100644 --- a/settings/settings.json +++ b/settings/settings.json @@ -132,10 +132,24 @@ }, { "title": "Item Titles", - "description": "Always show the titles below the poster images. (If disabled, the title will be shown under the highlighted item only).", - "settingName": "itemgrid.alwaysShowTitles", - "type": "bool", - "default": "false" + "description": "Select when to show titles.", + "settingName": "itemgrid.gridTitles", + "type": "radio", + "default": "showonhover", + "options": [ + { + "title": "Show On Hover", + "id": "showonhover" + }, + { + "title": "Always Show", + "id": "showalways" + }, + { + "title": "Always Hide", + "id": "hidealways" + } + ] } ] }, From 0bba93977932b670f07999a4c314226519fa35ec Mon Sep 17 00:00:00 2001 From: sevenrats Date: Fri, 27 Jan 2023 01:06:01 -0500 Subject: [PATCH 07/14] apply suggestions --- settings/settings.json | 92 ++++++++++++++++++++---------------------- 1 file changed, 43 insertions(+), 49 deletions(-) diff --git a/settings/settings.json b/settings/settings.json index f746fe0f..a5905fdb 100644 --- a/settings/settings.json +++ b/settings/settings.json @@ -75,9 +75,9 @@ "description": "Settings relating to the appearance of the Home screen and the program in general.", "children": [ { - "title": "Use Splashscreen as Home Background", - "description": "Use generated splashscreen image as Jellyfin's screensaver background. Jellyfin will need to be closed and reopened for change to take effect.", - "settingName": "ui.screensaver.splashBackground", + "title": "Hide Clock", + "description": "Hides all clocks in Jellyfin. Jellyfin will need to be closed and reopened for change to take effect.", + "settingName": "ui.design.hideclock", "type": "bool", "default": "false" }, @@ -88,13 +88,6 @@ "type": "integer", "default": "365" }, - { - "title": "Use Splashscreen as Home Background", - "description": "Use generated splashscreen image as Jellyfin's home background. Jellyfin will need to be closed and reopened for change to take effect.", - "settingName": "ui.home.splashBackground", - "type": "bool", - "default": "false" - }, { "title": "Show What's New Popup", "description": "Show What's New popup when Jellyfin is updated to a new version.", @@ -103,9 +96,16 @@ "default": "true" }, { - "title": "Hide Clock", - "description": "Hides all clocks in Jellyfin. Jellyfin will need to be closed and reopened for change to take effect.", - "settingName": "ui.design.hideclock", + "title": "Use Splashscreen as Home Background", + "description": "Use generated splashscreen image as Jellyfin's home background. Jellyfin will need to be closed and reopened for change to take effect.", + "settingName": "ui.home.splashBackground", + "type": "bool", + "default": "false" + }, + { + "title": "Use Splashscreen as Screensaver", + "description": "Use generated splashscreen image as Jellyfin's screensaver background. Jellyfin will need to be closed and reopened for change to take effect.", + "settingName": "ui.screensaver.splashBackground", "type": "bool", "default": "false" } @@ -153,25 +153,42 @@ } ] }, - { - "title": "Presentation View Settings", - "description": "Settings that apply when Presentation views are enabled.", - "children": [ - { - "title": "Return to Top", - "description": "Use the replay button to slowly animate to the first item in the folder. (If disabled, the folder will reset to the first item immediately).", - "settingName": "itemgrid.reset", - "type": "bool", - "default": "true" - } - ] - }, { "title": "Hide Taglines", "description": "Hides tagline text on details pages.", "settingName": "ui.details.hidetagline", "type": "bool", "default": "false" + }, + { + "title": "Return to Top", + "description": "Use the replay button to slowly animate to the first item in the folder. (If disabled, the folder will reset to the first item immediately).", + "settingName": "itemgrid.reset", + "type": "bool", + "default": "true" + } + ] + }, + { + "title": "Movies", + "description": "Settings relating to the appearance of pages in Movie Libraries.", + "children": [ + { + "title": "Default View", + "description": "Default view for Movie Libraries.", + "settingName": "itemgrid.movieDefaultView", + "type": "radio", + "default": "movies", + "options": [ + { + "title": "Movies (Presentation)", + "id": "Movies" + }, + { + "title": "Movies (Grid)", + "id": "MoviesGrid" + } + ] } ] }, @@ -201,29 +218,6 @@ "default": "false" } ] - }, - { - "title": "Movies", - "description": "Settings relating to the appearance of pages in Movie Libraries.", - "children": [ - { - "title": "Default View", - "description": "Default view for Movie Libraries.", - "settingName": "itemgrid.movieDefaultView", - "type": "radio", - "default": "movies", - "options": [ - { - "title": "Movies (Presentation)", - "id": "Movies" - }, - { - "title": "Movies (Grid)", - "id": "MoviesGrid" - } - ] - } - ] } ] } From 48545f1f1a3e47f2c8c514f216a67f67cec61ad5 Mon Sep 17 00:00:00 2001 From: sevenrats Date: Sat, 28 Jan 2023 11:24:40 -0500 Subject: [PATCH 08/14] implement hide and hover titles in griditem --- components/ItemGrid/GridItem.brs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/components/ItemGrid/GridItem.brs b/components/ItemGrid/GridItem.brs index 3d167109..77c7a6c3 100644 --- a/components/ItemGrid/GridItem.brs +++ b/components/ItemGrid/GridItem.brs @@ -13,11 +13,11 @@ sub init() m.itemText.translation = [0, m.itemPoster.height + 7] - m.alwaysShowTitles = get_user_setting("itemgrid.gridTitles") = "showalways" - m.itemText.visible = m.alwaysShowTitles + m.gridTitles = get_user_setting("itemgrid.gridTitles") + m.itemText.visible = m.gridTitles = "showalways" ' Add some padding space when Item Titles are always showing - if m.alwaysShowTitles then m.itemText.maxWidth = 250 + if m.itemText.visible then m.itemText.maxWidth = 250 'Parent is MarkupGrid and it's parent is the ItemGrid m.topParent = m.top.GetParent().GetParent() @@ -131,16 +131,17 @@ end sub 'Display or hide title Visibility on focus change sub focusChanged() if m.top.itemHasFocus = true - m.itemText.visible = true m.itemText.repeatCount = -1 m.posterMask.scale = [1, 1] else - m.itemText.visible = m.alwaysShowTitles m.itemText.repeatCount = 0 if m.topParent.alphaActive = true m.posterMask.scale = [0.85, 0.85] end if end if + if m.gridTitles = "showonhover" + m.itemText.visible = m.top.itemHasFocus + end if end sub 'Hide backdrop and text when poster loaded From 1eba4cb5a9f1ca085e2071d410a43b16000b7eb0 Mon Sep 17 00:00:00 2001 From: sevenrats Date: Sat, 28 Jan 2023 11:26:02 -0500 Subject: [PATCH 09/14] grammatical consistency --- settings/settings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/settings/settings.json b/settings/settings.json index a5905fdb..3dd36467 100644 --- a/settings/settings.json +++ b/settings/settings.json @@ -113,7 +113,7 @@ }, { "title": "Libraries", - "description": "Setting related to the appearance of Library pages.", + "description": "Settings relating to the appearance of Library pages.", "children": [ { "title": "General", From 21de119ff7c2f834dd6e89097cb1b58fe6d1461a Mon Sep 17 00:00:00 2001 From: sevenrats Date: Sat, 28 Jan 2023 12:37:57 -0500 Subject: [PATCH 10/14] implement hover and hide in musicartists --- components/ItemGrid/MusicArtistGridItem.brs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/components/ItemGrid/MusicArtistGridItem.brs b/components/ItemGrid/MusicArtistGridItem.brs index 438d7321..c0cc0a6d 100644 --- a/components/ItemGrid/MusicArtistGridItem.brs +++ b/components/ItemGrid/MusicArtistGridItem.brs @@ -1,5 +1,6 @@ sub init() m.itemPoster = m.top.findNode("itemPoster") + m.postTextBackground = m.top.findNode("postTextBackground") m.posterText = m.top.findNode("posterText") m.posterText.font.size = 30 m.backdrop = m.top.findNode("backdrop") @@ -14,6 +15,10 @@ sub init() m.itemPoster.loadDisplayMode = m.topParent.imageDisplayMode end if + m.gridTitles = get_user_setting("itemgrid.gridTitles") + m.posterText.visible = m.gridTitles = "showalways" + m.postTextBackground.visible = m.posterText.visible + end sub sub itemContentChanged() @@ -40,6 +45,14 @@ sub itemContentChanged() end if end sub +'Display or hide title Visibility on focus change +sub focusChanged() + if m.gridTitles = "showonhover" + m.posterText.visible = m.top.itemHasFocus + m.postTextBackground.visible = m.posterText.visible + end if +end sub + 'Hide backdrop and text when poster loaded sub onPosterLoadStatusChanged() if m.itemPoster.loadStatus = "ready" From 2fa3a98defb3b88d95b9d0954b1b4bfbf79d2132 Mon Sep 17 00:00:00 2001 From: sevenrats Date: Sat, 28 Jan 2023 13:24:31 -0500 Subject: [PATCH 11/14] update translator strings --- locale/en_US/translations.ts | 80 +++++++++++++++++++++--------------- 1 file changed, 47 insertions(+), 33 deletions(-) diff --git a/locale/en_US/translations.ts b/locale/en_US/translations.ts index 00be207c..796968b3 100644 --- a/locale/en_US/translations.ts +++ b/locale/en_US/translations.ts @@ -557,16 +557,6 @@ Media Grid options. Media Grid options. - - Item Titles - Item Titles - UI -> Media Grid -> Item Title in user setting screen. - - - Always show the titles below the poster images. (If disabled, the title will be shown under the highlighted item only). - Always show the titles below the poster images. (If disabled, the title will be shown under the highlighted item only). - Description for option in Setting Screen - Item Count Item Count @@ -660,15 +650,6 @@ Use the replay button to slowly animate to the first item in the folder. (If disabled, the folder will reset to the first item immediately). Description for option in Setting Screen - - Details Page - Details Page - - - Options for Details pages. - Options for Details pages. - Description for Details page user settings. - Hide Taglines Hide Taglines @@ -712,8 +693,8 @@ Description for Screensaver user settings. - Use Splashscreen as Screensaver Background - Use Splashscreen as Screensaver Background + Use Splashscreen as Screensaver + Use Splashscreen as Screensaver Option Title in user setting screen @@ -795,15 +776,6 @@ Settings relating to how the application looks. Settings relating to how the application looks. - - Home Page - Home Page - - - Options for Home Page. - Options for Home Page. - Description for Home Page user settings. - Max Days Next Up Max Days Next Up @@ -992,9 +964,9 @@ Movies (Grid) - Movie Library Grid Titles - Movie Library Grid Titles - Settings Menu - Title for option + Item Titles + Item Titles + Title of a setting - when should we show the title text of a grid item Select when to show titles. @@ -1049,5 +1021,47 @@ If enabled, the star and community rating for episodes of a TV show will be removed. This is to prevent spoilers of an upcoming good/bad episode. If enabled, the star and community rating for episodes of a TV show will be removed. This is to prevent spoilers of an upcoming good/bad episode. + + Libraries + Libraries + + + Settings relating to the appearance of Library pages + Settings relating to the appearance of Library pages + + + General + General + + + Settings relating to the appearance of the Home screen and the program in general. + Settings relating to the appearance of the Home screen and the program in general. + + + Grid View Settings + Grid View Settings + + + Settings that apply when Grid views are enabled. + Settings that apply when Grid views are enabled. + + + Settings relating to the appearance of pages in TV Libraries. + Settings relating to the appearance of pages in TV Libraries. + + + Settings relating to the appearance of pages in Movie Libraries. + Settings relating to the appearance of pages in Movie Libraries. + + + Presentation + Presentation + Title of an option - name of presentation view + + + Grid + Grid + Title of an option - name of grid view + From 9d1b4d3f79909d6b6cdce64ed3765b785309660b Mon Sep 17 00:00:00 2001 From: sevenrats Date: Sat, 28 Jan 2023 15:27:32 -0500 Subject: [PATCH 12/14] dont muck up music presentation views --- components/ItemGrid/MusicArtistGridItem.brs | 22 ++++++++++++++++----- components/ItemGrid/MusicLibraryView.brs | 3 +++ components/ItemGrid/MusicLibraryView.xml | 2 +- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/components/ItemGrid/MusicArtistGridItem.brs b/components/ItemGrid/MusicArtistGridItem.brs index c0cc0a6d..68eaf1be 100644 --- a/components/ItemGrid/MusicArtistGridItem.brs +++ b/components/ItemGrid/MusicArtistGridItem.brs @@ -16,14 +16,24 @@ sub init() end if m.gridTitles = get_user_setting("itemgrid.gridTitles") - m.posterText.visible = m.gridTitles = "showalways" - m.postTextBackground.visible = m.posterText.visible + m.posterText.visible = false + m.postTextBackground.visible = false end sub sub itemContentChanged() m.backdrop.blendColor = "#101010" + m.posterText.visible = false + m.postTextBackground.visible = false + + if isValid(m.topParent.showItemTitles) + if LCase(m.topParent.showItemTitles) = "showalways" + m.posterText.visible = true + m.postTextBackground.visible = true + end if + end if + itemData = m.top.itemContent if not isValid(itemData) then return @@ -47,9 +57,11 @@ end sub 'Display or hide title Visibility on focus change sub focusChanged() - if m.gridTitles = "showonhover" - m.posterText.visible = m.top.itemHasFocus - m.postTextBackground.visible = m.posterText.visible + if isValid(m.topParent.showItemTitles) + if LCase(m.topParent.showItemTitles) = "showonhover" + m.posterText.visible = m.top.itemHasFocus + m.postTextBackground.visible = m.posterText.visible + end if end if end sub diff --git a/components/ItemGrid/MusicLibraryView.brs b/components/ItemGrid/MusicLibraryView.brs index a1f18b66..615df55c 100644 --- a/components/ItemGrid/MusicLibraryView.brs +++ b/components/ItemGrid/MusicLibraryView.brs @@ -135,6 +135,8 @@ sub loadInitialItems() m.sortAscending = false end if + m.top.showItemTitles = get_user_setting("itemgrid.gridTitles") + if LCase(m.top.parentItem.json.type) = "musicgenre" m.itemGrid.translation = "[96, 60]" m.loadItemsTask.itemType = "MusicAlbum" @@ -143,6 +145,7 @@ sub loadInitialItems() m.loadItemsTask.itemId = m.top.parentItem.parentFolder else if LCase(m.view) = "artistspresentation" or LCase(m.options.view) = "artistspresentation" m.loadItemsTask.genreIds = "" + m.top.showItemTitles = "hidealways" else if LCase(m.view) = "artistsgrid" or LCase(m.options.view) = "artistsgrid" m.loadItemsTask.genreIds = "" else diff --git a/components/ItemGrid/MusicLibraryView.xml b/components/ItemGrid/MusicLibraryView.xml index 5f2ec51c..f887468f 100644 --- a/components/ItemGrid/MusicLibraryView.xml +++ b/components/ItemGrid/MusicLibraryView.xml @@ -33,13 +33,13 @@ - +