From 5b8017e59f336dc3716a31ec234a8f0f022f511e Mon Sep 17 00:00:00 2001 From: witbrock Date: Fri, 11 Sep 2026 12:51:50 -0400 Subject: [PATCH] Resolve canonical task names in link metadata (JVNAUTOSCI-2744) --- src/backend/server/routes/vontology_routes.py | 15 +++-- .../test_vontology_node_content_route_soft.py | 58 +++++++++++++++++++ .../frontend/selectConceptByIdHandler.test.js | 20 +++++++ tests/frontend/taskPanelReference.test.js | 19 ++++++ 4 files changed, 107 insertions(+), 5 deletions(-) diff --git a/src/backend/server/routes/vontology_routes.py b/src/backend/server/routes/vontology_routes.py index b0e471138..7a5075c75 100644 --- a/src/backend/server/routes/vontology_routes.py +++ b/src/backend/server/routes/vontology_routes.py @@ -54,7 +54,7 @@ resolve_concept_display_names, update_concept_description, ) -from ...services.text_value_service import get_texts_for_concept +from ...services.text_value_service import get_texts_for_concept, get_texts_for_concepts from ...services.concept_relation_service import build_concept_relations_payload from ...services.relationship_extent_index_service import ( incoming_dynamic_extent_rows_page_for_target, @@ -1265,9 +1265,13 @@ def get_node_content_route(): raw_doc = trimmed.get("raw_doc") if concept_id and raw_doc is not None and isinstance(raw_doc, dict): try: - names_from_relations = get_texts_for_concept( - subject_concept_id=concept_id, predicate="hasName", limit=100 - ) + # Tasks store their title under the canonical #V#hasName. + # Read both supported name spellings in one bounded query. + names_from_relations = get_texts_for_concepts( + [concept_id], + predicates=["#V#hasName", "hasName"], + limit_per_concept=100, + ).get(concept_id, []) enriched_names = [ { "name": item.get("text", ""), @@ -1277,7 +1281,8 @@ def get_node_content_route(): } for item in names_from_relations ] - raw_doc["names"] = enriched_names + if enriched_names: + raw_doc["names"] = enriched_names # Also update display_name if we found NL names (prefer en-NZ) if enriched_names: # Priority: NL names in en-NZ, then any NL, then ABBR, then first available diff --git a/tests/backend/test_vontology_node_content_route_soft.py b/tests/backend/test_vontology_node_content_route_soft.py index 90d26dda0..9e08ad7e3 100644 --- a/tests/backend/test_vontology_node_content_route_soft.py +++ b/tests/backend/test_vontology_node_content_route_soft.py @@ -12,6 +12,64 @@ import pytest +@pytest.mark.parametrize("name_predicate", ["#V#hasName", "hasName", None]) +def test_raw_task_metadata_uses_name_aliases_and_preserves_link_identity( + app_client, monkeypatch, name_predicate +): + from src.backend.server.routes import vontology_routes as routes + from src.backend.services import vontology_concept_stats_service as stats + + _, client = app_client + task_id = "#V#task_agent_opaque_identifier" + title = "Implement slideable desktop conversation tray" + stored_names = [{"name": title, "language": "en-NZ", "type": "NL"}] + monkeypatch.setattr( + routes, + "get_vontology_node_content", + lambda *a, **k: { + "concept_id": task_id, + "display_name": "Task Agent Opaque Identifier", + "kind": "individual", + "raw_doc": { + "concept_id": task_id, + "names": stored_names if name_predicate is None else [], + "relationships": {"is_an_instance_of": ["#V#task_specification"]}, + }, + }, + ) + monkeypatch.setattr(stats, "get_vontology_concept_stats", lambda *a, **k: {}) + calls = [] + + def read_names(ids, **kwargs): + calls.append((ids, kwargs)) + assert set(kwargs["predicates"]) == {"hasName", "#V#hasName"} + return { + task_id: [ + { + "text": title, + "lang": "en-NZ", + "predicate": name_predicate, + "context": {}, + } + ] + if name_predicate + else [] + } + + monkeypatch.setattr(routes, "get_texts_for_concepts", read_names) + response = client.get( + "/vontology/api/vontology/node_content", + query_string={"identifier": task_id, "raw_only": "1"}, + ) + payload = response.get_json() + assert response.status_code == 200 + assert payload["concept_id"] == payload["raw_doc"]["concept_id"] == task_id + assert payload["raw_doc"]["names"][0]["name"] == title + if name_predicate: + assert payload["display_name"] == title + assert len(calls) == 1 + + @pytest.fixture def app_client(monkeypatch): # Stub Google auth deps pulled in by utils_flask -> auth_routes imports. diff --git a/tests/frontend/selectConceptByIdHandler.test.js b/tests/frontend/selectConceptByIdHandler.test.js index cd09bf378..775c76b93 100644 --- a/tests/frontend/selectConceptByIdHandler.test.js +++ b/tests/frontend/selectConceptByIdHandler.test.js @@ -4,6 +4,26 @@ const handlerPath = '../../src/frontend/web/von_interface/static/js/utils/select const decoratorPath = '../../src/frontend/web/von_interface/static/js/utils/textDecorator.js'; describe('handleSelectConceptByIdDetail', () => { + test('a readable task title keeps the exact task identity when opening its panel', async () => { + const { handleSelectConceptByIdDetail, hydrateConceptCartouchesInRoot } = require(handlerPath); + const { createVontologyCartouche } = require(decoratorPath); + const taskId = '#V#task_agent_opaque_identifier'; + const title = 'Implement slideable desktop conversation tray'; + const cartouche = createVontologyCartouche(taskId); + document.body.appendChild(cartouche); + const deps = { + createOrActivateConceptTab: jest.fn(), closeDynamicConceptTab: jest.fn(), openTaskPanel: jest.fn(), + fetchFn: jest.fn(async () => ({ ok: true, json: async () => ({ + concept_id: taskId, kind: 'individual', display_name: title, + raw_doc: { names: [{ name: title, language: 'en-NZ', type: 'NL' }], relationships: { is_an_instance_of: ['#V#task_specification'] } } + }) })) + }; + await hydrateConceptCartouchesInRoot(document.body, { fetchFn: deps.fetchFn }); + expect(cartouche.querySelector('.vontology-cartouche-name').textContent).toBe(title); + expect(cartouche.dataset.fullConceptId).toBe(taskId); + await handleSelectConceptByIdDetail({ conceptId: taskId, createConceptTab: true }, deps); + expect(deps.openTaskPanel).toHaveBeenCalledWith(taskId); + }); test('a represented task opens the shared task panel; explicit concept presentation remains available', async () => { const { handleSelectConceptByIdDetail } = require(handlerPath); const deps = { diff --git a/tests/frontend/taskPanelReference.test.js b/tests/frontend/taskPanelReference.test.js index f9829a437..8298011e6 100644 --- a/tests/frontend/taskPanelReference.test.js +++ b/tests/frontend/taskPanelReference.test.js @@ -2,6 +2,7 @@ jest.mock('../../src/frontend/web/von_interface/static/js/apiService.js', () => ({ getJson: jest.fn(), getUserContext: jest.fn(() => ({})) })); jest.mock('../../src/frontend/web/von_interface/static/js/tabNavigation.js', () => ({ activateTab: jest.fn() })); const base = '../../src/frontend/web/von_interface/static/js/'; +beforeEach(() => jest.resetModules()); test('opens a completed referenced task from Messages outside the hidden Conversations tab', async () => { document.body.innerHTML = '
'; const { getJson } = require(base + 'apiService.js'); @@ -14,3 +15,21 @@ test('opens a completed referenced task from Messages outside the hidden Convers expect(document.getElementById('taskList').dataset.viewMode).toBe('list'); expect(getJson.mock.calls.some(([url]) => url.startsWith('/api/tasks/my'))).toBe(false); }); + +test('an older task response cannot replace the most recently selected task', async () => { + document.body.innerHTML = ''; + const { getJson } = require(base + 'apiService.js'); + let resolveEarlier; + getJson.mockImplementation(async url => { + if (url === '/api/tasks/%23V%23earlier_task') return new Promise(resolve => { resolveEarlier = resolve; }); + if (url === '/api/tasks/%23V%23latest_task') return { task_concept_id: '#V#latest_task', title: 'Latest selected task', status: 'completed' }; + return {}; + }); + const { openTaskInPanel } = require(base + 'components/taskPanel.js'); + const earlier = openTaskInPanel('#V#earlier_task'); + await openTaskInPanel('#V#latest_task'); + resolveEarlier({ task_concept_id: '#V#earlier_task', title: 'Earlier task', status: 'pending' }); + await earlier; + expect(document.getElementById('taskList').textContent).toContain('Latest selected task'); + expect(document.getElementById('taskList').textContent).not.toContain('Earlier task'); +});