@@ -468,3 +468,240 @@ def test_tracking_topology_sorter_empty_graph() -> None:
468468 with pytest .raises (ValueError ) as excinfo :
469469 topo .get_available ()
470470 assert "topology is not active" in str (excinfo .value )
471+
472+
473+ def _build_graph (* edges : tuple [str , str , str ]) -> DependencyGraph :
474+ """Build a DependencyGraph from (parent, child, req_type) triples.
475+
476+ Use "ROOT" as parent name for top-level dependencies.
477+ """
478+ graph = DependencyGraph ()
479+ for parent_name , child_name , req_type_str in edges :
480+ parent_n = None if parent_name == "ROOT" else canonicalize_name (parent_name )
481+ parent_v = None if parent_name == "ROOT" else Version ("1.0" )
482+ graph .add_dependency (
483+ parent_name = parent_n ,
484+ parent_version = parent_v ,
485+ req_type = RequirementType (req_type_str ),
486+ req = Requirement (f"{ child_name } ==1.0" ),
487+ req_version = Version ("1.0" ),
488+ )
489+ return graph
490+
491+
492+ def test_remove_dependency_basic () -> None :
493+ """Removing a leaf node cleans it from nodes and parent's children."""
494+ graph = _build_graph (
495+ ("ROOT" , "a" , "toplevel" ),
496+ ("a" , "b" , "install" ),
497+ )
498+ assert "b==1.0" in graph .nodes
499+ assert len (graph .nodes ["a==1.0" ].children ) == 1
500+
501+ graph .remove_dependency (canonicalize_name ("b" ), Version ("1.0" ))
502+
503+ assert "b==1.0" not in graph .nodes
504+ assert len (graph .nodes ["a==1.0" ].children ) == 0
505+
506+
507+ def test_remove_dependency_cascades_orphans () -> None :
508+ """Removing a node recursively removes its orphaned descendants."""
509+ # ROOT -> a -> b -> c (linear chain)
510+ graph = _build_graph (
511+ ("ROOT" , "a" , "toplevel" ),
512+ ("a" , "b" , "install" ),
513+ ("b" , "c" , "install" ),
514+ )
515+ assert "a==1.0" in graph .nodes
516+ assert "b==1.0" in graph .nodes
517+ assert "c==1.0" in graph .nodes
518+
519+ graph .remove_dependency (canonicalize_name ("a" ), Version ("1.0" ))
520+
521+ assert "a==1.0" not in graph .nodes
522+ assert "b==1.0" not in graph .nodes
523+ assert "c==1.0" not in graph .nodes
524+ assert len (graph ) == 0
525+
526+
527+ def test_remove_dependency_keeps_shared_children () -> None :
528+ """Children with other parents are kept; only the stale parent edge is removed."""
529+ # ROOT -> a -> shared
530+ # ROOT -> b -> shared
531+ graph = _build_graph (
532+ ("ROOT" , "a" , "toplevel" ),
533+ ("ROOT" , "b" , "toplevel" ),
534+ ("a" , "shared" , "install" ),
535+ ("b" , "shared" , "install" ),
536+ )
537+ shared_node = graph .nodes ["shared==1.0" ]
538+ assert len (shared_node .parents ) == 2
539+
540+ graph .remove_dependency (canonicalize_name ("a" ), Version ("1.0" ))
541+
542+ assert "a==1.0" not in graph .nodes
543+ assert "shared==1.0" in graph .nodes
544+ assert len (shared_node .parents ) == 1
545+ assert shared_node .parents [0 ].destination_node .key == "b==1.0"
546+
547+
548+ def test_remove_dependency_diamond_sequential () -> None :
549+ """Diamond: shared child survives first removal, cleaned up by second."""
550+ # ROOT -> a -> c
551+ # ROOT -> b -> c
552+ graph = _build_graph (
553+ ("ROOT" , "a" , "toplevel" ),
554+ ("ROOT" , "b" , "toplevel" ),
555+ ("a" , "c" , "install" ),
556+ ("b" , "c" , "install" ),
557+ )
558+
559+ graph .remove_dependency (canonicalize_name ("a" ), Version ("1.0" ))
560+
561+ assert "a==1.0" not in graph .nodes
562+ assert "c==1.0" in graph .nodes
563+ assert len (graph .nodes ["c==1.0" ].parents ) == 1
564+
565+ graph .remove_dependency (canonicalize_name ("b" ), Version ("1.0" ))
566+
567+ assert "b==1.0" not in graph .nodes
568+ assert "c==1.0" not in graph .nodes
569+ assert len (graph ) == 0
570+
571+
572+ def test_remove_dependency_already_removed_child () -> None :
573+ """Removing a node whose child was already removed is safe."""
574+ # ROOT -> a -> b -> c
575+ graph = _build_graph (
576+ ("ROOT" , "a" , "toplevel" ),
577+ ("a" , "b" , "install" ),
578+ ("b" , "c" , "install" ),
579+ )
580+
581+ graph .remove_dependency (canonicalize_name ("c" ), Version ("1.0" ))
582+ graph .remove_dependency (canonicalize_name ("b" ), Version ("1.0" ))
583+
584+ assert "b==1.0" not in graph .nodes
585+ assert "c==1.0" not in graph .nodes
586+ assert "a==1.0" in graph .nodes
587+ assert len (graph .nodes ["a==1.0" ].children ) == 0
588+
589+
590+ def test_remove_dependency_mid_graph_cascades () -> None :
591+ """Removing a mid-graph node cascades to its exclusive subtree.
592+
593+ Verifies both that orphaned nodes are gone and that the surviving
594+ tree structure (edges in both directions) is fully intact.
595+ """
596+ # ROOT -> a -> b -> d
597+ # b -> e
598+ # a -> c
599+ graph = _build_graph (
600+ ("ROOT" , "a" , "toplevel" ),
601+ ("a" , "b" , "install" ),
602+ ("a" , "c" , "install" ),
603+ ("b" , "d" , "install" ),
604+ ("b" , "e" , "install" ),
605+ )
606+
607+ graph .remove_dependency (canonicalize_name ("b" ), Version ("1.0" ))
608+
609+ # Removed subtree is gone
610+ assert "b==1.0" not in graph .nodes
611+ assert "d==1.0" not in graph .nodes
612+ assert "e==1.0" not in graph .nodes
613+
614+ # Exactly ROOT, a, c remain
615+ assert set (graph .nodes .keys ()) == {"" , "a==1.0" , "c==1.0" }
616+
617+ # ROOT -> a edge intact
618+ root = graph .get_root_node ()
619+ assert len (root .children ) == 1
620+ assert root .children [0 ].destination_node .key == "a==1.0"
621+
622+ # a -> c is the only surviving child edge, and a's parent is ROOT
623+ node_a = graph .nodes ["a==1.0" ]
624+ assert len (node_a .children ) == 1
625+ assert node_a .children [0 ].destination_node .key == "c==1.0"
626+ assert len (node_a .parents ) == 1
627+ assert node_a .parents [0 ].destination_node .key == ""
628+
629+ # c has a as parent, no children
630+ node_c = graph .nodes ["c==1.0" ]
631+ assert len (node_c .children ) == 0
632+ assert len (node_c .parents ) == 1
633+ assert node_c .parents [0 ].destination_node .key == "a==1.0"
634+
635+
636+ def test_remove_dependency_shared_child_kept_by_other_subtree () -> None :
637+ """Shared child and its descendants survive when kept alive by another subtree.
638+
639+ ROOT -> a -> b -> d
640+ a -> c -> d
641+ ROOT -> e -> c
642+
643+ Removing a should remove a, b; c and d survive because e still parents c.
644+ """
645+ graph = _build_graph (
646+ ("ROOT" , "a" , "toplevel" ),
647+ ("ROOT" , "e" , "toplevel" ),
648+ ("a" , "b" , "install" ),
649+ ("a" , "c" , "install" ),
650+ ("b" , "d" , "install" ),
651+ ("c" , "d" , "install" ),
652+ ("e" , "c" , "install" ),
653+ )
654+
655+ graph .remove_dependency (canonicalize_name ("a" ), Version ("1.0" ))
656+
657+ assert "a==1.0" not in graph .nodes
658+ assert "b==1.0" not in graph .nodes
659+ assert "c==1.0" in graph .nodes
660+ assert "d==1.0" in graph .nodes
661+ assert "e==1.0" in graph .nodes
662+
663+ node_c = graph .nodes ["c==1.0" ]
664+ assert len (node_c .parents ) == 1
665+ assert node_c .parents [0 ].destination_node .key == "e==1.0"
666+ assert len (node_c .children ) == 1
667+ assert node_c .children [0 ].destination_node .key == "d==1.0"
668+
669+ node_d = graph .nodes ["d==1.0" ]
670+ assert len (node_d .parents ) == 1
671+ assert node_d .parents [0 ].destination_node .key == "c==1.0"
672+
673+
674+ def test_remove_dependency_cascades_through_diamond () -> None :
675+ """Single removal cascades through a diamond, removing all orphans.
676+
677+ ROOT -> a -> b -> d
678+ a -> c -> d
679+
680+ Removing a orphans b and c; as both are removed, d loses all parents
681+ and is also removed.
682+ """
683+ graph = _build_graph (
684+ ("ROOT" , "a" , "toplevel" ),
685+ ("a" , "b" , "install" ),
686+ ("a" , "c" , "install" ),
687+ ("b" , "d" , "install" ),
688+ ("c" , "d" , "install" ),
689+ )
690+
691+ graph .remove_dependency (canonicalize_name ("a" ), Version ("1.0" ))
692+
693+ assert "a==1.0" not in graph .nodes
694+ assert "b==1.0" not in graph .nodes
695+ assert "c==1.0" not in graph .nodes
696+ assert "d==1.0" not in graph .nodes
697+ assert len (graph ) == 0
698+
699+
700+ def test_remove_dependency_nonexistent () -> None :
701+ """Removing a node not in the graph is a no-op."""
702+ graph = _build_graph (("ROOT" , "a" , "toplevel" ))
703+ node_count = len (graph .nodes )
704+
705+ graph .remove_dependency (canonicalize_name ("nonexistent" ), Version ("1.0" ))
706+
707+ assert len (graph .nodes ) == node_count
0 commit comments