Skip to content

Commit 1901208

Browse files
authored
test: add custom properties sorting validation (#915)
Tests ensure items with custom properties appear first sorted by value, followed by items without the property sorted by ID.
1 parent ec2f94d commit 1901208

3 files changed

Lines changed: 283 additions & 1 deletion

File tree

tests/model_registry/model_catalog/conftest.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,8 @@ def randomly_picked_model_from_catalog_api_by_source(
203203
headers = model_registry_rest_headers
204204
else:
205205
headers = get_rest_headers(token=user_token_for_api_calls)
206+
wait_for_model_catalog_api(url=f"{model_catalog_rest_url[0]}", headers=headers)
207+
206208
if not model_name:
207209
LOGGER.info(f"Picking random model from catalog: {catalog_id} with header_type: {header_type}")
208210
models_response = execute_get_command(

tests/model_registry/model_catalog/test_sorting_functionality.py

Lines changed: 170 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import pytest
22
from typing import Self
3-
3+
import random
4+
from ocp_resources.config_map import ConfigMap
45
from simple_logger.logger import get_logger
56
from tests.model_registry.model_catalog.utils import (
67
get_models_from_catalog_api,
78
get_sources_with_sorting,
89
get_artifacts_with_sorting,
910
validate_items_sorted_correctly,
11+
verify_custom_properties_sorted,
1012
)
1113
from tests.model_registry.model_catalog.constants import VALIDATED_CATALOG_ID
1214

@@ -45,6 +47,7 @@ class TestModelsSorting:
4547
)
4648
def test_models_sorting_works_correctly(
4749
self: Self,
50+
enabled_model_catalog_config_map: ConfigMap,
4851
order_by: str,
4952
sort_order: str,
5053
model_catalog_rest_url: list[str],
@@ -79,6 +82,7 @@ class TestSourcesSorting:
7982
)
8083
def test_sources_sorting_works_correctly(
8184
self: Self,
85+
enabled_model_catalog_config_map: ConfigMap,
8286
order_by: str,
8387
sort_order: str,
8488
model_catalog_rest_url: list[str],
@@ -101,6 +105,7 @@ def test_sources_sorting_works_correctly(
101105
@pytest.mark.parametrize("unsupported_field", ["CREATE_TIME", "LAST_UPDATE_TIME"])
102106
def test_sources_rejects_unsupported_fields(
103107
self: Self,
108+
enabled_model_catalog_config_map: ConfigMap,
104109
unsupported_field: str,
105110
model_catalog_rest_url: list[str],
106111
model_registry_rest_headers: dict[str, str],
@@ -148,6 +153,7 @@ class TestArtifactsSorting:
148153
)
149154
def test_artifacts_sorting_works_correctly(
150155
self: Self,
156+
enabled_model_catalog_config_map: ConfigMap,
151157
order_by: str,
152158
sort_order: str,
153159
model_catalog_rest_url: list[str],
@@ -170,3 +176,166 @@ def test_artifacts_sorting_works_correctly(
170176
)
171177

172178
assert validate_items_sorted_correctly(response["items"], order_by, sort_order)
179+
180+
181+
@pytest.mark.downstream_only
182+
class TestCustomPropertiesSorting:
183+
"""Test sorting functionality for custom properties"""
184+
185+
MODEL_NAMEs_CUSTOM_PROPERTIES: list[str] = [
186+
"RedHatAI/Llama-3.1-Nemotron-70B-Instruct-HF",
187+
"RedHatAI/phi-4-quantized.w8a8",
188+
"RedHatAI/Qwen2.5-7B-Instruct-quantized.w4a16",
189+
]
190+
191+
@pytest.mark.parametrize(
192+
"order_by,sort_order,randomly_picked_model_from_catalog_api_by_source,expect_pure_fallback",
193+
[
194+
(
195+
"e2e_p90.double_value",
196+
"ASC",
197+
{
198+
"catalog_id": VALIDATED_CATALOG_ID,
199+
"header_type": "registry",
200+
"model_name": random.choice(MODEL_NAMEs_CUSTOM_PROPERTIES),
201+
},
202+
False,
203+
),
204+
(
205+
"e2e_p90.double_value",
206+
"DESC",
207+
{
208+
"catalog_id": VALIDATED_CATALOG_ID,
209+
"header_type": "registry",
210+
"model_name": random.choice(MODEL_NAMEs_CUSTOM_PROPERTIES),
211+
},
212+
False,
213+
),
214+
(
215+
"hardware_count.int_value",
216+
"ASC",
217+
{
218+
"catalog_id": VALIDATED_CATALOG_ID,
219+
"header_type": "registry",
220+
"model_name": random.choice(MODEL_NAMEs_CUSTOM_PROPERTIES),
221+
},
222+
False,
223+
),
224+
(
225+
"hardware_count.int_value",
226+
"DESC",
227+
{
228+
"catalog_id": VALIDATED_CATALOG_ID,
229+
"header_type": "registry",
230+
"model_name": random.choice(MODEL_NAMEs_CUSTOM_PROPERTIES),
231+
},
232+
False,
233+
),
234+
(
235+
"hardware_type.string_value",
236+
"ASC",
237+
{
238+
"catalog_id": VALIDATED_CATALOG_ID,
239+
"header_type": "registry",
240+
"model_name": random.choice(MODEL_NAMEs_CUSTOM_PROPERTIES),
241+
},
242+
False,
243+
),
244+
(
245+
"hardware_type.string_value",
246+
"DESC",
247+
{
248+
"catalog_id": VALIDATED_CATALOG_ID,
249+
"header_type": "registry",
250+
"model_name": random.choice(MODEL_NAMEs_CUSTOM_PROPERTIES),
251+
},
252+
False,
253+
),
254+
(
255+
"non_existing_property.double_value",
256+
"ASC",
257+
{
258+
"catalog_id": VALIDATED_CATALOG_ID,
259+
"header_type": "registry",
260+
"model_name": random.choice(MODEL_NAMEs_CUSTOM_PROPERTIES),
261+
},
262+
True,
263+
),
264+
(
265+
"non_existing_property.double_value",
266+
"DESC",
267+
{
268+
"catalog_id": VALIDATED_CATALOG_ID,
269+
"header_type": "registry",
270+
"model_name": random.choice(MODEL_NAMEs_CUSTOM_PROPERTIES),
271+
},
272+
True,
273+
),
274+
],
275+
indirect=["randomly_picked_model_from_catalog_api_by_source"],
276+
)
277+
def test_custom_properties_sorting_works_correctly(
278+
self: Self,
279+
enabled_model_catalog_config_map: ConfigMap,
280+
order_by: str,
281+
sort_order: str,
282+
model_catalog_rest_url: list[str],
283+
model_registry_rest_headers: dict[str, str],
284+
randomly_picked_model_from_catalog_api_by_source: tuple[dict, str, str],
285+
expect_pure_fallback: bool,
286+
):
287+
"""
288+
RHOAIENG-38010: Test custom properties endpoint sorts correctly by supported fields
289+
290+
This test validates two scenarios:
291+
1. expect_pure_fallback=False: Tests custom property sorting where at least some artifacts
292+
have the property. Items with the property are sorted by the property value (ASC/DESC),
293+
followed by items without the property sorted by ID ASC (fallback behavior).
294+
295+
2. expect_pure_fallback=True: Tests pure fallback behavior where NO artifacts have the
296+
property. All items are sorted by ID ASC, regardless of the requested sortOrder.
297+
"""
298+
_, model_name, _ = randomly_picked_model_from_catalog_api_by_source
299+
LOGGER.info(
300+
f"Testing custom properties sorting for {model_name}: "
301+
f"orderBy={order_by}, sortOrder={sort_order}, expect_pure_fallback={expect_pure_fallback}"
302+
)
303+
304+
response = get_artifacts_with_sorting(
305+
model_catalog_rest_url=model_catalog_rest_url,
306+
model_registry_rest_headers=model_registry_rest_headers,
307+
source_id=VALIDATED_CATALOG_ID,
308+
model_name=model_name,
309+
order_by=order_by,
310+
sort_order=sort_order,
311+
)
312+
313+
# Verify how many artifacts have the custom property
314+
property_name = order_by.rsplit(".", 1)[0]
315+
artifacts_with_property = sum(
316+
1 for item in response["items"] if property_name in item.get("customProperties", {})
317+
)
318+
319+
if expect_pure_fallback:
320+
# When property doesn't exist, sorting always falls back to ID ASC regardless of sortOrder
321+
assert artifacts_with_property == 0, (
322+
f"Expected no artifacts to have property {property_name} for pure fallback test, "
323+
f"but found {artifacts_with_property} artifacts with it"
324+
)
325+
is_sorted = validate_items_sorted_correctly(items=response["items"], field="ID", order="ASC")
326+
assert is_sorted, f"Pure fallback to ID ASC sorting failed for non-existing property {order_by}"
327+
else:
328+
# This ensures we're testing actual custom property sorting (not silent fallback)
329+
assert artifacts_with_property > 0, (
330+
f"Cannot test custom property sorting: no artifacts have property {property_name}. "
331+
f"This would result in silent fallback to ID sorting."
332+
)
333+
LOGGER.info(f"{artifacts_with_property}/{len(response['items'])} artifacts have property {property_name}")
334+
335+
# verify_custom_properties_sorted validates:
336+
# - Items WITH property come first, sorted by property value (respecting sortOrder)
337+
# - Items WITHOUT property come after, sorted by ID ASC (fallback)
338+
is_sorted = verify_custom_properties_sorted(
339+
items=response["items"], property_field=order_by, sort_order=sort_order
340+
)
341+
assert is_sorted, f"Custom properties are not sorted correctly for {model_name}"

tests/model_registry/model_catalog/utils.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1009,6 +1009,117 @@ def validate_items_sorted_correctly(items: list[dict], field: str, order: str) -
10091009
raise ValueError(f"Invalid sort order: {order}")
10101010

10111011

1012+
def verify_custom_properties_sorted(items: list[dict], property_field: str, sort_order: str) -> bool:
1013+
"""
1014+
Verify if a list of items is sorted by a specific custom property value.
1015+
1016+
Expected sorting behavior:
1017+
1. Items WITH the custom property appear first, sorted by property value (ASC or DESC)
1018+
2. Items WITHOUT the custom property appear after, sorted by ID in ASC order
1019+
1020+
Args:
1021+
items: List of artifact items (with 'id' and 'customProperties')
1022+
property_field: Property field to sort by (e.g., "e2e_p90.double_value")
1023+
sort_order: "ASC" or "DESC" (applies only to items with the property)
1024+
1025+
Returns:
1026+
True if sorted correctly, False otherwise
1027+
1028+
Raises:
1029+
ValueError: If there are not enough items to verify sorting
1030+
"""
1031+
property_name, value_type = property_field.rsplit(".", 1)
1032+
# Separate items into two groups
1033+
items_with_property, items_without_property = _split_items_by_custom_property(
1034+
items=items, property_name=property_name, value_type=value_type, property_field=property_field
1035+
)
1036+
1037+
LOGGER.info(
1038+
f"Total items: {len(items)}, with '{property_name}': {len(items_with_property)}, "
1039+
f"without: {len(items_without_property)}"
1040+
)
1041+
1042+
# Verify items with property come first
1043+
if items_with_property and items_without_property and items_with_property[-1][0] > items_without_property[0][0]:
1044+
LOGGER.error(
1045+
f"Items without property '{property_name}' appear before items with property. "
1046+
f"Last with property at index {items_with_property[-1][0]}, "
1047+
f"first without at index {items_without_property[0][0]}"
1048+
)
1049+
return False
1050+
1051+
if items_with_property and not _verify_items_with_property_sorted(
1052+
items=items_with_property, property_name=property_name, value_type=value_type, sort_order=sort_order
1053+
):
1054+
return False
1055+
1056+
if items_without_property and not _verify_items_without_property_sorted(items=items_without_property):
1057+
return False
1058+
1059+
LOGGER.info(f"All items sorted correctly by '{property_name}' ({sort_order})")
1060+
return True
1061+
1062+
1063+
def _split_items_by_custom_property(
1064+
items: list[dict],
1065+
property_name: str,
1066+
value_type: str,
1067+
property_field: str,
1068+
) -> tuple[list[dict], list[dict]]:
1069+
"""
1070+
Split items into two lists based on the presence of a custom property.
1071+
"""
1072+
items_with_property = []
1073+
items_without_property = []
1074+
1075+
for idx, item in enumerate(items):
1076+
custom_props = item.get("customProperties", {})
1077+
if property_name in custom_props:
1078+
prop = custom_props[property_name]
1079+
value = prop.get(value_type) if isinstance(prop, dict) else None
1080+
1081+
# Only add to items_with_property if value is not None
1082+
if value is not None:
1083+
LOGGER.info(f" [{idx}] ID: {item['id']}, {property_field}: {value}")
1084+
items_with_property.append((idx, item))
1085+
else:
1086+
LOGGER.info(f" [{idx}] ID: {item['id']} (has {property_name} but value is None)")
1087+
items_without_property.append((idx, item))
1088+
else:
1089+
LOGGER.info(f" [{idx}] ID: {item['id']} (no {property_name})")
1090+
items_without_property.append((idx, item))
1091+
return items_with_property, items_without_property
1092+
1093+
1094+
def _verify_items_with_property_sorted(items: list[dict], property_name: str, value_type: str, sort_order: str) -> bool:
1095+
"""
1096+
Verify if items with property are sorted correctly by property value
1097+
"""
1098+
values = []
1099+
for _, item in items:
1100+
prop = item["customProperties"][property_name]
1101+
value = prop.get(value_type)
1102+
values.append(value)
1103+
1104+
expected_values = sorted(values, reverse=(sort_order == "DESC"))
1105+
if values != expected_values:
1106+
LOGGER.error(f"Items with property not sorted {sort_order}: {values}")
1107+
return False
1108+
return True
1109+
1110+
1111+
def _verify_items_without_property_sorted(items: list[dict]) -> bool:
1112+
"""
1113+
Verify if items without property are sorted correctly by ID in ASC order
1114+
"""
1115+
ids = [int(item["id"]) for _, item in items]
1116+
expected_ids = sorted(ids)
1117+
if ids != expected_ids:
1118+
LOGGER.error(f"Items without property not sorted by ID ASC: {ids}")
1119+
return False
1120+
return True
1121+
1122+
10121123
def _validate_single_criterion(
10131124
artifact_name: str, custom_properties: dict[str, Any], validation: dict[str, Any]
10141125
) -> tuple[bool, str]:

0 commit comments

Comments
 (0)