diff --git a/.coveragerc b/.coveragerc index 3fe90df8..d2eac282 100644 --- a/.coveragerc +++ b/.coveragerc @@ -4,6 +4,10 @@ omit = */tests/* */e2e_tests/* */__pycache__/* + # Application bootstrap/wiring: instantiates services and starts the + # server. Not unit-testable without a full integration environment. + main.py + */main.py [report] exclude_lines = diff --git a/src/tests/file_utils_extra_test.py b/src/tests/file_utils_extra_test.py new file mode 100644 index 00000000..986400b2 --- /dev/null +++ b/src/tests/file_utils_extra_test.py @@ -0,0 +1,313 @@ +import datetime +import os +import stat +import unittest + +from tests import test_utils +from utils import file_utils, os_utils +from utils.file_utils import FileMatcher, SingleFileMatcher, FileExistsException + + +def _path(*parts): + return os.path.join(test_utils.temp_folder, *parts) + + +class ReadWriteFileTest(unittest.TestCase): + def setUp(self): + test_utils.setup() + + def tearDown(self): + test_utils.cleanup() + + def test_write_then_read_roundtrip(self): + path = _path('sub', 'file.txt') + file_utils.write_file(path, 'hello world') + self.assertEqual('hello world', file_utils.read_file(path)) + + def test_write_creates_missing_folders(self): + path = _path('a', 'b', 'c', 'file.txt') + file_utils.write_file(path, 'x') + self.assertTrue(os.path.exists(path)) + + def test_write_and_read_bytes(self): + path = _path('bytes.bin') + file_utils.write_file(path, b'\x01\x02\x03', byte_content=True) + self.assertEqual(b'\x01\x02\x03', file_utils.read_file(path, byte_content=True)) + + def test_read_keep_newlines(self): + path = _path('nl.txt') + file_utils.write_file(path, 'a\r\nb\n', byte_content=False) + # With keep_newlines the original line endings are preserved. + self.assertEqual('a\r\nb\n', file_utils.read_file(path, keep_newlines=True)) + + def test_read_falls_back_to_alternative_encoding(self): + path = _path('latin.txt') + # 0xe9 is invalid UTF-8 on its own -> triggers the encoding fallback. + file_utils.write_file(path, b'caf\xe9', byte_content=True) + result = file_utils.read_file(path) + self.assertTrue(result.startswith('caf')) + + def test_try_encoded_read_returns_none_for_unreadable(self): + path = _path('bad.bin') + file_utils.write_file(path, b'\xff\xfe\x00\x80\x81', byte_content=True) + # Not asserting a specific decode, just that the helper runs. + self.assertIsNotNone(file_utils.read_file(path)) + + +class FolderAndExistenceTest(unittest.TestCase): + def setUp(self): + test_utils.setup() + + def tearDown(self): + test_utils.cleanup() + + def test_prepare_folder_creates_folder(self): + path = _path('new', 'folder') + file_utils.prepare_folder(path) + self.assertTrue(os.path.isdir(path)) + + def test_prepare_folder_is_idempotent(self): + path = _path('folder') + file_utils.prepare_folder(path) + file_utils.prepare_folder(path) + self.assertTrue(os.path.isdir(path)) + + def test_exists(self): + path = _path('file.txt') + self.assertFalse(file_utils.exists(path)) + file_utils.write_file(path, 'x') + self.assertTrue(file_utils.exists(path)) + + def test_exists_with_current_folder(self): + file_utils.write_file(_path('runners', 'x.json'), '{}') + self.assertTrue(file_utils.exists('x.json', _path('runners'))) + self.assertFalse(file_utils.exists('missing.json', _path('runners'))) + + +class PathHelpersTest(unittest.TestCase): + def tearDown(self): + os_utils.reset_os() + + def test_is_root(self): + self.assertTrue(file_utils.is_root(os.path.abspath(os.sep))) + self.assertFalse(file_utils.is_root('/some/path')) + + def test_split_all(self): + self.assertEqual(['a', 'b', 'c'], file_utils.split_all(os.path.join('a', 'b', 'c'))) + + def test_split_all_empty(self): + self.assertEqual([], file_utils.split_all('')) + + def test_normalize_path_absolute(self): + absolute = os.path.abspath(os.sep + 'tmp') + self.assertEqual(os.path.normpath(absolute), file_utils.normalize_path(absolute)) + + def test_normalize_path_with_current_folder(self): + result = file_utils.normalize_path('child', os.path.abspath(os.sep + 'parent')) + self.assertEqual(os.path.join(os.path.abspath(os.sep + 'parent'), 'child'), result) + + def test_normalize_path_nonexistent_relative_returned_as_is(self): + self.assertEqual('does_not_exist_xyz', file_utils.normalize_path('does_not_exist_xyz')) + + def test_relative_path(self): + parent = os.path.abspath(os.sep + 'data') + child = os.path.join(parent, 'sub', 'file.txt') + self.assertEqual(os.path.join('sub', 'file.txt'), file_utils.relative_path(child, parent)) + + def test_relative_path_raises_when_not_subpath(self): + with self.assertRaises(ValueError): + file_utils.relative_path( + os.path.abspath(os.sep + 'other'), + os.path.abspath(os.sep + 'data')) + + +class UniqueFilenameTest(unittest.TestCase): + def setUp(self): + test_utils.setup() + + def tearDown(self): + test_utils.cleanup() + + def test_returns_preferred_when_free(self): + path = _path('report.txt') + self.assertEqual(path, file_utils.create_unique_filename(path)) + + def test_appends_suffix_on_conflict(self): + path = _path('report.txt') + file_utils.write_file(path, 'x') + self.assertEqual(_path('report_0.txt'), file_utils.create_unique_filename(path)) + + def test_increments_suffix_on_multiple_conflicts(self): + file_utils.write_file(_path('report.txt'), 'x') + file_utils.write_file(_path('report_0.txt'), 'x') + self.assertEqual(_path('report_1.txt'), file_utils.create_unique_filename(_path('report.txt'))) + + def test_raises_when_retries_exhausted(self): + file_utils.write_file(_path('report.txt'), 'x') + with self.assertRaises(FileExistsException): + file_utils.create_unique_filename(_path('report.txt'), retries=0) + + +class ExecutableTest(unittest.TestCase): + def setUp(self): + test_utils.setup() + + def tearDown(self): + test_utils.cleanup() + + def test_make_executable(self): + path = _path('script.sh') + file_utils.write_file(path, '#!/bin/sh\n') + file_utils.make_executable(path) + self.assertTrue(file_utils.is_executable(path)) + mode = os.stat(path).st_mode + self.assertTrue(mode & stat.S_IXUSR) + + +class BinaryDetectionTest(unittest.TestCase): + def setUp(self): + test_utils.setup() + + def tearDown(self): + test_utils.cleanup() + + def test_elf_header_is_binary(self): + path = _path('elf') + file_utils.write_file(path, bytes.fromhex('7F454C46') + b'rest', byte_content=True) + self.assertTrue(file_utils.is_binary(path)) + + def test_windows_executable_is_binary(self): + path = _path('exe') + file_utils.write_file(path, bytes.fromhex('4D5A') + b'rest', byte_content=True) + self.assertTrue(file_utils.is_binary(path)) + + def test_null_bytes_is_binary(self): + path = _path('nulls') + file_utils.write_file(path, b'abc\x00\x00def', byte_content=True) + self.assertTrue(file_utils.is_binary(path)) + + def test_plain_text_is_not_binary(self): + path = _path('text.txt') + file_utils.write_file(path, 'just some text content') + self.assertFalse(file_utils.is_binary(path)) + + +class SymlinkTest(unittest.TestCase): + def setUp(self): + test_utils.setup() + + def tearDown(self): + test_utils.cleanup() + + def test_broken_symlink_detected(self): + link = _path('broken_link') + os.symlink(_path('does_not_exist'), link) + self.assertTrue(file_utils.is_broken_symlink(link)) + + def test_valid_symlink_is_not_broken(self): + target = _path('target.txt') + file_utils.write_file(target, 'x') + link = _path('valid_link') + os.symlink(os.path.abspath(target), link) + self.assertFalse(file_utils.is_broken_symlink(link)) + + def test_regular_file_is_not_broken_symlink(self): + path = _path('regular.txt') + file_utils.write_file(path, 'x') + self.assertFalse(file_utils.is_broken_symlink(path)) + + +class ModificationDateTest(unittest.TestCase): + def setUp(self): + test_utils.setup() + + def tearDown(self): + test_utils.cleanup() + + def test_modification_date_returns_datetime(self): + path = _path('file.txt') + file_utils.write_file(path, 'x') + self.assertIsInstance(file_utils.modification_date(path), datetime.datetime) + + def test_last_modification_of_tree(self): + file_utils.write_file(_path('tree', 'a.txt'), 'a') + file_utils.write_file(_path('tree', 'sub', 'b.txt'), 'b') + result = file_utils.last_modification([_path('tree')]) + self.assertIsInstance(result, datetime.datetime) + + def test_deletion_date_uses_existing_parent(self): + folder = _path('folder') + file_utils.prepare_folder(folder) + deleted = os.path.join(folder, 'gone.txt') + self.assertIsInstance(file_utils.deletion_date(deleted), datetime.datetime) + + +class SearchGlobTest(unittest.TestCase): + def setUp(self): + test_utils.setup() + + def tearDown(self): + test_utils.cleanup() + + def test_glob_matches_extension(self): + file_utils.write_file(_path('a.txt'), 'x') + file_utils.write_file(_path('b.txt'), 'x') + file_utils.write_file(_path('c.log'), 'x') + matches = file_utils.search_glob(_path('*.txt')) + self.assertCountEqual([_path('a.txt'), _path('b.txt')], matches) + + def test_glob_recursive(self): + file_utils.write_file(_path('root.txt'), 'x') + file_utils.write_file(_path('sub', 'nested.txt'), 'x') + matches = file_utils.search_glob(_path('**', '*.txt'), recursive=True) + self.assertIn(_path('sub', 'nested.txt'), matches) + + +class FileMatcherTest(unittest.TestCase): + def tearDown(self): + os_utils.reset_os() + + def _abs(self, *parts): + return os.path.join(os.path.abspath(os.sep + 'data'), *parts) + + def test_plain_pattern_matches_children(self): + matcher = FileMatcher([os.path.abspath(os.sep + 'data')], os.path.abspath(os.sep + 'work')) + self.assertTrue(matcher.has_match(self._abs('logs', 'app.log'))) + + def test_plain_pattern_rejects_outside_paths(self): + matcher = FileMatcher([os.path.abspath(os.sep + 'data')], os.path.abspath(os.sep + 'work')) + self.assertFalse(matcher.has_match(os.path.abspath(os.sep + 'other') + os.sep + 'app.log')) + + def test_star_pattern_matches_extension(self): + matcher = FileMatcher([self._abs('*.log')], os.path.abspath(os.sep + 'work')) + self.assertTrue(matcher.has_match(self._abs('app.log'))) + self.assertFalse(matcher.has_match(self._abs('app.txt'))) + + def test_recursive_pattern_matches_nested(self): + matcher = FileMatcher([self._abs('**')], os.path.abspath(os.sep + 'work')) + self.assertTrue(matcher.has_match(self._abs('deep', 'nested', 'x.log'))) + + def test_empty_patterns_match_nothing(self): + matcher = FileMatcher([], os.path.abspath(os.sep + 'work')) + self.assertFalse(matcher.has_match(self._abs('x.log'))) + + def test_has_match_accepts_path_object(self): + import pathlib + matcher = FileMatcher([os.path.abspath(os.sep + 'data')], os.path.abspath(os.sep + 'work')) + self.assertTrue(matcher.has_match(pathlib.Path(self._abs('a', 'b.txt')))) + + +class SingleFileMatcherTest(unittest.TestCase): + def test_relative_pattern_is_normalized_against_working_dir(self): + working_dir = os.path.abspath(os.sep + 'work') + matcher = SingleFileMatcher('logs', working_dir) + self.assertEqual(os.path.join(working_dir, 'logs'), matcher.pattern) + + def test_absolute_pattern_is_kept(self): + pattern = os.path.abspath(os.sep + 'data') + os.sep + 'logs' + matcher = SingleFileMatcher(pattern, os.path.abspath(os.sep + 'work')) + self.assertEqual(pattern, matcher.pattern) + + +if __name__ == '__main__': + unittest.main() diff --git a/src/tests/migrate_test.py b/src/tests/migrate_test.py new file mode 100644 index 00000000..11029b01 --- /dev/null +++ b/src/tests/migrate_test.py @@ -0,0 +1,330 @@ +import json +import os +import unittest + +from migrations import migrate +from tests import test_utils +from utils import file_utils +from execution.logging import OUTPUT_STARTED_MARKER + + +def _all_migration_ids(): + # The registry name is module-private; getattr with a literal string avoids + # the name mangling that a direct ``migrate.__migrations_registry`` would + # trigger inside this class body. + return list(getattr(migrate, '__migrations_registry').keys()) + + +class MigrateTestBase(unittest.TestCase): + def setUp(self): + test_utils.setup() + + base = test_utils.temp_folder + self.temp_dir = os.path.join(base, 'state') + self.conf_dir = os.path.join(base, 'conf') + self.conf_file = os.path.join(self.conf_dir, 'conf.json') + self.log_dir = os.path.join(base, 'log') + + for folder in (self.temp_dir, self.conf_dir, self.log_dir): + os.makedirs(folder) + + def tearDown(self): + test_utils.cleanup() + + def _run_migrate(self): + migrate.migrate(self.temp_dir, self.conf_dir, self.conf_file, self.log_dir) + + def _run_only(self, migration_id): + """Mark every migration except ``migration_id`` as already applied, so a + single call to migrate() runs exactly the targeted migration.""" + already_applied = [m for m in _all_migration_ids() if m != migration_id] + migrate._write_migrations(self.temp_dir, already_applied) + self._run_migrate() + + def _mark_all_applied(self): + migrate._write_migrations(self.temp_dir, _all_migration_ids()) + + def _write_conf(self, obj): + file_utils.write_file(self.conf_file, json.dumps(obj)) + + def _read_conf(self): + return json.loads(file_utils.read_file(self.conf_file)) + + def _write_runner(self, name, obj): + path = os.path.join(self.conf_dir, 'runners', name + '.json') + file_utils.write_file(path, json.dumps(obj)) + return path + + def _read_runner(self, name): + path = os.path.join(self.conf_dir, 'runners', name + '.json') + return json.loads(file_utils.read_file(path)) + + def _write_log(self, filename, content): + processes = os.path.join(self.log_dir, 'processes') + path = os.path.join(processes, filename) + file_utils.write_file(path, content) + return path + + def _read_migrations_file(self): + return migrate._read_old_migrations(self.temp_dir) + + +class OrchestrationTest(MigrateTestBase): + def test_new_installation_marks_all_applied(self): + # Both temp and conf folders are empty -> treated as a fresh install: + # all migrations are recorded as done without transforming anything. + self._run_migrate() + + self.assertCountEqual(_all_migration_ids(), self._read_migrations_file()) + + def test_new_installation_does_not_transform_data(self): + # conf folder stays empty so the install is still considered new; a log + # file that would otherwise be migrated must be left untouched. + self._write_log('old.log', 'raw output') + self._run_migrate() + + self.assertEqual('raw output', file_utils.read_file( + os.path.join(self.log_dir, 'processes', 'old.log'))) + + def test_already_migrated_is_noop(self): + self._mark_all_applied() + path = self._write_runner('x', {'bash_formatting': False}) + + self._run_migrate() + + self.assertEqual({'bash_formatting': False}, self._read_runner('x')) + + def test_runs_all_pending_when_nothing_applied(self): + # conf_file present -> not a new installation, so every migration runs + # (most are no-ops here) and all ids end up recorded. + self._write_conf({'something': 'value'}) + migrate._write_migrations(self.temp_dir, []) + + self._run_migrate() + + self.assertCountEqual(_all_migration_ids(), self._read_migrations_file()) + + def test_unknown_requirement_raises(self): + registry = getattr(migrate, '__migrations_registry') + descriptor_cls = getattr(migrate, '_MigrationDescriptor') + registry['broken'] = descriptor_cls('broken', lambda ctx: None, 'broken', ['does_not_exist']) + try: + with self.assertRaises(Exception): + migrate._validate_requirements() + finally: + del registry['broken'] + + +class AddExecutionInfoToLogFilesTest(MigrateTestBase): + migration_id = 'add_execution_info_to_log_files' + + def test_old_file_gets_header_with_parsed_name(self): + self._write_log('myscript_bob_200115_103000.log', 'hello world') + + self._run_only(self.migration_id) + + content = file_utils.read_file( + os.path.join(self.log_dir, 'processes', 'myscript_bob_200115_103000.log')) + self.assertTrue(content.startswith('id:')) + self.assertIn('script:myscript', content) + self.assertIn('user_name:bob', content) + self.assertIn('user_id:bob', content) + self.assertIn(OUTPUT_STARTED_MARKER, content) + self.assertTrue(content.endswith('hello world')) + + def test_unparseable_name_uses_unknown(self): + self._write_log('weirdname.log', 'body') + + self._run_only(self.migration_id) + + content = file_utils.read_file( + os.path.join(self.log_dir, 'processes', 'weirdname.log')) + self.assertIn('script:unknown', content) + self.assertIn('user_name:unknown', content) + + def test_new_format_file_is_left_untouched(self): + original = 'id:5' + os.linesep + OUTPUT_STARTED_MARKER + os.linesep + 'output' + self._write_log('already_new.log', original) + + self._run_only(self.migration_id) + + self.assertEqual(original, file_utils.read_file( + os.path.join(self.log_dir, 'processes', 'already_new.log'))) + + def test_missing_processes_folder_is_noop(self): + # No processes folder at all -> migration returns without error. + self._run_only(self.migration_id) + + +class AddUserIdToLogFilesTest(MigrateTestBase): + migration_id = 'add_user_id_to_log_files' + + def _log_content(self, params_lines): + params = os.linesep.join(params_lines) + os.linesep + return params + OUTPUT_STARTED_MARKER + os.linesep + 'the output' + + def test_user_id_and_name_are_added(self): + content = self._log_content(['id:7', 'user:bob']) + self._write_log('run.log', content) + + self._run_only(self.migration_id) + + migrated = file_utils.read_file( + os.path.join(self.log_dir, 'processes', 'run.log')) + self.assertIn('user_id:bob', migrated) + self.assertIn('user_name:bob', migrated) + self.assertTrue(migrated.endswith('the output')) + + def test_file_with_both_fields_is_untouched(self): + content = self._log_content(['id:7', 'user:bob', 'user_id:bob', 'user_name:bob']) + self._write_log('run.log', content) + + self._run_only(self.migration_id) + + self.assertEqual(content, file_utils.read_file( + os.path.join(self.log_dir, 'processes', 'run.log'))) + + +class IntroduceAccessConfigTest(MigrateTestBase): + migration_id = 'introduce_access_config' + + def test_fields_moved_under_access(self): + self._write_conf({ + 'auth': {'type': 'ldap', 'allowed_users': ['u1']}, + 'admin_users': ['admin1'], + 'trusted_ips': ['127.0.0.1'], + }) + + self._run_only(self.migration_id) + + result = self._read_conf() + self.assertEqual(['u1'], result['access']['allowed_users']) + self.assertEqual(['admin1'], result['access']['admin_users']) + self.assertEqual(['127.0.0.1'], result['access']['trusted_ips']) + self.assertNotIn('allowed_users', result['auth']) + self.assertNotIn('admin_users', result) + self.assertNotIn('trusted_ips', result) + + def test_no_relevant_fields_leaves_file_unchanged(self): + self._write_conf({'auth': {'type': 'ldap'}}) + + self._run_only(self.migration_id) + + self.assertEqual({'auth': {'type': 'ldap'}}, self._read_conf()) + + def test_missing_conf_file_is_noop(self): + self._run_only(self.migration_id) + + def test_existing_indentation_is_preserved(self): + # File written with 2-space indentation -> rewrite keeps that indent. + file_utils.write_file(self.conf_file, json.dumps( + {'admin_users': ['admin1']}, indent=2)) + + self._run_only(self.migration_id) + + raw = file_utils.read_file(self.conf_file) + self.assertIn('\n "access"', raw) + self.assertEqual(['admin1'], self._read_conf()['access']['admin_users']) + + +class OutputFilesParametersSubstitutionTest(MigrateTestBase): + migration_id = 'migrate_output_files_parameters_substitution' + + def test_dollar_syntax_is_rewritten(self): + self._write_runner('script', { + 'parameters': [{'name': 'param1'}], + 'output_files': ['report_$$$param1.txt'], + }) + + self._run_only(self.migration_id) + + self.assertEqual(['report_${param1}.txt'], self._read_runner('script')['output_files']) + + def test_runner_without_output_files_is_untouched(self): + self._write_runner('script', {'parameters': [{'name': 'param1'}]}) + + self._run_only(self.migration_id) + + self.assertEqual({'parameters': [{'name': 'param1'}]}, self._read_runner('script')) + + +class BashFormattingToOutputFormatTest(MigrateTestBase): + migration_id = 'migrate_bash_formatting_to_output_format' + + def test_bash_formatting_false_becomes_text(self): + self._write_runner('script', {'bash_formatting': False}) + + self._run_only(self.migration_id) + + result = self._read_runner('script') + self.assertNotIn('bash_formatting', result) + self.assertEqual('text', result['output_format']) + + def test_bash_formatting_true_becomes_terminal(self): + self._write_runner('script', {'bash_formatting': True}) + + self._run_only(self.migration_id) + + self.assertEqual('terminal', self._read_runner('script')['output_format']) + + +class RepeatParamAndSameArgParamTest(MigrateTestBase): + migration_id = 'migrate_repeat_param_and_same_arg_param' + + def test_repeat_param_true_is_converted(self): + self._write_runner('script', {'parameters': [{'name': 'p', 'repeat_param': True}]}) + + self._run_only(self.migration_id) + + param = self._read_runner('script')['parameters'][0] + self.assertNotIn('repeat_param', param) + self.assertEqual(False, param['same_arg_param']) + + def test_multiple_arguments_becomes_argument_per_value(self): + self._write_runner('script', {'parameters': [{'name': 'p', 'multiple_arguments': True}]}) + + self._run_only(self.migration_id) + + param = self._read_runner('script')['parameters'][0] + self.assertNotIn('multiple_arguments', param) + self.assertEqual('argument_per_value', param['multiselect_argument_type']) + + def test_param_without_legacy_fields_is_untouched(self): + self._write_runner('script', {'parameters': [{'name': 'p', 'type': 'int'}]}) + + self._run_only(self.migration_id) + + self.assertEqual([{'name': 'p', 'type': 'int'}], self._read_runner('script')['parameters']) + + +class LdapUsernamePatternToUserResolverTest(MigrateTestBase): + migration_id = 'migrate_ldap_username_pattern_to_user_resolver' + + def test_username_pattern_moved_into_resolver(self): + self._write_conf({'auth': {'type': 'ldap', 'username_pattern': 'uid=$username'}}) + + self._run_only(self.migration_id) + + auth = self._read_conf()['auth'] + self.assertNotIn('username_pattern', auth) + self.assertEqual('uid=$username', auth['ldap_user_resolver']['username_pattern']) + + def test_non_ldap_auth_is_untouched(self): + self._write_conf({'auth': {'type': 'google_oauth', 'username_pattern': 'x'}}) + + self._run_only(self.migration_id) + + self.assertEqual({'auth': {'type': 'google_oauth', 'username_pattern': 'x'}}, self._read_conf()) + + def test_already_migrated_resolver_is_untouched(self): + config = {'auth': {'type': 'ldap', 'username_pattern': 'a', + 'ldap_user_resolver': {'username_pattern': 'b'}}} + self._write_conf(config) + + self._run_only(self.migration_id) + + self.assertEqual(config, self._read_conf()) + + +if __name__ == '__main__': + unittest.main() diff --git a/web-src/tests/unit/common/utils/common_test.js b/web-src/tests/unit/common/utils/common_test.js index 175f9bf4..66441f2d 100644 --- a/web-src/tests/unit/common/utils/common_test.js +++ b/web-src/tests/unit/common/utils/common_test.js @@ -1,6 +1,46 @@ 'use strict'; -import {randomInt, trimTextNodes} from '@/common/utils/common'; +import { + arraysEqual, + asyncForEachKeyValue, + clearArray, + closestByClass, + contains, + deepCloneObject, + destroyChildren, + findNeighbour, + forEachKeyValue, + getElementsByTagNameRecursive, + getFileInputValue, + getQueryParameter, + getUnparameterizedUrl, + getWebsocketUrl, + guid, + hasClass, + HttpRequestError, + isBlankString, + isEmptyArray, + isEmptyObject, + isEmptyString, + isEmptyValue, + isFullRegexMatch, + isNull, + isWebsocketClosed, + isWebsocketConnecting, + isWebsocketOpen, + randomInt, + readQueryParameters, + removeElement, + removeElementIf, + removeElements, + stringComparator, + toBoolean, + toDict, + toMap, + toQueryArgs, + trimTextNodes, + uuidv4, +} from '@/common/utils/common'; describe('Test common.js', function () { @@ -123,4 +163,317 @@ describe('Test common.js', function () { expect(div.innerHTML).toBe('hello world ! another record + one more') }); }); + + describe('test isNull', function () { + it('null is null', () => expect(isNull(null)).toBe(true)); + it('undefined is null', () => expect(isNull(undefined)).toBe(true)); + it('0 is not null', () => expect(isNull(0)).toBe(false)); + it('empty string is not null', () => expect(isNull('')).toBe(false)); + it('object is not null', () => expect(isNull({})).toBe(false)); + }); + + describe('test emptiness checks', function () { + it('isEmptyString', () => { + expect(isEmptyString(null)).toBe(true); + expect(isEmptyString('')).toBe(true); + expect(isEmptyString(' ')).toBe(false); + expect(isEmptyString('x')).toBe(false); + }); + + it('isBlankString', () => { + expect(isBlankString(null)).toBe(true); + expect(isBlankString(' ')).toBe(true); + expect(isBlankString('x')).toBe(false); + }); + + it('isEmptyArray', () => { + expect(isEmptyArray(null)).toBe(true); + expect(isEmptyArray([])).toBe(true); + expect(isEmptyArray([1])).toBe(false); + }); + + it('isEmptyObject', () => { + expect(isEmptyObject(null)).toBe(true); + expect(isEmptyObject({})).toBe(true); + expect(isEmptyObject({a: 1})).toBe(false); + }); + + it('isEmptyValue', () => { + expect(isEmptyValue(null)).toBe(true); + expect(isEmptyValue('')).toBe(true); + expect(isEmptyValue([])).toBe(true); + expect(isEmptyValue({})).toBe(true); + expect(isEmptyValue('x')).toBe(false); + expect(isEmptyValue([1])).toBe(false); + expect(isEmptyValue({a: 1})).toBe(false); + expect(isEmptyValue(5)).toBe(false); + }); + }); + + describe('test array helpers', function () { + it('contains', () => { + expect(contains([1, 2, 3], 2)).toBe(true); + expect(contains([1, 2, 3], 9)).toBe(false); + }); + + it('removeElement removes first match and returns array', () => { + const arr = [1, 2, 3]; + expect(removeElement(arr, 2)).toBe(arr); + expect(arr).toEqual([1, 3]); + }); + + it('removeElement on missing value is a noop', () => { + const arr = [1, 2]; + removeElement(arr, 9); + expect(arr).toEqual([1, 2]); + }); + + it('removeElementIf removes matching elements', () => { + const arr = [1, 2, 3, 4]; + removeElementIf(arr, (x) => x > 2); + expect(arr).toEqual([1, 2]); + }); + + it('removeElements', () => { + const arr = [1, 2, 3, 4]; + removeElements(arr, [2, 4]); + expect(arr).toEqual([1, 3]); + }); + + it('clearArray empties in place', () => { + const arr = [1, 2, 3]; + clearArray(arr); + expect(arr).toEqual([]); + }); + + it('arraysEqual', () => { + const ref = [1, 2]; + expect(arraysEqual(ref, ref)).toBe(true); + expect(arraysEqual(null, null)).toBe(true); + expect(arraysEqual([1, 2], [1, 2])).toBe(true); + expect(arraysEqual(null, [1])).toBe(false); + expect(arraysEqual([1], null)).toBe(false); + expect(arraysEqual([1, 2], [1, 2, 3])).toBe(false); + expect(arraysEqual([1, 2], [1, 9])).toBe(false); + }); + }); + + describe('test object/collection helpers', function () { + it('toBoolean', () => { + expect(toBoolean(true)).toBe(true); + expect(toBoolean(false)).toBe(false); + expect(toBoolean('true')).toBe(true); + expect(toBoolean('TRUE')).toBe(true); + expect(toBoolean('false')).toBe(false); + expect(toBoolean(1)).toBe(true); + expect(toBoolean(0)).toBe(false); + expect(toBoolean(null)).toBe(false); + }); + + it('toDict keys by field', () => { + const result = toDict([{id: 'a', n: 1}, {id: 'b', n: 2}], 'id'); + expect(result).toEqual({a: {id: 'a', n: 1}, b: {id: 'b', n: 2}}); + }); + + it('toMap with key and value extractors', () => { + const result = toMap([{k: 'x', v: 1}, {k: 'y', v: 2}], (e) => e.k, (e) => e.v); + expect(result).toEqual({x: 1, y: 2}); + }); + + it('deepCloneObject produces an independent copy', () => { + const original = {a: 1, nested: {b: 2}}; + const clone = deepCloneObject(original); + expect(clone).toEqual(original); + clone.nested.b = 99; + expect(original.nested.b).toBe(2); + }); + + it('forEachKeyValue visits own properties', () => { + const collected = {}; + forEachKeyValue({a: 1, b: 2}, (key, value) => { + collected[key] = value; + }); + expect(collected).toEqual({a: 1, b: 2}); + }); + + it('asyncForEachKeyValue awaits callback for each entry', async () => { + const collected = []; + await asyncForEachKeyValue({a: 1, b: 2}, async (key, value) => { + collected.push([key, value]); + }); + expect(collected).toEqual([['a', 1], ['b', 2]]); + }); + }); + + describe('test stringComparator', function () { + it('sorts by field, case-insensitive', () => { + const arr = [{name: 'banana'}, {name: 'Apple'}, {name: 'cherry'}]; + arr.sort(stringComparator('name')); + expect(arr.map((e) => e.name)).toEqual(['Apple', 'banana', 'cherry']); + }); + + it('andThen falls back to a secondary comparator', () => { + const arr = [ + {name: 'a', age: 2}, + {name: 'a', age: 1}, + {name: 'b', age: 5}, + ]; + const byAge = (x, y) => x.age - y.age; + arr.sort(stringComparator('name').andThen(byAge)); + expect(arr.map((e) => e.age)).toEqual([1, 2, 5]); + }); + }); + + describe('test websocket state helpers', function () { + it('isWebsocketConnecting', () => { + expect(isWebsocketConnecting({readyState: 0})).toBe(true); + expect(isWebsocketConnecting({readyState: 1})).toBe(false); + }); + + it('isWebsocketOpen', () => { + expect(isWebsocketOpen({readyState: 1})).toBe(true); + expect(isWebsocketOpen({readyState: 0})).toBe(false); + expect(isWebsocketOpen(null)).toBe(false); + }); + + it('isWebsocketClosed', () => { + expect(isWebsocketClosed({readyState: 2})).toBe(true); + expect(isWebsocketClosed({readyState: 3})).toBe(true); + expect(isWebsocketClosed({readyState: 1})).toBe(false); + }); + }); + + describe('test url helpers', function () { + afterEach(() => { + window.history.pushState({}, '', '/'); + }); + + it('readQueryParameters parses the query string', () => { + window.history.pushState({}, '', '/?foo=bar&hello=world'); + expect(readQueryParameters()).toEqual({foo: 'bar', hello: 'world'}); + }); + + it('readQueryParameters returns empty object when no query', () => { + window.history.pushState({}, '', '/'); + expect(readQueryParameters()).toEqual({}); + }); + + it('getQueryParameter returns a single value', () => { + window.history.pushState({}, '', '/?foo=bar'); + expect(getQueryParameter('foo')).toBe('bar'); + }); + + it('getUnparameterizedUrl drops the query', () => { + expect(getUnparameterizedUrl()).toBe('http://localhost:3000/'); + }); + + it('getWebsocketUrl builds a ws url with relative path', () => { + expect(getWebsocketUrl('events')).toBe('ws://localhost:3000/events'); + }); + + it('getWebsocketUrl without path returns host url', () => { + expect(getWebsocketUrl('')).toBe('ws://localhost:3000'); + }); + + it('toQueryArgs serializes scalars and arrays', () => { + expect(toQueryArgs({a: 1, b: [2, 3]})).toBe('a=1&b=2&b=3'); + }); + }); + + describe('test id generators', function () { + it('guid default has uuid-like length', () => { + expect(guid().length).toBe(36); + }); + + it('guid truncates to requested length', () => { + expect(guid(8).length).toBe(8); + }); + + it('guid pads to requested length', () => { + expect(guid(50).length).toBe(50); + }); + + it('uuidv4 matches the v4 format', () => { + expect(uuidv4()).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i); + }); + }); + + describe('test isFullRegexMatch', function () { + it('anchors a bare pattern', () => { + expect(isFullRegexMatch('\\d+', '123')).toBe(true); + expect(isFullRegexMatch('\\d+', '12a')).toBe(false); + }); + + it('respects existing anchors', () => { + expect(isFullRegexMatch('^abc$', 'abc')).toBe(true); + expect(isFullRegexMatch('^abc$', 'abcd')).toBe(false); + }); + }); + + describe('test DOM helpers', function () { + it('hasClass', () => { + const el = document.createElement('div'); + el.className = 'foo bar'; + expect(hasClass(el, 'foo')).toBe(true); + expect(hasClass(el, 'baz')).toBe(false); + }); + + it('destroyChildren removes all children', () => { + const el = document.createElement('div'); + el.appendChild(document.createElement('span')); + el.appendChild(document.createElement('span')); + destroyChildren(el); + expect(el.childNodes.length).toBe(0); + }); + + it('findNeighbour finds sibling by tag', () => { + const parent = document.createElement('div'); + const span = document.createElement('span'); + const input = document.createElement('input'); + parent.appendChild(span); + parent.appendChild(input); + expect(findNeighbour(input, 'span')).toBe(span); + expect(findNeighbour(span, 'a')).toBeNull(); + }); + + it('closestByClass walks up the tree', () => { + const grandparent = document.createElement('div'); + grandparent.className = 'target'; + const parent = document.createElement('div'); + const child = document.createElement('span'); + grandparent.appendChild(parent); + parent.appendChild(child); + expect(closestByClass(child, 'target')).toBe(grandparent); + expect(closestByClass(child, 'missing')).toBeNull(); + }); + + it('getElementsByTagNameRecursive finds nested elements', () => { + const root = document.createElement('div'); + root.innerHTML = '
1
2
'; + const spans = getElementsByTagNameRecursive(root, 'span'); + expect(spans.length).toBe(2); + }); + + it('getFileInputValue returns first file or null', () => { + expect(getFileInputValue({files: null})).toBeNull(); + expect(getFileInputValue({files: []})).toBeNull(); + const file = {name: 'f.txt'}; + expect(getFileInputValue({files: [file]})).toBe(file); + }); + }); + + describe('test HttpRequestError', function () { + it('carries code and message', () => { + const error = new HttpRequestError(404, 'Not found'); + expect(error.code).toBe(404); + expect(error.message).toBe('Not found'); + expect(error instanceof Error).toBe(true); + }); + + it('defaults code to -1', () => { + const error = new HttpRequestError(); + expect(error.code).toBe(-1); + }); + }); }); \ No newline at end of file diff --git a/web-src/tests/unit/common/utils/parameterHistory_test.js b/web-src/tests/unit/common/utils/parameterHistory_test.js new file mode 100644 index 00000000..069176fa --- /dev/null +++ b/web-src/tests/unit/common/utils/parameterHistory_test.js @@ -0,0 +1,213 @@ +'use strict'; + +import {vi} from 'vitest'; +import { + getMostRecentValues, + loadParameterHistory, + removeParameterHistoryEntry, + saveParameterHistory, + shouldUseHistoricalValues, + toggleFavoriteEntry, +} from '@/common/utils/parameterHistory'; + +const SCRIPT = 'my-script'; +const KEY = 'script_server_param_history_' + SCRIPT; + +// jsdom doesn't expose a working localStorage here, so use an in-memory mock. +function createLocalStorageMock() { + let store = {}; + return { + getItem: (key) => (key in store ? store[key] : null), + setItem: (key, value) => { store[key] = String(value); }, + removeItem: (key) => { delete store[key]; }, + clear: () => { store = {}; }, + }; +} + +describe('Test parameterHistory', function () { + beforeEach(function () { + vi.stubGlobal('localStorage', createLocalStorageMock()); + }); + + afterEach(function () { + vi.unstubAllGlobals(); + }); + + describe('saveParameterHistory / loadParameterHistory', function () { + it('saves a new entry at the front', function () { + saveParameterHistory(SCRIPT, {a: 1}); + const history = loadParameterHistory(SCRIPT); + expect(history.length).toBe(1); + expect(history[0].values).toEqual({a: 1}); + expect(history[0].favorite).toBe(false); + }); + + it('ignores empty parameter values', function () { + saveParameterHistory(SCRIPT, {}); + expect(loadParameterHistory(SCRIPT)).toEqual([]); + }); + + it('prepends newer distinct entries', function () { + saveParameterHistory(SCRIPT, {a: 1}); + saveParameterHistory(SCRIPT, {a: 2}); + const history = loadParameterHistory(SCRIPT); + expect(history.map(e => e.values)).toEqual([{a: 2}, {a: 1}]); + }); + + it('updates timestamp instead of duplicating identical values', function () { + saveParameterHistory(SCRIPT, {a: 1}); + saveParameterHistory(SCRIPT, {a: 1}); + expect(loadParameterHistory(SCRIPT).length).toBe(1); + }); + + it('caps non-favorite entries at 10', function () { + for (let i = 0; i < 12; i++) { + saveParameterHistory(SCRIPT, {n: i}); + } + const history = loadParameterHistory(SCRIPT); + expect(history.length).toBe(10); + // newest first + expect(history[0].values).toEqual({n: 11}); + }); + + it('returns empty array when nothing stored', function () { + expect(loadParameterHistory(SCRIPT)).toEqual([]); + }); + + it('returns empty array on corrupted storage', function () { + localStorage.setItem(KEY, 'not-json'); + expect(loadParameterHistory(SCRIPT)).toEqual([]); + }); + + it('defaults missing favorite flag to false', function () { + localStorage.setItem(KEY, JSON.stringify([{timestamp: 1, values: {a: 1}}])); + expect(loadParameterHistory(SCRIPT)[0].favorite).toBe(false); + }); + }); + + describe('getMostRecentValues', function () { + it('returns null when no history', function () { + expect(getMostRecentValues(SCRIPT)).toBeNull(); + }); + + it('returns the most recent values', function () { + saveParameterHistory(SCRIPT, {a: 1}); + saveParameterHistory(SCRIPT, {a: 2}); + expect(getMostRecentValues(SCRIPT)).toEqual({a: 2}); + }); + }); + + describe('removeParameterHistoryEntry', function () { + it('removes a valid entry', function () { + saveParameterHistory(SCRIPT, {a: 1}); + saveParameterHistory(SCRIPT, {a: 2}); + removeParameterHistoryEntry(SCRIPT, 0); + expect(loadParameterHistory(SCRIPT).map(e => e.values)).toEqual([{a: 1}]); + }); + + it('ignores an out-of-range index', function () { + saveParameterHistory(SCRIPT, {a: 1}); + removeParameterHistoryEntry(SCRIPT, 5); + expect(loadParameterHistory(SCRIPT).length).toBe(1); + }); + + it('does not remove a favorite entry', function () { + saveParameterHistory(SCRIPT, {a: 1}); + toggleFavoriteEntry(SCRIPT, 0); + removeParameterHistoryEntry(SCRIPT, 0); + expect(loadParameterHistory(SCRIPT).length).toBe(1); + }); + }); + + describe('toggleFavoriteEntry', function () { + it('marks an entry as favorite', function () { + saveParameterHistory(SCRIPT, {a: 1}); + toggleFavoriteEntry(SCRIPT, 0); + expect(loadParameterHistory(SCRIPT)[0].favorite).toBe(true); + }); + + it('toggles back to non-favorite', function () { + saveParameterHistory(SCRIPT, {a: 1}); + toggleFavoriteEntry(SCRIPT, 0); + toggleFavoriteEntry(SCRIPT, 0); + expect(loadParameterHistory(SCRIPT)[0].favorite).toBe(false); + }); + + it('ignores an out-of-range index', function () { + saveParameterHistory(SCRIPT, {a: 1}); + toggleFavoriteEntry(SCRIPT, 9); + expect(loadParameterHistory(SCRIPT)[0].favorite).toBe(false); + }); + + it('moves favorites ahead of non-favorites', function () { + saveParameterHistory(SCRIPT, {a: 1}); + saveParameterHistory(SCRIPT, {a: 2}); // index 0 now + // make the older entry ({a:1}, index 1) a favorite + toggleFavoriteEntry(SCRIPT, 1); + const history = loadParameterHistory(SCRIPT); + expect(history[0].values).toEqual({a: 1}); + expect(history[0].favorite).toBe(true); + }); + + it('keeps favorites even beyond the non-favorite cap', function () { + saveParameterHistory(SCRIPT, {fav: true}); + toggleFavoriteEntry(SCRIPT, 0); + for (let i = 0; i < 12; i++) { + saveParameterHistory(SCRIPT, {n: i}); + } + const history = loadParameterHistory(SCRIPT); + const favorites = history.filter(e => e.favorite); + expect(favorites.length).toBe(1); + expect(favorites[0].values).toEqual({fav: true}); + }); + }); + + describe('graceful error handling', function () { + function stubThrowingStorage() { + vi.stubGlobal('localStorage', { + getItem: () => JSON.stringify([{timestamp: 1, values: {a: 1}, favorite: false}]), + setItem: () => { throw new Error('quota exceeded'); }, + removeItem: () => {}, + clear: () => {}, + }); + } + + it('saveParameterHistory swallows storage errors', function () { + stubThrowingStorage(); + expect(() => saveParameterHistory(SCRIPT, {b: 2})).not.toThrow(); + }); + + it('removeParameterHistoryEntry swallows storage errors', function () { + stubThrowingStorage(); + expect(() => removeParameterHistoryEntry(SCRIPT, 0)).not.toThrow(); + }); + + it('toggleFavoriteEntry swallows storage errors', function () { + stubThrowingStorage(); + expect(() => toggleFavoriteEntry(SCRIPT, 0)).not.toThrow(); + }); + + it('shouldUseHistoricalValues returns false on storage error', function () { + vi.stubGlobal('localStorage', { + getItem: () => { throw new Error('blocked'); }, + }); + expect(shouldUseHistoricalValues(SCRIPT)).toBe(false); + }); + }); + + describe('shouldUseHistoricalValues', function () { + it('is true when the flag is set to "true"', function () { + localStorage.setItem('useHistoricalValues_' + SCRIPT, 'true'); + expect(shouldUseHistoricalValues(SCRIPT)).toBe(true); + }); + + it('is false when the flag is anything else', function () { + localStorage.setItem('useHistoricalValues_' + SCRIPT, 'false'); + expect(shouldUseHistoricalValues(SCRIPT)).toBe(false); + }); + + it('is false when the flag is absent', function () { + expect(shouldUseHistoricalValues(SCRIPT)).toBe(false); + }); + }); +}); diff --git a/web-src/tests/unit/main-app/components/favicon_manager_test.js b/web-src/tests/unit/main-app/components/favicon_manager_test.js new file mode 100644 index 00000000..d4e9ae68 --- /dev/null +++ b/web-src/tests/unit/main-app/components/favicon_manager_test.js @@ -0,0 +1,69 @@ +'use strict'; + +import { + defaultFavicon, + setDefaultFavicon, + setExecutingFavicon, + setFinishedFavicon, +} from '@/main-app/components/favicon_manager'; + +function iconLinks() { + const head = document.getElementsByTagName('head')[0]; + return Array.from(head.childNodes).filter( + node => node.tagName === 'LINK' && node.type === 'image/x-icon'); +} + +describe('Test favicon_manager', function () { + beforeEach(function () { + iconLinks().forEach(link => link.parentNode.removeChild(link)); + }); + + afterEach(function () { + iconLinks().forEach(link => link.parentNode.removeChild(link)); + }); + + describe('defaultFavicon', function () { + it('is a shortcut icon link', function () { + expect(defaultFavicon.tagName).toBe('LINK'); + expect(defaultFavicon.type).toBe('image/x-icon'); + expect(defaultFavicon.rel).toBe('shortcut icon'); + expect(defaultFavicon.href).toContain('favicon.ico'); + }); + }); + + describe('setFavicon behaviour', function () { + it('appends a favicon link when none is present', function () { + expect(iconLinks().length).toBe(0); + setDefaultFavicon(); + expect(iconLinks()).toContain(defaultFavicon); + }); + + it('replaces an existing favicon link instead of adding another', function () { + const stale = document.createElement('link'); + stale.type = 'image/x-icon'; + stale.rel = 'shortcut icon'; + stale.href = 'old.ico'; + document.getElementsByTagName('head')[0].appendChild(stale); + + setDefaultFavicon(); + + const links = iconLinks(); + expect(links.length).toBe(1); + expect(links[0]).toBe(defaultFavicon); + }); + }); + + describe('fallback to default when generated icons are unavailable', function () { + // In jsdom the base image never fires onload, so executingFavicon / + // finishedFavicon stay undefined and the setters fall back to default. + it('setExecutingFavicon falls back to the default favicon', function () { + setExecutingFavicon(); + expect(iconLinks()).toContain(defaultFavicon); + }); + + it('setFinishedFavicon falls back to the default favicon', function () { + setFinishedFavicon(); + expect(iconLinks()).toContain(defaultFavicon); + }); + }); +}); diff --git a/web-src/tests/unit/main-app/components/schedule/SchedulePanel_test.js b/web-src/tests/unit/main-app/components/schedule/SchedulePanel_test.js new file mode 100644 index 00000000..4784a214 --- /dev/null +++ b/web-src/tests/unit/main-app/components/schedule/SchedulePanel_test.js @@ -0,0 +1,191 @@ +'use strict'; + +import SchedulePanel from '@/main-app/components/schedule/SchedulePanel'; +import {shallowMount} from '@vue/test-utils'; +import {createPinia, setActivePinia} from 'pinia'; +import {useScriptScheduleStore} from '@/main-app/stores/scriptSchedule'; +import {vi} from 'vitest'; +import {vueTicks} from '../../../test_utils'; + +describe('Test SchedulePanel', function () { + let pinia; + let panel; + + beforeEach(function () { + pinia = createPinia(); + setActivePinia(pinia); + + panel = shallowMount(SchedulePanel, { + global: {plugins: [pinia]} + }); + }); + + afterEach(function () { + panel.unmount(); + }); + + describe('scheduleType computed', function () { + it('defaults to one-time', function () { + expect(panel.vm.scheduleType).toBe('one-time'); + }); + + it('switching to repeat clears oneTimeSchedule', async function () { + panel.vm.scheduleType = 'repeat'; + expect(panel.vm.oneTimeSchedule).toBe(false); + expect(panel.vm.scheduleType).toBe('repeat'); + }); + + it('switching back to one-time sets oneTimeSchedule', function () { + panel.vm.scheduleType = 'repeat'; + panel.vm.scheduleType = 'one-time'; + expect(panel.vm.oneTimeSchedule).toBe(true); + }); + }); + + describe('weekdaysError computed', function () { + it('is null for one-time schedule', function () { + expect(panel.vm.weekdaysError).toBeNull(); + }); + + it('is null when repeating by days', async function () { + await panel.setData({oneTimeSchedule: false, repeatTimeUnit: 'days'}); + expect(panel.vm.weekdaysError).toBeNull(); + }); + + it('is required when repeating by weeks with no active day', async function () { + const weekDays = panel.vm.weekDays.map(d => ({...d, active: false})); + await panel.setData({oneTimeSchedule: false, repeatTimeUnit: 'weeks', weekDays}); + expect(panel.vm.weekdaysError).toBe('required'); + }); + + it('is null when repeating by weeks with an active day', async function () { + const weekDays = panel.vm.weekDays.map((d, i) => ({...d, active: i === 0})); + await panel.setData({oneTimeSchedule: false, repeatTimeUnit: 'weeks', weekDays}); + expect(panel.vm.weekdaysError).toBeNull(); + }); + }); + + describe('buildScheduleSetup', function () { + it('builds a one-time setup', async function () { + await panel.setData({ + oneTimeSchedule: true, + startDate: new Date(2026, 5, 18), + startTime: '14:30', + }); + + const setup = panel.vm.buildScheduleSetup(); + + expect(setup.repeatable).toBe(false); + expect(setup.endOption).toBe('never'); + expect(setup.endArg).toBeNull(); + expect(setup.startDatetime.getHours()).toBe(14); + expect(setup.startDatetime.getMinutes()).toBe(30); + }); + + it('maps maxExecuteCount to max_executions', async function () { + await panel.setData({ + oneTimeSchedule: false, + endOption: 'maxExecuteCount', + maxExecuteCount: 7, + }); + + const setup = panel.vm.buildScheduleSetup(); + + expect(setup.repeatable).toBe(true); + expect(setup.endOption).toBe('max_executions'); + expect(setup.endArg).toBe(7); + }); + + it('maps endDatetime to end_datetime with a Date arg', async function () { + await panel.setData({ + oneTimeSchedule: false, + endOption: 'endDatetime', + endDate: new Date(2026, 5, 20), + endTime: '09:15', + }); + + const setup = panel.vm.buildScheduleSetup(); + + expect(setup.endOption).toBe('end_datetime'); + expect(setup.endArg).toBeInstanceOf(Date); + expect(setup.endArg.getHours()).toBe(9); + expect(setup.endArg.getMinutes()).toBe(15); + }); + + it('includes only active weekdays as names', async function () { + const weekDays = panel.vm.weekDays.map(d => ({...d, active: false})); + weekDays[0].active = true; // Monday + weekDays[2].active = true; // Wednesday + await panel.setData({oneTimeSchedule: false, repeatTimeUnit: 'weeks', weekDays}); + + const setup = panel.vm.buildScheduleSetup(); + + expect(setup.weekDays).toEqual(['Monday', 'Wednesday']); + }); + + it('carries repeat unit and period', async function () { + await panel.setData({ + oneTimeSchedule: false, + repeatTimeUnit: 'hours', + repeatPeriod: 3, + }); + + const setup = panel.vm.buildScheduleSetup(); + + expect(setup.repeatUnit).toBe('hours'); + expect(setup.repeatPeriod).toBe(3); + }); + }); + + describe('error tracking', function () { + it('records a field error and exposes it in errors', function () { + panel.vm.onFieldError('startTime', 'invalid time'); + expect(panel.vm.errors).toContain('invalid time'); + }); + + it('clears errors when the field becomes valid', function () { + panel.vm.onFieldError('startTime', 'invalid time'); + panel.vm.onFieldError('startTime', ''); + expect(panel.vm.errors).toEqual([]); + }); + + it('ignores repeat-only field errors in one-time mode', function () { + panel.vm.onFieldError('repeatPeriod', 'bad'); + expect(panel.vm.errors).toEqual([]); + }); + + it('tracks repeat-only field errors in repeat mode', async function () { + await panel.setData({oneTimeSchedule: false}); + panel.vm.onFieldError('repeatPeriod', 'bad'); + expect(panel.vm.errors).toContain('bad'); + }); + + it('tracks maxExecuteCount error only with maxExecuteCount end option', async function () { + await panel.setData({oneTimeSchedule: false, endOption: 'maxExecuteCount'}); + panel.vm.onFieldError('maxExecuteCount', 'bad count'); + expect(panel.vm.errors).toContain('bad count'); + }); + }); + + describe('actions', function () { + it('close emits close', function () { + panel.vm.close(); + expect(panel.emitted('close')).toBeTruthy(); + }); + + it('runScheduleAction schedules, emits scheduled with id and closes', async function () { + const store = useScriptScheduleStore(); + const scheduleSpy = vi.spyOn(store, 'schedule') + .mockResolvedValue({data: {id: 42}}); + + await panel.vm.runScheduleAction(); + await vueTicks(); + + expect(scheduleSpy).toHaveBeenCalledOnce(); + const passedSetup = scheduleSpy.mock.calls[0][0].scheduleSetup; + expect(passedSetup).toBeDefined(); + expect(panel.emitted('scheduled')[0]).toEqual([42]); + expect(panel.emitted('close')).toBeTruthy(); + }); + }); +});