返回 last30days-skill
test_planner_v3.py
根目录 / tests / test_planner_v3.py
1 import unittest
2
3 from lib import planner
4
5
6 class PlannerV3Tests(unittest.TestCase):
7 def test_external_plan_rejects_valid_json_with_wrong_structure(self):
8 with self.assertRaisesRegex(ValueError, "intent"):
9 planner.validate_external_plan({"queries": {"web": ["Berlin"]}})
10
11 def test_external_plan_accepts_documented_shape_without_source_weights(self):
12 planner.validate_external_plan(
13 {
14 "intent": "breaking_news",
15 "freshness_mode": "strict_recent",
16 "cluster_mode": "story",
17 "subqueries": [
18 {
19 "label": "primary",
20 "search_query": "kanye west",
21 "ranking_query": "What happened with Kanye West?",
22 "sources": ["reddit", "x"],
23 "weight": 1.0,
24 }
25 ],
26 }
27 )
28
29 def test_external_plan_rejects_non_numeric_weight(self):
30 base_plan = {
31 "intent": "breaking_news",
32 "freshness_mode": "strict_recent",
33 "cluster_mode": "story",
34 "subqueries": [
35 {
36 "label": "primary",
37 "search_query": "kanye west",
38 "ranking_query": "What happened with Kanye West?",
39 "sources": ["reddit", "x"],
40 "weight": 1.0,
41 }
42 ],
43 }
44 for invalid_weight in ("heavy", True):
45 with self.subTest(subquery_weight=invalid_weight):
46 invalid_plan = dict(base_plan)
47 invalid_plan["subqueries"] = [
48 dict(base_plan["subqueries"][0], weight=invalid_weight)
49 ]
50 with self.assertRaisesRegex(ValueError, "weight"):
51 planner.validate_external_plan(invalid_plan)
52
53 invalid_source_weight = dict(base_plan, source_weights={"x": True})
54 with self.assertRaisesRegex(ValueError, "source_weights"):
55 planner.validate_external_plan(invalid_source_weight)
56
57 def test_default_how_to_expands_past_llm_narrow_source_weights(self):
58 raw = {
59 "intent": "how_to",
60 "freshness_mode": "balanced_recent",
61 "cluster_mode": "workflow",
62 "source_weights": {"hackernews": 0.7, "reddit": 0.3},
63 "subqueries": [
64 {
65 "label": "primary",
66 "search_query": "deploy app to Fly.io guide",
67 "ranking_query": "How do I deploy an app to Fly.io?",
68 "sources": ["hackernews"],
69 "weight": 1.0,
70 }
71 ],
72 }
73 plan = planner._sanitize_plan(
74 raw,
75 "how to deploy on Fly.io",
76 ["reddit", "x", "youtube", "hackernews"],
77 None,
78 "default",
79 )
80 sources = plan.subqueries[0].sources
81 # how_to capability routing selects video + discussion
82 self.assertIn("reddit", sources)
83 self.assertIn("youtube", sources)
84 self.assertIn("reddit", plan.source_weights)
85 self.assertIn("youtube", plan.source_weights)
86 self.assertEqual("evergreen_ok", plan.freshness_mode)
87
88 def test_comparison_uses_deterministic_plan_and_preserves_entities(self):
89 plan = planner.plan_query(
90 topic="openclaw vs nanoclaw vs ironclaw",
91 available_sources=["reddit", "x", "youtube", "hackernews", "polymarket"],
92 requested_sources=None,
93 depth="default",
94 provider=object(),
95 model="ignored",
96 )
97 self.assertEqual("comparison", plan.intent)
98 self.assertEqual(["deterministic-comparison-plan"], plan.notes)
99 self.assertEqual(4, len(plan.subqueries))
100 joined_queries = "\n".join(subquery.search_query for subquery in plan.subqueries).lower()
101 self.assertIn("openclaw", joined_queries)
102 self.assertIn("nanoclaw", joined_queries)
103 self.assertIn("ironclaw", joined_queries)
104
105 def test_fallback_plan_emits_dual_query_fields(self):
106 plan = planner.plan_query(
107 topic="codex vs claude code",
108 available_sources=["reddit", "x"],
109 requested_sources=None,
110 depth="default",
111 provider=None,
112 model=None,
113 )
114 self.assertEqual("comparison", plan.intent)
115 self.assertGreaterEqual(len(plan.subqueries), 2)
116 for subquery in plan.subqueries:
117 self.assertTrue(subquery.search_query)
118 self.assertTrue(subquery.ranking_query)
119
120 def test_factual_topic_uses_no_cluster_mode(self):
121 plan = planner.plan_query(
122 topic="what is the parameter count of claude code",
123 available_sources=["reddit", "hackernews"],
124 requested_sources=None,
125 depth="default",
126 provider=None,
127 model=None,
128 )
129 self.assertEqual("factual", plan.intent)
130 self.assertEqual("none", plan.cluster_mode)
131
132 def test_quick_mode_collapses_fallback_to_single_subquery(self):
133 plan = planner.plan_query(
134 topic="codex vs claude code",
135 available_sources=["reddit", "x"],
136 requested_sources=None,
137 depth="quick",
138 provider=None,
139 model=None,
140 )
141 self.assertEqual("comparison", plan.intent)
142 self.assertEqual(1, len(plan.subqueries))
143 self.assertEqual(["reddit", "x"], plan.subqueries[0].sources)
144
145 def test_operator_plan_keeps_per_subquery_sources_at_default_and_deep(self):
146 # Issue #1073: operator --plan sources must not be replaced with the
147 # full available list outside --quick.
148 raw = {
149 "intent": "opinion",
150 "freshness_mode": "balanced_recent",
151 "cluster_mode": "debate",
152 "subqueries": [{
153 "label": "primary",
154 "search_query": "late diagnosed autism adults",
155 "ranking_query": "late diagnosed autism adults",
156 "sources": ["reddit", "x", "youtube"],
157 "weight": 1.0,
158 }],
159 }
160 avail = ["reddit", "x", "youtube", "hackernews", "polymarket", "github"]
161 for depth in ("default", "deep"):
162 with self.subTest(depth=depth):
163 plan = planner._sanitize_plan(
164 raw,
165 "late diagnosed autism adults",
166 avail,
167 None,
168 depth,
169 honor_plan_sources=True,
170 )
171 self.assertEqual(["reddit", "x", "youtube"], plan.subqueries[0].sources)
172
173 def test_llm_plan_still_expands_narrow_sources_at_default_and_deep(self):
174 # Engine-internal LLM plans keep the expansion that lets fusion
175 # decide quality. Same snippet as #1073, without honor_plan_sources.
176 raw = {
177 "intent": "opinion",
178 "freshness_mode": "balanced_recent",
179 "cluster_mode": "debate",
180 "subqueries": [{
181 "label": "primary",
182 "search_query": "late diagnosed autism adults",
183 "ranking_query": "late diagnosed autism adults",
184 "sources": ["reddit", "x", "youtube"],
185 "weight": 1.0,
186 }],
187 }
188 avail = ["reddit", "x", "youtube", "hackernews", "polymarket", "github"]
189 for depth in ("default", "deep"):
190 with self.subTest(depth=depth):
191 plan = planner._sanitize_plan(
192 raw,
193 "late diagnosed autism adults",
194 avail,
195 None,
196 depth,
197 )
198 self.assertEqual(avail, plan.subqueries[0].sources)
199
200 def test_operator_plan_preserves_distinct_per_subquery_source_lists(self):
201 raw = {
202 "intent": "breaking_news",
203 "freshness_mode": "strict_recent",
204 "cluster_mode": "story",
205 "subqueries": [
206 {
207 "label": "primary",
208 "search_query": "kanye west",
209 "ranking_query": "What happened with Kanye West?",
210 "sources": ["reddit", "x", "youtube"],
211 "weight": 1.0,
212 },
213 {
214 "label": "album",
215 "search_query": "kanye west bully album",
216 "ranking_query": "How was Kanye West's BULLY album received?",
217 "sources": ["youtube", "reddit"],
218 "weight": 0.8,
219 },
220 ],
221 }
222 avail = ["reddit", "x", "youtube", "hackernews", "polymarket", "github"]
223 plan = planner._sanitize_plan(
224 raw,
225 "Kanye West",
226 avail,
227 None,
228 "default",
229 honor_plan_sources=True,
230 )
231 by_label = {sq.label: sq.sources for sq in plan.subqueries}
232 self.assertEqual(["reddit", "x", "youtube"], by_label["primary"])
233 self.assertEqual(["youtube", "reddit"], by_label["album"])
234
235 def test_quick_mode_prioritizes_explicit_requested_sources_within_cap(self):
236 raw = {
237 "intent": "product",
238 "freshness_mode": "balanced_recent",
239 "cluster_mode": "debate",
240 "subqueries": [
241 {
242 "label": "primary",
243 "search_query": "AI coding agents",
244 "ranking_query": "What are people saying about AI coding agents?",
245 "sources": ["reddit", "youtube", "grounding", "digg"],
246 "weight": 1.0,
247 }
248 ],
249 }
250 plan = planner._sanitize_plan(
251 raw,
252 "AI coding agents",
253 ["reddit", "youtube", "grounding", "digg"],
254 ["digg", "reddit", "youtube", "grounding"],
255 "quick",
256 )
257 self.assertIn("digg", plan.subqueries[0].sources)
258 self.assertLessEqual(len(plan.subqueries[0].sources), 2)
259
260 def test_quick_mode_preserves_explicit_requested_sources_in_fallback_plan(self):
261 plan = planner.plan_query(
262 topic="AI coding agents",
263 available_sources=["reddit", "youtube", "github"],
264 requested_sources=["reddit", "github"],
265 depth="quick",
266 provider=None,
267 model=None,
268 )
269 self.assertIn("github", plan.subqueries[0].sources)
270
271 def test_default_comparison_uses_all_capable_sources(self):
272 plan = planner.plan_query(
273 topic="codex vs claude code",
274 available_sources=["reddit", "x", "youtube", "hackernews", "polymarket"],
275 requested_sources=None,
276 depth="default",
277 provider=None,
278 model=None,
279 )
280 self.assertEqual("comparison", plan.intent)
281 for subquery in plan.subqueries:
282 # Default depth should not artificially cap sources
283 self.assertGreaterEqual(len(subquery.sources), 4)
284
285 def test_default_how_to_keeps_youtube_in_source_mix(self):
286 plan = planner.plan_query(
287 topic="how to deploy remotion animations for claude code",
288 available_sources=["reddit", "x", "youtube", "hackernews"],
289 requested_sources=None,
290 depth="default",
291 provider=None,
292 model=None,
293 )
294 self.assertEqual("how_to", plan.intent)
295 sources = plan.subqueries[0].sources
296 self.assertIn("youtube", sources)
297 self.assertIn("reddit", sources)
298
299 def test_how_to_sources_includes_capability_matched_extras(self):
300 """how_to routing should include additional sources beyond the core ones."""
301 plan = planner.plan_query(
302 topic="how to deploy on Fly.io",
303 available_sources=["reddit", "tiktok", "instagram", "youtube", "hackernews"],
304 requested_sources=None,
305 depth="default",
306 provider=None,
307 model=None,
308 )
309 self.assertEqual("how_to", plan.intent)
310 sources = plan.subqueries[0].sources
311 self.assertIn("youtube", sources)
312 self.assertIn("reddit", sources)
313 # Additional capability-matched sources should also be included
314 self.assertGreater(len(sources), 2,
315 f"how_to should include >2 sources, got {len(sources)}: {sources}")
316
317 def test_ncaa_tournament_is_breaking_news(self):
318 intent = planner._infer_intent("NCAA tournament brackets")
319 self.assertEqual("breaking_news", intent)
320
321 def test_march_madness_is_breaking_news(self):
322 intent = planner._infer_intent("2026 March Madness")
323 self.assertEqual("breaking_news", intent)
324
325 def test_factual_plan_has_at_most_2_subqueries(self):
326 plan = planner.plan_query(
327 topic="who acquired Wiz",
328 available_sources=["reddit", "x", "hackernews"],
329 requested_sources=None,
330 depth="default",
331 provider=None,
332 model=None,
333 )
334 self.assertEqual("factual", plan.intent)
335 self.assertLessEqual(len(plan.subqueries), 2)
336
337 def test_default_how_to_prefers_longform_video_over_shortform(self):
338 plan = planner.plan_query(
339 topic="how to deploy on Fly.io",
340 available_sources=["reddit", "tiktok", "instagram", "youtube", "hackernews"],
341 requested_sources=None,
342 depth="default",
343 provider=None,
344 model=None,
345 )
346 self.assertEqual("how_to", plan.intent)
347 sources = plan.subqueries[0].sources
348 # how_to routing should include youtube (longform) over tiktok/instagram
349 self.assertIn("youtube", sources)
350 self.assertIn("reddit", sources)
351
352 def test_product_plan_can_include_jobs_source(self):
353 plan = planner.plan_query(
354 topic="Listen Labs features",
355 available_sources=["reddit", "youtube", "jobs", "hackernews"],
356 requested_sources=None,
357 depth="default",
358 provider=None,
359 model=None,
360 )
361 self.assertEqual("product", plan.intent)
362 self.assertIn("jobs", plan.subqueries[0].sources)
363 self.assertGreater(plan.source_weights["jobs"], plan.source_weights["youtube"])
364
365 def test_prediction_includes_tiktok_and_instagram(self):
366 """TikTok and Instagram are no longer excluded from prediction intent."""
367 plan = planner.plan_query(
368 topic="odds of US recession 2026",
369 available_sources=["reddit", "x", "tiktok", "instagram", "youtube", "hackernews", "polymarket"],
370 requested_sources=None,
371 depth="default",
372 provider=None,
373 model=None,
374 )
375 self.assertEqual("prediction", plan.intent)
376 all_sources = set()
377 for subquery in plan.subqueries:
378 all_sources.update(subquery.sources)
379 self.assertIn("tiktok", all_sources)
380 self.assertIn("instagram", all_sources)
381
382 def test_opinion_includes_tiktok_and_instagram(self):
383 """TikTok and Instagram are no longer excluded from opinion intent."""
384 plan = planner.plan_query(
385 topic="thoughts on OpenAI Codex pricing",
386 available_sources=["reddit", "x", "tiktok", "instagram", "youtube", "hackernews"],
387 requested_sources=None,
388 depth="default",
389 provider=None,
390 model=None,
391 )
392 self.assertEqual("opinion", plan.intent)
393 all_sources = set()
394 for subquery in plan.subqueries:
395 all_sources.update(subquery.sources)
396 self.assertIn("tiktok", all_sources)
397 self.assertIn("instagram", all_sources)
398
399 def test_comparison_includes_polymarket(self):
400 """Polymarket should not be excluded from comparison intent plans."""
401 plan = planner.plan_query(
402 topic="Sam Altman vs Dario Amodei",
403 available_sources=["reddit", "x", "youtube", "hackernews", "polymarket"],
404 requested_sources=None,
405 depth="default",
406 provider=None,
407 model=None,
408 )
409 self.assertEqual("comparison", plan.intent)
410 all_sources = set()
411 for subquery in plan.subqueries:
412 all_sources.update(subquery.sources)
413 self.assertIn("polymarket", all_sources)
414
415 def test_polymarket_excluded_from_how_to_and_concept(self):
416 """Polymarket should remain excluded from how_to and concept intents."""
417 for topic, expected_intent in [
418 ("how to deploy on Fly.io", "how_to"),
419 ("explain transformer architecture", "concept"),
420 ]:
421 plan = planner.plan_query(
422 topic=topic,
423 available_sources=["reddit", "x", "youtube", "hackernews", "polymarket"],
424 requested_sources=None,
425 depth="default",
426 provider=None,
427 model=None,
428 )
429 self.assertEqual(expected_intent, plan.intent)
430 all_sources = set()
431 for subquery in plan.subqueries:
432 all_sources.update(subquery.sources)
433 self.assertNotIn("polymarket", all_sources,
434 f"polymarket should be excluded from {expected_intent}")
435
436 def test_opinion_includes_polymarket(self):
437 """Polymarket should not be excluded from opinion intent plans."""
438 plan = planner.plan_query(
439 topic="thoughts on OpenAI future",
440 available_sources=["reddit", "x", "youtube", "hackernews", "polymarket"],
441 requested_sources=None,
442 depth="default",
443 provider=None,
444 model=None,
445 )
446 self.assertEqual("opinion", plan.intent)
447 all_sources = set()
448 for subquery in plan.subqueries:
449 all_sources.update(subquery.sources)
450 self.assertIn("polymarket", all_sources)
451
452 def test_breaking_news_includes_tiktok_and_instagram(self):
453 plan = planner.plan_query(
454 topic="2026 March Madness",
455 available_sources=["reddit", "x", "tiktok", "instagram", "youtube", "hackernews"],
456 requested_sources=None,
457 depth="default",
458 provider=None,
459 model=None,
460 )
461 self.assertEqual("breaking_news", plan.intent)
462 all_sources = set()
463 for subquery in plan.subqueries:
464 all_sources.update(subquery.sources)
465 self.assertIn("tiktok", all_sources)
466 self.assertIn("instagram", all_sources)
467
468
469 class IntentModifierBreadthTests(unittest.TestCase):
470 """Unit 2: Topics with intent modifiers (use cases, workflows, examples,
471 review, comparison) must fan out across paraphrased subqueries rather
472 than echo the literal phrase. 2026-04-19 Hermes Agent Use Cases failure.
473 """
474
475 def test_max_subqueries_raised_to_5_for_how_to(self):
476 self.assertEqual(5, planner._max_subqueries("how_to"))
477
478 def test_max_subqueries_raised_to_5_for_opinion(self):
479 self.assertEqual(5, planner._max_subqueries("opinion"))
480
481 def test_max_subqueries_raised_to_5_for_product(self):
482 self.assertEqual(5, planner._max_subqueries("product"))
483
484 def test_max_subqueries_unchanged_for_comparison(self):
485 from lib import competitors
486 self.assertEqual(
487 competitors.COMPARISON_ENTITY_MAX + 1,
488 planner._max_subqueries("comparison"),
489 )
490
491 def test_max_subqueries_unchanged_for_factual_and_concept(self):
492 self.assertEqual(2, planner._max_subqueries("factual"))
493 self.assertEqual(2, planner._max_subqueries("concept"))
494
495 def test_has_intent_modifier_detects_use_cases(self):
496 self.assertTrue(planner._has_intent_modifier("Hermes Agent use cases"))
497 self.assertTrue(planner._has_intent_modifier("Hermes Agent Actual Use Cases"))
498
499 def test_has_intent_modifier_detects_workflows(self):
500 self.assertTrue(planner._has_intent_modifier("Claude Code workflows"))
501
502 def test_has_intent_modifier_detects_review_and_tutorial(self):
503 self.assertTrue(planner._has_intent_modifier("Ollama review"))
504 self.assertTrue(planner._has_intent_modifier("DSPy tutorial"))
505
506 def test_has_intent_modifier_false_for_bare_entity(self):
507 self.assertFalse(planner._has_intent_modifier("Kanye West"))
508 self.assertFalse(planner._has_intent_modifier("hermes agent"))
509
510 def test_fallback_fans_out_when_intent_modifier_present(self):
511 plan = planner.plan_query(
512 topic="Hermes Agent use cases",
513 available_sources=["reddit", "x", "youtube", "hackernews"],
514 requested_sources=None,
515 depth="default",
516 provider=None,
517 model=None,
518 )
519 # Expect at least 3 subqueries total (primary + fanout); cap is 5 for
520 # how_to/opinion/product/breaking_news. Label set should include at
521 # least one of the paraphrase labels.
522 labels = {sq.label for sq in plan.subqueries}
523 self.assertGreaterEqual(len(plan.subqueries), 3)
524 self.assertTrue(
525 labels & {"workflows", "production", "experience"},
526 f"Expected paraphrase labels in {labels}",
527 )
528
529 def test_fallback_does_not_fan_out_for_bare_entity(self):
530 plan = planner.plan_query(
531 topic="Kanye West",
532 available_sources=["reddit", "x", "grounding"],
533 requested_sources=None,
534 depth="default",
535 provider=None,
536 model=None,
537 )
538 # Bare entity without intent modifier should not trigger the paraphrase
539 # fanout (those labels are not in the plan).
540 labels = {sq.label for sq in plan.subqueries}
541 self.assertFalse(labels & {"workflows", "production", "experience"})
542
543 def test_prompt_includes_intent_modifier_rule(self):
544 prompt = planner._build_prompt(
545 topic="Hermes Agent use cases",
546 available_sources=["reddit", "x", "youtube"],
547 requested_sources=None,
548 depth="default",
549 )
550 self.assertIn("INTENT-MODIFIER HANDLING", prompt)
551 self.assertIn("use cases", prompt)
552 self.assertIn("STRIP that phrase", prompt)
553
554
555 class FallbackDefaultsTests(unittest.TestCase):
556 """Unit 3: Deterministic fallback defaults and keyword_query quoting.
557 2026-04-19 Hermes Agent Use Cases failure.
558 """
559
560 def test_unclassified_topic_defaults_to_concept_not_breaking_news(self):
561 # Prior default was "breaking_news" with strict_recent freshness,
562 # which biased against older relevant material on unfamiliar topics.
563 self.assertEqual("concept", planner._infer_intent("some unfamiliar topic"))
564 self.assertEqual("concept", planner._infer_intent("Hermes Agent"))
565
566 def test_recency_signals_still_break_out_to_breaking_news(self):
567 self.assertEqual("breaking_news", planner._infer_intent("trending AI tools"))
568 self.assertEqual("breaking_news", planner._infer_intent("what's happening today"))
569 self.assertEqual("breaking_news", planner._infer_intent("this week in AI"))
570
571 def test_specific_intents_still_classify_correctly(self):
572 # Regression: other regex branches still fire as before.
573 self.assertEqual("how_to", planner._infer_intent("how to deploy Docker"))
574 self.assertEqual("factual", planner._infer_intent("who acquired Wiz"))
575 self.assertEqual("opinion", planner._infer_intent("thoughts on OpenAI Codex"))
576 self.assertEqual("comparison", planner._infer_intent("Codex vs Claude Code"))
577
578 def test_keyword_query_quotes_only_title_cased_proper_nouns(self):
579 # "Hermes Agent" is a multi-word title-cased proper noun — keep quoted.
580 # "Use Cases" is also title-cased BUT we only quote the first 2
581 # title-cased compounds; the first extracted is "Hermes Agent".
582 search = planner._keyword_query("Hermes Agent use cases", "hermes agent")
583 self.assertIn('"Hermes Agent"', search)
584 # The old behavior quoted the entire typed topic; confirm it does not.
585 self.assertNotIn('"Hermes Agent Actual Use Cases"', search)
586
587 def test_keyword_query_does_not_quote_bare_lowercase_topic(self):
588 search = planner._keyword_query("kanye west bully", "kanye west bully")
589 # Lowercase topics have no title-cased compound to quote.
590 self.assertNotIn('"', search)
591
592 def test_fallback_logs_warning_when_no_provider(self):
593 import io
594 import contextlib
595 buf = io.StringIO()
596 with contextlib.redirect_stderr(buf):
597 planner.plan_query(
598 topic="Hermes Agent use cases",
599 available_sources=["reddit", "x"],
600 requested_sources=None,
601 depth="default",
602 provider=None,
603 model=None,
604 )
605 output = buf.getvalue()
606 # New language: "No --plan passed" + "YOU ARE the planner" +
607 # runtime enumeration. Unit 4 (2026-04-19) rewrite to stop the
608 # "no provider = no LLM = I need a key" misread.
609 self.assertIn("No --plan passed", output)
610 self.assertIn("YOU ARE the planner", output)
611 self.assertIn("you ARE the LLM", output)
612 # Runtime-agnostic: each supported runtime name should appear.
613 for runtime_name in ("Claude Code", "Codex", "Hermes", "Gemini"):
614 self.assertIn(runtime_name, output)
615 # The old misleading phrasing must NOT appear.
616 self.assertNotIn("No --plan and no LLM provider configured", output)
617
618 def test_fallback_does_not_log_new_warning_when_provider_present(self):
619 # When a provider is configured, the provider path runs; if it
620 # errors, we get the "LLM planning failed" message, NOT the
621 # "No --plan passed" guidance (which is specifically for the
622 # no-provider-no-plan caller path).
623 import io
624 import contextlib
625 buf = io.StringIO()
626
627 class _NoopProvider:
628 def generate_json(self, model, prompt):
629 raise ValueError("force fallback for test")
630
631 with contextlib.redirect_stderr(buf):
632 planner.plan_query(
633 topic="Kanye West",
634 available_sources=["reddit", "x"],
635 requested_sources=None,
636 depth="default",
637 provider=_NoopProvider(),
638 model="some-model",
639 )
640 output = buf.getvalue()
641 self.assertIn("LLM planning failed", output)
642 self.assertNotIn("No --plan passed", output)
643
644 if __name__ == "__main__":
645 unittest.main()
646
646 lines PYTHON