Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions src/backend/server/routes/vontology_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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", ""),
Expand All @@ -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
Expand Down
58 changes: 58 additions & 0 deletions tests/backend/test_vontology_node_content_route_soft.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 20 additions & 0 deletions tests/frontend/selectConceptByIdHandler.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
19 changes: 19 additions & 0 deletions tests/frontend/taskPanelReference.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<div id="chatTab" hidden><div id="taskPanel" class="hidden"><div id="taskList"></div></div></div><div id="messagesTab"></div>';
const { getJson } = require(base + 'apiService.js');
Expand All @@ -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 = '<div id="taskPanel" class="hidden"><div id="taskList"></div></div>';
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');
});
Loading