@@ -299,3 +299,241 @@ def test_uses_local_ip(self):
299299 with patch .object (h , "_get_local_ip" , return_value = "172.16.0.5" ):
300300 url = h ._get_log_viewer_url ()
301301 assert "172.16.0.5" in url
302+
303+
304+ # ---------------------------------------------------------------------------
305+ # stop() — error path
306+ # ---------------------------------------------------------------------------
307+
308+
309+ @pytest .mark .unit
310+ class TestLogStreamHandlerStopError :
311+ def test_logs_error_when_http_server_stop_raises (self ):
312+ h , mock_srv = _make_handler ()
313+ h .is_running = True
314+ mock_srv .stop .side_effect = RuntimeError ("stop failed" )
315+ with patch ("th_cli.test_run.log_stream_handler.logger" ) as mock_logger :
316+ h .stop ()
317+ mock_logger .error .assert_called_once_with ("Error stopping log stream handler: stop failed" )
318+
319+
320+ # ---------------------------------------------------------------------------
321+ # init_tree()
322+ # ---------------------------------------------------------------------------
323+
324+
325+ @pytest .mark .unit
326+ class TestInitTree :
327+ def test_noop_when_not_running (self ):
328+ h , _ = _make_handler ()
329+ h .is_running = False
330+ with patch .object (h , "_broadcast" ) as mock_bcast :
331+ h .init_tree (MagicMock ())
332+ mock_bcast .assert_not_called ()
333+
334+ def test_broadcasts_tree_init_event (self ):
335+ h , _ = _make_handler ()
336+ h .is_running = True
337+ client_q = queue .Queue ()
338+ h ._clients .add (client_q )
339+ run = MagicMock ()
340+ run .title = "My Run"
341+ run .test_suite_executions = []
342+ with patch ("th_cli.test_run.log_stream_handler.logger" ):
343+ h .init_tree (run )
344+ event = client_q .get_nowait ()
345+ assert event ["type" ] == "tree_init"
346+ assert "data" in event
347+
348+ def test_updates_tree_state_in_place (self ):
349+ h , _ = _make_handler ()
350+ h .is_running = True
351+ run = MagicMock ()
352+ run .title = "Run Title"
353+ run .test_suite_executions = []
354+ with patch ("th_cli.test_run.log_stream_handler.logger" ):
355+ h .init_tree (run )
356+ assert h .tree_state .get ("title" ) == "Run Title"
357+
358+ def test_handles_build_exception_silently (self ):
359+ h , _ = _make_handler ()
360+ h .is_running = True
361+ with patch .object (h , "_build_tree" , side_effect = RuntimeError ("fail" )):
362+ with patch ("th_cli.test_run.log_stream_handler.logger" ) as mock_logger :
363+ h .init_tree (MagicMock ())
364+ mock_logger .debug .assert_called_once_with ("Error initialising tree: fail" )
365+
366+
367+ # ---------------------------------------------------------------------------
368+ # update_tree_node()
369+ # ---------------------------------------------------------------------------
370+
371+
372+ @pytest .mark .unit
373+ class TestUpdateTreeNode :
374+ def test_noop_when_not_running (self ):
375+ h , _ = _make_handler ()
376+ h .is_running = False
377+ with patch .object (h , "_broadcast" ) as mock_bcast :
378+ h .update_tree_node (state = "passed" )
379+ mock_bcast .assert_not_called ()
380+
381+ def test_run_level_update (self ):
382+ h , _ = _make_handler ()
383+ h .is_running = True
384+ h .tree_state = {"state" : "pending" , "suites" : []}
385+ client_q = queue .Queue ()
386+ h ._clients .add (client_q )
387+ h .update_tree_node (state = "executing" )
388+ assert h .tree_state ["state" ] == "executing"
389+ event = client_q .get_nowait ()
390+ assert event ["node_type" ] == "run"
391+ assert event ["state" ] == "executing"
392+
393+ def test_suite_level_update (self ):
394+ h , _ = _make_handler ()
395+ h .is_running = True
396+ h .tree_state = {"suites" : [{"state" : "pending" , "cases" : []}]}
397+ client_q = queue .Queue ()
398+ h ._clients .add (client_q )
399+ h .update_tree_node (state = "passed" , suite_idx = 0 )
400+ assert h .tree_state ["suites" ][0 ]["state" ] == "passed"
401+ event = client_q .get_nowait ()
402+ assert event ["node_type" ] == "suite"
403+
404+ def test_case_level_update (self ):
405+ h , _ = _make_handler ()
406+ h .is_running = True
407+ h .tree_state = {"suites" : [{"cases" : [{"state" : "pending" , "steps" : []}]}]}
408+ client_q = queue .Queue ()
409+ h ._clients .add (client_q )
410+ h .update_tree_node (state = "failed" , suite_idx = 0 , case_idx = 0 )
411+ assert h .tree_state ["suites" ][0 ]["cases" ][0 ]["state" ] == "failed"
412+ event = client_q .get_nowait ()
413+ assert event ["node_type" ] == "case"
414+
415+ def test_step_level_update (self ):
416+ h , _ = _make_handler ()
417+ h .is_running = True
418+ h .tree_state = {"suites" : [{"cases" : [{"steps" : [{"state" : "pending" }]}]}]}
419+ client_q = queue .Queue ()
420+ h ._clients .add (client_q )
421+ h .update_tree_node (state = "passed" , suite_idx = 0 , case_idx = 0 , step_idx = 0 )
422+ assert h .tree_state ["suites" ][0 ]["cases" ][0 ]["steps" ][0 ]["state" ] == "passed"
423+ event = client_q .get_nowait ()
424+ assert event ["node_type" ] == "step"
425+
426+ def test_tolerates_index_error_on_invalid_tree_state (self ):
427+ h , _ = _make_handler ()
428+ h .is_running = True
429+ h .tree_state = {} # missing expected structure
430+ h .update_tree_node (state = "passed" , suite_idx = 99 ) # must not raise
431+
432+
433+ # ---------------------------------------------------------------------------
434+ # _build_tree()
435+ # ---------------------------------------------------------------------------
436+
437+
438+ @pytest .mark .unit
439+ class TestBuildTree :
440+ def test_minimal_run_no_suites (self ):
441+ h , _ = _make_handler ()
442+ run = MagicMock ()
443+ run .title = "My Run"
444+ run .state = MagicMock (value = "pending" )
445+ run .test_suite_executions = []
446+ tree = h ._build_tree (run )
447+ assert tree ["title" ] == "My Run"
448+ assert tree ["suites" ] == []
449+
450+ def test_builds_suite_case_step_hierarchy (self ):
451+ h , _ = _make_handler ()
452+
453+ step = MagicMock ()
454+ step .title = "Step 1"
455+ step .state = MagicMock (value = "pending" )
456+
457+ case = MagicMock ()
458+ case .test_case_metadata .title = "Case 1"
459+ case .test_case_metadata .public_id = "TC-1"
460+ case .state = MagicMock (value = "pending" )
461+ case .test_step_executions = [step ]
462+
463+ suite = MagicMock ()
464+ suite .test_suite_metadata .title = "Suite 1"
465+ suite .state = MagicMock (value = "pending" )
466+ suite .test_case_executions = [case ]
467+
468+ run = MagicMock ()
469+ run .title = "Run"
470+ run .state = MagicMock (value = "executing" )
471+ run .test_suite_executions = [suite ]
472+
473+ tree = h ._build_tree (run )
474+ assert len (tree ["suites" ]) == 1
475+ assert tree ["suites" ][0 ]["title" ] == "Suite 1"
476+ assert len (tree ["suites" ][0 ]["cases" ]) == 1
477+ assert len (tree ["suites" ][0 ]["cases" ][0 ]["steps" ]) == 1
478+ assert tree ["suites" ][0 ]["cases" ][0 ]["steps" ][0 ]["title" ] == "Step 1"
479+
480+ def test_falls_back_to_default_titles_when_metadata_none (self ):
481+ h , _ = _make_handler ()
482+
483+ case = MagicMock ()
484+ case .test_case_metadata = None
485+ case .state = MagicMock (value = "pending" )
486+ case .test_step_executions = []
487+
488+ suite = MagicMock ()
489+ suite .test_suite_metadata = None
490+ suite .state = MagicMock (value = "pending" )
491+ suite .test_case_executions = [case ]
492+
493+ run = MagicMock ()
494+ run .title = "Run"
495+ run .state = MagicMock (value = "pending" )
496+ run .test_suite_executions = [suite ]
497+
498+ tree = h ._build_tree (run )
499+ assert "Suite 0" in tree ["suites" ][0 ]["title" ]
500+ assert "Case 0" in tree ["suites" ][0 ]["cases" ][0 ]["title" ]
501+
502+
503+ # ---------------------------------------------------------------------------
504+ # _get_state()
505+ # ---------------------------------------------------------------------------
506+
507+
508+ @pytest .mark .unit
509+ class TestGetState :
510+ def test_returns_pending_when_state_is_none (self ):
511+ h , _ = _make_handler ()
512+ obj = MagicMock ()
513+ obj .state = None
514+ assert h ._get_state (obj ) == "pending"
515+
516+ def test_returns_state_value_for_enum_like_object (self ):
517+ h , _ = _make_handler ()
518+ obj = MagicMock ()
519+ obj .state = MagicMock (value = "executing" )
520+ assert h ._get_state (obj ) == "executing"
521+
522+ def test_returns_str_state_when_no_value_attr (self ):
523+ h , _ = _make_handler ()
524+ obj = MagicMock ()
525+ obj .state = "passed" # plain string — no .value attribute
526+ assert h ._get_state (obj ) == "passed"
527+
528+ def test_returns_pending_on_exception (self ):
529+ h , _ = _make_handler ()
530+
531+ class BrokenState :
532+ @property
533+ def value (self ):
534+ raise RuntimeError ("broken" )
535+
536+ obj = MagicMock ()
537+ obj .state = BrokenState ()
538+ assert h ._get_state (obj ) == "pending"
539+
0 commit comments