| 1 | """CLI parsing and validation for --competitors / --competitors-list.""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | import io |
| 6 | import unittest |
| 7 | from contextlib import redirect_stderr |
| 8 | |
| 9 | import last30days as cli |
| 10 | |
| 11 | |
| 12 | def _parse(*argv: str): |
| 13 | parser = cli.build_parser() |
| 14 | args, _extra = parser.parse_known_args(argv) |
| 15 | return args |
| 16 | |
| 17 | |
| 18 | class CompetitorsCliTests(unittest.TestCase): |
| 19 | def test_flag_absent_returns_disabled(self): |
| 20 | args = _parse("Kanye West") |
| 21 | enabled, count, explicit = cli.resolve_competitors_args(args) |
| 22 | self.assertFalse(enabled) |
| 23 | self.assertEqual(count, 0) |
| 24 | self.assertEqual(explicit, []) |
| 25 | |
| 26 | def test_bare_flag_defaults_to_two(self): |
| 27 | args = _parse("Kanye West", "--competitors") |
| 28 | enabled, count, explicit = cli.resolve_competitors_args(args) |
| 29 | self.assertTrue(enabled) |
| 30 | self.assertEqual(count, 2) |
| 31 | self.assertEqual(explicit, []) |
| 32 | |
| 33 | def test_explicit_three_still_supported(self): |
| 34 | args = _parse("OpenAI", "--competitors", "3") |
| 35 | enabled, count, _explicit = cli.resolve_competitors_args(args) |
| 36 | self.assertTrue(enabled) |
| 37 | self.assertEqual(count, 3) |
| 38 | |
| 39 | def test_explicit_count(self): |
| 40 | args = _parse("OpenAI", "--competitors", "4") |
| 41 | enabled, count, explicit = cli.resolve_competitors_args(args) |
| 42 | self.assertTrue(enabled) |
| 43 | self.assertEqual(count, 4) |
| 44 | self.assertEqual(explicit, []) |
| 45 | |
| 46 | def test_explicit_list_preferred_over_discovery(self): |
| 47 | args = _parse( |
| 48 | "OpenAI", |
| 49 | "--competitors", |
| 50 | "--competitors-list", |
| 51 | "Anthropic,xAI,Google Gemini", |
| 52 | ) |
| 53 | enabled, count, explicit = cli.resolve_competitors_args(args) |
| 54 | self.assertTrue(enabled) |
| 55 | self.assertEqual(count, 3) |
| 56 | self.assertEqual(explicit, ["Anthropic", "xAI", "Google Gemini"]) |
| 57 | |
| 58 | def test_explicit_list_without_flag_implies_enabled(self): |
| 59 | args = _parse("OpenAI", "--competitors-list", "Anthropic,xAI") |
| 60 | enabled, count, explicit = cli.resolve_competitors_args(args) |
| 61 | self.assertTrue(enabled) |
| 62 | self.assertEqual(count, 2) |
| 63 | self.assertEqual(explicit, ["Anthropic", "xAI"]) |
| 64 | |
| 65 | def test_list_whitespace_normalized(self): |
| 66 | args = _parse("OpenAI", "--competitors-list", " Anthropic , xAI , Gemini ") |
| 67 | _enabled, count, explicit = cli.resolve_competitors_args(args) |
| 68 | self.assertEqual(count, 3) |
| 69 | self.assertEqual(explicit, ["Anthropic", "xAI", "Gemini"]) |
| 70 | |
| 71 | def test_zero_count_rejected(self): |
| 72 | args = _parse("Topic", "--competitors", "0") |
| 73 | with self.assertRaises(SystemExit) as cm, redirect_stderr(io.StringIO()) as err: |
| 74 | cli.resolve_competitors_args(args) |
| 75 | self.assertEqual(cm.exception.code, 2) |
| 76 | self.assertIn("--competitors must be >= 1", err.getvalue()) |
| 77 | |
| 78 | def test_negative_count_rejected(self): |
| 79 | args = _parse("Topic", "--competitors", "-1") |
| 80 | with self.assertRaises(SystemExit), redirect_stderr(io.StringIO()): |
| 81 | cli.resolve_competitors_args(args) |
| 82 | |
| 83 | def test_over_max_count_clamps_with_warning(self): |
| 84 | args = _parse("Topic", "--competitors", "99") |
| 85 | err = io.StringIO() |
| 86 | with redirect_stderr(err): |
| 87 | enabled, count, explicit = cli.resolve_competitors_args(args) |
| 88 | self.assertTrue(enabled) |
| 89 | self.assertEqual(count, cli.COMPETITORS_MAX) |
| 90 | self.assertEqual(explicit, []) |
| 91 | self.assertIn("clamping", err.getvalue()) |
| 92 | |
| 93 | def test_overlong_list_clamps_with_warning(self): |
| 94 | args = _parse( |
| 95 | "Topic", |
| 96 | "--competitors-list", |
| 97 | "A,B,C,D,E,F,G,H", |
| 98 | ) |
| 99 | err = io.StringIO() |
| 100 | with redirect_stderr(err): |
| 101 | enabled, count, explicit = cli.resolve_competitors_args(args) |
| 102 | self.assertTrue(enabled) |
| 103 | self.assertEqual(count, cli.COMPETITORS_MAX) |
| 104 | self.assertEqual(len(explicit), cli.COMPETITORS_MAX) |
| 105 | self.assertIn("clamping to", err.getvalue()) |
| 106 | |
| 107 | def test_list_count_mismatch_warns(self): |
| 108 | args = _parse( |
| 109 | "Topic", |
| 110 | "--competitors", |
| 111 | "5", |
| 112 | "--competitors-list", |
| 113 | "A,B", |
| 114 | ) |
| 115 | err = io.StringIO() |
| 116 | with redirect_stderr(err): |
| 117 | enabled, count, explicit = cli.resolve_competitors_args(args) |
| 118 | self.assertTrue(enabled) |
| 119 | self.assertEqual(count, 2) |
| 120 | self.assertEqual(explicit, ["A", "B"]) |
| 121 | self.assertIn("--competitors=5 ignored", err.getvalue()) |
| 122 | |
| 123 | def test_empty_list_rejected(self): |
| 124 | args = _parse("Topic", "--competitors-list", ",, ,") |
| 125 | with self.assertRaises(SystemExit) as cm, redirect_stderr(io.StringIO()): |
| 126 | cli.resolve_competitors_args(args) |
| 127 | self.assertEqual(cm.exception.code, 2) |
| 128 | |
| 129 | if __name__ == "__main__": |
| 130 | unittest.main() |
| 131 |