diff --git a/launchable/commands/subset.py b/launchable/commands/subset.py index f93b0c3d6..bb415f719 100644 --- a/launchable/commands/subset.py +++ b/launchable/commands/subset.py @@ -491,17 +491,17 @@ def get_payload( if target is not None: payload["goal"] = { "type": "subset-by-percentage", - "percentage": target, + "percentage": float(target), } elif duration is not None: payload["goal"] = { "type": "subset-by-absolute-time", - "duration": duration, + "duration": float(duration), } elif confidence is not None: payload["goal"] = { "type": "subset-by-confidence", - "percentage": confidence + "percentage": float(confidence) } elif goal_spec is not None: payload["goal"] = { diff --git a/launchable/utils/exceptions.py b/launchable/utils/exceptions.py index 7504d0333..2fc2f4c12 100644 --- a/launchable/utils/exceptions.py +++ b/launchable/utils/exceptions.py @@ -1,4 +1,10 @@ # TODO: add cli-specific custom exceptions +import sys + +import typer + +from smart_tests.utils.tracking import Tracking, TrackingClient + class ParseSessionException(Exception): def __init__( @@ -20,3 +26,9 @@ def __init__( self.filename = filename self.message = "{message}: {filename}".format(message=message, filename=self.filename) super().__init__(self.message) + + +def print_error_and_die(msg: str, tracking_client: TrackingClient, event: Tracking.ErrorEvent): + typer.secho(msg, fg=typer.colors.RED, err=True) + tracking_client.send_error_event(event_name=event, stack_trace=msg) + sys.exit(1) diff --git a/setup.cfg b/setup.cfg index d86cc5b35..176e2e11d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,9 +1,9 @@ [metadata] -name = launchable +name = smart-tests author = Launchable, Inc. author_email = info@launchableinc.com license = Apache Software License v2 -description = Launchable CLI +description = Smart Tests CLI url = https://launchableinc.com/ long_description = file: README.md long_description_content_type = text/markdown @@ -30,7 +30,7 @@ setup_requires = setuptools-scm [options.entry_points] -console_scripts = launchable = launchable.__main__:main +console_scripts = smart-tests = smart_tests.__main__:main [options.package_data] -launchable = jar/exe_deploy.jar +smart_tests = jar/exe_deploy.jar diff --git a/src/main/java/com/launchableinc/ingest/commits/CommitIngester.java b/src/main/java/com/launchableinc/ingest/commits/CommitIngester.java index 2efff19c0..302b30759 100644 --- a/src/main/java/com/launchableinc/ingest/commits/CommitIngester.java +++ b/src/main/java/com/launchableinc/ingest/commits/CommitIngester.java @@ -88,18 +88,18 @@ public void setNoCommitMessage(boolean b) { private void parseConfiguration() throws CmdLineException { String apiToken = launchableToken; if (launchableToken == null) { - apiToken = System.getenv("LAUNCHABLE_TOKEN"); + apiToken = System.getenv("SMART_TESTS_TOKEN"); } if (apiToken == null || apiToken.isEmpty()) { if (System.getenv("GITHUB_ACTIONS") != null) { - String o = System.getenv("LAUNCHABLE_ORGANIZATION"); + String o = System.getenv("SMART_TESTS_ORGANIZATION"); if (org == null && o == null) { - throw new CmdLineException("LAUNCHABLE_ORGANIZATION env variable is not set"); + throw new CmdLineException("SMART_TESTS_ORGANIZATION env variable is not set"); } - String w = System.getenv("LAUNCHABLE_WORKSPACE"); + String w = System.getenv("SMART_TESTS_WORKSPACE"); if (ws == null && w == null) { - throw new CmdLineException("LAUNCHABLE_WORKSPACE env variable is not set"); + throw new CmdLineException("SMART_TESTS_WORKSPACE env variable is not set"); } if (org == null) { @@ -118,7 +118,7 @@ private void parseConfiguration() throws CmdLineException { return; } - throw new CmdLineException("LAUNCHABLE_TOKEN env variable is not set"); + throw new CmdLineException("SMART_TESTS_TOKEN env variable is not set"); } this.parseLaunchableToken(apiToken); @@ -163,11 +163,11 @@ private void parseLaunchableToken(String token) throws CmdLineException { if (token.startsWith("v1:")) { String[] v = token.split(":"); if (v.length != 3) { - throw new IllegalStateException("Malformed LAUNCHABLE_TOKEN"); + throw new IllegalStateException("Malformed SMART_TESTS_TOKEN"); } v = v[1].split("/"); if (v.length != 2) { - throw new IllegalStateException("Malformed LAUNCHABLE_TOKEN"); + throw new IllegalStateException("Malformed SMART_TESTS_TOKEN"); } // for backward compatibility, allow command line options to take precedence diff --git a/tests/cli_test_case.py b/tests/cli_test_case.py index e79611cc5..8b962bc3a 100644 --- a/tests/cli_test_case.py +++ b/tests/cli_test_case.py @@ -216,7 +216,17 @@ def cli(self, *args, **kwargs) -> click.testing.Result: mix_stderr = kwargs['mix_stderr'] del kwargs['mix_stderr'] - return CliRunner(mix_stderr=mix_stderr).invoke(main, args, catch_exceptions=False, **kwargs) + # Disable rich colors for testing by setting the environment variable + import os + old_no_color = os.environ.get('NO_COLOR') + os.environ['NO_COLOR'] = '1' + try: + return CliRunner(mix_stderr=mix_stderr).invoke(main, args, catch_exceptions=False, **kwargs) + finally: + if old_no_color is None: + os.environ.pop('NO_COLOR', None) + else: + os.environ['NO_COLOR'] = old_no_color def assert_success(self, result: click.testing.Result): self.assert_exit_code(result, 0) diff --git a/tests/commands/record/test_build.py b/tests/commands/record/test_build.py index 6f6591146..a7aa69df7 100644 --- a/tests/commands/record/test_build.py +++ b/tests/commands/record/test_build.py @@ -191,24 +191,26 @@ def test_commit_option_and_build_option(self): }, payload) responses.calls.reset() - # case --commit option and --branch option but another one + # case --commit option and --repo-branch-map option with invalid repo result = self.cli( "record", "build", + "--build", + self.build_name, "--no-commit-collection", "--commit", "A=abc12", "--branch", - "B=feature-yyy", - "--name", - self.build_name) + "main", + "--repo-branch-map", + "B=feature-yyy") self.assert_success(result) payload = json.loads(responses.calls[1].request.body.decode()) self.assert_json_orderless_equal( { "buildNumber": "123", - "lineage": None, + "lineage": "main", "commitHashes": [ { "repositoryName": "A", @@ -226,17 +228,17 @@ def test_commit_option_and_build_option(self): result = self.cli( "record", "build", + "--build", + self.build_name, "--no-commit-collection", "--commit", "A=abc12", - "--branch", + "--repo-branch-map", "B=feature-yyy", "--commit", "B=56cde", - "--branch", - "A=feature-xxx", - "--name", - self.build_name) + "--repo-branch-map", + "A=feature-xxx") self.assert_success(result) payload = json.loads(responses.calls[1].request.body.decode()) diff --git a/tests/commands/record/test_session.py b/tests/commands/record/test_session.py index b3206b802..2a18df06b 100644 --- a/tests/commands/record/test_session.py +++ b/tests/commands/record/test_session.py @@ -24,7 +24,18 @@ class SessionTest(CliTestCase): 'LANG': 'C.UTF-8', }, clear=True) def test_run_session_without_flavor(self): - result = self.cli("record", "session", "--build", self.build_name) + # Mock session name check + responses.add( + responses.GET, + "{}/intake/organizations/{}/workspaces/{}/builds/{}/test_sessions/{}".format( + get_base_url(), + self.organization, + self.workspace, + self.build_name, + self.session_name), + status=404) + + result = self.cli("record", "session", "--build", self.build_name, "--session", self.session_name) self.assert_success(result) payload = json.loads(responses.calls[1].request.body.decode()) @@ -44,7 +55,19 @@ def test_run_session_without_flavor(self): 'LANG': 'C.UTF-8', }, clear=True) def test_run_session_with_flavor(self): + # Mock session name check + responses.add( + responses.GET, + "{}/intake/organizations/{}/workspaces/{}/builds/{}/test_sessions/{}".format( + get_base_url(), + self.organization, + self.workspace, + self.build_name, + self.session_name), + status=404) + result = self.cli("record", "session", "--build", self.build_name, + "--session", self.session_name, "--flavor", "key=value", "--flavor", "k:v", "--flavor", "k e y = v a l u e") self.assert_success(result) @@ -63,9 +86,20 @@ def test_run_session_with_flavor(self): "timestamp": None, }, payload) - result = self.cli("record", "session", "--build", self.build_name, "--flavor", "only-key") + # Mock session name check for second call + responses.add( + responses.GET, + "{}/intake/organizations/{}/workspaces/{}/builds/{}/test_sessions/{}".format( + get_base_url(), + self.organization, + self.workspace, + self.build_name, + self.session_name), + status=404) + + result = self.cli("record", "session", "--build", self.build_name, "--session", self.session_name, "--flavor", "only-key") self.assert_exit_code(result, 2) - self.assertIn("Expected a key-value pair formatted as --option key=value", result.output) + self.assertIn("but got 'only-key'", result.output) @responses.activate @mock.patch.dict(os.environ, { @@ -73,7 +107,18 @@ def test_run_session_with_flavor(self): 'LANG': 'C.UTF-8', }, clear=True) def test_run_session_with_observation(self): - result = self.cli("record", "session", "--build", self.build_name, "--observation") + # Mock session name check + responses.add( + responses.GET, + "{}/intake/organizations/{}/workspaces/{}/builds/{}/test_sessions/{}".format( + get_base_url(), + self.organization, + self.workspace, + self.build_name, + self.session_name), + status=404) + + result = self.cli("record", "session", "--build", self.build_name, "--session", self.session_name, "--observation") self.assert_success(result) payload = json.loads(responses.calls[1].request.body.decode()) @@ -116,7 +161,7 @@ def test_run_session_with_session_name(self): result = self.cli("record", "session", "--build", self.build_name, "--session-name", self.session_name) self.assert_success(result) - payload = json.loads(responses.calls[5].request.body.decode()) + payload = json.loads(responses.calls[3].request.body.decode()) self.assert_json_orderless_equal({ "flavors": {}, "isObservation": False, @@ -133,7 +178,19 @@ def test_run_session_with_session_name(self): 'LANG': 'C.UTF-8', }, clear=True) def test_run_session_with_lineage(self): + # Mock session name check + responses.add( + responses.GET, + "{}/intake/organizations/{}/workspaces/{}/builds/{}/test_sessions/{}".format( + get_base_url(), + self.organization, + self.workspace, + self.build_name, + self.session_name), + status=404) + result = self.cli("record", "session", "--build", self.build_name, + "--session", self.session_name, "--lineage", "example-lineage") self.assert_success(result) @@ -154,7 +211,19 @@ def test_run_session_with_lineage(self): 'LANG': 'C.UTF-8', }, clear=True) def test_run_session_with_test_suite(self): + # Mock session name check + responses.add( + responses.GET, + "{}/intake/organizations/{}/workspaces/{}/builds/{}/test_sessions/{}".format( + get_base_url(), + self.organization, + self.workspace, + self.build_name, + self.session_name), + status=404) + result = self.cli("record", "session", "--build", self.build_name, + "--session", self.session_name, "--test-suite", "example-test-suite") self.assert_success(result) @@ -175,7 +244,19 @@ def test_run_session_with_test_suite(self): 'LANG': 'C.UTF-8', }, clear=True) def test_run_session_with_timestamp(self): + # Mock session name check + responses.add( + responses.GET, + "{}/intake/organizations/{}/workspaces/{}/builds/{}/test_sessions/{}".format( + get_base_url(), + self.organization, + self.workspace, + self.build_name, + self.session_name), + status=404) + result = self.cli("record", "session", "--build", self.build_name, + "--session", self.session_name, "--timestamp", "2023-10-01T12:00:00Z") self.assert_success(result) diff --git a/tests/commands/test_api_error.py b/tests/commands/test_api_error.py index 8a9249a35..69602c85c 100644 --- a/tests/commands/test_api_error.py +++ b/tests/commands/test_api_error.py @@ -132,7 +132,7 @@ def test_record_build(self): self.assert_success(result) self.assertEqual(result.exception, None) # Since HTTPError is occurred outside of LaunchableClient, the count is 1. - self.assert_tracking_count(tracking=tracking, count=3) + self.assert_tracking_count(tracking=tracking, count=2) success_server.shutdown() thread.join(timeout=3) @@ -166,7 +166,7 @@ def test_record_build(self): self.assert_success(result) self.assertEqual(result.exception, None) # Since HTTPError is occurred outside of LaunchableClient, the count is 1. - self.assert_tracking_count(tracking=tracking, count=3) + self.assert_tracking_count(tracking=tracking, count=2) error_server.shutdown() thread.join(timeout=3) @@ -175,6 +175,16 @@ def test_record_build(self): @mock.patch.dict(os.environ, {"LAUNCHABLE_TOKEN": CliTestCase.launchable_token}) def test_record_session(self): build = "internal_server_error" + # Mock session name check + responses.add( + responses.GET, + "{base}/intake/organizations/{org}/workspaces/{ws}/builds/{build}/test_sessions/{session}".format( + base=get_base_url(), + org=self.organization, + ws=self.workspace, + build=build, + session=self.session_name), + status=404) responses.add( responses.POST, "{base}/intake/organizations/{org}/workspaces/{ws}/builds/{build}/test_sessions".format( @@ -189,12 +199,22 @@ def test_record_session(self): base=get_base_url()), body=ReadTimeout("error")) - result = self.cli("record", "session", "--build", build) + result = self.cli("record", "session", "--build", build, "--session", self.session_name) self.assert_success(result) - # Since HTTPError is occurred outside of LaunchableClient, the count is 1. - self.assert_tracking_count(tracking=tracking, count=1) + # Since HTTPError is occurred outside of LaunchableClient, the count is 2 (one for GET check, one for POST). + self.assert_tracking_count(tracking=tracking, count=2) build = "not_found" + # Mock session name check + responses.add( + responses.GET, + "{base}/intake/organizations/{org}/workspaces/{ws}/builds/{build}/test_sessions/{session}".format( + base=get_base_url(), + org=self.organization, + ws=self.workspace, + build=build, + session=self.session_name), + status=404) responses.add( responses.POST, "{base}/intake/organizations/{org}/workspaces/{ws}/builds/{build}/test_sessions".format( @@ -209,13 +229,14 @@ def test_record_session(self): base=get_base_url()), body=ReadTimeout("error")) - result = self.cli("record", "session", "--build", build) + result = self.cli("record", "session", "--build", build, "--session", self.session_name) self.assert_exit_code(result, 1) self.assert_tracking_count(tracking=tracking, count=1) - responses.replace( + # Mock session name check with ReadTimeout error + responses.add( responses.GET, - "{}/intake/organizations/{}/workspaces/{}/builds/{}/test_session_names/{}".format( + "{}/intake/organizations/{}/workspaces/{}/builds/{}/test_sessions/{}".format( get_base_url(), self.organization, self.workspace, @@ -438,7 +459,7 @@ def test_all_workflow_when_server_down(self): self.assert_success(result) # Since Timeout error is caught inside of LaunchableClient, the tracking event is sent twice. - self.assert_tracking_count(tracking=tracking, count=6) + self.assert_tracking_count(tracking=tracking, count=5) # set delete=False to solve the error `PermissionError: [Errno 13] Permission denied:` on Windows. with tempfile.NamedTemporaryFile(delete=False) as rest_file: @@ -455,12 +476,12 @@ def test_all_workflow_when_server_down(self): self.assert_success(result) # Since Timeout error is caught inside of LaunchableClient, the tracking event is sent twice. - self.assert_tracking_count(tracking=tracking, count=9) + self.assert_tracking_count(tracking=tracking, count=7) result = self.cli("record", "tests", "--session", self.session, "minitest", str(self.test_files_dir) + "/") self.assert_success(result) # Since Timeout error is caught inside of LaunchableClient, the tracking event is sent twice. - self.assert_tracking_count(tracking=tracking, count=13) + self.assert_tracking_count(tracking=tracking, count=9) def assert_tracking_count(self, tracking, count: int): # Prior to 3.6, `Response` object can't be obtained. diff --git a/tests/commands/test_flake_detection.py b/tests/commands/test_flake_detection.py new file mode 100644 index 000000000..4589b8c1f --- /dev/null +++ b/tests/commands/test_flake_detection.py @@ -0,0 +1,83 @@ +import os +from unittest import mock + +import responses # type: ignore + +from launchable.utils.http_client import get_base_url +from tests.cli_test_case import CliTestCase + + +class FlakeDetectionTest(CliTestCase): + @responses.activate + @mock.patch.dict(os.environ, {"LAUNCHABLE_TOKEN": CliTestCase.launchable_token}) + def test_flake_detection_success(self): + mock_json_response = { + "testPaths": [ + [{"type": "file", "name": "test_flaky_1.py"}], + [{"type": "file", "name": "test_flaky_2.py"}], + ] + } + responses.add( + responses.GET, + f"{get_base_url()}/intake/organizations/{self.organization}/workspaces/{self.workspace}/retry/flake-detection", + json=mock_json_response, + status=200, + ) + result = self.cli( + "retry", + "flake-detection", + "--session", + self.session, + "--confidence", + "high", + "file", + mix_stderr=False, + ) + self.assert_success(result) + self.assertIn("test_flaky_1.py", result.stdout) + self.assertIn("test_flaky_2.py", result.stdout) + + @responses.activate + @mock.patch.dict(os.environ, {"LAUNCHABLE_TOKEN": CliTestCase.launchable_token}) + def test_flake_detection_no_flakes(self): + mock_json_response = {"testPaths": []} + responses.add( + responses.GET, + f"{get_base_url()}/intake/organizations/{self.organization}/workspaces/{self.workspace}/retry/flake-detection", + json=mock_json_response, + status=200, + ) + result = self.cli( + "retry", + "flake-detection", + "--session", + self.session, + "--confidence", + "low", + "file", + mix_stderr=False, + ) + self.assert_success(result) + self.assertEqual(result.stdout, "") + + @responses.activate + @mock.patch.dict(os.environ, {"LAUNCHABLE_TOKEN": CliTestCase.launchable_token}) + def test_flake_detection_api_error(self): + responses.add( + responses.GET, + f"{get_base_url()}/intake/organizations/{self.organization}/workspaces/{self.workspace}/retry/flake-detection", + status=500, + ) + result = self.cli( + "retry", + "flake-detection", + "--session", + self.session, + "--confidence", + "medium", + "file", + mix_stderr=False, + ) + self.assert_exit_code(result, 0) + self.assertIn("Error", result.stderr) + self.assertEqual(result.stdout, "") diff --git a/tests/utils/test_fail_fast_mode.py b/tests/utils/test_fail_fast_mode.py index c150e5f8f..8fc2c8437 100644 --- a/tests/utils/test_fail_fast_mode.py +++ b/tests/utils/test_fail_fast_mode.py @@ -1,8 +1,8 @@ import io from contextlib import contextmanager, redirect_stderr -from launchable.utils.commands import Command -from launchable.utils.fail_fast_mode import FailFastModeValidateParams, fail_fast_mode_validate +from smart_tests.utils.commands import Command +from smart_tests.utils.fail_fast_mode import FailFastModeValidateParams, fail_fast_mode_validate from tests.cli_test_case import CliTestCase @@ -30,7 +30,7 @@ def test_fail_fast_mode_validate(self): @contextmanager def tmp_set_fail_fast_mode(enabled: bool): - from launchable.utils.fail_fast_mode import _fail_fast_mode_cache, set_fail_fast_mode + from smart_tests.utils.fail_fast_mode import _fail_fast_mode_cache, set_fail_fast_mode original = _fail_fast_mode_cache try: set_fail_fast_mode(enabled) diff --git a/tests/utils/test_typer.py b/tests/utils/test_typer.py new file mode 100644 index 000000000..202c6f492 --- /dev/null +++ b/tests/utils/test_typer.py @@ -0,0 +1,59 @@ +import datetime +from datetime import timezone +from unittest import TestCase + +import typer +from dateutil.tz import tzlocal + +from launchable.utils.typer_types import (DATETIME_WITH_TZ, KEY_VALUE, convert_to_seconds, + validate_datetime_with_tz, validate_key_value) + + +class DurationTypeTest(TestCase): + def test_convert_to_seconds(self): + self.assertEqual(convert_to_seconds('30s'), 30) + self.assertEqual(convert_to_seconds('5m'), 300) + self.assertEqual(convert_to_seconds('1h30m'), 5400) + self.assertEqual(convert_to_seconds('1d10h15m'), 123300) + self.assertEqual(convert_to_seconds('15m 1d 10h'), 123300) + + with self.assertRaises(ValueError): + convert_to_seconds('1h30k') + + +class KeyValueTypeTest(TestCase): + def test_conversion(self): + # Test the validate_key_value function directly + self.assertEqual(validate_key_value('bar=zot'), ('bar', 'zot')) + self.assertEqual(validate_key_value('a=b'), ('a', 'b')) + self.assertEqual(validate_key_value('key:value'), ('key', 'value')) + + with self.assertRaises(typer.BadParameter): + validate_key_value('invalid') + + # Test the parser class + parser = KEY_VALUE + self.assertEqual(parser('bar=zot'), ('bar', 'zot')) + self.assertEqual(parser('a=b'), ('a', 'b')) + + +class TimestampTypeTest(TestCase): + def test_conversion(self): + # Test the validate_datetime_with_tz function directly + result1 = validate_datetime_with_tz('2023-10-01 12:00:00') + expected1 = datetime.datetime(2023, 10, 1, 12, 0, 0, tzinfo=tzlocal()) + self.assertEqual(result1, expected1) + + result2 = validate_datetime_with_tz('2023-10-01 20:00:00+00:00') + expected2 = datetime.datetime(2023, 10, 1, 20, 0, 0, tzinfo=timezone.utc) + self.assertEqual(result2, expected2) + + result3 = validate_datetime_with_tz('2023-10-01T20:00:00Z') + expected3 = datetime.datetime(2023, 10, 1, 20, 0, 0, tzinfo=timezone.utc) + self.assertEqual(result3, expected3) + + # Test the parser class + parser = DATETIME_WITH_TZ + result4 = parser('2023-10-01 12:00:00') + expected4 = datetime.datetime(2023, 10, 1, 12, 0, 0, tzinfo=tzlocal()) + self.assertEqual(result4, expected4) diff --git a/tests/utils/test_typer_types.py b/tests/utils/test_typer_types.py new file mode 100644 index 000000000..9b605e33e --- /dev/null +++ b/tests/utils/test_typer_types.py @@ -0,0 +1,251 @@ +import datetime +import sys +from datetime import timezone +from unittest import TestCase + +import typer +from dateutil.tz import tzlocal + +from smart_tests.utils.typer_types import (DATETIME_WITH_TZ, EMOJI, KEY_VALUE, DateTimeWithTimezone, Duration, Fraction, + KeyValue, Percentage, convert_to_seconds, emoji, parse_datetime_with_timezone, + parse_duration, parse_fraction, parse_key_value, parse_percentage, + validate_datetime_with_tz, validate_key_value, validate_past_datetime) + + +class PercentageTest(TestCase): + def test_parse_valid_percentage(self): + pct = parse_percentage("50%") + self.assertIsInstance(pct, Percentage) + self.assertEqual(pct.value, 0.5) + self.assertEqual(float(pct), 0.5) + self.assertEqual(str(pct), "50.0%") + + def test_parse_edge_cases(self): + # Test 0% and 100% + self.assertEqual(parse_percentage("0%").value, 0.0) + self.assertEqual(parse_percentage("100%").value, 1.0) + + # Test decimal percentages + self.assertEqual(parse_percentage("25.5%").value, 0.255) + + def test_parse_invalid_percentage_missing_percent(self): + orig_platform = sys.platform + try: + # Test Windows behavior + sys.platform = "win32" + with self.assertRaises(typer.BadParameter) as cm: + parse_percentage("50") + msg = str(cm.exception) + self.assertIn("Expected percentage like 50% but got '50'", msg) + self.assertIn("please write '50%%' to pass in '50%'", msg) + + # Test non-Windows behavior + sys.platform = "linux" + with self.assertRaises(typer.BadParameter) as cm: + parse_percentage("50") + msg = str(cm.exception) + self.assertIn("Expected percentage like 50% but got '50'", msg) + self.assertNotIn("please write '50%%' to pass in '50%'", msg) + finally: + sys.platform = orig_platform + + def test_parse_invalid_percentage_non_numeric(self): + with self.assertRaises(typer.BadParameter) as cm: + parse_percentage("abc%") + msg = str(cm.exception) + self.assertIn("Expected percentage like 50% but got 'abc%'", msg) + + def test_percentage_class_methods(self): + pct = Percentage(0.75) + self.assertEqual(str(pct), "75.0%") + self.assertEqual(float(pct), 0.75) + + +class DurationTest(TestCase): + def test_convert_to_seconds(self): + self.assertEqual(convert_to_seconds('30s'), 30) + self.assertEqual(convert_to_seconds('5m'), 300) + self.assertEqual(convert_to_seconds('1h30m'), 5400) + self.assertEqual(convert_to_seconds('1d10h15m'), 123300) + self.assertEqual(convert_to_seconds('15m 1d 10h'), 123300) + self.assertEqual(convert_to_seconds('1w'), 604800) # 7 days + + # Test numeric only + self.assertEqual(convert_to_seconds('3600'), 3600) + + def test_convert_to_seconds_invalid(self): + with self.assertRaises(ValueError): + convert_to_seconds('1h30k') + + def test_parse_duration(self): + duration = parse_duration("30s") + self.assertIsInstance(duration, Duration) + self.assertEqual(duration.seconds, 30) + self.assertEqual(float(duration), 30) + self.assertEqual(str(duration), "30.0s") + + def test_parse_duration_invalid(self): + # Note: convert_to_seconds returns 0.0 for invalid input instead of raising ValueError + # So parse_duration returns Duration(0.0) for invalid input + duration = parse_duration("invalid") + self.assertEqual(duration.seconds, 0.0) + + +class KeyValueTest(TestCase): + def test_parse_key_value_equals(self): + kv = parse_key_value("key=value") + self.assertIsInstance(kv, KeyValue) + self.assertEqual(kv.key, "key") + self.assertEqual(kv.value, "value") + self.assertEqual(str(kv), "key=value") + + # Test tuple-like behavior + self.assertEqual(kv[0], "key") + self.assertEqual(kv[1], "value") + self.assertEqual(list(kv), ["key", "value"]) + + def test_parse_key_value_colon(self): + kv = parse_key_value("key:value") + self.assertEqual(kv.key, "key") + self.assertEqual(kv.value, "value") + + def test_parse_key_value_with_spaces(self): + kv = parse_key_value(" key = value ") + self.assertEqual(kv.key, "key") + self.assertEqual(kv.value, "value") + + def test_parse_key_value_with_multiple_delimiters(self): + # Should split on first occurrence only + kv = parse_key_value("key=value=extra") + self.assertEqual(kv.key, "key") + self.assertEqual(kv.value, "value=extra") + + def test_parse_key_value_invalid(self): + with self.assertRaises(typer.BadParameter) as cm: + parse_key_value("invalid") + msg = str(cm.exception) + self.assertIn("Expected a key-value pair formatted as --option key=value, but got 'invalid'", msg) + + def test_validate_key_value_compat(self): + # Test backward compatibility function + result = validate_key_value("key=value") + self.assertEqual(result, ("key", "value")) + + def test_key_value_compat_function(self): + # Test the KEY_VALUE constant + result = KEY_VALUE("key=value") + self.assertEqual(result, ("key", "value")) + + +class FractionTest(TestCase): + def test_parse_fraction(self): + frac = parse_fraction("3/4") + self.assertIsInstance(frac, Fraction) + self.assertEqual(frac.numerator, 3) + self.assertEqual(frac.denominator, 4) + self.assertEqual(str(frac), "3/4") + self.assertEqual(float(frac), 0.75) + + # Test tuple-like behavior + self.assertEqual(frac[0], 3) + self.assertEqual(frac[1], 4) + self.assertEqual(list(frac), [3, 4]) + + def test_parse_fraction_with_spaces(self): + frac = parse_fraction(" 1 / 2 ") + self.assertEqual(frac.numerator, 1) + self.assertEqual(frac.denominator, 2) + + def test_parse_fraction_invalid(self): + with self.assertRaises(typer.BadParameter) as cm: + parse_fraction("invalid") + msg = str(cm.exception) + self.assertIn("Expected fraction like 1/2 but got 'invalid'", msg) + + def test_parse_fraction_invalid_numbers(self): + with self.assertRaises(typer.BadParameter): + parse_fraction("a/b") + + +class DateTimeWithTimezoneTest(TestCase): + def test_parse_datetime_with_timezone(self): + dt_str = "2023-10-01 12:00:00+00:00" + dt_obj = parse_datetime_with_timezone(dt_str) + self.assertIsInstance(dt_obj, DateTimeWithTimezone) + self.assertEqual(dt_obj.dt.year, 2023) + self.assertEqual(dt_obj.dt.month, 10) + self.assertEqual(dt_obj.dt.day, 1) + self.assertEqual(dt_obj.dt.hour, 12) + # dateutil.parser creates tzutc() which is equivalent to but not equal to timezone.utc + self.assertEqual(dt_obj.dt.utcoffset(), timezone.utc.utcoffset(None)) + + def test_parse_datetime_without_timezone(self): + dt_str = "2023-10-01 12:00:00" + dt_obj = parse_datetime_with_timezone(dt_str) + self.assertEqual(dt_obj.dt.tzinfo, tzlocal()) + + def test_parse_datetime_iso_format(self): + dt_str = "2023-10-01T20:00:00Z" + dt_obj = parse_datetime_with_timezone(dt_str) + # dateutil.parser creates tzutc() which is equivalent to but not equal to timezone.utc + self.assertEqual(dt_obj.dt.utcoffset(), timezone.utc.utcoffset(None)) + + def test_parse_datetime_invalid(self): + with self.assertRaises(typer.BadParameter) as cm: + parse_datetime_with_timezone("invalid") + msg = str(cm.exception) + self.assertIn("Expected datetime like 2023-10-01T12:00:00 but got 'invalid'", msg) + + def test_datetime_with_timezone_methods(self): + dt_obj = parse_datetime_with_timezone("2023-10-01T12:00:00Z") + self.assertEqual(dt_obj.datetime(), dt_obj.dt) + # Test string representation + self.assertIn("2023-10-01T12:00:00", str(dt_obj)) + + def test_validate_datetime_with_tz_compat(self): + # Test backward compatibility function + result = validate_datetime_with_tz("2023-10-01T12:00:00Z") + self.assertIsInstance(result, datetime.datetime) + # dateutil.parser creates tzutc() which is equivalent to but not equal to timezone.utc + self.assertEqual(result.utcoffset(), timezone.utc.utcoffset(None)) + + def test_datetime_with_tz_compat_function(self): + # Test the DATETIME_WITH_TZ constant + result = DATETIME_WITH_TZ("2023-10-01T12:00:00Z") + self.assertIsInstance(result, datetime.datetime) + + def test_validate_past_datetime(self): + # Test with None + self.assertIsNone(validate_past_datetime(None)) + + # Test with past datetime + past_dt = datetime.datetime(2020, 1, 1, tzinfo=tzlocal()) + self.assertEqual(validate_past_datetime(past_dt), past_dt) + + # Test with future datetime + future_dt = datetime.datetime(2030, 1, 1, tzinfo=tzlocal()) + with self.assertRaises(typer.BadParameter) as cm: + validate_past_datetime(future_dt) + msg = str(cm.exception) + self.assertIn("The provided timestamp must be in the past", msg) + + # Test with non-datetime object + with self.assertRaises(typer.BadParameter) as cm: + validate_past_datetime("not a datetime") + msg = str(cm.exception) + self.assertIn("Expected a datetime object", msg) + + +class EmojiTest(TestCase): + def test_emoji_function(self): + # Test with fallback + result = emoji("🎉", "!") + self.assertIn(result, ["🎉", "!"]) # Depends on system capability + + # Test without fallback + result = emoji("🎉") + self.assertIn(result, ["🎉", ""]) # Depends on system capability + + def test_emoji_constant(self): + # EMOJI should be a boolean + self.assertIsInstance(EMOJI, bool)