diff --git a/src/backend/server/routes/vontology_routes.py b/src/backend/server/routes/vontology_routes.py index f9e02014..370f0ecb 100644 --- a/src/backend/server/routes/vontology_routes.py +++ b/src/backend/server/routes/vontology_routes.py @@ -48,6 +48,7 @@ from ...services.concept_service import ( get_concept_by_id, ConceptNotFoundError, + resolve_concept_display_names, update_concept_description, ) from ...services.text_value_service import get_texts_for_concept @@ -3158,71 +3159,15 @@ def get_node_children(): sort=[("name", 1)], ) - children = [] - for doc in children_cursor: - # Compute display name with fallback - resolved_name = ( - get_concept_display_name_with_names_fallback(doc) - or doc.get("name", "") - or (doc.get("concept_id") or "") - ) - - # Opportunistically persist derived NL name if doc lacks top-level name and has no NL entry - try: - top_name = doc.get("name") - names = doc.get("names") - cid = doc.get("concept_id") - if cid and (not isinstance(top_name, str) or not top_name.strip()): - has_matching_nl = False - has_any_nl = False - if isinstance(names, list): - for entry in names: - if not isinstance(entry, dict): - continue - if entry.get("type") == "NL": - has_any_nl = True - nm = str(entry.get("name", "")).strip() - lang = entry.get("language") - if nm == resolved_name and ( - lang in (None, "", "en-NZ") - ): - has_matching_nl = True - break - # Only persist if we actually derived a human name and it's not already present - if ( - (not has_matching_nl) - and isinstance(resolved_name, str) - and resolved_name - and not resolved_name.startswith("#V#") - and resolved_name != "Unnamed Concept" - ): - new_entry = { - "name": resolved_name, - "language": "en-NZ", - "type": "NL", - } - if ( - isinstance(names, list) - and len(names) > 0 - and not has_any_nl - ): - # Append only when there's no NL yet - repo.update_one( - {"concept_id": cid}, {"$push": {"names": new_entry}} - ) - elif not isinstance(names, list) or len(names) == 0: - repo.update_one( - {"concept_id": cid}, {"$set": {"names": [new_entry]}} - ) - except Exception: - current_app.logger.debug( - "Failed to persist derived NL name for child %s", - doc.get("concept_id"), - exc_info=True, - ) + child_docs = list(children_cursor) + display_names = resolve_concept_display_names(child_docs) + children = [] + for doc in child_docs: + concept_id = doc.get("concept_id") + resolved_name = display_names.get(concept_id) or concept_id or "" child_data = { - "id": doc.get("concept_id"), + "id": concept_id, "name": resolved_name, "path": doc.get("path", ""), # 'description' suppressed (JVNAUTOSCI-573 global removal); resolve via relations if needed @@ -3301,68 +3246,15 @@ def get_node_instances(): sort=[("name", 1)], ) - instances = [] - for doc in instances_cursor: - resolved_name = ( - get_concept_display_name_with_names_fallback(doc) - or doc.get("name", "") - or (doc.get("concept_id") or "") - ) - - # Opportunistically persist derived NL name if doc lacks top-level name and has no NL entry - try: - top_name = doc.get("name") - names = doc.get("names") - cid = doc.get("concept_id") - if cid and (not isinstance(top_name, str) or not top_name.strip()): - has_matching_nl = False - has_any_nl = False - if isinstance(names, list): - for entry in names: - if not isinstance(entry, dict): - continue - if entry.get("type") == "NL": - has_any_nl = True - nm = str(entry.get("name", "")).strip() - lang = entry.get("language") - if nm == resolved_name and ( - lang in (None, "", "en-NZ") - ): - has_matching_nl = True - break - if ( - (not has_matching_nl) - and isinstance(resolved_name, str) - and resolved_name - and not resolved_name.startswith("#V#") - and resolved_name != "Unnamed Concept" - ): - new_entry = { - "name": resolved_name, - "language": "en-NZ", - "type": "NL", - } - if ( - isinstance(names, list) - and len(names) > 0 - and not has_any_nl - ): - repo.update_one( - {"concept_id": cid}, {"$push": {"names": new_entry}} - ) - elif not isinstance(names, list) or len(names) == 0: - repo.update_one( - {"concept_id": cid}, {"$set": {"names": [new_entry]}} - ) - except Exception: - current_app.logger.debug( - "Failed to persist derived NL name for instance %s", - doc.get("concept_id"), - exc_info=True, - ) + instance_docs = list(instances_cursor) + display_names = resolve_concept_display_names(instance_docs) + instances = [] + for doc in instance_docs: + concept_id = doc.get("concept_id") + resolved_name = display_names.get(concept_id) or concept_id or "" instance_data = { - "id": doc.get("concept_id"), + "id": concept_id, "name": resolved_name, "notes": get_concept_notes(doc) or "", # 'description' suppressed (JVNAUTOSCI-573 global removal); resolve via relations if needed diff --git a/src/backend/services/concept_service.py b/src/backend/services/concept_service.py index 91cd9687..b8bb8960 100644 --- a/src/backend/services/concept_service.py +++ b/src/backend/services/concept_service.py @@ -77,7 +77,11 @@ def invalidate_phrase_cache(): # type: ignore get_event_workflow_integration_enabled, get_workflow_discovery_cache_invalidation_enabled, ) -from .text_value_service import get_texts_for_concept, upsert_text_for_concept +from .text_value_service import ( + get_texts_for_concept, + get_texts_for_concepts, + upsert_text_for_concept, +) from ..db.repositories.text_value_repository import TextRelationsRepository # Setup logger @@ -1268,6 +1272,12 @@ def enrich_concept_with_text_relations( ) existing_name_keys.add(key) + # Keep the convenience field consistent with the canonical relation-backed + # names just attached above. Do not reformat authored text. + relation_backed_name = get_concept_display_name_with_names_fallback(concept) + if isinstance(relation_backed_name, str) and relation_backed_name.strip(): + concept["name"] = relation_backed_name.strip() + # Ensure CODE identifiers are present for UI/debugging (JVNAUTOSCI-938): # Some concepts may not have had CODE names persisted historically, but users # expect to see IDs (e.g., #V#..., db ObjectId, stable guid) in the Names section. @@ -1782,6 +1792,109 @@ def _cascade_delete_text_relations(concept_id: str) -> tuple[int, int]: return (removed, tv_removed) +def resolve_concept_display_names( + concept_docs: Iterable[Dict[str, Any]], + *, + preferred_language: Optional[str] = None, +) -> Dict[str, str]: + """Resolve display names from canonical ``hasName`` relations in one batch. + + Authored names are returned verbatim apart from surrounding whitespace. + Legacy embedded names and ID-derived labels are compatibility fallbacks + only: an ID cannot faithfully reconstruct acronym boundaries or + non-English display text. + """ + + docs_by_id: Dict[str, Dict[str, Any]] = {} + for concept_doc in concept_docs: + if not isinstance(concept_doc, dict): + continue + concept_id = concept_doc.get("concept_id") + if isinstance(concept_id, str) and concept_id.strip(): + docs_by_id[concept_id.strip()] = concept_doc + + if not docs_by_id: + return {} + + language = str(preferred_language or "").strip() + if not language: + try: + from .settings_service import get_preferred_language + + language = str(get_preferred_language() or "").strip() + except Exception: + language = "" + language = language or "en-NZ" + preferred_token = language.casefold() + preferred_base = preferred_token.split("-", 1)[0] + + try: + rows_by_concept = get_texts_for_concepts( + list(docs_by_id), + predicate="hasName", + limit_per_concept=100, + ) + except Exception as exc: + logger.debug( + "Canonical display-name batch read failed; using legacy fallbacks: %s", + exc, + exc_info=True, + ) + rows_by_concept = {} + + def _language_rank(candidate_language: Any) -> int: + candidate = str(candidate_language or "").strip().casefold() + if candidate == preferred_token: + return 0 + candidate_base = candidate.split("-", 1)[0] if candidate else "" + if candidate_base and candidate_base == preferred_base: + return 1 + return 2 if candidate else 3 + + type_rank = {"NL": 0, "ABBR": 1} + resolved: Dict[str, str] = {} + for concept_id, concept_doc in docs_by_id.items(): + candidates: list[tuple[tuple[int, int, str, str], str]] = [] + for row in rows_by_concept.get(concept_id, []): + if not isinstance(row, dict): + continue + text = row.get("text") + if not isinstance(text, str) or not text.strip(): + continue + context = row.get("context") + name_type = ( + str(context.get("name_type") or "NL").strip().upper() + if isinstance(context, dict) + else "NL" + ) + if name_type not in type_rank: + continue + exact_text = text.strip() + relation_id = str(row.get("relation_id") or "~") + candidates.append( + ( + ( + _language_rank(row.get("lang")), + type_rank[name_type], + relation_id, + exact_text.casefold(), + ), + exact_text, + ) + ) + + if candidates: + candidates.sort(key=lambda item: item[0]) + resolved[concept_id] = candidates[0][1] + continue + + fallback = get_concept_display_name_with_names_fallback(concept_doc) + if isinstance(fallback, str) and fallback.strip(): + resolved[concept_id] = fallback.strip() + + return resolved + + def list_concepts( concept_id: Optional[str] = None, vontology_path: Optional[str] = None, @@ -1891,8 +2004,38 @@ def list_concepts( query, sort=sort_criteria, skip=skip_amount, limit=per_page ) + concept_docs = list(concepts_cursor) + primary_type_ids: list[str] = [] + for concept_doc in concept_docs: + relationships = concept_doc.get("relationships", {}) or {} + raw_instance_of = relationships.get("is_an_instance_of", []) + if isinstance(raw_instance_of, str): + candidate_type_ids = [raw_instance_of] + elif isinstance(raw_instance_of, list): + candidate_type_ids = raw_instance_of + else: + candidate_type_ids = [] + if candidate_type_ids and isinstance(candidate_type_ids[0], str): + primary_type_ids.append(candidate_type_ids[0]) + + unique_type_ids = list(dict.fromkeys(primary_type_ids)) + type_docs: list[Dict[str, Any]] = [] + if unique_type_ids: + type_docs = list( + ConceptsRepository.find( + apply_concept_query_filter( + {"concept_id": {"$in": unique_type_ids}} + ), + {"concept_id": 1, "name": 1, "names": 1}, + limit=len(unique_type_ids), + ) + ) + display_names = resolve_concept_display_names( + [*concept_docs, *type_docs] + ) + concepts_list = [] - for concept_doc in concepts_cursor: + for concept_doc in concept_docs: concept_doc["_id"] = str(concept_doc["_id"]) # Convert ObjectId for JSON # Convert datetime objects to ISO format strings for JSON if isinstance(concept_doc.get("created_at"), datetime): @@ -1900,21 +2043,12 @@ def list_concepts( if isinstance(concept_doc.get("updated_at"), datetime): concept_doc["updated_at"] = concept_doc["updated_at"].isoformat() - # Ensure display name present for frontend using names[] accessor with fallbacks - try: - from ..vontology.utils_vontology import ( - get_concept_display_name_with_names_fallback, - ) - - resolved = get_concept_display_name_with_names_fallback(concept_doc) + # Canonical relation-backed names preserve acronyms and Unicode exactly. + own_concept_id = concept_doc.get("concept_id") + if isinstance(own_concept_id, str): + resolved = display_names.get(own_concept_id) if resolved: concept_doc["name"] = resolved - except Exception: - # Legacy fallback from concept_id - if not concept_doc.get("name"): - cid = concept_doc.get("concept_id") - if isinstance(cid, str) and cid.startswith("#V#"): - concept_doc["name"] = cid[3:].replace("_", " ").title() # Get concept type from relationships.is_an_instance_of relationships = concept_doc.get("relationships", {}) @@ -1949,32 +2083,18 @@ def list_concepts( concept_doc["direct_concept_id"] = "" # Don't overwrite the concept's own concept_id - that stays as-is - # Look up the proper name/title for the TYPE (what goes in parentheses) - try: - type_name = get_concept_name_by_id(str(primary_type_id)) - if type_name: - concept_doc["direct_concept_name"] = type_name - else: - # Fallback: Extract readable name from type concept_id like "#V#person" -> "Person" - if isinstance( - primary_type_id, str - ) and primary_type_id.startswith("#V#"): - type_name = primary_type_id[3:].replace("_", " ").title() - concept_doc["direct_concept_name"] = type_name - else: - concept_doc["direct_concept_name"] = str(primary_type_id) - except Exception as e: - logger.warning( - f"Could not lookup name for type {primary_type_id}: {e}" + # Resolve all direct type names in the same canonical batch. + primary_type_text = str(primary_type_id) + type_name = display_names.get(primary_type_text) + if not type_name: + type_name = ( + get_concept_display_name_with_names_fallback( + {"concept_id": primary_type_text} + ) + if primary_type_text.startswith("#V#") + else primary_type_text ) - # Fallback: Extract readable name from type concept_id - if isinstance(primary_type_id, str) and primary_type_id.startswith( - "#V#" - ): - type_name = primary_type_id[3:].replace("_", " ").title() - concept_doc["direct_concept_name"] = type_name - else: - concept_doc["direct_concept_name"] = str(primary_type_id) + concept_doc["direct_concept_name"] = type_name concepts_list.append(concept_doc) diff --git a/src/frontend/web/von_interface/static/js/conceptTab.js b/src/frontend/web/von_interface/static/js/conceptTab.js index 11f96a93..c4a75ef2 100644 --- a/src/frontend/web/von_interface/static/js/conceptTab.js +++ b/src/frontend/web/von_interface/static/js/conceptTab.js @@ -2722,6 +2722,7 @@ export async function fetchConceptListWithSuffix(conceptType, suffix) { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'concept-item-button'; + btn.dir = 'auto'; btn.classList.add('individual'); // Visual cue: individual (pure instance) btn.style.display = 'inline-block'; btn.style.padding = '2px 6px'; @@ -2797,6 +2798,7 @@ export async function fetchConceptListWithSuffix(conceptType, suffix) { const typeBtn = document.createElement('button'); typeBtn.type = 'button'; typeBtn.className = 'concept-item-button type'; // Visual cue: type + typeBtn.dir = 'auto'; typeBtn.style.display = 'inline-block'; typeBtn.style.padding = '2px 6px'; typeBtn.style.margin = '2px 0 2px 6px'; @@ -2867,6 +2869,7 @@ export async function fetchSubtypesWithSuffix(conceptId, suffix) { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'concept-item-button'; + btn.dir = 'auto'; // Safety fallback: if no name, derive from concept_id like "#V#some_type" -> "Some Type" const fallback = (child.id || child.node_id || child._id || '').toString().replace(/^#V#/, '').replace(/_/g, ' ').replace(/\s+/g, ' ').trim(); const pretty = fallback ? (fallback.charAt(0).toUpperCase() + fallback.slice(1)) : 'Type'; @@ -2910,6 +2913,7 @@ export async function fetchInstancesWithSuffix(conceptId, suffix) { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'concept-item-button'; + btn.dir = 'auto'; // Safety fallback for instances const fallback = (inst.id || inst._id || '').toString().replace(/^#V#/, '').replace(/_/g, ' ').replace(/\s+/g, ' ').trim(); const pretty = fallback ? (fallback.charAt(0).toUpperCase() + fallback.slice(1)) : 'Individual'; @@ -3125,46 +3129,23 @@ export async function displayConceptNames(names = [], suffix = '') { // Name text (click to edit) const nameText = document.createElement('span'); nameText.className = 'name-text'; + nameText.dir = 'auto'; nameText.textContent = nameObj.name || nameObj.text || ''; nameText.title = nameObj.name || nameObj.text || ''; - // Helpers for gated CamelCase spacing - const isLatinLanguageCode = (code) => { - if (!code) return false; - const c = String(code).toLowerCase(); - // Allow en, en-nz, es, fr, mi (Māori uses Latin script) - return c === 'en' || c === 'en-nz' || c === 'es' || c === 'fr' || c === 'mi'; - }; - const containsCJKorHangul = (s) => { - if (!s) return false; - // CJK Unified Ideographs, Hiragana, Katakana, Hangul - const cjk = /[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\u3000-\u303f]/; - const hangul = /[\u1100-\u11ff\u3130-\u318f\uac00-\ud7af]/; - return cjk.test(s) || hangul.test(s); - }; - // Auto-insert spaces for CamelCase/PascalCase when proposing edits - const autoSpaceCamelCase = (s) => { - if (!s) return s; - // Insert space between lowercase-to-uppercase and between acronyms followed by words - let spaced = s.replace(/([a-z])([A-Z])/g, '$1 $2'); - spaced = spaced.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2'); - return spaced; - }; - const isCodeName = (nameObj.type || '').toString().trim().toUpperCase() === 'CODE'; // Inline edit behaviour (JVNAUTOSCI-937): CODE names are read-only (for now). if (!isCodeName) nameText.addEventListener('click', () => { try { const original = nameObj.name || nameObj.text || ''; - // Only propose CamelCase spacing for Latin languages and when text looks Latin-script - const lang = (nameObj.language || '').toString(); - const shouldSpace = isLatinLanguageCode(lang) && !containsCJKorHangul(original); - const proposed = shouldSpace ? autoSpaceCamelCase(original) : original; const input = document.createElement('input'); input.type = 'text'; input.className = 'name-edit-input'; - input.value = proposed !== original ? proposed : original; + input.dir = 'auto'; + // Natural-language names are data, not identifiers to humanise. Keep + // acronyms, combining marks, and every Unicode script unchanged. + input.value = original; input.setAttribute('aria-label', 'Edit name text'); // Keep badges and delete button; replace only the text span cartouche.replaceChild(input, nameText); diff --git a/tests/backend/test_concept_display_name_projection.py b/tests/backend/test_concept_display_name_projection.py new file mode 100644 index 00000000..97a565de --- /dev/null +++ b/tests/backend/test_concept_display_name_projection.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +import pytest + + +def test_oldest_relation_name_beats_later_generated_duplicate_and_legacy(monkeypatch): + from src.backend.services import concept_service + + concept_id = "#V#current_uo_asail_ph_d_student" + monkeypatch.setattr( + concept_service, + "get_texts_for_concepts", + lambda *_args, **_kwargs: { + concept_id: [ + { + "text": "Current UoA SAIL PhD Student", + "lang": "en-NZ", + "context": {"name_type": "NL"}, + "relation_id": "697848218f11ba4cb9f7fa15", + }, + { + "text": "Current Uo Asail Ph D Student", + "lang": "en-NZ", + "context": {"name_type": "NL"}, + "relation_id": "69e95d03937092432f1c7111", + }, + ] + }, + ) + + resolved = concept_service.resolve_concept_display_names( + [ + { + "concept_id": concept_id, + "name": "Current Uo Asail Ph D Student", + } + ], + preferred_language="en-NZ", + ) + + assert resolved[concept_id] == "Current UoA SAIL PhD Student" + + +@pytest.mark.parametrize( + ("preferred_language", "stored_language", "stored_name"), + [ + ("sl-SI", "sl", "Študentka doktorskega študija"), + ("mi", "mi", "Te Whare Wānanga"), + ("zh", "zh", "博士研究生"), + ("ar", "ar", "طالبة دكتوراه"), + ("fr", "fr", "E\u0301tudiante en IA"), + ], +) +def test_relation_backed_name_preserves_unicode_exactly( + monkeypatch, + preferred_language, + stored_language, + stored_name, +): + from src.backend.services import concept_service + + concept_id = "#V#doctoral_student" + monkeypatch.setattr( + concept_service, + "get_texts_for_concepts", + lambda *_args, **_kwargs: { + concept_id: [ + { + "text": "Doctoral Student", + "lang": "en-NZ", + "context": {"name_type": "NL"}, + "relation_id": "2", + }, + { + "text": stored_name, + "lang": stored_language, + "context": {"name_type": "NL"}, + "relation_id": "1", + }, + ] + }, + ) + + resolved = concept_service.resolve_concept_display_names( + [{"concept_id": concept_id}], + preferred_language=preferred_language, + ) + + assert resolved[concept_id] == stored_name + assert [ord(char) for char in resolved[concept_id]] == [ + ord(char) for char in stored_name + ] + + +def test_list_concepts_batches_canonical_instance_and_type_names(monkeypatch): + from src.backend.services import concept_service, settings_service + + instance_id = "#V#ana_novak" + type_id = "#V#current_uo_a_ph_d_student" + instance_doc = { + "_id": "instance-object-id", + "concept_id": instance_id, + "name": "Ana Novak legacy", + "relationships": {"is_an_instance_of": [type_id]}, + } + type_doc = { + "concept_id": type_id, + "name": "Current Uo A Ph D Student", + } + find_calls = [] + + def fake_find(_query, projection=None, **_kwargs): + find_calls.append(projection) + return [instance_doc] if projection is None else [type_doc] + + monkeypatch.setattr( + concept_service.ConceptsRepository, + "collection", + staticmethod(lambda: object()), + ) + monkeypatch.setattr( + concept_service.ConceptsRepository, + "count_documents", + staticmethod(lambda _query: 1), + ) + monkeypatch.setattr( + concept_service.ConceptsRepository, + "find", + staticmethod(fake_find), + ) + monkeypatch.setattr( + concept_service, + "apply_concept_query_filter", + lambda query: query, + ) + monkeypatch.setattr( + concept_service, + "get_texts_for_concepts", + lambda *_args, **_kwargs: { + instance_id: [ + { + "text": "Ana Š. Novak", + "lang": "sl", + "context": {"name_type": "NL"}, + "relation_id": "1", + } + ], + type_id: [ + { + "text": "Current UoA PhD Student", + "lang": "en-NZ", + "context": {"name_type": "NL"}, + "relation_id": "2", + } + ], + }, + ) + monkeypatch.setattr(settings_service, "get_preferred_language", lambda: "en-NZ") + monkeypatch.setattr( + concept_service, + "get_concept_name_by_id", + lambda _concept_id: pytest.fail("per-row type lookup should not run"), + ) + + concepts, count = concept_service.list_concepts( + concept_id=type_id, + include_descendants=False, + ) + + assert count == 1 + assert len(find_calls) == 2 + assert concepts[0]["name"] == "Ana Š. Novak" + assert concepts[0]["direct_concept_name"] == "Current UoA PhD Student" + + +def test_text_relation_enrichment_updates_convenience_name(monkeypatch): + from src.backend.services import concept_service, text_value_service + + concept_id = "#V#current_uo_a_ph_d_student" + monkeypatch.setattr( + text_value_service, + "get_texts_for_concepts", + lambda *_args, **_kwargs: { + concept_id: [ + { + "predicate": "hasName", + "text": "Current UoA PhD Student", + "lang": "en-NZ", + "context": {"name_type": "NL"}, + "relation_id": "1", + } + ] + }, + ) + + enriched = concept_service.enrich_concept_with_text_relations( + { + "concept_id": concept_id, + "name": "Current Uo A Ph D Student", + "names": [], + } + ) + + assert enriched["name"] == "Current UoA PhD Student" diff --git a/tests/backend/test_vontology_entity_instances_routes.py b/tests/backend/test_vontology_entity_instances_routes.py index 47cb2a22..9a8db7e4 100644 --- a/tests/backend/test_vontology_entity_instances_routes.py +++ b/tests/backend/test_vontology_entity_instances_routes.py @@ -1,5 +1,6 @@ from __future__ import annotations +import pytest from flask import Flask from src.backend.server.routes.vontology_routes import vontology_bp @@ -147,6 +148,16 @@ def _fake_find(query, _projection, sort=None): "src.backend.server.routes.vontology_routes.ConceptsRepository.find", _fake_find, ) + monkeypatch.setattr( + "src.backend.server.routes.vontology_routes.resolve_concept_display_names", + lambda instance_docs: { + doc["concept_id"]: doc["name"] for doc in instance_docs + }, + ) + monkeypatch.setattr( + "src.backend.server.routes.vontology_routes.ConceptsRepository.update_one", + lambda *_args, **_kwargs: pytest.fail("GET /instances must not write names"), + ) resp = client.get( "/vontology/api/vontology/instances?node_id=%23V%23mammal&include_subtypes=true" @@ -157,3 +168,40 @@ def _fake_find(query, _projection, sort=None): ids = [item["id"] for item in payload["instances"]] assert ids == ["#V#alice", "#V#chimp"] assert "#V#mammal_kind" not in ids + + +def test_children_route_uses_canonical_unicode_name_without_writing(monkeypatch): + client = _create_client() + child_doc = { + "concept_id": "#V#doctoral_student", + "name": "Doctoral Student legacy", + "path": "", + } + + monkeypatch.setattr( + "src.backend.server.routes.vontology_routes.ConceptsRepository.find", + lambda *_args, **_kwargs: [child_doc], + ) + monkeypatch.setattr( + "src.backend.server.routes.vontology_routes.resolve_concept_display_names", + lambda child_docs: { + child_docs[0]["concept_id"]: "طالبة دكتوراه" + }, + ) + monkeypatch.setattr( + "src.backend.server.routes.vontology_routes.ConceptsRepository.update_one", + lambda *_args, **_kwargs: pytest.fail("GET /children must not write names"), + ) + + response = client.get( + "/vontology/api/vontology/children?node_id=%23V%23student" + ) + + assert response.status_code == 200 + assert response.get_json()["children"] == [ + { + "id": "#V#doctoral_student", + "name": "طالبة دكتوراه", + "path": "", + } + ] diff --git a/tests/frontend/conceptTabNameResolution.test.js b/tests/frontend/conceptTabNameResolution.test.js index c806d0e4..eed2131d 100644 --- a/tests/frontend/conceptTabNameResolution.test.js +++ b/tests/frontend/conceptTabNameResolution.test.js @@ -2,11 +2,13 @@ import { addNewName, + displayConceptNames, executeConceptIdRename, + fetchSubtypesWithSuffix, loadConceptNames, previewConceptIdRename, } from "../../src/frontend/web/von_interface/static/js/conceptTab.js"; -import { postJson } from "../../src/frontend/web/von_interface/static/js/apiService.js"; +import { getJson, patchJson, postJson } from "../../src/frontend/web/von_interface/static/js/apiService.js"; jest.mock('../../src/frontend/web/von_interface/static/js/apiService.js', () => ({ deleteJson: jest.fn(), @@ -72,7 +74,10 @@ function okJson(data) { describe('conceptTab name resolution', () => { beforeEach(() => { + jest.clearAllMocks(); selectedConceptState.selected = '#V#concept_beta'; + localStorage.clear(); + localStorage.setItem('von_preferred_language', 'en-NZ'); document.body.innerHTML = `
@@ -143,6 +148,62 @@ describe('conceptTab name resolution', () => { expect(namesList.textContent).toContain('Person Name Two'); }); + test('renders subtype names verbatim with automatic text direction', async () => { + document.body.innerHTML = ''; + getJson.mockResolvedValue({ + children: [ + { id: '#V#doctoral_student', name: 'طالبة دكتوراه' }, + { id: '#V#phd_student', name: 'Current UoA SAIL PhD Student' }, + ], + }); + + await fetchSubtypesWithSuffix('#V#student', 'alpha'); + + const buttons = Array.from(document.querySelectorAll('#subtypesList_alpha button')); + expect(buttons.map((button) => button.textContent)).toEqual([ + 'طالبة دكتوراه', + 'Current UoA SAIL PhD Student', + ]); + expect(buttons.every((button) => button.dir === 'auto')).toBe(true); + }); + + test.each([ + ['Current UoA SAIL PhD Student', 'en-NZ'], + ['Študentka doktorskega študija', 'sl'], + ['博士研究生', 'zh'], + ['طالبة دكتوراه', 'ar'], + ['E\u0301tudiante en IA', 'fr'], + ])('opens the name editor without reformatting %s', async (storedName, language) => { + document.body.innerHTML = ` +
+
+ +
+ `; + + await displayConceptNames([ + { + name: storedName, + language, + type: 'NL', + relation_id: 'name-relation-1', + }, + ], 'alpha'); + + const nameText = document.querySelector('#namesList_alpha .name-text'); + expect(nameText.textContent).toBe(storedName); + expect(nameText.dir).toBe('auto'); + nameText.click(); + + const input = document.querySelector('#namesList_alpha .name-edit-input'); + expect(input.value).toBe(storedName); + expect(input.dir).toBe('auto'); + input.blur(); + await Promise.resolve(); + + expect(patchJson).not.toHaveBeenCalled(); + }); + test('previews concept ID rename and enables execution only after a successful preview', async () => { document.body.innerHTML = `