|
20 | 20 | from sleap.gui.commands import ( |
21 | 21 | AddInstance, |
22 | 22 | CommandContext, |
| 23 | + DeleteNode, |
23 | 24 | ExportAnalysisFile, |
24 | 25 | ExportDatasetWithImages, |
25 | 26 | ExportFullPackage, |
@@ -611,6 +612,104 @@ def OpenSkeleton_ask(context: CommandContext, params: dict) -> bool: |
611 | 612 | assert_skeletons_match(labels.skeleton, fly32_skeleton) |
612 | 613 |
|
613 | 614 |
|
| 615 | +def test_DeleteNode_updates_instances(centered_pair_predictions: Labels, tmpdir): |
| 616 | + """Test that DeleteNode properly updates instance point data. |
| 617 | +
|
| 618 | + This is a regression test for GitHub Discussion #2500 where removing nodes from |
| 619 | + a skeleton did not update instance point data, causing file corruption that |
| 620 | + prevented the file from being loaded. |
| 621 | +
|
| 622 | + The bug occurred because DeleteNode called skeleton.remove_node() directly, |
| 623 | + which only updates the skeleton but NOT the instance point arrays. The fix |
| 624 | + is to use Labels.remove_nodes() which properly calls Instance.update_skeleton() |
| 625 | + to update all instances. |
| 626 | + """ |
| 627 | + from sleap.sleap_io_adaptors.lf_labels_utils import labels_copy, labels_load_file |
| 628 | + |
| 629 | + # Create a copy to avoid mutating the fixture |
| 630 | + labels = labels_copy(centered_pair_predictions) |
| 631 | + |
| 632 | + # Get original state |
| 633 | + original_skeleton = labels.skeleton |
| 634 | + original_num_nodes = len(original_skeleton.nodes) |
| 635 | + |
| 636 | + # Pick a node to delete (use a middle node to test index handling) |
| 637 | + node_to_delete = original_skeleton.nodes[1] # Second node |
| 638 | + node_name_to_delete = node_to_delete.name |
| 639 | + |
| 640 | + # Set up command context |
| 641 | + context = CommandContext.from_labels(labels) |
| 642 | + context.state["skeleton"] = original_skeleton |
| 643 | + context.state["selected_node"] = node_to_delete |
| 644 | + |
| 645 | + # Execute the DeleteNode command |
| 646 | + DeleteNode.do_action(context, params={}) |
| 647 | + |
| 648 | + # Verify skeleton was updated |
| 649 | + assert len(labels.skeleton.nodes) == original_num_nodes - 1 |
| 650 | + assert node_name_to_delete not in labels.skeleton.node_names |
| 651 | + |
| 652 | + # Verify ALL instances have updated point arrays that match the new skeleton |
| 653 | + for lf in labels.labeled_frames: |
| 654 | + for inst in lf.instances: |
| 655 | + # Instance points should have the same number of points as skeleton nodes |
| 656 | + assert len(inst.points) == len(labels.skeleton.nodes), ( |
| 657 | + f"Instance has {len(inst.points)} points but skeleton has " |
| 658 | + f"{len(labels.skeleton.nodes)} nodes. " |
| 659 | + "Instance point data was not updated when node was deleted!" |
| 660 | + ) |
| 661 | + # Point names should match skeleton node names |
| 662 | + assert list(inst.points["name"]) == labels.skeleton.node_names, ( |
| 663 | + "Instance point names do not match skeleton node names!" |
| 664 | + ) |
| 665 | + |
| 666 | + # Save and reload to verify file is not corrupted |
| 667 | + save_path = Path(tmpdir) / "test_delete_node.slp" |
| 668 | + labels.save(str(save_path)) |
| 669 | + |
| 670 | + # This should NOT raise an error - the bug caused a ValueError here |
| 671 | + reloaded_labels = labels_load_file(str(save_path)) |
| 672 | + |
| 673 | + # Verify reloaded data is correct |
| 674 | + assert len(reloaded_labels.skeleton.nodes) == original_num_nodes - 1 |
| 675 | + assert node_name_to_delete not in reloaded_labels.skeleton.node_names |
| 676 | + assert len(reloaded_labels.labeled_frames) == len(labels.labeled_frames) |
| 677 | + |
| 678 | + # Verify all reloaded instances have correct point counts |
| 679 | + for lf in reloaded_labels.labeled_frames: |
| 680 | + for inst in lf.instances: |
| 681 | + assert len(inst.points) == len(reloaded_labels.skeleton.nodes) |
| 682 | + |
| 683 | + |
| 684 | +def test_DeleteNode_without_labels(): |
| 685 | + """Test that DeleteNode still works when no Labels object is available. |
| 686 | +
|
| 687 | + When editing a skeleton without a Labels context (e.g., in a skeleton-only |
| 688 | + editor), the command should fall back to calling skeleton.remove_node() |
| 689 | + directly. |
| 690 | + """ |
| 691 | + # Create a standalone skeleton (not associated with Labels) |
| 692 | + skeleton = Skeleton( |
| 693 | + nodes=["head", "neck", "tail"], |
| 694 | + edges=[("head", "neck"), ("neck", "tail")], |
| 695 | + ) |
| 696 | + |
| 697 | + # Set up command context without labels |
| 698 | + context = CommandContext.from_labels(None) |
| 699 | + context.state["skeleton"] = skeleton |
| 700 | + context.state["selected_node"] = skeleton.nodes[1] # "neck" |
| 701 | + |
| 702 | + # Execute the DeleteNode command |
| 703 | + DeleteNode.do_action(context, params={}) |
| 704 | + |
| 705 | + # Verify skeleton was updated |
| 706 | + assert len(skeleton.nodes) == 2 |
| 707 | + assert "neck" not in skeleton.node_names |
| 708 | + assert skeleton.node_names == ["head", "tail"] |
| 709 | + # Edge from head to neck should be removed |
| 710 | + assert len(skeleton.edges) == 0 |
| 711 | + |
| 712 | + |
614 | 713 | def test_SaveProjectAs(centered_pair_predictions: Labels, tmpdir): |
615 | 714 | """Test that project can be saved as default slp extension""" |
616 | 715 |
|
|
0 commit comments