1010from pycellin .classes import Property
1111from pycellin .io .geff .loader import (
1212 _build_generic_metadata ,
13+ _build_props_metadata ,
1314 _extract_axes_metadata ,
1415 _extract_generic_metadata ,
1516 _extract_lin_props_metadata ,
2829def geff_node_props_md ():
2930 """Geff node properties metadata where the target prop has a unit."""
3031 return {
31- "frame" : geff_spec .PropMetadata (identifier = "frame" , dtype = "int " , unit = None ),
32+ "frame" : geff_spec .PropMetadata (identifier = "frame" , dtype = "int64 " , unit = None ),
3233 "position_x" : geff_spec .PropMetadata (
33- identifier = "position_x" , dtype = "float64" , unit = "micrometer "
34+ identifier = "position_x" , dtype = "float64" , unit = "um "
3435 ),
3536 }
3637
3738
39+ @pytest .fixture
40+ def geff_edge_props_md ():
41+ """Geff edge properties metadata where the target prop has a unit."""
42+ return {
43+ "speed" : geff_spec .PropMetadata (
44+ identifier = "speed" , dtype = "float64" , unit = "um/second"
45+ ),
46+ "cost" : geff_spec .PropMetadata (identifier = "cost" , dtype = "float64" , unit = None ),
47+ }
48+
49+
3850@pytest .fixture
3951def geff_axes ():
4052 """A list of geff axes containing time and space axes."""
4153 return [
4254 geff_spec .Axis (name = "frame" , type = "time" , unit = "second" ),
43- geff_spec .Axis (name = "position_x" , type = "space" , unit = "micrometer " ),
44- geff_spec .Axis (name = "position_y" , type = "space" , unit = "micrometer " ),
55+ geff_spec .Axis (name = "position_x" , type = "space" , unit = "um " ),
56+ geff_spec .Axis (name = "position_y" , type = "space" , unit = "um " ),
4557 ]
4658
4759
@@ -88,9 +100,9 @@ def geff_md_3d_axes(geff_node_props_md):
88100 directed = True ,
89101 axes = [
90102 geff_spec .Axis (name = "frame" , type = "time" , unit = "second" ),
91- geff_spec .Axis (name = "position_x" , type = "space" , unit = "micrometer " ),
92- geff_spec .Axis (name = "position_y" , type = "space" , unit = "micrometer " ),
93- geff_spec .Axis (name = "position_z" , type = "space" , unit = "micrometer " ),
103+ geff_spec .Axis (name = "position_x" , type = "space" , unit = "um " ),
104+ geff_spec .Axis (name = "position_y" , type = "space" , unit = "um " ),
105+ geff_spec .Axis (name = "position_z" , type = "space" , unit = "um " ),
94106 ],
95107 node_props_metadata = geff_node_props_md ,
96108 edge_props_metadata = {},
@@ -231,6 +243,20 @@ def prop_pycellin_lin_position_x():
231243 )
232244
233245
246+ @pytest .fixture
247+ def prop_speed ():
248+ """A Property for 'speed' with prop_type='edge'."""
249+ return Property (
250+ identifier = "speed" ,
251+ name = "speed" ,
252+ description = "speed" ,
253+ provenance = "test" ,
254+ prop_type = "edge" ,
255+ lin_type = "CellLineage" ,
256+ dtype = "float" ,
257+ )
258+
259+
234260@pytest .fixture
235261def lin_props_md ():
236262 """A dict of lineage properties metadata with expected fields."""
@@ -239,7 +265,7 @@ def lin_props_md():
239265 "displacement" : {
240266 "name" : "Lineage displacement" ,
241267 "dtype" : "float" ,
242- "unit" : "micrometer " ,
268+ "unit" : "um " ,
243269 },
244270 }
245271
@@ -527,7 +553,7 @@ def test_new_key_is_all_fields_added(self, geff_node_props_md):
527553 assert props_dict ["position_x" ].identifier == "position_x"
528554 assert props_dict ["position_x" ].prop_type == "node"
529555 assert props_dict ["position_x" ].dtype == "float64"
530- assert props_dict ["position_x" ].unit == "micrometer "
556+ assert props_dict ["position_x" ].unit == "um "
531557
532558 def test_new_key_name_defaults_to_key_when_prop_name_is_none (
533559 self , geff_node_props_md
@@ -650,7 +676,7 @@ def test_new_key_all_fields_added(self, lin_props_md):
650676 assert props_dict ["displacement" ].identifier == "displacement"
651677 assert props_dict ["displacement" ].prop_type == "lineage"
652678 assert props_dict ["displacement" ].dtype == "float"
653- assert props_dict ["displacement" ].unit == "micrometer "
679+ assert props_dict ["displacement" ].unit == "um "
654680
655681 def test_new_key_name_defaults_to_key_when_name_is_none (self , lin_props_md ):
656682 """When prop dict has name=None, Property.name falls back to the key."""
@@ -741,13 +767,160 @@ def test_both_rename_candidates_taken_raises_key_error(
741767 _extract_lin_props_metadata ({"position_x" : {"dtype" : "float" }}, props_dict )
742768
743769
770+ class TestBuildPropsMetadata :
771+ """Test cases for _build_props_metadata function."""
772+
773+ def test_empty_node_and_edge_props_returns_empty_dict (self ):
774+ """When node and edge props metadata are empty dicts and extra is None,
775+ return an empty props_dict."""
776+ geff_md = geff .GeffMetadata (
777+ directed = True , node_props_metadata = {}, edge_props_metadata = {}
778+ )
779+ result = _build_props_metadata (geff_md )
780+ assert result == {}
781+
782+ def test_node_props_only_all_added_as_node (self , geff_node_props_md ):
783+ """When only node_props_metadata is provided, all keys are added with
784+ prop_type='node'."""
785+ geff_md = geff .GeffMetadata (
786+ directed = True ,
787+ node_props_metadata = geff_node_props_md ,
788+ edge_props_metadata = {},
789+ )
790+ result = _build_props_metadata (geff_md )
791+ assert "frame" in result
792+ assert result ["frame" ].prop_type == "node"
793+ assert result ["frame" ].dtype == "int64"
794+ assert "position_x" in result
795+ assert result ["position_x" ].prop_type == "node"
796+ assert result ["position_x" ].unit == "um"
797+
798+ def test_edge_props_only_all_added_as_edge (self , geff_edge_props_md ):
799+ """When only edge_props_metadata is provided, all keys are added with
800+ prop_type='edge'."""
801+ geff_md = geff .GeffMetadata (
802+ directed = True ,
803+ node_props_metadata = {},
804+ edge_props_metadata = geff_edge_props_md ,
805+ )
806+ result = _build_props_metadata (geff_md )
807+ assert "speed" in result
808+ assert result ["speed" ].prop_type == "edge"
809+ assert result ["speed" ].dtype == "float64"
810+ assert "cost" in result
811+ assert result ["cost" ].prop_type == "edge"
812+ assert result ["cost" ].unit is None
813+
814+ def test_node_and_edge_props_no_collision_both_added (
815+ self , geff_node_props_md , geff_edge_props_md
816+ ):
817+ """When node and edge metadata have different keys, all props are added."""
818+ geff_md = geff .GeffMetadata (
819+ directed = True ,
820+ node_props_metadata = geff_node_props_md ,
821+ edge_props_metadata = geff_edge_props_md ,
822+ )
823+ result = _build_props_metadata (geff_md )
824+ assert "frame" in result
825+ assert result ["frame" ].prop_type == "node"
826+ assert "position_x" in result
827+ assert result ["position_x" ].prop_type == "node"
828+ assert "speed" in result
829+ assert result ["speed" ].prop_type == "edge"
830+ assert "cost" in result
831+ assert result ["cost" ].prop_type == "edge"
832+
833+ def test_node_and_edge_collision_both_renamed (self , geff_node_props_md ):
834+ """When a key appears in both node and edge metadata, both props are renamed
835+ with appropriate prefixes."""
836+ geff_md = geff .GeffMetadata (
837+ directed = True ,
838+ node_props_metadata = geff_node_props_md ,
839+ edge_props_metadata = {
840+ "position_x" : geff_spec .PropMetadata (
841+ identifier = "position_x" ,
842+ dtype = "float64" ,
843+ unit = "um" ,
844+ )
845+ },
846+ )
847+ with pytest .warns (UserWarning ):
848+ result = _build_props_metadata (geff_md )
849+ assert "position_x" not in result
850+ assert "cell_position_x" in result
851+ assert result ["cell_position_x" ].prop_type == "node"
852+ assert "link_position_x" in result
853+ assert result ["link_position_x" ].prop_type == "edge"
854+
855+ def test_lineage_props_in_extra_direct_key (self , lin_props_md ):
856+ """When extra contains 'lineage_props_metadata' at the top level, lineage
857+ props are extracted and added with prop_type='lineage'."""
858+ geff_md = geff .GeffMetadata (
859+ directed = True ,
860+ node_props_metadata = {},
861+ edge_props_metadata = {},
862+ extra = {"lineage_props_metadata" : lin_props_md },
863+ )
864+ result = _build_props_metadata (geff_md )
865+ assert "n_divisions" in result
866+ assert result ["n_divisions" ].prop_type == "lineage"
867+ assert result ["n_divisions" ].dtype == "int"
868+ assert "displacement" in result
869+ assert result ["displacement" ].prop_type == "lineage"
870+ assert result ["displacement" ].unit == "um"
871+
872+ def test_lineage_props_nested_in_extra (self , lin_props_md ):
873+ """When 'lineage_props_metadata' is nested inside extra, it is still found
874+ and extracted."""
875+ geff_md = geff .GeffMetadata (
876+ directed = True ,
877+ node_props_metadata = {},
878+ edge_props_metadata = {},
879+ extra = {"some_section" : {"lineage_props_metadata" : lin_props_md }},
880+ )
881+ result = _build_props_metadata (geff_md )
882+ assert result ["n_divisions" ].prop_type == "lineage"
883+ assert result ["displacement" ].prop_type == "lineage"
884+
885+ def test_extra_without_lineage_props_metadata_no_lineage_added (self ):
886+ """When extra exists but contains no 'lineage_props_metadata' key, no
887+ lineage props are added."""
888+ geff_md = geff .GeffMetadata (
889+ directed = True ,
890+ node_props_metadata = {},
891+ edge_props_metadata = {},
892+ extra = {"some_other_key" : {"unrelated" : "data" }},
893+ )
894+ result = _build_props_metadata (geff_md )
895+ assert result == {}
896+
897+ def test_node_edge_and_lineage_props_all_combined (
898+ self , geff_node_props_md , geff_edge_props_md , lin_props_md
899+ ):
900+ """When node, edge, and lineage props are all present without collisions,
901+ all are added to the result."""
902+ geff_md = geff .GeffMetadata (
903+ directed = True ,
904+ node_props_metadata = geff_node_props_md ,
905+ edge_props_metadata = geff_edge_props_md ,
906+ extra = {"lineage_props_metadata" : lin_props_md },
907+ )
908+ result = _build_props_metadata (geff_md )
909+ assert result ["frame" ].prop_type == "node"
910+ assert result ["position_x" ].prop_type == "node"
911+ assert result ["speed" ].prop_type == "edge"
912+ assert result ["cost" ].prop_type == "edge"
913+ assert result ["n_divisions" ].prop_type == "lineage"
914+ assert result ["displacement" ].prop_type == "lineage"
915+
916+
744917class TestGetPropUnit :
745918 """Test cases for _get_prop_unit function."""
746919
747920 def test_unit_from_node_props_md (self , geff_node_props_md ):
748921 """When node_props_md contains the prop with a unit, return it directly."""
749922 result = _get_prop_unit ("position_x" , "space" , geff_node_props_md , [])
750- assert result == "micrometer "
923+ assert result == "um "
751924
752925 def test_node_props_md_unit_takes_priority_over_axes (self , geff_node_props_md ):
753926 """When the prop is in node_props_md with a unit, axes are not consulted
@@ -758,7 +931,7 @@ def test_node_props_md_unit_takes_priority_over_axes(self, geff_node_props_md):
758931 result = _get_prop_unit (
759932 "position_x" , "space" , geff_node_props_md , conflicting_axes
760933 )
761- assert result == "micrometer "
934+ assert result == "um "
762935
763936 def test_fallback_to_axes_when_prop_unit_is_none (self , geff_node_props_md , geff_axes ):
764937 """When node_props_md has the prop but unit is None, fall back to axes."""
@@ -768,14 +941,14 @@ def test_fallback_to_axes_when_prop_unit_is_none(self, geff_node_props_md, geff_
768941 def test_fallback_to_axes_when_node_props_md_is_none (self , geff_axes ):
769942 """When node_props_md is None, fall back to axes."""
770943 result = _get_prop_unit ("position_x" , "space" , None , geff_axes )
771- assert result == "micrometer "
944+ assert result == "um "
772945
773946 def test_fallback_to_axes_when_prop_not_in_node_props_md (
774947 self , geff_node_props_md , geff_axes
775948 ):
776949 """When prop is absent from node_props_md, fall back to axes."""
777950 result = _get_prop_unit ("position_y" , "space" , geff_node_props_md , geff_axes )
778- assert result == "micrometer "
951+ assert result == "um "
779952
780953 def test_returns_none_when_no_unit_found_anywhere (self , geff_node_props_md ):
781954 """When unit is None in node_props_md and the prop has no matching axis, return None."""
@@ -821,7 +994,7 @@ def test_returns_both_time_and_space_unit(self, geff_axes):
821994 geff_md , "frame" , "position_x" , "position_y" , None
822995 )
823996 assert result ["time_unit" ] == "second"
824- assert result ["space_unit" ] == "micrometer "
997+ assert result ["space_unit" ] == "um "
825998
826999 def test_no_time_unit_warns_and_absent_from_result (self , geff_axes ):
8271000 """When no unit is found for the time property, warn and omit time_unit."""
@@ -831,7 +1004,7 @@ def test_no_time_unit_warns_and_absent_from_result(self, geff_axes):
8311004 geff_md , "unknown_time" , "position_x" , None , None
8321005 )
8331006 assert "time_unit" not in result
834- assert result ["space_unit" ] == "micrometer "
1007+ assert result ["space_unit" ] == "um "
8351008
8361009 def test_all_space_props_none_warns (self , geff_axes ):
8371010 """When all space props are None, warn and omit space_unit."""
@@ -852,7 +1025,7 @@ def test_multiple_space_units_warns(self):
8521025 """When x and y props have different units, warn and omit space_unit."""
8531026 mixed_axes = [
8541027 geff_spec .Axis (name = "frame" , type = "time" , unit = "second" ),
855- geff_spec .Axis (name = "position_x" , type = "space" , unit = "micrometer " ),
1028+ geff_spec .Axis (name = "position_x" , type = "space" , unit = "um " ),
8561029 geff_spec .Axis (name = "position_y" , type = "space" , unit = "millimeter" ),
8571030 ]
8581031 geff_md = self ._make_geff_md (axes = mixed_axes )
@@ -866,14 +1039,14 @@ def test_partial_space_props_single_unit(self, geff_axes):
8661039 """When only x_prop is provided and has a unit, space_unit is set."""
8671040 geff_md = self ._make_geff_md (axes = geff_axes )
8681041 result = _extract_axes_metadata (geff_md , "frame" , "position_x" , None , None )
869- assert result ["space_unit" ] == "micrometer "
1042+ assert result ["space_unit" ] == "um "
8701043
8711044 def test_space_unit_from_node_props_md (self , geff_node_props_md ):
8721045 """When unit comes from node_props_md (no axes), space_unit is set correctly."""
8731046 geff_md = self ._make_geff_md (axes = None , node_props_md = geff_node_props_md )
8741047 with pytest .warns (UserWarning , match = "No unit found for time property" ):
8751048 result = _extract_axes_metadata (geff_md , "frame" , "position_x" , None , None )
876- assert result ["space_unit" ] == "micrometer "
1049+ assert result ["space_unit" ] == "um "
8771050 assert "time_unit" not in result
8781051
8791052
@@ -961,7 +1134,7 @@ def test_units_merged_from_axes(self, geff_md_axes):
9611134 "/some/tracks.geff" , geff_md_axes , "frame" , "position_x" , None , None
9621135 )
9631136 assert result ["time_unit" ] == "second"
964- assert result ["space_unit" ] == "micrometer "
1137+ assert result ["space_unit" ] == "um "
9651138
9661139 def test_missing_units_produce_warnings_and_absent_from_result (self ):
9671140 """When no units are found, appropriate warnings are raised and
0 commit comments