diff --git a/medcat-trainer/webapp/.dockerignore b/medcat-trainer/webapp/.dockerignore index 62c88576a..8ae4a023e 100644 --- a/medcat-trainer/webapp/.dockerignore +++ b/medcat-trainer/webapp/.dockerignore @@ -9,4 +9,15 @@ __pycache__/ *.md .pytest_cache .mypy_cache -node_modules/ + +# Frontend — rebuilt in the frontend-builder stage +frontend/node_modules/ +frontend/dist/ +frontend/coverage/ + +# Backend tests — not needed in the production image +api/**/tests/ + +# User-uploaded models and datasets — mounted at /home/api/media in compose +api/media/* +!api/media/.keep diff --git a/medcat-trainer/webapp/Dockerfile b/medcat-trainer/webapp/Dockerfile index 47c3c22af..831012915 100644 --- a/medcat-trainer/webapp/Dockerfile +++ b/medcat-trainer/webapp/Dockerfile @@ -1,57 +1,69 @@ -FROM python:3.12 +# ----------------------------------------------------------------------------- +# Stage 1: Build frontend assets (Node toolchain discarded after this stage) +# ----------------------------------------------------------------------------- +FROM node:20-bookworm-slim AS frontend-builder -# Update and upgrade everything -RUN apt-get update -y && \ - apt-get upgrade -y - -# install vim as its annoying not to have an editor -RUN apt-get install -y vim - -# install supervisor -RUN apt-get install -y supervisor +WORKDIR /build +COPY frontend/package.json frontend/package-lock.json ./ +RUN --mount=type=cache,target=/root/.npm \ + npm ci --prefer-offline --no-audit --no-fund -# install gettext for envsubst (used to generate runtime config) -RUN apt-get install -y gettext +COPY frontend/ ./ +# CI test-frontend already runs type-check; build-only avoids a second vue-tsc pass. +# No sourcemaps in the image — saves ~25MB+ and build I/O. +RUN NODE_OPTIONS=--max-old-space-size=4096 \ + npm run build-only -- --sourcemap false -# install cron - and remove any default tabs -RUN apt-get install -y cron && which cron && rm -rf /etc/cron.*/* +# ----------------------------------------------------------------------------- +# Stage 2: Install Python deps (Rust/build tools discarded after this stage) +# ----------------------------------------------------------------------------- +FROM python:3.12-bookworm AS python-builder -# Get node and npm -RUN apt install -y nodejs && apt install -y npm +RUN apt-get update -y && \ + apt-get install -y --no-install-recommends build-essential curl && \ + rm -rf /var/lib/apt/lists/* -# Install Rust - for tokenziers dep in medcat. RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y ENV PATH="/root/.cargo/bin:${PATH}" -# Copy dependency files first for better layer caching -WORKDIR /home/frontend -COPY frontend/package.json frontend/package-lock.json ./ -RUN npm install - -# Install uv and Python dependencies WORKDIR /home -COPY pyproject.toml uv.lock* ./ -# Install dependencies using a buildkit cache mount for speed on repeat +COPY pyproject.toml uv.lock ./ RUN --mount=type=cache,target=/root/.cache/uv \ - pip install uv && \ + pip install --no-cache-dir uv && \ uv sync --frozen --cache-dir=/root/.cache/uv --no-install-project --extra observability -# Ensure venv has pip (uv venvs don't include it; spacy download needs it) RUN uv run python -m ensurepip --upgrade -# Download spaCy models (only requires spaCy, not application code) ARG SPACY_MODELS="en_core_web_md" RUN for SPACY_MODEL in ${SPACY_MODELS}; do uv run python -m spacy download ${SPACY_MODEL}; done -# Copy rest of project +# ----------------------------------------------------------------------------- +# Stage 3: Runtime image — no Node, npm, Rust, or frontend devDependencies +# ----------------------------------------------------------------------------- +FROM python:3.12-bookworm + +RUN apt-get update -y && \ + apt-get install -y --no-install-recommends \ + vim \ + supervisor \ + gettext \ + cron \ + && rm -rf /var/lib/apt/lists/* \ + && rm -rf /etc/cron.*/* + +RUN pip install --no-cache-dir uv + WORKDIR /home -COPY ./ . +COPY pyproject.toml uv.lock ./ +COPY --from=python-builder /home/.venv /home/.venv +COPY api ./api +COPY scripts ./scripts +COPY templates ./templates +COPY --from=frontend-builder /build/dist ./frontend/dist -# Build frontend -WORKDIR /home/frontend -RUN npm run build +# MEDIA_ROOT is a runtime volume; ensure the directory exists without baking in local uploads +RUN mkdir -p /home/api/media -# copy backup crontab and chmod scripts RUN chmod u+x /home/scripts/entry.sh && \ chmod u+x /home/scripts/crontab && cp /home/scripts/crontab /etc/crontab && \ chmod a+x /home/scripts/run.sh && \ @@ -59,4 +71,3 @@ RUN chmod u+x /home/scripts/entry.sh && \ chmod a+x /home/scripts/nginx-entrypoint.sh WORKDIR /home/api/ - diff --git a/medcat-trainer/webapp/api/api/tests/_helpers.py b/medcat-trainer/webapp/api/api/tests/_helpers.py new file mode 100644 index 000000000..44103e5ac --- /dev/null +++ b/medcat-trainer/webapp/api/api/tests/_helpers.py @@ -0,0 +1,91 @@ +"""Shared helpers for backend tests. + +These utilities make it easier to construct lightweight model fixtures +without triggering MedCAT model loading or expensive dataset parsing. +""" + +import os +import tempfile +from contextlib import contextmanager + +import pandas as pd + +from django.contrib.auth.models import User + +from .. import signals as api_signals +from ..models import ( + ConceptDB, + Dataset, + Document, + Entity, + ProjectAnnotateEntities, + Vocabulary, +) + + +@contextmanager +def dataset_signals_disconnected(): + """Temporarily disconnect Dataset post_save / pre_save signals. + + Useful in unit tests that want to insert a Dataset row without triggering + `dataset_from_file` which expects a CSV/XLSX on disk with the right schema. + """ + from django.db.models.signals import post_save, pre_save + + post_save.disconnect(api_signals.save_dataset, sender=Dataset) + pre_save.disconnect(api_signals.pre_save_dataset, sender=Dataset) + try: + yield + finally: + post_save.connect(api_signals.save_dataset, sender=Dataset) + pre_save.connect(api_signals.pre_save_dataset, sender=Dataset) + + +def create_dataset(name='test-dataset', file_name='test-dataset.csv'): + """Create a Dataset row without firing the file-parsing signals.""" + with dataset_signals_disconnected(): + ds = Dataset.objects.create(name=name, original_file=file_name) + return ds + + +def make_csv_file(tmp_dir, rows=None, file_name='dataset.csv'): + """Write a small CSV with 'name' and 'text' columns and return its path.""" + if rows is None: + rows = [ + {'name': 'doc-a', 'text': 'Patient reports chest pain.'}, + {'name': 'doc-b', 'text': 'No fever or cough.'}, + ] + path = os.path.join(tmp_dir, file_name) + pd.DataFrame(rows).to_csv(path, index=False) + return path + + +def create_basic_project(name='test-project'): + """Create a ProjectAnnotateEntities along with a CDB / Vocab / Dataset.""" + cdb = ConceptDB(name=f'{name}-cdb', cdb_file=f'{name}-cdb.dat') + cdb.save(skip_load=True) + vocab = Vocabulary(name=f'{name}-vocab', vocab_file=f'{name}-vocab.dat') + vocab.save(skip_load=True) + + ds = create_dataset(name=f'{name}-ds', file_name=f'{name}-ds.csv') + + project = ProjectAnnotateEntities() + project.name = name + project.dataset = ds + project.concept_db = cdb + project.vocab = vocab + project.cuis = '' + project.save() + return project + + +def create_document(project, name='doc1', text='hello world'): + return Document.objects.create(name=name, text=text, dataset=project.dataset) + + +def create_user(username='testuser', password='pw', **extra): + return User.objects.create_user(username=username, password=password, **extra) + + +def create_entity(label='C001'): + return Entity.objects.create(label=label) diff --git a/medcat-trainer/webapp/api/api/tests/test_admin_actions.py b/medcat-trainer/webapp/api/api/tests/test_admin_actions.py new file mode 100644 index 000000000..54e635646 --- /dev/null +++ b/medcat-trainer/webapp/api/api/tests/test_admin_actions.py @@ -0,0 +1,150 @@ +"""Unit tests for api.admin.actions. + +These tests focus on retrieve_project_data and the download_* helpers since +they back the JSON export feature that the upload tests already validate. +""" + +import json + +from django.test import TestCase, override_settings + +from ..admin.actions import ( + download_projects_with_text, + download_projects_without_text, + retrieve_project_data, +) +from ..models import ( + AnnotatedEntity, + EntityRelation, + MetaAnnotation, + MetaTask, + MetaTaskValue, + ProjectAnnotateEntities, + Relation, +) +from ._helpers import ( + create_basic_project, + create_document, + create_entity, + create_user, +) + + +@override_settings(MEDIA_ROOT='/tmp/mct-tests-admin') +class RetrieveProjectDataTests(TestCase): + def setUp(self): + self.user = create_user(username='admin-actions-user') + self.project = create_basic_project(name='admin-actions-proj') + self.doc = create_document(self.project, name='doc-1', text='hello world') + self.entity = create_entity(label='C100') + self.entity_b = create_entity(label='C200') + + self.ann_a = AnnotatedEntity.objects.create( + user=self.user, project=self.project, document=self.doc, entity=self.entity, + value='hello', start_ind=0, end_ind=5, acc=0.9, validated=True, correct=True, + ) + self.ann_b = AnnotatedEntity.objects.create( + user=self.user, project=self.project, document=self.doc, entity=self.entity_b, + value='world', start_ind=6, end_ind=11, acc=0.95, validated=True, correct=True, + ) + + self.task = MetaTask.objects.create(name='Presence') + self.value = MetaTaskValue.objects.create(name='True') + MetaAnnotation.objects.create( + annotated_entity=self.ann_a, + meta_task=self.task, + meta_task_value=self.value, + validated=True, + ) + + self.project.validated_documents.add(self.doc) + + def test_returns_basic_project_metadata(self): + out = retrieve_project_data(ProjectAnnotateEntities.objects.filter(id=self.project.id)) + self.assertEqual(len(out['projects']), 1) + proj = out['projects'][0] + self.assertEqual(proj['name'], 'admin-actions-proj') + self.assertEqual(proj['cuis'], self.project.cuis) + self.assertEqual(proj['project_status'], 'A') + self.assertEqual(len(proj['documents']), 1) + + def test_includes_annotation_text_and_indices(self): + out = retrieve_project_data(ProjectAnnotateEntities.objects.filter(id=self.project.id)) + doc = out['projects'][0]['documents'][0] + cuis = sorted(a['cui'] for a in doc['annotations']) + self.assertEqual(cuis, ['C100', 'C200']) + # check start/end indices match + ann_a = next(a for a in doc['annotations'] if a['cui'] == 'C100') + self.assertEqual(ann_a['start'], 0) + self.assertEqual(ann_a['end'], 5) + self.assertEqual(ann_a['value'], 'hello') + self.assertTrue(ann_a['validated']) + self.assertTrue(ann_a['correct']) + + def test_includes_meta_annotations(self): + out = retrieve_project_data(ProjectAnnotateEntities.objects.filter(id=self.project.id)) + doc = out['projects'][0]['documents'][0] + ann_a = next(a for a in doc['annotations'] if a['cui'] == 'C100') + self.assertIn('Presence', ann_a['meta_anns']) + self.assertEqual(ann_a['meta_anns']['Presence']['value'], 'True') + + def test_relations_included(self): + rel = Relation.objects.create(label='hasFinding') + EntityRelation.objects.create( + user=self.user, + project=self.project, + document=self.doc, + relation=rel, + start_entity=self.ann_a, + end_entity=self.ann_b, + validated=True, + ) + + out = retrieve_project_data(ProjectAnnotateEntities.objects.filter(id=self.project.id)) + rels = out['projects'][0]['documents'][0]['relations'] + self.assertEqual(len(rels), 1) + self.assertEqual(rels[0]['relation'], 'hasFinding') + self.assertEqual(rels[0]['start_entity_cui'], 'C100') + self.assertEqual(rels[0]['end_entity_cui'], 'C200') + + +@override_settings(MEDIA_ROOT='/tmp/mct-tests-admin') +class DownloadProjectsTests(TestCase): + def setUp(self): + self.user = create_user(username='dl-action-user') + self.project = create_basic_project(name='dl-action-proj') + self.doc = create_document(self.project, name='doc-only', text='annotated text') + ent = create_entity(label='C-DL') + AnnotatedEntity.objects.create( + user=self.user, project=self.project, document=self.doc, entity=ent, + value='annotated', start_ind=0, end_ind=9, acc=1.0, validated=True, correct=True, + ) + self.project.validated_documents.add(self.doc) + + def test_download_with_text_includes_document_text(self): + resp = download_projects_with_text( + ProjectAnnotateEntities.objects.filter(id=self.project.id) + ) + self.assertEqual(resp.status_code, 200) + body = json.loads(resp.content) + self.assertEqual(body['projects'][0]['documents'][0]['text'], 'annotated text') + + def test_download_without_text_omits_document_text(self): + resp = download_projects_without_text( + ProjectAnnotateEntities.objects.filter(id=self.project.id), + with_doc_name=False, + ) + self.assertEqual(resp.status_code, 200) + body = json.loads(resp.content) + doc = body['projects'][0]['documents'][0] + self.assertNotIn('text', doc) + + def test_download_without_text_with_doc_name_includes_name(self): + resp = download_projects_without_text( + ProjectAnnotateEntities.objects.filter(id=self.project.id), + with_doc_name=True, + ) + body = json.loads(resp.content) + doc = body['projects'][0]['documents'][0] + self.assertEqual(doc['name'], 'doc-only') + self.assertNotIn('text', doc) diff --git a/medcat-trainer/webapp/api/api/tests/test_data_utils_extras.py b/medcat-trainer/webapp/api/api/tests/test_data_utils_extras.py new file mode 100644 index 000000000..ae312fcd2 --- /dev/null +++ b/medcat-trainer/webapp/api/api/tests/test_data_utils_extras.py @@ -0,0 +1,157 @@ +"""Additional unit tests for api.data_utils functions not already covered by +test_data_utils.py. +""" + +import os +import tempfile + +import pandas as pd +from django.test import TestCase, override_settings + +from ..data_utils import dataset_from_file, delete_orphan_docs, sanitise_input +from ..models import Document +from ._helpers import create_dataset, dataset_signals_disconnected, make_csv_file + + +class SanitiseInputTests(TestCase): + def test_replaces_br_with_newline(self): + self.assertEqual(sanitise_input('a
b'), 'a\nb') + + def test_replaces_paragraph_with_newline(self): + self.assertEqual(sanitise_input('

hi

'), '\nhi\n') + + def test_strips_span_tags_keeping_content(self): + self.assertEqual(sanitise_input('word'), 'word') + + def test_replaces_div_tags_with_newlines(self): + # Opening tag requires attributes in the regex; closing becomes a newline. + self.assertEqual(sanitise_input('
part1
'), '\npart1\n') + + def test_strips_html_body_head(self): + self.assertEqual( + sanitise_input('data'), + 'data', + ) + + def test_plain_text_returned_unchanged(self): + self.assertEqual(sanitise_input('just text'), 'just text') + + def test_multiple_tags_in_single_string(self): + text = '

Line1


Line2' + self.assertEqual(sanitise_input(text), '\nLine1\n\nLine2') + + +@override_settings(MEDIA_ROOT='/tmp/mct-tests-data-utils') +class DeleteOrphanDocsTests(TestCase): + def test_removes_all_documents_for_dataset(self): + ds = create_dataset(name='orphan-ds', file_name='orphan-ds.csv') + Document.objects.create(name='a', text='x', dataset=ds) + Document.objects.create(name='b', text='y', dataset=ds) + + self.assertEqual(Document.objects.filter(dataset=ds).count(), 2) + + delete_orphan_docs(ds) + + self.assertEqual(Document.objects.filter(dataset=ds).count(), 0) + + +class DatasetFromFileTests(TestCase): + def setUp(self): + self.tmp_dir = tempfile.mkdtemp() + self.addCleanup(self._cleanup) + + def _cleanup(self): + import shutil + shutil.rmtree(self.tmp_dir, ignore_errors=True) + + def _make_dataset_with_file(self, name, csv_path): + # Bypass the post_save signal that would re-trigger dataset_from_file + with dataset_signals_disconnected(): + ds = create_dataset(name=name, file_name='ignored.csv') + ds.original_file.name = csv_path + return ds + + @override_settings(MEDIA_ROOT='/') + def test_creates_documents_for_each_row_in_csv(self): + csv_path = make_csv_file( + self.tmp_dir, + rows=[ + {'name': 'd1', 'text': 'first text'}, + {'name': 'd2', 'text': 'second text'}, + ], + file_name='data.csv', + ) + ds = self._make_dataset_with_file('dff-1', csv_path) + + dataset_from_file(ds) + + docs = Document.objects.filter(dataset=ds).order_by('name') + self.assertEqual(docs.count(), 2) + self.assertEqual(docs[0].name, 'd1') + self.assertEqual(docs[0].text, 'first text') + + @override_settings(MEDIA_ROOT='/') + def test_sanitises_html_in_text_column(self): + csv_path = make_csv_file( + self.tmp_dir, + rows=[{'name': 'd1', 'text': '

hello

'}], + file_name='data.csv', + ) + ds = self._make_dataset_with_file('dff-2', csv_path) + + dataset_from_file(ds) + + doc = Document.objects.get(dataset=ds, name='d1') + self.assertEqual(doc.text, '\nhello\n') + + @override_settings(MEDIA_ROOT='/') + def test_raises_on_non_unique_names(self): + csv_path = make_csv_file( + self.tmp_dir, + rows=[ + {'name': 'dup', 'text': 'a'}, + {'name': 'dup', 'text': 'b'}, + ], + file_name='data.csv', + ) + ds = self._make_dataset_with_file('dff-3', csv_path) + + with self.assertRaises(Exception) as ctx: + dataset_from_file(ds) + self.assertIn('name column', str(ctx.exception)) + + @override_settings(MEDIA_ROOT='/') + def test_raises_when_exceeding_max_size(self): + old = os.environ.get('MAX_DATASET_SIZE') + os.environ['MAX_DATASET_SIZE'] = '1' + try: + csv_path = make_csv_file( + self.tmp_dir, + rows=[ + {'name': 'a', 'text': 't1'}, + {'name': 'b', 'text': 't2'}, + ], + file_name='data.csv', + ) + ds = self._make_dataset_with_file('dff-4', csv_path) + + with self.assertRaises(Exception) as ctx: + dataset_from_file(ds) + self.assertIn('Max dataset size', str(ctx.exception)) + finally: + if old is None: + os.environ.pop('MAX_DATASET_SIZE', None) + else: + os.environ['MAX_DATASET_SIZE'] = old + + @override_settings(MEDIA_ROOT='/') + def test_rejects_unsupported_extensions(self): + # The original_file path must end with neither .csv nor .xlsx + path = os.path.join(self.tmp_dir, 'bad_ext.tsv') + with open(path, 'w') as f: + f.write('name\ttext\n1\t2\n') + ds = self._make_dataset_with_file('dff-5', path) + + with self.assertRaises(Exception) as ctx: + dataset_from_file(ds) + self.assertIn('.csv or .xlsx', str(ctx.exception)) diff --git a/medcat-trainer/webapp/api/api/tests/test_metrics.py b/medcat-trainer/webapp/api/api/tests/test_metrics.py new file mode 100644 index 000000000..f938d529e --- /dev/null +++ b/medcat-trainer/webapp/api/api/tests/test_metrics.py @@ -0,0 +1,166 @@ +"""Unit tests for api.metrics.ProjectMetrics. + +These tests exercise the pure-Python data-shaping logic in ProjectMetrics +using a synthetic MedCAT export. Code paths requiring an actual CAT model +are exercised by passing cat=None. +""" + +import pandas as pd +from django.test import TestCase + +from ..metrics import ProjectMetrics + + +def _build_export(num_projects=1, num_docs=2, num_anns=2): + """Build a synthetic MedCAT trainer export structure used for metrics tests.""" + projects = [] + next_id = 1 + for p_idx in range(num_projects): + proj = { + 'id': p_idx + 1, + 'name': f'proj-{p_idx + 1}', + 'meta_anno_defs': [ + {'name': 'Presence', 'values': ['True', 'False']}, + ], + 'documents': [], + } + for d_idx in range(num_docs): + doc = { + 'id': next_id, + 'name': f'doc-{next_id}', + 'text': 'some clinical text', + 'annotations': [], + } + next_id += 1 + for a_idx in range(num_anns): + doc['annotations'].append({ + 'id': 1000 + a_idx + d_idx * 10 + p_idx * 100, + 'cui': 'C001' if a_idx % 2 == 0 else 'C002', + 'value': 'token', + 'start': a_idx * 10, + 'end': a_idx * 10 + 5, + 'validated': True, + 'correct': True, + 'deleted': False, + 'alternative': False, + 'killed': False, + 'irrelevant': False, + 'manually_created': False, + 'acc': 1.0, + 'user': f'user{a_idx % 2}', + 'last_modified': '2024-01-01 10:00:00.000000', + 'meta_anns': { + 'Presence': { + 'name': 'Presence', + 'value': 'True' if a_idx % 2 == 0 else 'False', + 'acc': 1.0, + 'validated': True, + } + }, + }) + proj['documents'].append(doc) + projects.append(proj) + return {'projects': projects} + + +class ProjectMetricsInitTests(TestCase): + def test_annotations_extracted_with_project_and_doc_metadata(self): + export = _build_export(num_projects=1, num_docs=1, num_anns=2) + pm = ProjectMetrics(export, cat=None) + self.assertEqual(len(pm.annotations), 2) + ann = pm.annotations[0] + self.assertEqual(ann['project'], 'proj-1') + self.assertEqual(ann['project_id'], 1) + self.assertEqual(ann['document_name'], 'doc-1') + self.assertEqual(ann['document_id'], 1) + self.assertIn('Presence', ann) # meta annotations flattened + + def test_projects2names_and_doc_maps_populated(self): + export = _build_export(num_projects=2, num_docs=2, num_anns=1) + pm = ProjectMetrics(export, cat=None) + self.assertEqual(pm.projects2names[1], 'proj-1') + self.assertEqual(pm.projects2names[2], 'proj-2') + self.assertEqual(len(pm.projects2doc_ids[1]), 2) + self.assertEqual(len(pm.projects2doc_ids[2]), 2) + # docs2names contains all docs + self.assertEqual(len(pm.docs2names), 4) + + def test_meta_annotation_values_flattened_per_annotation(self): + export = _build_export(num_projects=1, num_docs=1, num_anns=2) + pm = ProjectMetrics(export, cat=None) + # Two annotations: one True, one False + presence_values = sorted(a['Presence'] for a in pm.annotations) + self.assertEqual(presence_values, ['False', 'True']) + + +class AnnotationDataFrameTests(TestCase): + def test_annotation_df_without_cat_does_not_add_concept_name(self): + export = _build_export(num_anns=2) + pm = ProjectMetrics(export, cat=None) + df = pm.annotation_df() + self.assertIsInstance(df, pd.DataFrame) + self.assertEqual(len(df), 2 * 2) # docs * anns + self.assertNotIn('concept_name', df.columns) + + def test_concept_summary_without_cat_returns_basic_records(self): + export = _build_export(num_anns=2) + pm = ProjectMetrics(export, cat=None) + summary = pm.concept_summary() + self.assertIsInstance(summary, list) + # All annotations are validated+correct, so all should appear + cuis = {row['cui'] for row in summary} + self.assertEqual(cuis, {'C001', 'C002'}) + + def test_user_stats_groups_by_user(self): + export = _build_export(num_docs=2, num_anns=2) + pm = ProjectMetrics(export, cat=None) + stats = pm.user_stats(by_user=True) + self.assertIsInstance(stats, pd.DataFrame) + users = set(stats['user'].tolist()) + self.assertEqual(users, {'user0', 'user1'}) + + def test_user_stats_by_date_includes_date_column(self): + export = _build_export(num_docs=1, num_anns=2) + pm = ProjectMetrics(export, cat=None) + stats = pm.user_stats(by_user=False) + self.assertIn('date', stats.columns) + self.assertIn('user', stats.columns) + self.assertIn('count', stats.columns) + + +class RenameMetaAnnsTests(TestCase): + def test_rename_meta_task_name(self): + export = _build_export(num_docs=1, num_anns=1) + pm = ProjectMetrics(export, cat=None) + # The annotation initially has 'Presence' key. + self.assertIn('Presence', pm.annotations[0]) + + pm.rename_meta_anns(meta_anns2rename={'Presence': 'Existence'}) + + # Original 'Presence' should be renamed to 'Existence' + # Note: the rename happens on the underlying mct_export then _annotations() + # is rebuilt. So check on the rebuilt annotations list. + self.assertNotIn('Presence', pm.annotations[0]) + self.assertIn('Existence', pm.annotations[0]) + + def test_rename_meta_value_when_specified(self): + export = _build_export(num_docs=1, num_anns=1) + pm = ProjectMetrics(export, cat=None) + # Rename 'True' to 'Yes' inside the renamed task 'Existence' + pm.rename_meta_anns( + meta_anns2rename={'Presence': 'Existence'}, + meta_ann_values2rename={'Existence': {'True': 'Yes'}}, + ) + self.assertEqual(pm.annotations[0]['Existence'], 'Yes') + + +class EmptyExportTests(TestCase): + def test_handles_empty_documents_without_error(self): + export = {'projects': [{'id': 1, 'name': 'empty', 'documents': [], + 'meta_anno_defs': []}]} + pm = ProjectMetrics(export, cat=None) + self.assertEqual(pm.annotations, []) + # annotation_df on empty annotations raises; just check the helpers + # that should still work. + self.assertEqual(pm.projects2names[1], 'empty') + self.assertEqual(pm.projects2doc_ids[1], []) diff --git a/medcat-trainer/webapp/api/api/tests/test_model_cache.py b/medcat-trainer/webapp/api/api/tests/test_model_cache.py new file mode 100644 index 000000000..983cf3370 --- /dev/null +++ b/medcat-trainer/webapp/api/api/tests/test_model_cache.py @@ -0,0 +1,206 @@ +"""Unit tests for api.model_cache. + +We avoid loading actual MedCAT artifacts by mocking CDB.load / Vocab.load / +CAT.load_model_pack. Cache state is reset in setUp and tearDown. +""" + +from unittest.mock import MagicMock, patch + +from django.test import TestCase, override_settings + +from .. import model_cache +from ..models import ConceptDB, ModelPack, Vocabulary +from ._helpers import create_basic_project + + +@override_settings(MEDIA_ROOT='/tmp/mct-tests-model-cache') +class ModelCacheTests(TestCase): + def setUp(self): + self.cdb_map = {} + self.vocab_map = {} + self.cat_map = {} + self.project = create_basic_project(name='mc-proj') + + def test_is_model_loaded_returns_false_when_cache_empty(self): + self.assertFalse( + model_cache.is_model_loaded(self.project, cdb_map=self.cdb_map, cat_map=self.cat_map) + ) + + def test_is_model_loaded_returns_true_when_cdb_cached(self): + self.cdb_map[self.project.concept_db.id] = MagicMock() + self.assertTrue( + model_cache.is_model_loaded(self.project, cdb_map=self.cdb_map, cat_map=self.cat_map) + ) + + def test_get_cached_medcat_returns_none_when_missing(self): + self.assertIsNone( + model_cache.get_cached_medcat(self.project, cat_map=self.cat_map) + ) + + def test_get_cached_medcat_returns_value_when_present(self): + cat_id = f'{self.project.concept_db.id}-{self.project.vocab.id}' + sentinel = MagicMock() + self.cat_map[cat_id] = sentinel + self.assertIs( + model_cache.get_cached_medcat(self.project, cat_map=self.cat_map), + sentinel, + ) + + def test_get_cached_medcat_raises_when_no_cdb_and_not_remote(self): + # Project without CDB but not using a remote service - should raise + self.project.concept_db = None + self.project.use_model_service = False + with self.assertRaises(Exception) as ctx: + model_cache.get_cached_medcat(self.project, cat_map=self.cat_map) + self.assertIn('misconfigured', str(ctx.exception)) + + def test_get_cached_medcat_raises_for_remote_service_project(self): + self.project.use_model_service = True + self.project.model_service_url = 'http://x' + self.project.concept_db = None + self.project.vocab = None + self.project.save() + with self.assertRaises(ValueError): + model_cache.get_cached_medcat(self.project, cat_map=self.cat_map) + + def test_clear_cached_medcat_removes_cat_from_cat_map(self): + cdb_id = self.project.concept_db.id + vocab_id = self.project.vocab.id + cat_id = f'{cdb_id}-{vocab_id}' + self.cat_map[cat_id] = MagicMock() + + model_cache.clear_cached_medcat(self.project, cat_map=self.cat_map) + + self.assertNotIn(cat_id, self.cat_map) + + def test_clear_cached_cdb_no_op_when_missing(self): + # Should not raise even if cdb not in map + model_cache.clear_cached_cdb(99999, cdb_map=self.cdb_map) + + def test_clear_cached_vocab_no_op_when_missing(self): + model_cache.clear_cached_vocab(99999, vocab_map=self.vocab_map) + + +@override_settings(MEDIA_ROOT='/tmp/mct-tests-model-cache') +class GetCachedCdbTests(TestCase): + def setUp(self): + self.cdb = ConceptDB(name='cached-cdb', cdb_file='cached-cdb.dat') + self.cdb.save(skip_load=True) + self.cdb_map = {} + + @patch('api.utils.clear_cdb_cnf_addons') + @patch('api.model_cache.CDB.load') + def test_loads_when_not_cached(self, mock_load, mock_clear): + loaded = MagicMock() + mock_load.return_value = loaded + + cached = model_cache.get_cached_cdb(self.cdb.id, cdb_map=self.cdb_map) + self.assertIs(cached, loaded) + self.assertIn(self.cdb.id, self.cdb_map) + mock_clear.assert_called_once() + + @patch('api.model_cache.CDB.load') + def test_returns_existing_when_cached(self, mock_load): + sentinel = MagicMock() + self.cdb_map[self.cdb.id] = sentinel + result = model_cache.get_cached_cdb(self.cdb.id, cdb_map=self.cdb_map) + self.assertIs(result, sentinel) + mock_load.assert_not_called() + + +@override_settings(MEDIA_ROOT='/tmp/mct-tests-model-cache') +class IsModelPackLoadedTests(TestCase): + def test_returns_true_when_present(self): + cat_map = {'mp42': MagicMock()} + self.assertTrue(model_cache.is_model_pack_loaded(42, cat_map=cat_map)) + + def test_returns_false_when_absent(self): + self.assertFalse(model_cache.is_model_pack_loaded(42, cat_map={})) + + def test_clear_by_modelpack_id_removes_entry(self): + cat_map = {'mp42': MagicMock(), 'mp7': MagicMock()} + model_cache.clear_cached_medcat_by_model_pack_id(42, cat_map=cat_map) + self.assertNotIn('mp42', cat_map) + self.assertIn('mp7', cat_map) + + def test_clear_by_modelpack_id_no_op_when_missing(self): + cat_map = {} + # Should not raise + model_cache.clear_cached_medcat_by_model_pack_id(42, cat_map=cat_map) + + +@override_settings(MEDIA_ROOT='/tmp/mct-tests-model-cache') +class GetMedcatFromModelPackIdTests(TestCase): + def setUp(self): + from django.core.files.uploadedfile import SimpleUploadedFile + + self.modelpack = ModelPack( + name='mp-cached', + model_pack=SimpleUploadedFile('mp.zip', b'fake'), + ) + self.modelpack.save(skip_load=True) + + @patch('api.model_cache.CAT.load_model_pack') + def test_loads_when_not_cached(self, mock_load): + cat_map = {} + loaded = MagicMock() + mock_load.return_value = loaded + result = model_cache.get_medcat_from_model_pack_id(self.modelpack.id, cat_map=cat_map) + self.assertIs(result, loaded) + self.assertIn(f'mp{self.modelpack.id}', cat_map) + + @patch('api.model_cache.CAT.load_model_pack') + def test_returns_cached_when_present(self, mock_load): + sentinel = MagicMock() + cat_map = {f'mp{self.modelpack.id}': sentinel} + result = model_cache.get_medcat_from_model_pack_id(self.modelpack.id, cat_map=cat_map) + self.assertIs(result, sentinel) + mock_load.assert_not_called() + + +@override_settings(MEDIA_ROOT='/tmp/mct-tests-model-cache') +class GetMedcatFromCdbVocabTests(TestCase): + def setUp(self): + self.project = create_basic_project(name='cv-proj') + + @patch('api.model_cache.CAT') + @patch('api.model_cache.Vocab.load') + @patch('api.model_cache.CDB.load') + @patch('api.utils.clear_cdb_cnf_addons') + def test_loads_and_caches(self, mock_clear, mock_cdb_load, mock_vocab_load, mock_cat_cls): + mock_cdb = MagicMock() + mock_vocab = MagicMock() + mock_cdb_load.return_value = mock_cdb + mock_vocab_load.return_value = mock_vocab + mock_cat_instance = MagicMock() + mock_cat_cls.return_value = mock_cat_instance + + cdb_map = {} + vocab_map = {} + cat_map = {} + + result = model_cache.get_medcat_from_cdb_vocab( + self.project, cdb_map=cdb_map, vocab_map=vocab_map, cat_map=cat_map + ) + + cat_id = f'{self.project.concept_db.id}-{self.project.vocab.id}' + self.assertIn(cat_id, cat_map) + self.assertIn(self.project.concept_db.id, cdb_map) + self.assertIn(self.project.vocab.id, vocab_map) + self.assertIs(result, mock_cat_instance) + + @patch('api.model_cache.CAT') + @patch('api.model_cache.Vocab.load') + @patch('api.model_cache.CDB.load') + def test_returns_cached_cat_when_present(self, mock_cdb_load, mock_vocab_load, mock_cat_cls): + cat_id = f'{self.project.concept_db.id}-{self.project.vocab.id}' + sentinel = MagicMock() + cat_map = {cat_id: sentinel} + + result = model_cache.get_medcat_from_cdb_vocab( + self.project, cdb_map={}, vocab_map={}, cat_map=cat_map + ) + self.assertIs(result, sentinel) + mock_cdb_load.assert_not_called() + mock_vocab_load.assert_not_called() + mock_cat_cls.assert_not_called() diff --git a/medcat-trainer/webapp/api/api/tests/test_models.py b/medcat-trainer/webapp/api/api/tests/test_models.py new file mode 100644 index 000000000..d48a5f786 --- /dev/null +++ b/medcat-trainer/webapp/api/api/tests/test_models.py @@ -0,0 +1,203 @@ +"""Unit tests for api.models validation and string representations.""" + +from django.core.exceptions import ValidationError +from django.test import TestCase, override_settings + +from ..models import ( + AnnotatedEntity, + ConceptDB, + Document, + Entity, + EntityRelation, + MetaAnnotation, + MetaTask, + MetaTaskValue, + ProjectAnnotateEntities, + Relation, + Vocabulary, + cdb_name_validator, +) +from ._helpers import ( + create_basic_project, + create_dataset, + create_document, + create_entity, + create_user, +) + + +class StringRepresentationTests(TestCase): + def test_entity_str(self): + ent = Entity.objects.create(label='C001') + self.assertEqual(str(ent), 'C001') + + def test_relation_str(self): + rel = Relation.objects.create(label='hasFinding') + self.assertEqual(str(rel), 'hasFinding') + + def test_meta_task_value_str(self): + v = MetaTaskValue.objects.create(name='True') + self.assertEqual(str(v), 'True') + + def test_meta_task_str(self): + mt = MetaTask.objects.create(name='Presence') + self.assertEqual(str(mt), 'Presence') + + +@override_settings(MEDIA_ROOT='/tmp/mct-tests-models') +class CdbNameValidatorTests(TestCase): + def test_validator_accepts_alphanumeric_with_underscore(self): + cdb_name_validator('abc_123') # should not raise + + def test_validator_rejects_leading_digit(self): + with self.assertRaises(ValidationError): + cdb_name_validator('1abc') + + def test_validator_rejects_special_chars(self): + with self.assertRaises(ValidationError): + cdb_name_validator('a-b') + + def test_validator_rejects_empty(self): + with self.assertRaises(ValidationError): + cdb_name_validator('') + + +@override_settings(MEDIA_ROOT='/tmp/mct-tests-models') +class ProjectAnnotateEntitiesValidationTests(TestCase): + @classmethod + def setUpTestData(cls): + cdb = ConceptDB(name='val_cdb', cdb_file='val_cdb.dat') + cdb.save(skip_load=True) + vocab = Vocabulary(name='val_vocab', vocab_file='val_vocab.dat') + vocab.save(skip_load=True) + cls.cdb = cdb + cls.vocab = vocab + cls.dataset = create_dataset(name='val_ds', file_name='val_ds.csv') + + def _new_project(self, **kwargs): + proj = ProjectAnnotateEntities() + proj.name = kwargs.pop('name', 'p1') + proj.dataset = kwargs.pop('dataset', self.dataset) + proj.cuis = '' + for k, v in kwargs.items(): + setattr(proj, k, v) + return proj + + def test_save_requires_cdb_vocab_or_model_pack(self): + proj = self._new_project() + with self.assertRaises(ValidationError): + proj.save() + + def test_save_with_cdb_and_vocab_succeeds(self): + proj = self._new_project(concept_db=self.cdb, vocab=self.vocab) + proj.save() # should not raise + self.assertIsNotNone(proj.id) + + def test_use_model_service_requires_url(self): + proj = self._new_project(use_model_service=True) + with self.assertRaises(ValidationError): + proj.save() + + def test_use_model_service_with_url_skips_model_validation(self): + proj = self._new_project(use_model_service=True, model_service_url='http://x') + proj.save() + self.assertIsNotNone(proj.id) + + +@override_settings(MEDIA_ROOT='/tmp/mct-tests-models') +class AnnotatedEntitySaveUpdatesProjectTests(TestCase): + def test_saving_annotation_updates_project_last_modified(self): + user = create_user(username='auser') + project = create_basic_project(name='ae-proj') + doc = create_document(project, name='doc', text='hello') + ent = create_entity(label='C100') + + before = project.last_modified + AnnotatedEntity.objects.create( + user=user, + project=project, + document=doc, + entity=ent, + value='hello', + start_ind=0, + end_ind=5, + acc=0.5, + ) + project.refresh_from_db() + self.assertGreaterEqual(project.last_modified, before) + + +@override_settings(MEDIA_ROOT='/tmp/mct-tests-models') +class MetaAnnotationSaveUpdatesParentTests(TestCase): + def test_saving_meta_annotation_updates_annotated_entity_last_modified(self): + user = create_user(username='muser') + project = create_basic_project(name='ma-proj') + doc = create_document(project, name='doc', text='hello') + ent = create_entity(label='C200') + ann = AnnotatedEntity.objects.create( + user=user, + project=project, + document=doc, + entity=ent, + value='hello', + start_ind=0, + end_ind=5, + acc=0.5, + ) + task = MetaTask.objects.create(name='Presence') + val = MetaTaskValue.objects.create(name='True') + + before = ann.last_modified + MetaAnnotation.objects.create( + annotated_entity=ann, + meta_task=task, + meta_task_value=val, + validated=True, + ) + ann.refresh_from_db() + self.assertGreaterEqual(ann.last_modified, before) + + +@override_settings(MEDIA_ROOT='/tmp/mct-tests-models') +class EntityRelationSaveUpdatesProjectTests(TestCase): + def test_saving_relation_updates_project(self): + user = create_user(username='ruser') + project = create_basic_project(name='rel-proj') + doc = create_document(project, name='doc', text='hello world') + ent_a = create_entity(label='A') + ent_b = create_entity(label='B') + + start = AnnotatedEntity.objects.create( + user=user, project=project, document=doc, entity=ent_a, + value='hello', start_ind=0, end_ind=5, acc=1.0, + ) + end = AnnotatedEntity.objects.create( + user=user, project=project, document=doc, entity=ent_b, + value='world', start_ind=6, end_ind=11, acc=1.0, + ) + + rel = Relation.objects.create(label='has') + before = project.last_modified + EntityRelation.objects.create( + user=user, + project=project, + document=doc, + relation=rel, + start_entity=start, + end_entity=end, + ) + project.refresh_from_db() + self.assertGreaterEqual(project.last_modified, before) + + +class ConceptDbCannotChangeFilePathTests(TestCase): + @override_settings(MEDIA_ROOT='/tmp/mct-tests-models') + def test_change_of_cdb_file_after_first_save_raises(self): + cdb = ConceptDB(name='cant_change', cdb_file='orig.dat') + cdb.save(skip_load=True) + + # Simulate Django reload semantics + reloaded = ConceptDB.objects.get(id=cdb.id) + reloaded.cdb_file.name = 'other.dat' + with self.assertRaises(ValidationError): + reloaded.save(skip_load=True) diff --git a/medcat-trainer/webapp/api/api/tests/test_oidc_utils.py b/medcat-trainer/webapp/api/api/tests/test_oidc_utils.py new file mode 100644 index 000000000..275dbeb05 --- /dev/null +++ b/medcat-trainer/webapp/api/api/tests/test_oidc_utils.py @@ -0,0 +1,93 @@ +"""Unit tests for api.oidc_utils.""" + +from django.contrib.auth import get_user_model +from django.test import TestCase + +from ..oidc_utils import get_user_by_email + + +class GetUserByEmailTests(TestCase): + def setUp(self): + self.User = get_user_model() + + def test_creates_new_user_from_full_claims(self): + claims = { + 'preferred_username': 'jdoe', + 'email': 'jdoe@example.com', + 'given_name': 'John', + 'family_name': 'Doe', + } + user = get_user_by_email(request=None, id_token=claims) + self.assertEqual(user.username, 'jdoe') + self.assertEqual(user.email, 'jdoe@example.com') + self.assertEqual(user.first_name, 'John') + self.assertEqual(user.last_name, 'Doe') + self.assertFalse(user.is_superuser) + self.assertFalse(user.is_staff) + + def test_assigns_superuser_when_role_present(self): + claims = { + 'preferred_username': 'admin', + 'email': 'admin@example.com', + 'roles': ['medcattrainer_superuser'], + } + user = get_user_by_email(request=None, id_token=claims) + self.assertTrue(user.is_superuser) + self.assertFalse(user.is_staff) + + def test_assigns_staff_when_role_present(self): + claims = { + 'preferred_username': 'staffuser', + 'email': 'staff@example.com', + 'roles': ['medcattrainer_staff'], + } + user = get_user_by_email(request=None, id_token=claims) + self.assertTrue(user.is_staff) + self.assertFalse(user.is_superuser) + + def test_falls_back_to_sub_when_username_missing(self): + claims = {'sub': 'unique-sub-id', 'email': 'a@b.com'} + user = get_user_by_email(request=None, id_token=claims) + self.assertEqual(user.username, 'unique-sub-id') + + def test_falls_back_to_client_id_when_no_username_or_sub(self): + claims = {'client_id': 'svc-client', 'email': 'svc@example.com'} + user = get_user_by_email(request=None, id_token=claims) + self.assertEqual(user.username, 'svc-client') + + def test_falls_back_to_random_username_when_nothing_provided(self): + # No username, no sub, no client_id - should still create a user with a random username + user = get_user_by_email(request=None, id_token={'email': 'nouser@example.com'}) + self.assertTrue(user.username.startswith('oidc-')) + self.assertEqual(user.email, 'nouser@example.com') + + def test_email_falls_back_to_username_when_missing(self): + claims = {'preferred_username': 'just-username'} + user = get_user_by_email(request=None, id_token=claims) + self.assertEqual(user.email, 'just-username') + + def test_returns_existing_user_when_email_matches(self): + existing = self.User.objects.create_user( + username='oldname', email='dup@example.com', password='x') + + claims = {'preferred_username': 'newname', 'email': 'dup@example.com'} + returned = get_user_by_email(request=None, id_token=claims) + + self.assertEqual(returned.id, existing.id) + # Existing user should have updated profile fields + returned.refresh_from_db() + self.assertEqual(returned.username, 'newname') + + def test_role_updates_existing_user(self): + existing = self.User.objects.create_user( + username='roleuser', email='role@example.com', password='x') + self.assertFalse(existing.is_superuser) + + get_user_by_email(request=None, id_token={ + 'preferred_username': 'roleuser', + 'email': 'role@example.com', + 'roles': ['medcattrainer_superuser', 'medcattrainer_staff'], + }) + existing.refresh_from_db() + self.assertTrue(existing.is_superuser) + self.assertTrue(existing.is_staff) diff --git a/medcat-trainer/webapp/api/api/tests/test_permissions.py b/medcat-trainer/webapp/api/api/tests/test_permissions.py new file mode 100644 index 000000000..8c0ee7412 --- /dev/null +++ b/medcat-trainer/webapp/api/api/tests/test_permissions.py @@ -0,0 +1,101 @@ +"""Unit tests for api.permissions.""" + +from unittest.mock import MagicMock + +from django.contrib.auth.models import User +from django.test import TestCase, RequestFactory, override_settings + +from ..models import ( + ConceptDB, + ProjectAnnotateEntities, + ProjectGroup, + Vocabulary, +) +from ..permissions import IsReadOnly, is_project_admin +from ._helpers import create_dataset + + +@override_settings(MEDIA_ROOT='/tmp/mct-tests-perms') +class IsReadOnlyTests(TestCase): + def setUp(self): + self.permission = IsReadOnly() + self.factory = RequestFactory() + + def test_allows_safe_methods(self): + for method in ('GET', 'HEAD', 'OPTIONS'): + request = self.factory.generic(method, '/api/x/') + self.assertTrue( + self.permission.has_permission(request, view=MagicMock()), + f'Expected {method} to be allowed', + ) + + def test_denies_unsafe_methods(self): + for method in ('POST', 'PUT', 'PATCH', 'DELETE'): + request = self.factory.generic(method, '/api/x/') + self.assertFalse( + self.permission.has_permission(request, view=MagicMock()), + f'Expected {method} to be denied', + ) + + +@override_settings(MEDIA_ROOT='/tmp/mct-tests-perms') +class IsProjectAdminTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.superuser = User.objects.create_superuser(username='su', password='pw', email='su@x') + cls.staff = User.objects.create_user(username='st', password='pw', is_staff=True) + cls.member = User.objects.create_user(username='m1', password='pw') + cls.group_admin = User.objects.create_user(username='ga', password='pw') + cls.outsider = User.objects.create_user(username='out', password='pw') + + cdb = ConceptDB(name='perm_cdb', cdb_file='perm_cdb.dat') + cdb.save(skip_load=True) + vocab = Vocabulary(name='perm_vocab', vocab_file='perm_vocab.dat') + vocab.save(skip_load=True) + dataset = create_dataset(name='perm_ds', file_name='perm_ds.csv') + + cls.group = ProjectGroup.objects.create( + name='grp1', + dataset=dataset, + concept_db=cdb, + vocab=vocab, + cuis='', + ) + cls.group.administrators.add(cls.group_admin) + + cls.project_no_group = ProjectAnnotateEntities() + cls.project_no_group.name = 'p-no-group' + cls.project_no_group.dataset = dataset + cls.project_no_group.concept_db = cdb + cls.project_no_group.vocab = vocab + cls.project_no_group.cuis = '' + cls.project_no_group.save() + cls.project_no_group.members.add(cls.member) + + cls.project_with_group = ProjectAnnotateEntities() + cls.project_with_group.name = 'p-grouped' + cls.project_with_group.dataset = dataset + cls.project_with_group.concept_db = cdb + cls.project_with_group.vocab = vocab + cls.project_with_group.group = cls.group + cls.project_with_group.cuis = '' + cls.project_with_group.save() + + def test_superuser_is_always_admin(self): + self.assertTrue(is_project_admin(self.superuser, self.project_no_group)) + + def test_staff_user_is_always_admin(self): + self.assertTrue(is_project_admin(self.staff, self.project_no_group)) + + def test_member_user_is_admin(self): + self.assertTrue(is_project_admin(self.member, self.project_no_group)) + + def test_group_admin_is_admin_of_group_project(self): + self.assertTrue(is_project_admin(self.group_admin, self.project_with_group)) + + def test_group_admin_is_not_admin_of_unrelated_project(self): + self.assertFalse(is_project_admin(self.group_admin, self.project_no_group)) + + def test_outsider_is_not_admin(self): + self.assertFalse(is_project_admin(self.outsider, self.project_no_group)) + self.assertFalse(is_project_admin(self.outsider, self.project_with_group)) diff --git a/medcat-trainer/webapp/api/api/tests/test_serializers.py b/medcat-trainer/webapp/api/api/tests/test_serializers.py new file mode 100644 index 000000000..45883bc58 --- /dev/null +++ b/medcat-trainer/webapp/api/api/tests/test_serializers.py @@ -0,0 +1,196 @@ +"""Unit tests for api.serializers.""" + +import json +import os +import tempfile + +from django.contrib.auth.models import User +from django.test import TestCase, override_settings + +from ..models import ( + AnnotatedEntity, + ConceptDB, + Dataset, + Document, + Entity, + ProjectAnnotateEntities, + ProjectGroup, + Vocabulary, +) +from ..serializers import ( + AnnotatedEntitySerializer, + DatasetSerializer, + DocumentSerializer, + EntitySerializer, + ProjectAnnotateEntitiesSerializer, + ProjectGroupSerializer, + UserSerializer, +) +from ._helpers import create_dataset + + +@override_settings(MEDIA_ROOT='/tmp/mct-tests-serializers') +class UserSerializerTests(TestCase): + def test_serializes_expected_fields(self): + user = User.objects.create_user(username='alice', email='a@x.com', password='pw') + data = UserSerializer(user, context={'request': None}).data + self.assertEqual(data['username'], 'alice') + self.assertEqual(data['email'], 'a@x.com') + self.assertIn('id', data) + self.assertIn('is_staff', data) + self.assertIn('is_superuser', data) + + +@override_settings(MEDIA_ROOT='/tmp/mct-tests-serializers') +class EntitySerializerTests(TestCase): + def test_serializes_entity_label(self): + ent = Entity.objects.create(label='C123') + data = EntitySerializer(ent).data + self.assertEqual(data['label'], 'C123') + + +@override_settings(MEDIA_ROOT='/tmp/mct-tests-serializers') +class DocumentAndAnnotationSerializerTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.user = User.objects.create_user(username='annu', password='pw') + cdb = ConceptDB(name='ser_cdb', cdb_file='ser_cdb.dat') + cdb.save(skip_load=True) + vocab = Vocabulary(name='ser_vocab', vocab_file='ser_vocab.dat') + vocab.save(skip_load=True) + cls.dataset = create_dataset(name='ser_ds', file_name='ser_ds.csv') + cls.document = Document.objects.create(name='doc', text='hello', dataset=cls.dataset) + + cls.project = ProjectAnnotateEntities() + cls.project.name = 'ser-proj' + cls.project.dataset = cls.dataset + cls.project.concept_db = cdb + cls.project.vocab = vocab + cls.project.cuis = '' + cls.project.save() + + cls.entity = Entity.objects.create(label='C001') + + def test_document_serializer(self): + data = DocumentSerializer(self.document).data + self.assertEqual(data['name'], 'doc') + self.assertEqual(data['text'], 'hello') + self.assertEqual(data['dataset'], self.dataset.id) + + def test_annotated_entity_serializer(self): + ann = AnnotatedEntity.objects.create( + user=self.user, + project=self.project, + document=self.document, + entity=self.entity, + value='hello', + start_ind=0, + end_ind=5, + acc=0.5, + ) + data = AnnotatedEntitySerializer(ann).data + self.assertEqual(data['value'], 'hello') + self.assertEqual(data['start_ind'], 0) + self.assertEqual(data['end_ind'], 5) + self.assertEqual(data['acc'], 0.5) + + +@override_settings(MEDIA_ROOT='/tmp/mct-tests-serializers') +class ProjectAnnotateEntitiesSerializerTests(TestCase): + @classmethod + def setUpTestData(cls): + cdb = ConceptDB(name='pas_cdb', cdb_file='pas_cdb.dat') + cdb.save(skip_load=True) + vocab = Vocabulary(name='pas_vocab', vocab_file='pas_vocab.dat') + vocab.save(skip_load=True) + cls.dataset = create_dataset(name='pas_ds', file_name='pas_ds.csv') + + cls.project = ProjectAnnotateEntities() + cls.project.name = 'pas-proj' + cls.project.dataset = cls.dataset + cls.project.concept_db = cdb + cls.project.vocab = vocab + cls.project.cuis = 'A,B,C' + cls.project.save() + + def test_to_representation_includes_inline_cuis_only_when_no_file(self): + data = ProjectAnnotateEntitiesSerializer(self.project).data + # Should contain the original CUIs separated by ',' + self.assertEqual(set(data['cuis'].split(',')), {'A', 'B', 'C'}) + + def test_to_representation_merges_cuis_from_file(self): + media_root = '/tmp/mct-tests-serializers' + os.makedirs(media_root, exist_ok=True) + rel_path = 'pas_cuis_file.json' + abs_path = os.path.join(media_root, rel_path) + with open(abs_path, 'w') as f: + json.dump(['X', 'Y'], f) + try: + self.project.cuis_file.name = rel_path + self.project.save() + + data = ProjectAnnotateEntitiesSerializer(self.project).data + self.assertEqual(set(data['cuis'].split(',')), {'A', 'B', 'C', 'X', 'Y'}) + finally: + if os.path.isfile(abs_path): + os.unlink(abs_path) + + def test_to_representation_handles_missing_cuis_file_gracefully(self): + # Do not save() — post_save would try to read cuis_file on disk. + self.project.cuis_file.name = 'missing_cuis_file.json' + + data = ProjectAnnotateEntitiesSerializer(self.project).data + self.assertEqual(set(data['cuis'].split(',')), {'A', 'B', 'C'}) + + +@override_settings(MEDIA_ROOT='/tmp/mct-tests-serializers') +class ProjectGroupSerializerTests(TestCase): + @classmethod + def setUpTestData(cls): + cdb = ConceptDB(name='pg_cdb', cdb_file='pg_cdb.dat') + cdb.save(skip_load=True) + vocab = Vocabulary(name='pg_vocab', vocab_file='pg_vocab.dat') + vocab.save(skip_load=True) + cls.dataset = create_dataset(name='pg_ds', file_name='pg_ds.csv') + cls.cdb = cdb + cls.vocab = vocab + + def test_last_modified_is_null_when_group_has_no_projects(self): + group = ProjectGroup.objects.create( + name='empty-group', + dataset=self.dataset, + concept_db=self.cdb, + vocab=self.vocab, + cuis='', + ) + data = ProjectGroupSerializer(group).data + self.assertIsNone(data['last_modified']) + + def test_last_modified_is_set_to_latest_project_in_group(self): + group = ProjectGroup.objects.create( + name='active-group', + dataset=self.dataset, + concept_db=self.cdb, + vocab=self.vocab, + cuis='', + ) + p = ProjectAnnotateEntities() + p.name = 'p-in-group' + p.dataset = self.dataset + p.cuis = '' + p.concept_db = self.cdb + p.vocab = self.vocab + p.group = group + p.save() + + data = ProjectGroupSerializer(group).data + self.assertIsNotNone(data['last_modified']) + + +@override_settings(MEDIA_ROOT='/tmp/mct-tests-serializers') +class DatasetSerializerTests(TestCase): + def test_serializes_dataset(self): + dataset = create_dataset(name='ds-test', file_name='ds-test.csv') + data = DatasetSerializer(dataset).data + self.assertEqual(data['name'], 'ds-test') + self.assertIn('original_file', data) diff --git a/medcat-trainer/webapp/api/api/tests/test_solr_utils.py b/medcat-trainer/webapp/api/api/tests/test_solr_utils.py new file mode 100644 index 000000000..dc38789a3 --- /dev/null +++ b/medcat-trainer/webapp/api/api/tests/test_solr_utils.py @@ -0,0 +1,212 @@ +"""Unit tests for api.solr_utils using mocked HTTP calls.""" + +import json +from unittest.mock import MagicMock, patch + +from django.test import TestCase, override_settings + +from .. import solr_utils +from ..models import ConceptDB +from ._helpers import dataset_signals_disconnected # noqa: F401 (ensures helper module imports) + + +@override_settings(MEDIA_ROOT='/tmp/mct-tests-solr') +class CollectionsAvailableTests(TestCase): + def setUp(self): + # Clear schema cache to avoid leakage between tests + solr_utils.SOLR_INDEX_SCHEMA.clear() + + @patch('api.solr_utils.requests.get') + def test_returns_imported_map_when_cdbs_provided(self, mock_get): + # First call: list collections; subsequent: schema + def side_effect(url, *args, **kwargs): + if 'admin/collections' in url: + return MagicMock(status_code=200, text=json.dumps({'collections': ['my_id_1']})) + if 'schema' in url: + return MagicMock(text=json.dumps({'schema': {'fields': [{'name': 'cui', 'type': 'string'}]}})) + return MagicMock(status_code=404, text='') + + mock_get.side_effect = side_effect + + response = solr_utils.collections_available(['1', '2']) + self.assertEqual(response.status_code, 200) + body = response.data + self.assertTrue(body['results']['1']) + self.assertFalse(body['results']['2']) + + @patch('api.solr_utils.requests.get') + def test_returns_full_collection_info_when_no_cdbs(self, mock_get): + def side_effect(url, *args, **kwargs): + if 'admin/collections' in url: + return MagicMock(status_code=200, text=json.dumps({'collections': ['my_id_1']})) + return MagicMock(text=json.dumps({'schema': {'fields': [{'name': 'cui', 'type': 'string'}]}})) + + mock_get.side_effect = side_effect + + response = solr_utils.collections_available([]) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['results']['1']['index_name'], 'my_id_1') + + @patch('api.solr_utils.requests.get') + def test_returns_500_when_solr_admin_unavailable(self, mock_get): + mock_get.return_value = MagicMock(status_code=500, text='boom') + response = solr_utils.collections_available(['1']) + self.assertEqual(response.status_code, 500) + + +@override_settings(MEDIA_ROOT='/tmp/mct-tests-solr') +class SearchCollectionTests(TestCase): + @classmethod + def setUpTestData(cls): + cdb = ConceptDB(name='solrcdb', cdb_file='solrcdb.dat') + cdb.save(skip_load=True) + cls.cdb = cdb + + def setUp(self): + solr_utils.SOLR_INDEX_SCHEMA.clear() + solr_utils.SOLR_INDEX_SCHEMA[f'solrcdb_id_{self.cdb.id}'] = {'cui': 'string'} + + def test_empty_query_returns_empty_results(self): + response = solr_utils.search_collection([self.cdb.id], '') + self.assertEqual(response.data, {'results': []}) + + @patch('api.solr_utils.requests.get') + def test_returns_documents_for_text_query(self, mock_get): + mock_get.return_value = MagicMock( + text=json.dumps({ + 'response': { + 'docs': [ + { + 'cui': ['C001'], + 'pretty_name': ['Concept 1'], + 'type_ids': ['T001'], + 'synonyms': ['c1', 'c-one'], + }, + { + 'cui': ['C002'], + 'pretty_name': ['Concept 2'], + 'type_ids': ['T002'], + 'synonyms': ['c2'], + }, + ] + } + }) + ) + + response = solr_utils.search_collection([self.cdb.id], 'foo') + results = response.data['results'] + cuis = sorted(r['cui'] for r in results) + self.assertEqual(cuis, ['C001', 'C002']) + + @patch('api.solr_utils.requests.get') + def test_falls_back_to_wildcard_when_no_results(self, mock_get): + calls = [] + + def fake_get(url, *args, **kwargs): + calls.append(url) + if len(calls) == 1: + return MagicMock(text=json.dumps({'response': {'docs': []}})) + return MagicMock(text=json.dumps({ + 'response': { + 'docs': [{ + 'cui': ['C999'], + 'pretty_name': ['Wildcard match'], + 'type_ids': [], + 'synonyms': ['wm'], + }] + } + })) + + mock_get.side_effect = fake_get + + response = solr_utils.search_collection([self.cdb.id], 'foo') + self.assertEqual(len(response.data['results']), 1) + self.assertEqual(response.data['results'][0]['cui'], 'C999') + + +@override_settings(MEDIA_ROOT='/tmp/mct-tests-solr') +class HelperFunctionTests(TestCase): + def test_process_result_response_deduplicates_by_cui(self): + resp = { + 'response': { + 'docs': [ + {'cui': ['C001'], 'pretty_name': ['a'], 'type_ids': ['T1'], 'synonyms': ['x']}, + {'cui': ['C001'], 'pretty_name': ['a-dup'], 'type_ids': ['T1'], 'synonyms': ['x']}, + {'cui': ['C002'], 'pretty_name': ['b'], 'type_ids': [], 'synonyms': []}, + ] + } + } + result_map = solr_utils._process_result_repsonse(resp) + self.assertEqual(set(result_map.keys()), {'C001', 'C002'}) + + def test_concept_dct_uses_pretty_name_when_no_synonyms(self): + cdb = MagicMock() + cdb.get_name.return_value = 'Pretty (qualifier)' + info = {'original_names': [], 'type_ids': ['T1'], 'description': 'desc'} + out = solr_utils._concept_dct('C001', cdb, info) + self.assertEqual(out['cui'], 'C001') + # synonyms fall back to pretty name when original_names is empty + self.assertEqual(out['synonyms'], ['Pretty (qualifier)']) + # parenthesised qualifier removed in `name` + self.assertEqual(out['name'], 'Pretty') + + def test_concept_dct_uses_original_names_as_synonyms(self): + cdb = MagicMock() + cdb.get_name.return_value = 'Hypertension' + info = {'original_names': {'HTN', 'High blood pressure'}, 'type_ids': ['T'], 'description': 'd'} + out = solr_utils._concept_dct('C100', cdb, info) + self.assertSetEqual(set(out['synonyms']), {'HTN', 'High blood pressure'}) + + +@override_settings(MEDIA_ROOT='/tmp/mct-tests-solr') +class DropCollectionTests(TestCase): + @patch('api.solr_utils.requests.get') + def test_drop_collection_calls_delete_endpoint(self, mock_get): + mock_get.return_value = MagicMock(status_code=200, text='{}') + cdb = ConceptDB(name='drop_cdb', cdb_file='drop_cdb.dat') + cdb.save(skip_load=True) + solr_utils.drop_collection(cdb) + # Should call the DELETE action URL + call_url = mock_get.call_args[0][0] + self.assertIn(f'name=drop_cdb_id_{cdb.id}', call_url) + self.assertIn('action=DELETE', call_url) + + +@override_settings(MEDIA_ROOT='/tmp/mct-tests-solr') +class EnsureConceptSearchableTests(TestCase): + @patch('api.solr_utils.requests.post') + @patch('api.solr_utils.requests.get') + def test_uploads_concept_when_collection_exists(self, mock_get, mock_post): + cdb = ConceptDB(name='ecs_cdb', cdb_file='ecs_cdb.dat') + cdb.save(skip_load=True) + + mock_get.return_value = MagicMock( + status_code=200, + text=json.dumps({'collections': [f'ecs_cdb_id_{cdb.id}']}), + ) + mock_post.return_value = MagicMock(status_code=200, text='{}') + + mc_cdb = MagicMock() + mc_cdb.get_name.return_value = 'X' + mc_cdb.cui2info = {'C': {'original_names': [], 'type_ids': [], 'description': ''}} + + solr_utils.ensure_concept_searchable('C', mc_cdb, cdb) + mock_post.assert_called_once() + payload = mock_post.call_args.kwargs.get('json') + self.assertEqual(payload[0]['cui'], 'C') + + @patch('api.solr_utils.requests.post') + @patch('api.solr_utils.requests.get') + def test_does_not_upload_when_collection_missing(self, mock_get, mock_post): + cdb = ConceptDB(name='ecs_cdb2', cdb_file='ecs_cdb2.dat') + cdb.save(skip_load=True) + mock_get.return_value = MagicMock( + status_code=200, + text=json.dumps({'collections': []}), + ) + mc_cdb = MagicMock() + mc_cdb.get_name.return_value = 'X' + mc_cdb.cui2info = {'C': {'original_names': [], 'type_ids': [], 'description': ''}} + + solr_utils.ensure_concept_searchable('C', mc_cdb, cdb) + mock_post.assert_not_called() diff --git a/medcat-trainer/webapp/api/api/tests/test_utils.py b/medcat-trainer/webapp/api/api/tests/test_utils.py new file mode 100644 index 000000000..d26a4cfed --- /dev/null +++ b/medcat-trainer/webapp/api/api/tests/test_utils.py @@ -0,0 +1,386 @@ +"""Unit tests for api.utils. + +Covers the pure-Python helpers (RemoteEntity, RemoteSpacyDoc, SimpleFilters, +env_str_to_bool, call_remote_model_service_* and add_annotations/ +remove_annotations/create_annotation) without spinning up MedCAT itself. +""" + +import os +from unittest.mock import patch, MagicMock + +import requests +from django.contrib.auth.models import User +from django.test import TestCase, override_settings + +from .. import utils +from ..models import AnnotatedEntity, Entity + + +class RemoteEntityTests(TestCase): + """Tests for the RemoteEntity helper that mirrors spaCy's entity shape.""" + + def test_constructs_from_full_payload(self): + ent = utils.RemoteEntity( + { + 'cui': 'C001', + 'start': 5, + 'end': 12, + 'detected_name': 'fever', + 'context_similarity': 0.92, + 'meta_anns': {'Presence': {'value': 'True'}}, + }, + 'patient has fever now', + ) + self.assertEqual(ent.cui, 'C001') + self.assertEqual(ent.start_char_index, 5) + self.assertEqual(ent.end_char_index, 12) + self.assertEqual(ent.text, 'fever') + self.assertEqual(ent.context_similarity, 0.92) + self.assertEqual(ent.get_addon_data('meta_cat_meta_anns'), {'Presence': {'value': 'True'}}) + + def test_falls_back_to_source_value_and_acc(self): + ent = utils.RemoteEntity( + {'cui': 'C002', 'source_value': 'cough', 'acc': 0.42}, + 'cough', + ) + self.assertEqual(ent.text, 'cough') + self.assertEqual(ent.context_similarity, 0.42) + self.assertEqual(ent.start_char_index, 0) + self.assertEqual(ent.end_char_index, 0) + + def test_unknown_addon_key_returns_empty_dict(self): + ent = utils.RemoteEntity({'cui': 'C003'}, 'text') + self.assertEqual(ent.get_addon_data('some_other_key'), {}) + + def test_defaults_when_payload_empty(self): + ent = utils.RemoteEntity({}, '') + self.assertEqual(ent.cui, '') + self.assertEqual(ent.text, '') + self.assertEqual(ent.context_similarity, 0.0) + + +class RemoteSpacyDocTests(TestCase): + def test_wraps_linked_ents(self): + ents = [utils.RemoteEntity({'cui': 'A'}, 'text'), utils.RemoteEntity({'cui': 'B'}, 'text')] + doc = utils.RemoteSpacyDoc(ents) + self.assertEqual(doc.linked_ents, ents) + + +class SimpleFiltersTests(TestCase): + def test_default_empty_filters(self): + f = utils.SimpleFilters() + self.assertEqual(f.cuis, set()) + self.assertEqual(f.cuis_exclude, set()) + + def test_custom_filters_preserved(self): + f = utils.SimpleFilters(cuis={'X'}, cuis_exclude={'Y'}) + self.assertEqual(f.cuis, {'X'}) + self.assertEqual(f.cuis_exclude, {'Y'}) + + +class EnvStrToBoolTests(TestCase): + def _set_env(self, value): + os.environ['__MCT_TEST_FLAG__'] = value + self.addCleanup(os.environ.pop, '__MCT_TEST_FLAG__', None) + + def test_truthy_string_values(self): + for v in ('1', 'true', 't', 'y', 'TRUE', 'True'): + self._set_env(v) + self.assertIs(utils.env_str_to_bool('__MCT_TEST_FLAG__', False), True, f'Expected True for {v}') + + def test_falsy_string_values(self): + for v in ('0', 'false', 'f', 'n', 'False'): + self._set_env(v) + self.assertIs(utils.env_str_to_bool('__MCT_TEST_FLAG__', True), False, f'Expected False for {v}') + + def test_unknown_string_returns_value_unchanged(self): + self._set_env('maybe') + self.assertEqual(utils.env_str_to_bool('__MCT_TEST_FLAG__', True), 'maybe') + + def test_uses_default_when_not_set(self): + os.environ.pop('__MCT_TEST_FLAG__', None) + self.assertTrue(utils.env_str_to_bool('__MCT_TEST_FLAG__', True)) + self.assertFalse(utils.env_str_to_bool('__MCT_TEST_FLAG__', False)) + + +class CallRemoteModelServiceSpacyTests(TestCase): + """Tests for utils.call_remote_model_service_spacy with mocked requests.""" + + @patch('api.utils.requests.post') + def test_parses_spacy_response_into_remote_entities(self, mock_post): + mock_post.return_value = MagicMock( + status_code=200, + json=lambda: { + 'entities': { + '0': { + 'cui': 'C001', + 'start': 0, + 'end': 5, + 'detected_name': 'fever', + 'context_similarity': 0.9, + }, + '1': { + 'cui': 'C002', + 'start': 6, + 'end': 12, + 'detected_name': 'cough', + 'context_similarity': 0.8, + }, + } + }, + raise_for_status=lambda: None, + ) + + doc = utils.call_remote_model_service_spacy('http://service:8000/', 'fever cough') + self.assertEqual(len(doc.linked_ents), 2) + cui_set = {e.cui for e in doc.linked_ents} + self.assertEqual(cui_set, {'C001', 'C002'}) + + mock_post.assert_called_once() + call_args, call_kwargs = mock_post.call_args + self.assertEqual(call_args[0], 'http://service:8000/api/process') + self.assertEqual(call_kwargs['json'], {'text': 'fever cough'}) + + @patch('api.utils.requests.post') + def test_request_failure_is_re_raised(self, mock_post): + mock_post.side_effect = requests.exceptions.ConnectionError('boom') + with self.assertRaises(Exception) as ctx: + utils.call_remote_model_service_spacy('http://service:8000', 'text') + self.assertIn('Failed to call remote model service', str(ctx.exception)) + + +class CallRemoteModelServiceMedcatTests(TestCase): + @patch('api.utils.requests.post') + def test_parses_medcat_response_into_remote_entities(self, mock_post): + mock_post.return_value = MagicMock( + status_code=200, + json=lambda: { + 'medcat_info': {'version': '1.0'}, + 'result': { + 'text': 'fever cough', + 'annotations': [ + { + '0': {'cui': 'C001', 'start': 0, 'end': 5, 'detected_name': 'fever', 'context_similarity': 0.9}, + '1': {'cui': 'C002', 'start': 6, 'end': 12, 'detected_name': 'cough', 'context_similarity': 0.8}, + } + ], + }, + }, + raise_for_status=lambda: None, + ) + doc = utils.call_remote_model_service_medcat('http://service:8000', 'fever cough') + self.assertEqual(len(doc.linked_ents), 2) + + @patch('api.utils.requests.post') + def test_raises_when_result_missing(self, mock_post): + mock_post.return_value = MagicMock( + status_code=200, + json=lambda: {'medcat_info': {}}, + raise_for_status=lambda: None, + ) + with self.assertRaises(Exception) as ctx: + utils.call_remote_model_service_medcat('http://service:8000', 'text') + self.assertIn("missing 'result'", str(ctx.exception)) + + @patch('api.utils.requests.post') + def test_raises_when_result_contains_errors(self, mock_post): + mock_post.return_value = MagicMock( + status_code=200, + json=lambda: {'result': {'errors': ['bad input']}}, + raise_for_status=lambda: None, + ) + with self.assertRaises(Exception) as ctx: + utils.call_remote_model_service_medcat('http://service:8000', 'text') + self.assertIn('errors', str(ctx.exception)) + + +class CallRemoteModelServiceDispatchTests(TestCase): + """Top-level dispatcher should route by REMOTE_MODEL_SERVICE_TYPE.""" + + def setUp(self): + # Ensure we don't leak across tests + self._original = os.environ.get('REMOTE_MODEL_SERVICE_TYPE') + + def tearDown(self): + if self._original is None: + os.environ.pop('REMOTE_MODEL_SERVICE_TYPE', None) + else: + os.environ['REMOTE_MODEL_SERVICE_TYPE'] = self._original + + @patch('api.utils.call_remote_model_service_spacy') + def test_dispatches_to_spacy_by_default(self, mock_spacy): + mock_spacy.return_value = 'doc' + os.environ.pop('REMOTE_MODEL_SERVICE_TYPE', None) + result = utils.call_remote_model_service('http://x', 'text') + mock_spacy.assert_called_once_with('http://x', 'text') + self.assertEqual(result, 'doc') + + @patch('api.utils.call_remote_model_service_medcat') + def test_dispatches_to_medcat_when_configured(self, mock_mc): + mock_mc.return_value = 'doc' + os.environ['REMOTE_MODEL_SERVICE_TYPE'] = 'medcat' + result = utils.call_remote_model_service('http://x', 'text') + mock_mc.assert_called_once_with('http://x', 'text') + self.assertEqual(result, 'doc') + + def test_unknown_service_type_raises(self): + os.environ['REMOTE_MODEL_SERVICE_TYPE'] = 'unknown' + with self.assertRaises(ValueError): + utils.call_remote_model_service('http://x', 'text') + + +@override_settings(MEDIA_ROOT='/tmp/mct-tests-utils') +class DbHelperTests(TestCase): + """Tests for DB-backed helpers (remove_annotations / create_annotation / + add_annotations) using lightweight in-memory fixtures. + """ + + def setUp(self): + from ._helpers import create_basic_project, create_document, create_entity, create_user + + self.user = create_user(username='ann-user') + self.project = create_basic_project(name='utils-test-project') + self.document = create_document(self.project, name='doc1', text='fever and cough') + self.entity = create_entity(label='C001') + + def _make_ann(self, validated=False): + ann = AnnotatedEntity( + user=self.user, + project=self.project, + document=self.document, + entity=self.entity, + value='fever', + start_ind=0, + end_ind=5, + acc=0.9, + validated=validated, + ) + ann.save() + return ann + + def test_remove_annotations_full_clears_all(self): + self._make_ann(validated=True) + self._make_ann(validated=False) + utils.remove_annotations(self.document, self.project, partial=False) + self.assertFalse( + AnnotatedEntity.objects.filter(project=self.project, document=self.document).exists() + ) + + def test_remove_annotations_partial_keeps_validated(self): + kept = self._make_ann(validated=True) + unvalidated = self._make_ann(validated=False) + + utils.remove_annotations(self.document, self.project, partial=True) + + remaining = AnnotatedEntity.objects.filter(project=self.project, document=self.document) + ids = {a.id for a in remaining} + self.assertIn(kept.id, ids) + self.assertNotIn(unvalidated.id, ids) + + def test_create_annotation_persists_manually_created(self): + ann_id = utils.create_annotation( + source_val='fever', + selection_occurrence_index=0, + cui='C001', + user=self.user, + project=self.project, + document=self.document, + ) + self.assertIsNotNone(ann_id) + ann = AnnotatedEntity.objects.get(id=ann_id) + self.assertEqual(ann.start_ind, 0) + self.assertEqual(ann.end_ind, 5) + self.assertTrue(ann.manually_created) + self.assertTrue(ann.validated) + self.assertTrue(ann.correct) + + def test_create_annotation_creates_new_entity_when_missing(self): + self.assertFalse(Entity.objects.filter(label='C999').exists()) + utils.create_annotation( + source_val='cough', + selection_occurrence_index=0, + cui='C999', + user=self.user, + project=self.project, + document=self.document, + ) + self.assertTrue(Entity.objects.filter(label='C999').exists()) + + def test_create_annotation_returns_none_for_empty_cui(self): + ann_id = utils.create_annotation( + source_val='fever', + selection_occurrence_index=0, + cui='', + user=self.user, + project=self.project, + document=self.document, + ) + self.assertIsNone(ann_id) + + def test_add_annotations_with_simple_filters(self): + spacy_doc = utils.RemoteSpacyDoc([ + utils.RemoteEntity({'cui': 'C001', 'start': 0, 'end': 5, 'detected_name': 'fever', + 'context_similarity': 0.9}, 'fever and cough'), + utils.RemoteEntity({'cui': 'C002', 'start': 10, 'end': 15, 'detected_name': 'cough', + 'context_similarity': 0.85}, 'fever and cough'), + ]) + + utils.add_annotations( + spacy_doc=spacy_doc, + user=self.user, + project=self.project, + document=self.document, + existing_annotations=[], + cat=None, + filters=utils.SimpleFilters(cuis={'C001'}), + similarity_threshold=0.5, + ) + + anns = list(AnnotatedEntity.objects.filter(project=self.project, document=self.document)) + self.assertEqual(len(anns), 1) + self.assertEqual(anns[0].entity.label, 'C001') + + def test_add_annotations_marks_low_acc_as_deleted(self): + spacy_doc = utils.RemoteSpacyDoc([ + utils.RemoteEntity({'cui': 'C100', 'start': 0, 'end': 5, 'detected_name': 'fever', + 'context_similarity': 0.1}, 'fever and cough'), + ]) + + utils.add_annotations( + spacy_doc=spacy_doc, + user=self.user, + project=self.project, + document=self.document, + existing_annotations=[], + cat=None, + filters=utils.SimpleFilters(), + similarity_threshold=0.3, + ) + + ann = AnnotatedEntity.objects.get(entity__label='C100') + self.assertTrue(ann.deleted) + self.assertTrue(ann.validated) + + def test_add_annotations_respects_excludes(self): + spacy_doc = utils.RemoteSpacyDoc([ + utils.RemoteEntity({'cui': 'C200', 'start': 0, 'end': 5, 'detected_name': 'fever', + 'context_similarity': 0.9}, 'fever and cough'), + utils.RemoteEntity({'cui': 'C201', 'start': 10, 'end': 15, 'detected_name': 'cough', + 'context_similarity': 0.9}, 'fever and cough'), + ]) + + utils.add_annotations( + spacy_doc=spacy_doc, + user=self.user, + project=self.project, + document=self.document, + existing_annotations=[], + cat=None, + filters=utils.SimpleFilters(cuis_exclude={'C200'}), + similarity_threshold=0.5, + ) + + labels = {a.entity.label for a in AnnotatedEntity.objects.filter(project=self.project, + document=self.document)} + self.assertIn('C201', labels) + self.assertNotIn('C200', labels) diff --git a/medcat-trainer/webapp/api/api/tests/test_views.py b/medcat-trainer/webapp/api/api/tests/test_views.py new file mode 100644 index 000000000..f2b625ba5 --- /dev/null +++ b/medcat-trainer/webapp/api/api/tests/test_views.py @@ -0,0 +1,383 @@ +"""Integration tests for api.views using DRF's APIClient. + +These tests focus on endpoints that don't require MedCAT to be loaded. +""" + +import json +import os +from unittest.mock import patch + +from django.contrib.auth.models import User +from django.test import TestCase, override_settings +from rest_framework.test import APIClient + +from ..models import ( + AnnotatedEntity, + ConceptDB, + Document, + Entity, + ProjectAnnotateEntities, + Vocabulary, +) +from ._helpers import ( + create_basic_project, + create_dataset, + create_document, + create_entity, + create_user, +) + + +def _auth_client(user): + client = APIClient() + client.force_authenticate(user=user) + return client + + +@override_settings(MEDIA_ROOT='/tmp/mct-tests-views') +class AuthenticationRequiredTests(TestCase): + def test_anonymous_users_cannot_list_projects(self): + client = APIClient() + resp = client.get('/api/project-annotate-entities/') + # 401 (Unauthorized) or 403 (Forbidden) acceptable + self.assertIn(resp.status_code, (401, 403)) + + def test_anonymous_users_cannot_list_users(self): + client = APIClient() + resp = client.get('/api/users/') + self.assertIn(resp.status_code, (401, 403)) + + +@override_settings(MEDIA_ROOT='/tmp/mct-tests-views') +class SimpleInfoEndpointsTests(TestCase): + def setUp(self): + self.user = create_user(username='infouser') + self.client = _auth_client(self.user) + + def test_version_returns_env_value(self): + old = os.environ.get('MCT_VERSION') + os.environ['MCT_VERSION'] = 'v9.9.9-test' + try: + resp = self.client.get('/api/version/') + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.data, 'v9.9.9-test') + finally: + if old is None: + os.environ.pop('MCT_VERSION', None) + else: + os.environ['MCT_VERSION'] = old + + def test_behind_reverse_proxy_returns_value(self): + old = os.environ.get('BEHIND_RP') + os.environ['BEHIND_RP'] = '1' + try: + resp = self.client.get('/api/behind-rp/') + self.assertEqual(resp.status_code, 200) + self.assertTrue(resp.data) + finally: + if old is None: + os.environ.pop('BEHIND_RP', None) + else: + os.environ['BEHIND_RP'] = old + + def test_anno_tool_conf_returns_environment_dict(self): + resp = self.client.get('/api/anno-conf/') + self.assertEqual(resp.status_code, 200) + # Just make sure it returns a dict-like JSON object + self.assertIsInstance(resp.json(), dict) + + +@override_settings(MEDIA_ROOT='/tmp/mct-tests-views') +class UserViewSetTests(TestCase): + def setUp(self): + self.user = create_user(username='listuser', password='pw') + self.other = create_user(username='otheruser', password='pw') + + def test_authenticated_user_can_list_users(self): + client = _auth_client(self.user) + resp = client.get('/api/users/') + self.assertEqual(resp.status_code, 200) + usernames = [u['username'] for u in resp.json()['results']] + self.assertIn('listuser', usernames) + self.assertIn('otheruser', usernames) + + def test_filter_by_username(self): + client = _auth_client(self.user) + resp = client.get('/api/users/?username=otheruser') + self.assertEqual(resp.status_code, 200) + results = resp.json()['results'] + self.assertEqual(len(results), 1) + self.assertEqual(results[0]['username'], 'otheruser') + + +@override_settings(MEDIA_ROOT='/tmp/mct-tests-views') +class ProjectAnnotateEntitiesViewSetTests(TestCase): + def setUp(self): + self.member = create_user(username='m1', password='pw') + self.outsider = create_user(username='o1', password='pw') + self.superuser = User.objects.create_superuser( + username='su1', password='pw', email='su1@x', + ) + self.project = create_basic_project(name='pl-proj') + self.project.members.add(self.member) + + def test_member_sees_only_their_projects(self): + client = _auth_client(self.member) + resp = client.get('/api/project-annotate-entities/') + self.assertEqual(resp.status_code, 200) + names = [p['name'] for p in resp.json()['results']] + self.assertIn('pl-proj', names) + + def test_outsider_sees_no_projects(self): + client = _auth_client(self.outsider) + resp = client.get('/api/project-annotate-entities/') + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.json()['results'], []) + + def test_superuser_sees_all_projects(self): + client = _auth_client(self.superuser) + resp = client.get('/api/project-annotate-entities/') + self.assertEqual(resp.status_code, 200) + names = [p['name'] for p in resp.json()['results']] + self.assertIn('pl-proj', names) + + +@override_settings(MEDIA_ROOT='/tmp/mct-tests-views') +class GetCreateEntityTests(TestCase): + def setUp(self): + self.user = create_user(username='entuser') + self.client = _auth_client(self.user) + + def test_creates_entity_when_label_does_not_exist(self): + self.assertFalse(Entity.objects.filter(label='C-NEW').exists()) + resp = self.client.post('/api/get-create-entity/', {'label': 'C-NEW'}, format='json') + self.assertEqual(resp.status_code, 200) + self.assertTrue(Entity.objects.filter(label='C-NEW').exists()) + self.assertIn('entity_id', resp.json()) + + def test_returns_existing_entity_id_when_label_exists(self): + ent = create_entity(label='C-EXIST') + resp = self.client.post('/api/get-create-entity/', {'label': 'C-EXIST'}, format='json') + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.json()['entity_id'], ent.id) + + +@override_settings(MEDIA_ROOT='/tmp/mct-tests-views') +class ProjectProgressTests(TestCase): + def setUp(self): + self.user = create_user(username='ppuser') + self.client = _auth_client(self.user) + self.project = create_basic_project(name='pp-proj') + + # Create 3 documents but no annotations + for i in range(3): + create_document(self.project, name=f'doc-{i}', text=f'text {i}') + + def test_returns_progress_for_project(self): + resp = self.client.get(f'/api/project-progress/?projects={self.project.id}') + self.assertEqual(resp.status_code, 200) + data = resp.json() + # JSON dict keys are strings + key = str(self.project.id) + self.assertIn(key, data) + self.assertEqual(data[key]['validated_count'], 0) + self.assertEqual(data[key]['dataset_count'], 3) + + def test_returns_400_when_no_projects_param(self): + resp = self.client.get('/api/project-progress/') + self.assertEqual(resp.status_code, 400) + + +@override_settings(MEDIA_ROOT='/tmp/mct-tests-views') +class PrepareDocsBgTaskTests(TestCase): + def setUp(self): + self.user = create_user(username='bguser') + self.client = _auth_client(self.user) + self.project = create_basic_project(name='bg-proj') + for i in range(2): + create_document(self.project, name=f'd-{i}', text=f't-{i}') + + def test_returns_400_for_unknown_project(self): + resp = self.client.get('/api/prep-docs-bg-tasks/999999/') + self.assertEqual(resp.status_code, 400) + + def test_returns_doc_counts_for_known_project(self): + resp = self.client.get(f'/api/prep-docs-bg-tasks/{self.project.id}/') + self.assertEqual(resp.status_code, 200) + data = resp.json() + self.assertEqual(data['dataset_len'], 2) + self.assertEqual(data['prepd_docs_len'], 0) + + +@override_settings(MEDIA_ROOT='/tmp/mct-tests-views') +class ProjectAdminProjectsTests(TestCase): + def setUp(self): + self.member = create_user(username='admin-mem') + self.outsider = create_user(username='admin-out') + self.project = create_basic_project(name='admin-proj') + self.project.members.add(self.member) + + def test_member_can_list_admin_projects(self): + client = _auth_client(self.member) + resp = client.get('/api/project-admin/projects/') + self.assertEqual(resp.status_code, 200) + names = [p['name'] for p in resp.json()] + self.assertIn('admin-proj', names) + + def test_outsider_has_no_admin_projects(self): + client = _auth_client(self.outsider) + resp = client.get('/api/project-admin/projects/') + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.json(), []) + + +@override_settings(MEDIA_ROOT='/tmp/mct-tests-views') +class ProjectAdminDetailTests(TestCase): + def setUp(self): + self.member = create_user(username='detail-mem') + self.outsider = create_user(username='detail-out') + self.project = create_basic_project(name='detail-proj') + self.project.members.add(self.member) + + def test_member_can_access_project_detail(self): + client = _auth_client(self.member) + resp = client.get(f'/api/project-admin/projects/{self.project.id}/') + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.json()['name'], 'detail-proj') + + def test_outsider_gets_403(self): + client = _auth_client(self.outsider) + resp = client.get(f'/api/project-admin/projects/{self.project.id}/') + self.assertEqual(resp.status_code, 403) + + def test_returns_404_for_unknown_project(self): + client = _auth_client(self.member) + resp = client.get('/api/project-admin/projects/9999999/') + self.assertEqual(resp.status_code, 404) + + def test_member_can_delete_project(self): + client = _auth_client(self.member) + resp = client.delete(f'/api/project-admin/projects/{self.project.id}/') + self.assertEqual(resp.status_code, 200) + self.assertFalse( + ProjectAnnotateEntities.objects.filter(id=self.project.id).exists() + ) + + def test_outsider_cannot_delete(self): + client = _auth_client(self.outsider) + resp = client.delete(f'/api/project-admin/projects/{self.project.id}/') + self.assertEqual(resp.status_code, 403) + + +@override_settings(MEDIA_ROOT='/tmp/mct-tests-views') +class ProjectAdminResetTests(TestCase): + def setUp(self): + self.member = create_user(username='reset-mem') + self.outsider = create_user(username='reset-out') + self.project = create_basic_project(name='reset-proj') + self.project.members.add(self.member) + + doc = create_document(self.project, name='doc', text='hello') + ent = create_entity(label='C-RESET') + AnnotatedEntity.objects.create( + user=self.member, project=self.project, document=doc, entity=ent, + value='hello', start_ind=0, end_ind=5, acc=1.0, validated=True, + ) + self.project.validated_documents.add(doc) + + def test_member_resets_annotations(self): + self.assertEqual( + AnnotatedEntity.objects.filter(project=self.project).count(), 1 + ) + client = _auth_client(self.member) + resp = client.post(f'/api/project-admin/projects/{self.project.id}/reset/') + self.assertEqual(resp.status_code, 200) + self.assertEqual( + AnnotatedEntity.objects.filter(project=self.project).count(), 0 + ) + self.project.refresh_from_db() + self.assertEqual(self.project.validated_documents.count(), 0) + + def test_outsider_cannot_reset(self): + client = _auth_client(self.outsider) + resp = client.post(f'/api/project-admin/projects/{self.project.id}/reset/') + self.assertEqual(resp.status_code, 403) + + +@override_settings(MEDIA_ROOT='/tmp/mct-tests-views') +class ProjectAdminCloneTests(TestCase): + def setUp(self): + self.member = create_user(username='clone-mem') + self.outsider = create_user(username='clone-out') + self.project = create_basic_project(name='clone-proj') + self.project.members.add(self.member) + + def test_clone_returns_new_project(self): + client = _auth_client(self.member) + resp = client.post( + f'/api/project-admin/projects/{self.project.id}/clone/', + {'name': 'my-clone'}, + format='json', + ) + self.assertEqual(resp.status_code, 201, msg=resp.content) + self.assertEqual(resp.json()['name'], 'my-clone') + self.assertTrue(ProjectAnnotateEntities.objects.filter(name='my-clone').exists()) + + def test_clone_default_name_when_unspecified(self): + client = _auth_client(self.member) + resp = client.post( + f'/api/project-admin/projects/{self.project.id}/clone/', + {}, + format='json', + ) + self.assertEqual(resp.status_code, 201) + self.assertEqual(resp.json()['name'], 'clone-proj (Clone)') + + def test_clone_returns_404_for_unknown_project(self): + client = _auth_client(self.member) + resp = client.post('/api/project-admin/projects/99999/clone/', {}, format='json') + self.assertEqual(resp.status_code, 404) + + def test_outsider_cannot_clone(self): + client = _auth_client(self.outsider) + resp = client.post( + f'/api/project-admin/projects/{self.project.id}/clone/', + {}, + format='json', + ) + self.assertEqual(resp.status_code, 403) + + +@override_settings(MEDIA_ROOT='/tmp/mct-tests-views') +class ModelLoadedTests(TestCase): + def setUp(self): + self.user = create_user(username='ml-user') + self.client = _auth_client(self.user) + self.project = create_basic_project(name='ml-proj') + + def test_returns_model_states_for_all_projects(self): + resp = self.client.get('/api/model-loaded/') + self.assertEqual(resp.status_code, 200) + data = resp.json() + self.assertIn('model_states', data) + self.assertIn(str(self.project.id), {str(k) for k in data['model_states']}) + + +@override_settings(MEDIA_ROOT='/tmp/mct-tests-views') +class DownloadAnnosTests(TestCase): + def setUp(self): + self.regular = create_user(username='reg') + self.superuser = User.objects.create_superuser(username='dl-su', password='pw', email='dl@x') + + def test_non_superuser_cannot_download(self): + client = _auth_client(self.regular) + resp = client.get('/api/download-annos/?project_ids=1') + self.assertEqual(resp.status_code, 400) + + def test_superuser_can_download_for_existing_project(self): + project = create_basic_project(name='dl-proj') + client = _auth_client(self.superuser) + resp = client.get(f'/api/download-annos/?project_ids={project.id}&with_text=true') + self.assertEqual(resp.status_code, 200) + # Response is a streaming JSON document + self.assertIn('Content-Disposition', resp) diff --git a/medcat-trainer/webapp/frontend/env.d.ts b/medcat-trainer/webapp/frontend/env.d.ts index 356c5d67a..2b96ad0bb 100644 --- a/medcat-trainer/webapp/frontend/env.d.ts +++ b/medcat-trainer/webapp/frontend/env.d.ts @@ -5,3 +5,13 @@ declare module '*.vue' { const component: DefineComponent export default component } + +declare module 'tiny-emitter/instance' { + export interface TinyEmitterInstance { + on(event: string, callback: (...args: unknown[]) => void, ctx?: unknown): TinyEmitterInstance + off(event: string, callback?: (...args: unknown[]) => void): TinyEmitterInstance + emit(event: string, ...args: any[]): TinyEmitterInstance + } + const emitter: TinyEmitterInstance + export default emitter +} diff --git a/medcat-trainer/webapp/frontend/package-lock.json b/medcat-trainer/webapp/frontend/package-lock.json index e0f4eb126..91f097df1 100644 --- a/medcat-trainer/webapp/frontend/package-lock.json +++ b/medcat-trainer/webapp/frontend/package-lock.json @@ -33,6 +33,7 @@ "devDependencies": { "@tsconfig/node20": "^20.1.9", "@types/jsdom": "^21.1.7", + "@types/lodash": "^4.17.24", "@types/node": "^20.19.41", "@vitejs/plugin-vue": "^5.2.4", "@vitest/coverage-v8": "^3.2.4", @@ -2428,6 +2429,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.41", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", diff --git a/medcat-trainer/webapp/frontend/package.json b/medcat-trainer/webapp/frontend/package.json index 7b5955cbe..b5bd18fdd 100644 --- a/medcat-trainer/webapp/frontend/package.json +++ b/medcat-trainer/webapp/frontend/package.json @@ -41,6 +41,7 @@ "devDependencies": { "@tsconfig/node20": "^20.1.9", "@types/jsdom": "^21.1.7", + "@types/lodash": "^4.17.24", "@types/node": "^20.19.41", "@vitejs/plugin-vue": "^5.2.4", "@vitest/coverage-v8": "^3.2.4", @@ -63,4 +64,4 @@ "vitest": "^3.2.4", "vue-tsc": "^2.2.12" } -} \ No newline at end of file +} diff --git a/medcat-trainer/webapp/frontend/src/event-bus.ts b/medcat-trainer/webapp/frontend/src/event-bus.ts index 1d72ff679..b83191e55 100644 --- a/medcat-trainer/webapp/frontend/src/event-bus.ts +++ b/medcat-trainer/webapp/frontend/src/event-bus.ts @@ -2,7 +2,8 @@ import emitter from 'tiny-emitter/instance' export default { - $on: (...args: any[]) => emitter.on(...args), - $off: (...args: any[]) => emitter.off(...args), - $emit: (...args: any[]) => emitter.emit(...args) + $on: (event: string, callback: (...args: unknown[]) => void, ctx?: unknown) => + emitter.on(event, callback, ctx), + $off: (event: string, callback?: (...args: unknown[]) => void) => emitter.off(event, callback), + $emit: (event: string, ...args: unknown[]) => emitter.emit(event, ...args) } diff --git a/medcat-trainer/webapp/frontend/src/tests/components/AnnotationSummary.spec.ts b/medcat-trainer/webapp/frontend/src/tests/components/AnnotationSummary.spec.ts new file mode 100644 index 000000000..6b505b6a1 --- /dev/null +++ b/medcat-trainer/webapp/frontend/src/tests/components/AnnotationSummary.spec.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import AnnotationSummary from '@/components/common/AnnotationSummary.vue' + +const docText = 'The patient has diabetes mellitus today.' +const annos = [ + { + id: 1, + start_ind: 16, + end_ind: 33, + value: 'diabetes mellitus', + cui: 'C0011860', + pretty_name: 'Diabetes mellitus', + icd10: [{ code: 'E11', desc: 'Type 2 diabetes' }], + correct: true, + deleted: false, + killed: false, + alternative: false, + manually_created: false + }, + { + id: 2, + start_ind: 4, + end_ind: 11, + value: 'patient', + cui: 'C0030705', + pretty_name: 'Patients', + icd10: [], + manually_created: true, + deleted: false, + killed: false, + alternative: false, + correct: false + } +] + +describe('AnnotationSummary.vue', () => { + const mountSummary = (props: Record = {}) => + mount(AnnotationSummary, { + props: { + annos, + currentDoc: { text: docText }, + taskIDs: [], + ...props + }, + global: { + stubs: {} + } + }) + + it('renders annotation rows with concept details', () => { + const wrapper = mountSummary() + expect(wrapper.text()).toContain('diabetes mellitus') + expect(wrapper.text()).toContain('C0011860') + expect(wrapper.text()).toContain('Diabetes mellitus') + }) + + it('shows ICD-10 column when annotations include codes', () => { + const wrapper = mountSummary() + expect(wrapper.text()).toContain('ICD-10') + expect(wrapper.text()).toContain('E11 | Type 2 diabetes') + }) + + it('hides ICD-10 column when no annotations have codes', () => { + const wrapper = mountSummary({ + annos: [{ ...annos[1], icd10: [] }] + }) + expect(wrapper.text()).not.toContain('ICD-10') + }) + + it('leftContext and rightContext extract surrounding text', () => { + const wrapper = mountSummary() + const vm = wrapper.vm as { + leftContext: (c: (typeof annos)[0]) => string + rightContext: (c: (typeof annos)[0]) => string + } + expect(vm.leftContext(annos[0])).toBe('The patient has ') + expect(vm.rightContext(annos[0])).toBe(' today.') + }) + + it('highlightClass reflects annotation state', () => { + const wrapper = mountSummary() + const vm = wrapper.vm as { highlightClass: (c: (typeof annos)[0]) => Record } + expect(vm.highlightClass(annos[0])).toMatchObject({ 'highlight-task-0': true }) + expect(vm.highlightClass(annos[1])).toMatchObject({ 'highlight-task-new': true }) + }) + + it('emits select:AnnoSummaryConcept with annotation index on row click', async () => { + const wrapper = mountSummary() + await wrapper.find('tbody tr').trigger('click') + expect(wrapper.emitted('select:AnnoSummaryConcept')?.[0]).toEqual([0]) + }) +}) diff --git a/medcat-trainer/webapp/frontend/src/tests/components/ConceptFilter.spec.ts b/medcat-trainer/webapp/frontend/src/tests/components/ConceptFilter.spec.ts new file mode 100644 index 000000000..354c79af5 --- /dev/null +++ b/medcat-trainer/webapp/frontend/src/tests/components/ConceptFilter.spec.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import ConceptFilter from '@/components/common/ConceptFilter.vue' + +const conceptList = [ + { cui: 'C001', name: 'Aspirin' }, + { cui: 'C002', name: 'Ibuprofen' }, + { cui: 'C003', name: 'Paracetamol' } +] + +describe('ConceptFilter.vue', () => { + const mockPost = vi.fn() + + beforeEach(() => { + mockPost.mockResolvedValue({ data: { concept_list: [...conceptList] } }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + const mountFilter = (props: Record = {}) => + mount(ConceptFilter, { + props: { cuis: 'C001,C002,C003', cdb_id: 1, ...props }, + global: { + mocks: { $http: { post: mockPost } }, + stubs: { + 'v-overlay': true, + 'v-progress-circular': true, + 'v-row': { template: '
' }, + 'v-text-field': { + template: + '', + props: ['modelValue'] + }, + 'v-table': { + template: '
' + } + } + } + }) + + it('loads concepts on create and shows project filter size', async () => { + const wrapper = mountFilter() + await flushPromises() + + expect(mockPost).toHaveBeenCalledWith('/api/cuis-to-concepts/', { + cuis: ['C001', 'C002', 'C003'], + cdb_id: 1 + }) + expect(wrapper.text()).toContain('Project concept filter size:') + expect(wrapper.text()).toContain('3') + expect(wrapper.findAll('#concept-table tbody tr')).toHaveLength(3) + }) + + it('sends null cuis when filter string is empty', async () => { + mountFilter({ cuis: '' }) + await flushPromises() + + expect(mockPost).toHaveBeenCalledWith('/api/cuis-to-concepts/', { + cuis: null, + cdb_id: 1 + }) + }) + + it('filterItems narrows rows and highlights matches', async () => { + vi.useFakeTimers() + const wrapper = mountFilter() + await flushPromises() + + const vm = wrapper.vm as { + filterItems: (q: string) => void + items: { cui: string; name: string }[] + } + vm.filterItems('ibu') + await vi.advanceTimersByTimeAsync(500) + expect(vm.items).toHaveLength(1) + expect(vm.items[0].cui).toBe('C002') + expect(vm.items[0].name).toContain('') + vi.useRealTimers() + }) + + it('clears search restores first page of all items', async () => { + const wrapper = mountFilter() + await flushPromises() + + const vm = wrapper.vm as { + filter: string + allItems: typeof conceptList + items: unknown[] + } + vm.filter = 'asp' + await wrapper.vm.$nextTick() + vm.filter = '' + await wrapper.vm.$nextTick() + + expect(vm.items).toHaveLength(vm.allItems.length) + }) +}) diff --git a/medcat-trainer/webapp/frontend/src/tests/components/ConceptPicker.spec.ts b/medcat-trainer/webapp/frontend/src/tests/components/ConceptPicker.spec.ts new file mode 100644 index 000000000..33713ab09 --- /dev/null +++ b/medcat-trainer/webapp/frontend/src/tests/components/ConceptPicker.spec.ts @@ -0,0 +1,159 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import type { App } from 'vue' + +vi.mock('lodash', () => { + const immediateDebounce = unknown>(fn: T) => { + const debounced = function (this: unknown, ...args: Parameters) { + return fn.apply(this, args) + } + debounced.cancel = () => undefined + debounced.flush = () => undefined + return debounced + } + return { + default: { debounce: immediateDebounce }, + debounce: immediateDebounce + } +}) + +import ConceptPicker from '@/components/common/ConceptPicker.vue' + +describe('ConceptPicker.vue', () => { + const mockGet = vi.fn() + const originalSetTimeout = window.setTimeout.bind(window) + + const httpPlugin = { + install(app: App) { + app.config.globalProperties.$http = { get: mockGet } + } + } + + beforeEach(() => { + vi.spyOn(window, 'setTimeout').mockImplementation((( + handler: TimerHandler, + timeout?: number, + ...args: unknown[] + ) => { + if (timeout === 150) { + return 0 as unknown as ReturnType + } + return originalSetTimeout(handler, timeout, ...args) as unknown as ReturnType< + typeof window.setTimeout + > + }) as unknown as typeof window.setTimeout) + mockGet.mockResolvedValue({ + data: { + results: [ + { + cui: 'C001', + pretty_name: 'Diabetes', + type_ids: [], + desc: '', + icd10: [], + opcs4: [], + semantic_type: '', + synonyms: [] + }, + { + cui: 'C002', + pretty_name: 'Diabetes', + type_ids: [], + desc: '', + icd10: [], + opcs4: [], + semantic_type: '', + synonyms: [] + } + ] + } + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + const mountPicker = (props: Record = {}) => + mount(ConceptPicker, { + props: { + restrict_concept_lookup: false, + cui_filter: '', + cdb_search_filter: [], + concept_db: 1, + selection: '', + ...props + }, + global: { + plugins: [httpPlugin], + stubs: { + 'v-select': { + template: '
', + props: ['modelValue', 'options', 'loading'] + } + } + } + }) + + it('does not search when term is empty', async () => { + const wrapper = mountPicker() + const vm = wrapper.vm as { searchCUI: (term: string) => void } + vm.searchCUI(' ') + await flushPromises() + expect(mockGet).not.toHaveBeenCalled() + }) + + it('fetches concepts for a search term', async () => { + const wrapper = mountPicker({ cdb_search_filter: [2] }) + const vm = wrapper.vm as { + searchCUI: (term: string) => void + searchResults: { cui: string; name: string }[] + loadingResults: boolean + } + vm.searchCUI('diab') + await flushPromises() + + expect(mockGet).toHaveBeenCalledWith('/api/search-concepts/?search=diab&cdbs=2,1') + expect(vm.loadingResults).toBe(false) + expect(vm.searchResults).toHaveLength(2) + expect(vm.searchResults[0].name).toContain('C001') + }) + + it('restricts results to CUI filter when enabled', async () => { + const wrapper = mountPicker({ + restrict_concept_lookup: true, + cui_filter: 'C001' + }) + const vm = wrapper.vm as { + searchCUI: (term: string) => void + searchResults: { cui: string }[] + } + vm.searchCUI('diab') + await flushPromises() + + expect(vm.searchResults).toHaveLength(1) + expect(vm.searchResults[0].cui).toBe('C001') + }) + + it('emits pickedResult:concept when selection changes', async () => { + const wrapper = mountPicker() + const concept = { cui: 'C001', name: 'Diabetes' } + const vm = wrapper.vm as { selectedCUI: typeof concept } + vm.selectedCUI = concept + await wrapper.vm.$nextTick() + + expect(wrapper.emitted('pickedResult:concept')?.[0]).toEqual([concept]) + }) + + it('sets error message when search fails', async () => { + mockGet.mockRejectedValueOnce({ + response: { data: { message: 'Search unavailable' } } + }) + const wrapper = mountPicker() + const vm = wrapper.vm as { searchCUI: (term: string) => void; error: string | null } + vm.searchCUI('fail') + await flushPromises() + + expect(vm.error).toBe('Search unavailable') + }) +}) diff --git a/medcat-trainer/webapp/frontend/src/tests/components/DatasetsList.spec.ts b/medcat-trainer/webapp/frontend/src/tests/components/DatasetsList.spec.ts new file mode 100644 index 000000000..bfe8b6442 --- /dev/null +++ b/medcat-trainer/webapp/frontend/src/tests/components/DatasetsList.spec.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import DatasetsList from '@/components/admin/DatasetsList.vue' + +const datasets = [ + { id: 1, name: 'Clinical Notes', description: 'De-identified notes' }, + { id: 2, name: 'Discharge Summaries', description: 'Hospital discharges' } +] + +describe('DatasetsList.vue', () => { + const mountList = (props: Record = {}) => + mount(DatasetsList, { + props: { datasets, ...props }, + global: { + stubs: { + 'v-data-table': { + template: '
', + props: ['items', 'headers'] + }, + 'font-awesome-icon': true + } + } + }) + + it('shows empty state when there are no datasets', () => { + const wrapper = mountList({ datasets: [] }) + expect(wrapper.text()).toContain('No Datasets') + expect(wrapper.text()).toContain('Add Your First Dataset') + }) + + it('emits add-dataset from empty-state button', async () => { + const wrapper = mountList({ datasets: [] }) + await wrapper.find('.btn-create-empty').trigger('click') + expect(wrapper.emitted('add-dataset')).toBeTruthy() + }) + + it('emits select-dataset on row click', () => { + const wrapper = mountList() + const vm = wrapper.vm as { + handleRowClick: (e: Event, payload: { item: (typeof datasets)[0] }) => void + } + const event = new Event('click') + vm.handleRowClick(event, { item: datasets[0] }) + expect(wrapper.emitted('select-dataset')?.[0]).toEqual([event, { item: datasets[0] }]) + }) + + it('emits confirm-delete-dataset when delete is triggered', async () => { + const wrapper = mount(DatasetsList, { + props: { datasets }, + global: { + stubs: { + 'v-data-table': { + template: + '
', + props: ['items'] + }, + 'font-awesome-icon': true + } + } + }) + const vm = wrapper.vm as { + $emit: (event: string, item: (typeof datasets)[0]) => void + } + vm.$emit('confirm-delete-dataset', datasets[0]) + expect(wrapper.emitted('confirm-delete-dataset')?.[0]).toEqual([datasets[0]]) + }) +}) diff --git a/medcat-trainer/webapp/frontend/src/tests/components/DocumentSummary.spec.ts b/medcat-trainer/webapp/frontend/src/tests/components/DocumentSummary.spec.ts new file mode 100644 index 000000000..54db19729 --- /dev/null +++ b/medcat-trainer/webapp/frontend/src/tests/components/DocumentSummary.spec.ts @@ -0,0 +1,126 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mount } from '@vue/test-utils' +import DocumentSummary from '@/components/common/DocumentSummary.vue' + +const docs = [ + { id: 1, name: 'note-alpha', text: 'Line one\nLine two\nLine three\nLine four\nLine five\nLine six' }, + { id: 2, name: 'note-beta', text: 'Short note' }, + { id: 3, name: 'note-gamma', text: 'nan' } +] + +describe('DocumentSummary.vue', () => { + const originalSetTimeout = window.setTimeout.bind(window) + + beforeEach(() => { + vi.spyOn(window, 'addEventListener') + vi.spyOn(window, 'removeEventListener') + Element.prototype.scrollIntoView = vi.fn() + vi.spyOn(window, 'setTimeout').mockImplementation((( + handler: TimerHandler, + timeout?: number, + ...args: unknown[] + ) => { + if (timeout === 50) { + return 0 as unknown as ReturnType + } + return originalSetTimeout(handler, timeout, ...args) as unknown as ReturnType< + typeof window.setTimeout + > + }) as unknown as typeof window.setTimeout) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + const defaultProps = { + projId: 1, + docs, + moreDocs: true, + selectedDocId: 1, + loadingDoc: false, + validatedDocIds: [1], + preparedDocIds: [2] + } + + const mountComponent = (props = {}) => + mount(DocumentSummary, { + props: { ...defaultProps, ...props }, + global: { + stubs: { + 'font-awesome-icon': true, + 'v-tooltip': true + } + } + }) + + it('renders document list and title', () => { + const wrapper = mountComponent() + expect(wrapper.text()).toContain('Clinical Notes') + expect(wrapper.findAll('.doc')).toHaveLength(3) + }) + + it('highlights selected document', () => { + const wrapper = mountComponent({ selectedDocId: 2 }) + const selected = wrapper.find('.selected-doc') + expect(selected.exists()).toBe(true) + }) + + it('emits request:loadDoc when a document is clicked', async () => { + const wrapper = mountComponent() + const docEls = wrapper.findAll('.doc') + await docEls[1].trigger('click') + expect(wrapper.emitted('request:loadDoc')).toBeTruthy() + expect(wrapper.emitted('request:loadDoc')?.[0]).toEqual([docs[1]]) + }) + + it('emits request:nextDocSet when More Docs is clicked', async () => { + const wrapper = mountComponent({ moreDocs: true }) + await wrapper.find('.more-docs').trigger('click') + expect(wrapper.emitted('request:nextDocSet')).toBeTruthy() + }) + + it('limitText truncates to five lines', () => { + const wrapper = mountComponent() + const vm = wrapper.vm as { limitText: (v: string) => string } + const long = 'a\nb\nc\nd\ne\nf\ng' + expect(vm.limitText(long)).toBe('a\nb\nc\nd\ne') + expect(vm.limitText(' single line ')).toBe('single line') + }) + + it('searchDocs filters documents by name prefix', async () => { + const wrapper = mountComponent() + const vm = wrapper.vm as { + activateSearch: () => void + searchDocs: (e: { target: { value: string } }) => void + filteredDocs: typeof docs + searching: boolean + } + + vm.activateSearch() + await wrapper.vm.$nextTick() + expect(vm.searching).toBe(true) + + vm.searchDocs({ target: { value: 'note-b' } }) + expect(vm.filteredDocs).toHaveLength(1) + expect(vm.filteredDocs[0].name).toBe('note-beta') + }) + + it('shows search-filtered list when searchCrit is set', async () => { + const wrapper = mountComponent() + const vm = wrapper.vm as { + activateSearch: () => void + searchDocs: (e: { target: { value: string } }) => void + } + vm.activateSearch() + vm.searchDocs({ target: { value: 'note-a' } }) + await wrapper.vm.$nextTick() + expect(wrapper.findAll('.doc')).toHaveLength(1) + }) + + it('treats nan text as empty in preview', () => { + const wrapper = mountComponent() + const docWithNan = wrapper.findAll('.note-summary')[2] + expect(docWithNan.text()).toBe('') + }) +}) diff --git a/medcat-trainer/webapp/frontend/src/tests/components/HelpContent.spec.ts b/medcat-trainer/webapp/frontend/src/tests/components/HelpContent.spec.ts new file mode 100644 index 000000000..b4397a3a0 --- /dev/null +++ b/medcat-trainer/webapp/frontend/src/tests/components/HelpContent.spec.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import HelpContent from '@/components/usecases/HelpContent.vue' + +describe('HelpContent.vue', () => { + const descriptions = { + Presence: { + description: 'Whether the entity is present.', + values: { + Present: 'Entity is present in text', + Absent: 'Entity is negated or absent' + } + } + } + + it('renders task descriptions and value help table', () => { + const wrapper = mount(HelpContent, { + props: { descriptions } + }) + + expect(wrapper.text()).toContain('Task: Presence') + expect(wrapper.text()).toContain('Whether the entity is present.') + expect(wrapper.text()).toContain('Present') + expect(wrapper.text()).toContain('Entity is present in text') + expect(wrapper.text()).toContain('Absent') + expect(wrapper.findAll('tbody tr')).toHaveLength(2) + }) + + it('renders multiple tasks when provided', () => { + const wrapper = mount(HelpContent, { + props: { + descriptions: { + ...descriptions, + Subject: { + description: 'Who the finding applies to.', + values: { Patient: 'The patient' } + } + } + } + }) + + expect(wrapper.text()).toContain('Task: Presence') + expect(wrapper.text()).toContain('Task: Subject') + expect(wrapper.findAll('h4')).toHaveLength(2) + }) +}) diff --git a/medcat-trainer/webapp/frontend/src/tests/components/Login.spec.ts b/medcat-trainer/webapp/frontend/src/tests/components/Login.spec.ts new file mode 100644 index 000000000..ea72d430b --- /dev/null +++ b/medcat-trainer/webapp/frontend/src/tests/components/Login.spec.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import EventBus from '@/event-bus' + +const { mockAxiosPost } = vi.hoisted(() => ({ + mockAxiosPost: vi.fn() +})) + +vi.mock('axios', () => ({ + default: { + create: () => ({ post: mockAxiosPost }), + baseURL: '' + } +})) + +vi.mock('@/runtimeConfig.ts', () => ({ + isOidcEnabled: vi.fn(() => false) +})) + +import Login from '@/components/common/Login.vue' + +describe('Login.vue', () => { + const mockGet = vi.fn() + const mockCookies = { + get: vi.fn((key: string) => (key === 'api-token' ? 'test-token' : undefined)), + set: vi.fn(), + remove: vi.fn() + } + + beforeEach(() => { + vi.clearAllMocks() + mockAxiosPost.mockResolvedValue({ data: { token: 'test-token' } }) + mockGet.mockResolvedValue({ + data: { + results: [{ id: 1, username: 'alice', is_staff: false, is_superuser: false }] + } + }) + }) + + const mountLogin = () => + mount(Login, { + props: { closable: true }, + global: { + mocks: { + $http: { get: mockGet, defaults: { headers: { common: {} } } }, + $cookies: mockCookies + }, + stubs: { + Modal: { + template: + '' + } + } + }, + attachTo: document.body + }) + + it('renders login form when OIDC is disabled', () => { + const wrapper = mountLogin() + expect(wrapper.find('.login-modal').exists()).toBe(true) + expect(wrapper.text()).toContain('Login') + expect(wrapper.find('#uname').exists()).toBe(true) + expect(wrapper.find('#password').exists()).toBe(true) + }) + + it('posts credentials and sets cookies on successful login', async () => { + const emitSpy = vi.spyOn(EventBus, '$emit') + const wrapper = mountLogin() + await flushPromises() + + const vm = wrapper.vm as { uname: string; password: string; login: () => void } + vm.uname = 'alice' + vm.password = 'secret' + vm.login() + await flushPromises() + + expect(mockAxiosPost).toHaveBeenCalledWith( + '/api/api-token-auth/', + { username: 'alice', password: 'secret' }, + {} + ) + expect(mockCookies.set).toHaveBeenCalledWith('api-token', 'test-token', { expires: 7 }) + expect(mockCookies.set).toHaveBeenCalledWith('username', 'alice') + expect(mockGet).toHaveBeenCalledWith('/api/users/?username=alice') + expect(emitSpy).toHaveBeenCalledWith('login:success') + emitSpy.mockRestore() + wrapper.unmount() + }) + + it('shows error message on failed login', async () => { + mockAxiosPost.mockRejectedValueOnce(new Error('unauthorized')) + const wrapper = mountLogin() + await flushPromises() + + const vm = wrapper.vm as { + uname: string + password: string + login: () => void + failed: boolean + } + vm.uname = 'alice' + vm.password = 'wrong' + vm.login() + await flushPromises() + + expect(vm.failed).toBe(true) + expect(wrapper.text()).toContain('incorrect') + wrapper.unmount() + }) + + it('emits login:close when modal close is triggered', async () => { + const wrapper = mountLogin() + await wrapper.vm.$emit('login:close') + expect(wrapper.emitted('login:close')).toBeTruthy() + wrapper.unmount() + }) +}) diff --git a/medcat-trainer/webapp/frontend/src/tests/components/MetaAnnotationsSummary.spec.ts b/medcat-trainer/webapp/frontend/src/tests/components/MetaAnnotationsSummary.spec.ts new file mode 100644 index 000000000..15d7569d2 --- /dev/null +++ b/medcat-trainer/webapp/frontend/src/tests/components/MetaAnnotationsSummary.spec.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import MetaAnnotationsSummary from '@/components/common/MetaAnnotationsSummary.vue' + +describe('MetaAnnotationsSummary.vue', () => { + it('renders nothing when meta annotations are empty', () => { + const wrapper = mount(MetaAnnotationsSummary, { + props: { metaAnnotations: [] } + }) + expect(wrapper.find('.meta-annotations-section').exists()).toBe(false) + }) + + it('renders tasks and confidence scores', () => { + const metaAnnotations = [ + { task: 'Presence', value: 'Present', confidence: 0.9123 }, + { task: 'Subject', value: 'Patient', confidence: null } + ] + const wrapper = mount(MetaAnnotationsSummary, { props: { metaAnnotations } }) + + expect(wrapper.text()).toContain('Meta Annotations') + expect(wrapper.text()).toContain('Presence:') + expect(wrapper.text()).toContain('Present') + expect(wrapper.text()).toContain('score: 0.912') + expect(wrapper.text()).toContain('Subject:') + expect(wrapper.text()).toContain('Patient') + expect(wrapper.text()).not.toContain('score: null') + }) +}) diff --git a/medcat-trainer/webapp/frontend/src/tests/components/MetricCell.spec.ts b/medcat-trainer/webapp/frontend/src/tests/components/MetricCell.spec.ts new file mode 100644 index 000000000..05cf9e0db --- /dev/null +++ b/medcat-trainer/webapp/frontend/src/tests/components/MetricCell.spec.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import MetricCell from '@/components/metrics/MetricCell.vue' + +describe('MetricCell.vue', () => { + const mountCell = (props: Record = {}) => + mount(MetricCell, { + props: { value: 0.75, ...props }, + global: { + stubs: { + 'v-progress-linear': { + template: '
' + } + } + } + }) + + it('formats value with default decimal places', () => { + const wrapper = mountCell({ value: 0.756 }) + expect(wrapper.text()).toContain('0.76') + }) + + it('respects custom decimal places', () => { + const wrapper = mountCell({ value: 0.756, decimals: 3 }) + expect(wrapper.text()).toContain('0.756') + }) + + it('applies good-perf class when value exceeds threshold', () => { + const wrapper = mountCell({ value: 0.9, threshold: 0.4 }) + expect(wrapper.find('.good-perf').exists()).toBe(true) + }) + + it('does not apply good-perf class below threshold', () => { + const wrapper = mountCell({ value: 0.2, threshold: 0.4 }) + expect(wrapper.find('.good-perf').exists()).toBe(false) + }) +}) diff --git a/medcat-trainer/webapp/frontend/src/tests/components/Modal.spec.ts b/medcat-trainer/webapp/frontend/src/tests/components/Modal.spec.ts new file mode 100644 index 000000000..e00a6dc5c --- /dev/null +++ b/medcat-trainer/webapp/frontend/src/tests/components/Modal.spec.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import Modal from '@/components/common/Modal.vue' + +describe('Modal.vue', () => { + it('renders header, body and footer slots', () => { + const wrapper = mount(Modal, { + props: { closable: true }, + slots: { + header: '

Title

', + body: '

Body content

', + footer: '' + }, + global: { + stubs: { 'font-awesome-icon': true } + } + }) + + expect(wrapper.text()).toContain('Title') + expect(wrapper.text()).toContain('Body content') + expect(wrapper.text()).toContain('OK') + }) + + it('shows close icon when closable', () => { + const wrapper = mount(Modal, { + props: { closable: true }, + global: { stubs: { 'font-awesome-icon': true } } + }) + expect(wrapper.find('.close').exists()).toBe(true) + }) + + it('hides close icon when not closable', () => { + const wrapper = mount(Modal, { + props: { closable: false }, + global: { stubs: { 'font-awesome-icon': true } } + }) + expect(wrapper.find('.close').exists()).toBe(false) + }) + + it('emits modal:close when close icon clicked', async () => { + const wrapper = mount(Modal, { + props: { closable: true }, + global: { stubs: { 'font-awesome-icon': true } } + }) + await wrapper.find('.close').trigger('click') + expect(wrapper.emitted('modal:close')).toBeTruthy() + }) + + it('emits modal:close when mask clicked', async () => { + const wrapper = mount(Modal, { + props: { closable: true }, + global: { stubs: { 'font-awesome-icon': true } } + }) + await wrapper.find('.modal-mask').trigger('click') + expect(wrapper.emitted('modal:close')).toBeTruthy() + }) + + it('does not close when container clicked (stop propagation)', async () => { + const wrapper = mount(Modal, { + props: { closable: true }, + global: { stubs: { 'font-awesome-icon': true } } + }) + await wrapper.find('.modal-container').trigger('click') + expect(wrapper.emitted('modal:close')).toBeFalsy() + }) +}) diff --git a/medcat-trainer/webapp/frontend/src/tests/components/NavBar.spec.ts b/medcat-trainer/webapp/frontend/src/tests/components/NavBar.spec.ts new file mode 100644 index 000000000..b16d0eb02 --- /dev/null +++ b/medcat-trainer/webapp/frontend/src/tests/components/NavBar.spec.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mount } from '@vue/test-utils' +import NavBar from '@/components/common/NavBar.vue' + +const ents = [ + { id: 1, value: 'a' }, + { id: 2, value: 'b' }, + { id: 3, value: 'c' } +] + +describe('NavBar.vue', () => { + beforeEach(() => { + vi.spyOn(window, 'addEventListener') + vi.spyOn(window, 'removeEventListener') + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('disables back on first entity and next on last', () => { + const wrapper = mount(NavBar, { + props: { ents, currentEnt: ents[0], useEnts: true }, + global: { stubs: { 'font-awesome-icon': true } } + }) + const buttons = wrapper.findAll('button') + expect(buttons[0].attributes('disabled')).toBeDefined() + expect(buttons[1].attributes('disabled')).toBeUndefined() + }) + + it('enables both buttons for middle entity', () => { + const wrapper = mount(NavBar, { + props: { ents, currentEnt: ents[1], useEnts: true }, + global: { stubs: { 'font-awesome-icon': true } } + }) + const buttons = wrapper.findAll('button') + expect(buttons[0].attributes('disabled')).toBeUndefined() + expect(buttons[1].attributes('disabled')).toBeUndefined() + }) + + it('emits select:next when next clicked', async () => { + const wrapper = mount(NavBar, { + props: { ents, currentEnt: ents[0], useEnts: true }, + global: { stubs: { 'font-awesome-icon': true } } + }) + await wrapper.findAll('button')[1].trigger('click') + expect(wrapper.emitted('select:next')).toBeTruthy() + }) + + it('emits select:back when back clicked', async () => { + const wrapper = mount(NavBar, { + props: { ents, currentEnt: ents[2], useEnts: true }, + global: { stubs: { 'font-awesome-icon': true } } + }) + await wrapper.findAll('button')[0].trigger('click') + expect(wrapper.emitted('select:back')).toBeTruthy() + }) + + it('uses nextBtnDisabled when useEnts is false', () => { + const wrapper = mount(NavBar, { + props: { + useEnts: false, + nextBtnDisabled: true, + backBtnDisabled: false + }, + global: { stubs: { 'font-awesome-icon': true } } + }) + const buttons = wrapper.findAll('button') + expect(buttons[1].attributes('disabled')).toBeDefined() + expect(buttons[0].attributes('disabled')).toBeUndefined() + }) + + it('registers keyup listener on mount', () => { + mount(NavBar, { + props: { ents, currentEnt: ents[1], useEnts: true }, + global: { stubs: { 'font-awesome-icon': true } } + }) + expect(window.addEventListener).toHaveBeenCalledWith('keyup', expect.any(Function)) + }) +}) diff --git a/medcat-trainer/webapp/frontend/src/tests/components/ProjectList.spec.ts b/medcat-trainer/webapp/frontend/src/tests/components/ProjectList.spec.ts new file mode 100644 index 000000000..bdc9065ba --- /dev/null +++ b/medcat-trainer/webapp/frontend/src/tests/components/ProjectList.spec.ts @@ -0,0 +1,184 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import ProjectList from '@/components/common/ProjectList.vue' +import { ALL_FILTER } from '@/utils/projectListFilters' + +const sampleProjects = [ + { + id: 1, + name: 'Zebra Project', + description: 'desc', + create_time: '2024-01-01T00:00:00Z', + last_modified: '2024-06-01T10:00:00Z', + cuis: 'C001,C002', + require_entity_validation: true, + project_status: 'A', + project_locked: false, + annotation_classification: false, + progress: 2, + progress_max: 10, + cdb_search_filter: 1 + }, + { + id: 2, + name: 'Alpha Project', + description: 'desc2', + create_time: '2024-02-01T00:00:00Z', + last_modified: '2024-05-01T10:00:00Z', + cuis: '', + require_entity_validation: false, + project_status: 'C', + project_locked: true, + annotation_classification: true, + progress: 5, + progress_max: 5, + cdb_search_filter: null + } +] + +describe('ProjectList.vue', () => { + const mockGet = vi.fn() + const mockPost = vi.fn() + const mockDelete = vi.fn() + const originalSetTimeout = window.setTimeout.bind(window) + + beforeEach(() => { + mockGet.mockImplementation((url: string) => { + if (url === '/api/prep-docs-bg-tasks/') { + return Promise.resolve({ data: { comp_tasks: [], running_tasks: [] } }) + } + if (url === '/api/model-loaded/') { + return Promise.resolve({ data: { model_states: {} } }) + } + return Promise.resolve({ data: {} }) + }) + mockPost.mockResolvedValue({ data: {} }) + mockDelete.mockResolvedValue({ data: 'success' }) + vi.spyOn(window, 'setTimeout').mockImplementation((( + handler: TimerHandler, + timeout?: number, + ...args: unknown[] + ) => { + if (timeout === 8000) { + return 0 as unknown as ReturnType + } + return originalSetTimeout(handler, timeout, ...args) as unknown as ReturnType< + typeof window.setTimeout + > + }) as unknown as typeof window.setTimeout) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + const mountList = (props: Record = {}) => + mount(ProjectList, { + props: { + projectItems: sampleProjects, + isAdmin: false, + cdbSearchIndexStatus: { 1: true }, + ...props + }, + global: { + mocks: { + $http: { get: mockGet, post: mockPost, delete: mockDelete } + }, + stubs: { + 'v-data-table': { + template: '
', + props: ['items', 'headers'] + }, + 'v-overlay': true, + 'v-progress-circular': true, + 'v-progress-linear': true, + 'v-tooltip': true, + 'font-awesome-icon': true, + Modal: true, + 'router-link': true + } + } + }) + + it('formatShortDate returns em dash for empty values', async () => { + const wrapper = mountList() + await flushPromises() + const vm = wrapper.vm as { formatShortDate: (v: string | null) => string } + expect(vm.formatShortDate(null)).toBe('—') + expect(vm.formatShortDate('2024-06-01T10:00:00Z')).toBe( + new Date('2024-06-01T10:00:00Z').toLocaleDateString() + ) + }) + + it('clearFilters resets filter state', async () => { + const wrapper = mountList() + await flushPromises() + const vm = wrapper.vm as { + searchQuery: string + statusFilter: string + modeFilter: string + clearFilters: () => void + } + vm.searchQuery = 'zebra' + vm.statusFilter = 'C' + vm.modeFilter = 'annotate' + vm.clearFilters() + expect(vm.searchQuery).toBe('') + expect(vm.statusFilter).toBe(ALL_FILTER) + expect(vm.modeFilter).toBe(ALL_FILTER) + }) + + it('toggleSort flips order for same column', async () => { + const wrapper = mountList() + await flushPromises() + const vm = wrapper.vm as { + sortBy: string + sortOrder: string + toggleSort: (k: string) => void + } + vm.sortBy = 'name' + vm.sortOrder = 'asc' + vm.toggleSort('name') + expect(vm.sortOrder).toBe('desc') + vm.toggleSort('last_modified') + expect(vm.sortBy).toBe('last_modified') + expect(vm.sortOrder).toBe('desc') + }) + + it('filteredProjectItems filters by debounced search query', async () => { + const wrapper = mountList() + await flushPromises() + const vm = wrapper.vm as { + debouncedSearchQuery: string + filteredProjectItems: typeof sampleProjects + } + vm.debouncedSearchQuery = 'zebra' + await wrapper.vm.$nextTick() + expect(vm.filteredProjectItems).toHaveLength(1) + expect(vm.filteredProjectItems[0].name).toBe('Zebra Project') + }) + + it('visibleHeaders hides admin-only columns for non-admin', async () => { + const wrapper = mountList({ isAdmin: false }) + await flushPromises() + const vm = wrapper.vm as { visibleHeaders: { value: string }[] } + const values = vm.visibleHeaders.map(h => h.value) + expect(values).not.toContain('run_model') + expect(values).not.toContain('save_model') + }) + + it('visibleHeaders includes admin columns for admin users', async () => { + const wrapper = mountList({ isAdmin: true }) + await flushPromises() + const vm = wrapper.vm as { visibleHeaders: { value: string }[] } + const values = vm.visibleHeaders.map(h => h.value) + expect(values).toContain('run_model') + expect(values).toContain('metrics') + }) + + it('fetches model loaded state on create', async () => { + mountList() + await flushPromises() + expect(mockGet).toHaveBeenCalledWith('/api/model-loaded/') + }) +}) diff --git a/medcat-trainer/webapp/frontend/src/tests/components/ProjectsList.spec.ts b/medcat-trainer/webapp/frontend/src/tests/components/ProjectsList.spec.ts new file mode 100644 index 000000000..d2b7c14a7 --- /dev/null +++ b/medcat-trainer/webapp/frontend/src/tests/components/ProjectsList.spec.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import ProjectsList from '@/components/admin/ProjectsList.vue' +import { ALL_FILTER } from '@/utils/projectListFilters' + +const projects = [ + { + id: 1, + name: 'Zebra Study', + description: 'First project', + project_status: 'A', + require_entity_validation: true, + dataset: 10, + create_time: '2024-01-01T00:00:00Z', + last_modified: '2024-06-01T10:00:00Z' + }, + { + id: 2, + name: 'Alpha Trial', + description: 'Second project', + project_status: 'C', + require_entity_validation: false, + dataset: 20, + create_time: '2024-02-01T00:00:00Z', + last_modified: '2024-05-01T10:00:00Z' + } +] + +const datasets = [ + { id: 10, name: 'Dataset A' }, + { id: 20, name: 'Dataset B' } +] + +describe('ProjectsList.vue', () => { + const adminStubs = { + 'v-data-table': { + template: '
', + props: ['items', 'headers'] + }, + 'font-awesome-icon': true + } + + const mountList = (props: Record = {}) => + mount(ProjectsList, { + props: { projects, datasets, ...props }, + global: { stubs: adminStubs } + }) + + it('shows empty state when there are no projects', () => { + const wrapper = mountList({ projects: [] }) + expect(wrapper.text()).toContain('No Projects Yet') + expect(wrapper.find('.btn-create-empty').exists()).toBe(true) + }) + + it('getStatusText and getModeText format badges', () => { + const wrapper = mountList() + const vm = wrapper.vm as { + getStatusText: (s: string) => string + getModeText: (v: boolean) => string + getDatasetName: (id: number) => string + } + expect(vm.getStatusText('A')).toBe('Annotating') + expect(vm.getStatusText('C')).toBe('Complete') + expect(vm.getModeText(true)).toBe('Annotate') + expect(vm.getModeText(false)).toBe('Validate') + expect(vm.getDatasetName(10)).toBe('Dataset A') + expect(vm.getDatasetName(999)).toBe('N/A') + }) + + it('filteredProjects respects debounced search query', async () => { + const wrapper = mountList() + const vm = wrapper.vm as { + debouncedSearchQuery: string + filteredProjects: typeof projects + } + vm.debouncedSearchQuery = 'zebra' + await wrapper.vm.$nextTick() + expect(vm.filteredProjects).toHaveLength(1) + expect(vm.filteredProjects[0].name).toBe('Zebra Study') + }) + + it('clearFilters resets filter state', () => { + const wrapper = mountList() + const vm = wrapper.vm as { + searchQuery: string + statusFilter: string + modeFilter: string + clearFilters: () => void + } + vm.searchQuery = 'alpha' + vm.statusFilter = 'C' + vm.modeFilter = 'annotate' + vm.clearFilters() + expect(vm.searchQuery).toBe('') + expect(vm.statusFilter).toBe(ALL_FILTER) + expect(vm.modeFilter).toBe(ALL_FILTER) + }) + + it('toggleSort switches column and order', () => { + const wrapper = mountList() + const vm = wrapper.vm as { + sortBy: string + sortOrder: string + toggleSort: (k: string) => void + } + vm.sortBy = 'name' + vm.sortOrder = 'asc' + vm.toggleSort('name') + expect(vm.sortOrder).toBe('desc') + vm.toggleSort('create_time') + expect(vm.sortBy).toBe('create_time') + expect(vm.sortOrder).toBe('desc') + }) + + it('emits create-project from empty-state button', async () => { + const wrapper = mountList({ projects: [] }) + await wrapper.find('.btn-create-empty').trigger('click') + expect(wrapper.emitted('create-project')).toBeTruthy() + }) +}) diff --git a/medcat-trainer/webapp/frontend/src/tests/components/UsersList.spec.ts b/medcat-trainer/webapp/frontend/src/tests/components/UsersList.spec.ts new file mode 100644 index 000000000..4ccca9485 --- /dev/null +++ b/medcat-trainer/webapp/frontend/src/tests/components/UsersList.spec.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import UsersList from '@/components/admin/UsersList.vue' + +const users = [ + { id: 1, username: 'alice', email: 'alice@example.com', is_staff: true, is_superuser: false }, + { id: 2, username: 'bob', email: 'bob@example.com', is_staff: false, is_superuser: true } +] + +describe('UsersList.vue', () => { + const mountList = (props: Record = {}) => + mount(UsersList, { + props: { users, ...props }, + global: { + stubs: { + 'v-data-table': { + template: '
', + props: ['items', 'headers'] + }, + 'font-awesome-icon': true + } + } + }) + + it('shows empty state when there are no users', () => { + const wrapper = mountList({ users: [] }) + expect(wrapper.text()).toContain('No Users') + expect(wrapper.text()).toContain('Add Your First User') + }) + + it('emits add-user from empty-state button', async () => { + const wrapper = mountList({ users: [] }) + await wrapper.find('.btn-create-empty').trigger('click') + expect(wrapper.emitted('add-user')).toBeTruthy() + }) + + it('emits select-user on row click', () => { + const wrapper = mountList() + const vm = wrapper.vm as { + handleRowClick: (e: Event, payload: { item: (typeof users)[0] }) => void + } + const event = new Event('click') + vm.handleRowClick(event, { item: users[1] }) + expect(wrapper.emitted('select-user')?.[0]).toEqual([event, { item: users[1] }]) + }) +}) diff --git a/medcat-trainer/webapp/frontend/src/tests/event-bus.spec.ts b/medcat-trainer/webapp/frontend/src/tests/event-bus.spec.ts new file mode 100644 index 000000000..2e71c3e20 --- /dev/null +++ b/medcat-trainer/webapp/frontend/src/tests/event-bus.spec.ts @@ -0,0 +1,32 @@ +import { describe, it, expect, vi } from 'vitest' +import EventBus from '@/event-bus' + +describe('event-bus', () => { + it('emits and receives events', () => { + const handler = vi.fn() + EventBus.$on('test:event', handler) + EventBus.$emit('test:event', 'payload') + expect(handler).toHaveBeenCalledWith('payload') + EventBus.$off('test:event', handler) + }) + + it('stops receiving after $off', () => { + const handler = vi.fn() + EventBus.$on('test:off', handler) + EventBus.$off('test:off', handler) + EventBus.$emit('test:off') + expect(handler).not.toHaveBeenCalled() + }) + + it('supports multiple listeners on the same event', () => { + const h1 = vi.fn() + const h2 = vi.fn() + EventBus.$on('multi', h1) + EventBus.$on('multi', h2) + EventBus.$emit('multi', 42) + expect(h1).toHaveBeenCalledWith(42) + expect(h2).toHaveBeenCalledWith(42) + EventBus.$off('multi', h1) + EventBus.$off('multi', h2) + }) +}) diff --git a/medcat-trainer/webapp/frontend/src/tests/runtimeConfig.spec.ts b/medcat-trainer/webapp/frontend/src/tests/runtimeConfig.spec.ts new file mode 100644 index 000000000..0e3b7e1e1 --- /dev/null +++ b/medcat-trainer/webapp/frontend/src/tests/runtimeConfig.spec.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { + getRuntimeConfig, + isOidcEnabled, + loadRuntimeConfig +} from '@/runtimeConfig' + +describe('runtimeConfig', () => { + beforeEach(() => { + delete window.__RUNTIME_CONFIG__ + vi.restoreAllMocks() + }) + + describe('getRuntimeConfig', () => { + it('returns defaults when window config is unset', () => { + const config = getRuntimeConfig() + expect(config.USE_OIDC).toBe('0') + expect(config.KEYCLOAK_URL).toBe('') + expect(config.KEYCLOAK_REALM).toBe('') + expect(config.KEYCLOAK_CLIENT_ID).toBe('') + }) + + it('returns window config when set', () => { + window.__RUNTIME_CONFIG__ = { + USE_OIDC: '1', + KEYCLOAK_URL: 'https://auth.example.com', + KEYCLOAK_REALM: 'mct', + KEYCLOAK_CLIENT_ID: 'frontend', + KEYCLOAK_LOGOUT_REDIRECT_URI: 'https://app.example.com', + KEYCLOAK_TOKEN_MIN_VALIDITY: 30, + KEYCLOAK_TOKEN_REFRESH_INTERVAL: 60 + } + const config = getRuntimeConfig() + expect(config.USE_OIDC).toBe('1') + expect(config.KEYCLOAK_URL).toBe('https://auth.example.com') + }) + }) + + describe('isOidcEnabled', () => { + it('returns false when USE_OIDC is 0', () => { + window.__RUNTIME_CONFIG__ = { + USE_OIDC: '0', + KEYCLOAK_URL: '', + KEYCLOAK_REALM: '', + KEYCLOAK_CLIENT_ID: '', + KEYCLOAK_LOGOUT_REDIRECT_URI: '', + KEYCLOAK_TOKEN_MIN_VALIDITY: 0, + KEYCLOAK_TOKEN_REFRESH_INTERVAL: 0 + } + expect(isOidcEnabled()).toBe(false) + }) + + it('returns true when USE_OIDC is 1', () => { + window.__RUNTIME_CONFIG__ = { + USE_OIDC: '1', + KEYCLOAK_URL: 'https://auth.example.com', + KEYCLOAK_REALM: 'mct', + KEYCLOAK_CLIENT_ID: 'frontend', + KEYCLOAK_LOGOUT_REDIRECT_URI: '', + KEYCLOAK_TOKEN_MIN_VALIDITY: 0, + KEYCLOAK_TOKEN_REFRESH_INTERVAL: 0 + } + expect(isOidcEnabled()).toBe(true) + }) + }) + + describe('loadRuntimeConfig', () => { + it('loads config from /static/config.json', async () => { + const mockConfig = { USE_OIDC: '1', KEYCLOAK_URL: 'https://kc' } + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: async () => mockConfig + }) + ) + + await loadRuntimeConfig() + + expect(window.__RUNTIME_CONFIG__).toEqual(mockConfig) + expect(fetch).toHaveBeenCalledWith('/static/config.json') + }) + + it('falls back to defaults when fetch fails', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found' + }) + ) + + await loadRuntimeConfig() + + expect(window.__RUNTIME_CONFIG__?.USE_OIDC).toBe('0') + }) + + it('falls back to defaults when fetch throws', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network error'))) + + await loadRuntimeConfig() + + // loadRuntimeConfig catches errors without setting config on network throw + // getRuntimeConfig should still return defaults + expect(getRuntimeConfig().USE_OIDC).toBe('0') + }) + }) +}) diff --git a/medcat-trainer/webapp/frontend/src/tests/utils/projectListFilters.spec.ts b/medcat-trainer/webapp/frontend/src/tests/utils/projectListFilters.spec.ts new file mode 100644 index 000000000..69c11c3fe --- /dev/null +++ b/medcat-trainer/webapp/frontend/src/tests/utils/projectListFilters.spec.ts @@ -0,0 +1,181 @@ +import { describe, it, expect } from 'vitest' +import { + ALL_FILTER, + STATUS_FILTER_OPTIONS, + MODE_FILTER_OPTIONS, + SORT_OPTIONS, + hasActiveProjectFilters, + filterAndSortProjects +} from '@/utils/projectListFilters' + +const sampleProjects = [ + { + name: 'Alpha Project', + project_status: 'A', + require_entity_validation: true, + last_modified: '2024-06-01T10:00:00Z', + create_time: '2024-01-01T00:00:00Z' + }, + { + name: 'Beta Complete', + project_status: 'C', + require_entity_validation: false, + last_modified: '2024-05-01T10:00:00Z', + create_time: '2024-02-01T00:00:00Z' + }, + { + name: 'Gamma Discontinued', + project_status: 'D', + require_entity_validation: true, + last_modified: '2024-04-01T10:00:00Z', + create_time: '2024-03-01T00:00:00Z' + } +] + +describe('projectListFilters constants', () => { + it('exports filter option lists with All entries', () => { + expect(STATUS_FILTER_OPTIONS[0]).toEqual({ label: 'All', value: ALL_FILTER }) + expect(MODE_FILTER_OPTIONS[0]).toEqual({ label: 'All', value: ALL_FILTER }) + expect(SORT_OPTIONS.length).toBeGreaterThanOrEqual(3) + }) +}) + +describe('hasActiveProjectFilters', () => { + it('returns false when all filters are default', () => { + expect( + hasActiveProjectFilters({ + searchQuery: '', + statusFilter: ALL_FILTER, + modeFilter: ALL_FILTER + }) + ).toBe(false) + }) + + it('returns true when search query is non-empty', () => { + expect( + hasActiveProjectFilters({ + searchQuery: 'alpha', + statusFilter: ALL_FILTER, + modeFilter: ALL_FILTER + }) + ).toBe(true) + }) + + it('returns true when status filter is set', () => { + expect( + hasActiveProjectFilters({ + searchQuery: '', + statusFilter: 'C', + modeFilter: ALL_FILTER + }) + ).toBe(true) + }) + + it('returns true when mode filter is set', () => { + expect( + hasActiveProjectFilters({ + searchQuery: '', + statusFilter: ALL_FILTER, + modeFilter: 'validate' + }) + ).toBe(true) + }) +}) + +describe('filterAndSortProjects', () => { + it('returns all projects when no filters active', () => { + const result = filterAndSortProjects(sampleProjects, { + searchQuery: '', + statusFilter: ALL_FILTER, + modeFilter: ALL_FILTER, + sortBy: 'name', + sortOrder: 'asc' + }) + expect(result).toHaveLength(3) + expect(result[0].name).toBe('Alpha Project') + }) + + it('filters by name search (case-insensitive)', () => { + const result = filterAndSortProjects(sampleProjects, { + searchQuery: 'beta', + statusFilter: ALL_FILTER, + modeFilter: ALL_FILTER, + sortBy: 'name', + sortOrder: 'asc' + }) + expect(result).toHaveLength(1) + expect(result[0].name).toBe('Beta Complete') + }) + + it('filters by project status', () => { + const result = filterAndSortProjects(sampleProjects, { + searchQuery: '', + statusFilter: 'C', + modeFilter: ALL_FILTER, + sortBy: 'name', + sortOrder: 'asc' + }) + expect(result).toHaveLength(1) + expect(result[0].project_status).toBe('C') + }) + + it('filters annotate mode (require_entity_validation true)', () => { + const result = filterAndSortProjects(sampleProjects, { + searchQuery: '', + statusFilter: ALL_FILTER, + modeFilter: 'annotate', + sortBy: 'name', + sortOrder: 'asc' + }) + expect(result).toHaveLength(2) + expect(result.every(p => p.require_entity_validation)).toBe(true) + }) + + it('filters validate mode (require_entity_validation false)', () => { + const result = filterAndSortProjects(sampleProjects, { + searchQuery: '', + statusFilter: ALL_FILTER, + modeFilter: 'validate', + sortBy: 'name', + sortOrder: 'asc' + }) + expect(result).toHaveLength(1) + expect(result[0].require_entity_validation).toBe(false) + }) + + it('sorts by name descending', () => { + const result = filterAndSortProjects(sampleProjects, { + searchQuery: '', + statusFilter: ALL_FILTER, + modeFilter: ALL_FILTER, + sortBy: 'name', + sortOrder: 'desc' + }) + expect(result[0].name).toBe('Gamma Discontinued') + expect(result[2].name).toBe('Alpha Project') + }) + + it('sorts by last_modified ascending', () => { + const result = filterAndSortProjects(sampleProjects, { + searchQuery: '', + statusFilter: ALL_FILTER, + modeFilter: ALL_FILTER, + sortBy: 'last_modified', + sortOrder: 'asc' + }) + expect(result[0].name).toBe('Gamma Discontinued') + expect(result[2].name).toBe('Alpha Project') + }) + + it('handles null/undefined items list', () => { + expect( + filterAndSortProjects(null as unknown as typeof sampleProjects, { + searchQuery: '', + statusFilter: ALL_FILTER, + modeFilter: ALL_FILTER, + sortBy: 'name', + sortOrder: 'asc' + }) + ).toEqual([]) + }) +}) diff --git a/medcat-trainer/webapp/frontend/src/utils/projectListFilters.d.ts b/medcat-trainer/webapp/frontend/src/utils/projectListFilters.d.ts new file mode 100644 index 000000000..0bd9d6939 --- /dev/null +++ b/medcat-trainer/webapp/frontend/src/utils/projectListFilters.d.ts @@ -0,0 +1,43 @@ +export declare const ALL_FILTER: string + +export declare const STATUS_FILTER_OPTIONS: ReadonlyArray<{ + label: string + value: string +}> + +export declare const MODE_FILTER_OPTIONS: ReadonlyArray<{ + label: string + value: string +}> + +export declare const SORT_OPTIONS: ReadonlyArray<{ + label: string + value: string +}> + +export interface ProjectListFilterOptions { + searchQuery?: string + statusFilter?: string + modeFilter?: string +} + +export interface ProjectListSortOptions extends ProjectListFilterOptions { + sortBy?: string + sortOrder?: string +} + +export interface ProjectListItem { + name?: string + project_status?: string + require_entity_validation?: boolean + create_time?: string + last_modified?: string + [key: string]: unknown +} + +export declare function hasActiveProjectFilters(options: ProjectListFilterOptions): boolean + +export declare function filterAndSortProjects( + items: ProjectListItem[] | null | undefined, + options: ProjectListSortOptions +): ProjectListItem[] diff --git a/medcat-trainer/webapp/frontend/tsconfig.vitest.json b/medcat-trainer/webapp/frontend/tsconfig.vitest.json index 1f7f6ce5f..1bbed5a6a 100644 --- a/medcat-trainer/webapp/frontend/tsconfig.vitest.json +++ b/medcat-trainer/webapp/frontend/tsconfig.vitest.json @@ -1,6 +1,12 @@ { "extends": "./tsconfig.app.json", - "include": ["env.d.ts", "src/tests/**/*.ts"], + "include": [ + "env.d.ts", + "src/tests/**/*.ts", + "src/event-bus.ts", + "src/plugins/registry.ts", + "src/runtimeConfig.ts" + ], "exclude": [], "compilerOptions": { "composite": true, diff --git a/medcat-trainer/webapp/frontend/vite.config.ts b/medcat-trainer/webapp/frontend/vite.config.ts index 79b78db1d..67190c6ff 100644 --- a/medcat-trainer/webapp/frontend/vite.config.ts +++ b/medcat-trainer/webapp/frontend/vite.config.ts @@ -45,6 +45,14 @@ export default defineConfig({ css: { preprocessorOptions: { scss: { + // Bootstrap 5.x still uses deprecated Sass color/import builtins; harmless until v6. + // https://getbootstrap.com/docs/5.3/customize/sass/#sass-deprecation-warnings + silenceDeprecations: [ + 'color-functions', + 'global-builtin', + 'import', + 'if-function' + ], additionalData: ` @import "@/styles/_variables.scss"; @import "@/styles/_common.scss"; diff --git a/medcat-trainer/webapp/uv.lock b/medcat-trainer/webapp/uv.lock index f5a856980..c043d22cd 100644 --- a/medcat-trainer/webapp/uv.lock +++ b/medcat-trainer/webapp/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.14' and sys_platform != 'darwin'", @@ -1188,7 +1188,7 @@ wheels = [ [[package]] name = "medcat" -version = "2.5.3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "dill" }, @@ -1203,9 +1203,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "xxhash" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fd/db/7e707e7d59dd96da3cdfaf1ef227700cabfa0d9fd89fd2742f579811c02c/medcat-2.5.3.tar.gz", hash = "sha256:de4b8a1e74f22086c0b9763278ac96ec3f8f3b7be607f52506a8df5513204768", size = 76986569, upload-time = "2026-01-13T12:10:07.636Z" } +sdist = { url = "https://files.pythonhosted.org/packages/94/b8/06f6c427ef9e7d9a1513761f134b8f966835437c60690a5a4ee797a02347/medcat-2.7.0.tar.gz", hash = "sha256:36cb6656199a3063f1eb6f695e5dce92f6f54a9f05da760d51e89f01a9ec873f", size = 77123188, upload-time = "2026-04-01T15:30:35.64Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/83/21276e6211c607f577bb6eef9a90605cd0c1833e7479d031cf4a76ac2ef1/medcat-2.5.3-py3-none-any.whl", hash = "sha256:00cacfedde070c478be4614f5cfc7356bdabda1021ec1eef33c59b62e57e9762", size = 307807, upload-time = "2026-01-13T12:10:04.993Z" }, + { url = "https://files.pythonhosted.org/packages/a1/83/f09b8a3af1bfe51b354959c92b5d189d60df2e1ddaa94ad473e3dd537ece/medcat-2.7.0-py3-none-any.whl", hash = "sha256:5d305d0e542486a58e124a0b4554a2b941df15ebe8eb832a70b2ed9c74ee5c0b", size = 313180, upload-time = "2026-04-01T15:30:32.532Z" }, ] [package.optional-dependencies] @@ -1218,6 +1218,9 @@ deid = [ { name = "torch" }, { name = "transformers" }, ] +dict-ner = [ + { name = "pyahocorasick" }, +] meta-cat = [ { name = "peft" }, { name = "scikit-learn", version = "1.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, @@ -1250,7 +1253,7 @@ dependencies = [ { name = "django-polymorphic" }, { name = "djangorestframework" }, { name = "drf-oidc-auth" }, - { name = "medcat", extra = ["deid", "meta-cat", "rel-cat", "spacy"] }, + { name = "medcat", extra = ["deid", "dict-ner", "meta-cat", "rel-cat", "spacy"] }, { name = "openpyxl" }, { name = "opentelemetry-api" }, { name = "opentelemetry-semantic-conventions" }, @@ -1284,7 +1287,7 @@ requires-dist = [ { name = "django-polymorphic", specifier = ">=4.0,<5" }, { name = "djangorestframework", specifier = ">=3.16,<4" }, { name = "drf-oidc-auth", specifier = ">=3.0" }, - { name = "medcat", extras = ["meta-cat", "spacy", "rel-cat", "deid"], specifier = ">=2.3" }, + { name = "medcat", extras = ["meta-cat", "spacy", "rel-cat", "deid", "dict-ner"], specifier = ">=2.7.0" }, { name = "openpyxl", specifier = ">=3.1" }, { name = "opentelemetry-api", specifier = ">=1.0.0,<2" }, { name = "opentelemetry-distro", extras = ["otlp"], marker = "extra == 'observability'", specifier = ">=0.61b0" }, @@ -2570,6 +2573,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/c3/26b8a0908a9db249de3b4169692e1c7c19048a9bc41a4d3209cee7dbb758/psycopg_pool-3.3.0-py3-none-any.whl", hash = "sha256:2e44329155c410b5e8666372db44276a8b1ebd8c90f1c3026ebba40d4bc81063", size = 39995, upload-time = "2025-12-01T11:34:29.761Z" }, ] +[[package]] +name = "pyahocorasick" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/3c/dc9e31a0f004eabe2ef5d31456766555a02e2af29e159daa31266934af79/pyahocorasick-2.3.1.tar.gz", hash = "sha256:9d0f6bb522237ed7f111ed59c9e8baea7d1e75813587b6773babd43bda35db9f", size = 105024, upload-time = "2026-04-27T16:30:25.957Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/ae/55837133a70590fd36a412f5ae09eb497603da1dd1b036eb7b3486a34d1d/pyahocorasick-2.3.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d0dcad4cf8f472764870ab70bd810fe04b5fb9d290c13db1f3e112e62b91e023", size = 59719, upload-time = "2026-04-27T16:31:15.565Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d6/a829b06c264cd38e5c57ace7bed48226c3ec088e2f0e7930c8a5572cc89f/pyahocorasick-2.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1b9bc8f48c78897fd6f073098f7007a87ce0a7e0ad38099a4aad4d760f2f3161", size = 33993, upload-time = "2026-04-27T16:31:17.003Z" }, + { url = "https://files.pythonhosted.org/packages/47/17/d9dfb1df9c1d2b749377fec553af1dd62341ffc1c124d969f5fc738b3a87/pyahocorasick-2.3.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e70206da4ecfffdd31073b26e2e9c877503ccbeb87e1fd843ca6f9f55b16077", size = 109744, upload-time = "2026-04-27T16:31:18.47Z" }, + { url = "https://files.pythonhosted.org/packages/b7/31/5d2bc0107384a9426fbfad10e287db917929ce004b67fa54cb46f1a0b188/pyahocorasick-2.3.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e48e921996044f7d161368079663608813e82dd9c22a74ba5a51abc326bb731", size = 110375, upload-time = "2026-04-27T16:31:19.889Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9f/2a438bfbc7d445cfc7d595cee367e683e34514adc028f41d39caeb895380/pyahocorasick-2.3.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9dee8c8aa59914435f90f6fb7ad4e02f448ac0c2533cc525414b1dd0f730a6b8", size = 113107, upload-time = "2026-04-27T16:31:21.606Z" }, + { url = "https://files.pythonhosted.org/packages/69/0f/c7a359810bef1b10c1900016028dd83f630c53c152d80a6c035a391c3237/pyahocorasick-2.3.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f015ca482c8105e28fbd6a1952726f3376534caf8bea19ea0cda34a796f7a8f8", size = 113489, upload-time = "2026-04-27T16:31:23.583Z" }, + { url = "https://files.pythonhosted.org/packages/d0/23/6dfae42e0b23607566e1aae66a603c5e1b7a343a4c7e8baa43d21f675632/pyahocorasick-2.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:fb6be24637846604463cd414a7537c95bdab378b0796651f78a131d5871c8e3e", size = 35166, upload-time = "2026-04-27T16:31:24.894Z" }, + { url = "https://files.pythonhosted.org/packages/7c/06/2798edbcff0d50a51f8ef527cb3f861e69f694d80043826529c33fe15aa3/pyahocorasick-2.3.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3a69041f5fd665ec0edcffd9562dd0f2f23c236bbc950e18ada854e29fc3dd88", size = 59714, upload-time = "2026-04-27T16:31:26.083Z" }, + { url = "https://files.pythonhosted.org/packages/58/00/4b475d2f26240253bc6412c509c1c103844a8eac326a1353d9bc798beb74/pyahocorasick-2.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e8f9c21fd2bd72c0454ba6df0c7dbdfd7236c5cfd161fc983476fffbde92e18f", size = 33988, upload-time = "2026-04-27T16:31:27.351Z" }, + { url = "https://files.pythonhosted.org/packages/32/9b/5eef7545f3556d8b2ca8ee943938e94a62b659ee6f6978573efd2d597e2a/pyahocorasick-2.3.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0a8bed95da02e7c874818825d65e6e31d5b38c88ecba02a6c7144524074ddade", size = 113162, upload-time = "2026-04-27T16:31:28.704Z" }, + { url = "https://files.pythonhosted.org/packages/bf/55/807c408bd7baaa137643e99b4b642abd850d83c3e80b17e17f62b5842429/pyahocorasick-2.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2541c437dc0f04475729076ec36aac72604b767fa347107bcd6945d61d5ba437", size = 113939, upload-time = "2026-04-27T16:31:31.935Z" }, + { url = "https://files.pythonhosted.org/packages/b1/d4/ffe0a07979ed128ed55c9e4ac7007be4d2048c2582de68035bd84c22e585/pyahocorasick-2.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:aa05c56eaeee2e0242a84f53d9927d795d26002493c69ba8a4af1d86bdca7edb", size = 116159, upload-time = "2026-04-27T16:31:33.662Z" }, + { url = "https://files.pythonhosted.org/packages/1c/97/c5b6962d93d0e7870a8e0e1d76c71cd30133a96c642190531d5fae754de0/pyahocorasick-2.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dfc4749cca4df4327dd2fcbbd49e5148e72840366023429729cf468f28c938a2", size = 116390, upload-time = "2026-04-27T16:31:35.554Z" }, + { url = "https://files.pythonhosted.org/packages/12/63/7072ae6d6458518c277b256a14dd1b20726192e880915b4f6d3daeb0700d/pyahocorasick-2.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:cb75c32f73be3f70435e49bbc5518105b54f1320a51e7da18ac989bfe93f6c1c", size = 35152, upload-time = "2026-04-27T16:31:36.828Z" }, + { url = "https://files.pythonhosted.org/packages/29/a6/2ee9301a36c9d6bcd7e745e8a98e72fddf1ff1cd3ae899f498383c3ad1c9/pyahocorasick-2.3.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f0df14cb10ed1e942a30c0f11d242472452e7c567acbf3ac070e5d6912b71ca9", size = 60112, upload-time = "2026-04-27T16:31:38.39Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c6/f242c7966d8207822d7ecb183101522ca03df5f302ee6520fe4412f03fae/pyahocorasick-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:873911f1d80acd82ac00aae277a9a2b335a0c0cac0a0ef1c6635b57badc6f7a6", size = 34154, upload-time = "2026-04-27T16:31:39.719Z" }, + { url = "https://files.pythonhosted.org/packages/f7/01/0a7387a6327f4ef9b7dcf3cea84dfea3e4b0e85eb37a52b612985b1f9a9a/pyahocorasick-2.3.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9a4d4f5b05ce9d8af82c40ed39cd6892613e9e8bf1b5e6ea79009c566430adb1", size = 113543, upload-time = "2026-04-27T16:31:41.311Z" }, + { url = "https://files.pythonhosted.org/packages/a1/f2/d13807476195e4ec5999a78f22db592a64da54229c9183438f3165105779/pyahocorasick-2.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9ec1d3465f25a5063c7eaa85ecb106cbe256064669c754e0b13b2483cf613a98", size = 114873, upload-time = "2026-04-27T16:31:42.625Z" }, + { url = "https://files.pythonhosted.org/packages/af/32/d79302845be8629f9aee2a3dbeb9ad089b036f089e99589a08814e7e5910/pyahocorasick-2.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e4e1e90eb2e755c79b9b904fd8adcca61c22b4b48811b9435f0c4b2d718895d6", size = 116455, upload-time = "2026-04-27T16:31:44.366Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c9/2e3019eb9f4404dc1fe1309535d1220740cc95275ad1b4a70f7f891cb296/pyahocorasick-2.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e3922f66721b5b777eae758d2a0acffd98ee97dc7e6e452ba533d1c5892e15b7", size = 117863, upload-time = "2026-04-27T16:31:45.831Z" }, + { url = "https://files.pythonhosted.org/packages/3a/6e/5fa2f6fafb7a5bb82cad6e2ef3c8eed7c859ba16242766a5a425e19334b5/pyahocorasick-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:f5cc3c021be241fe9317c5991f8efba2b876e3956691322ad9e55c0d9ff7c599", size = 35258, upload-time = "2026-04-27T16:31:47.053Z" }, + { url = "https://files.pythonhosted.org/packages/31/16/4ea7db7a118778a2f56b217b8f142d1bd55e10cb6c6d59329bc58c41952a/pyahocorasick-2.3.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:1b16eab55f961671c6eff5ead4e3fda6e85982acea86fda734b68e39e52dcd3b", size = 60118, upload-time = "2026-04-27T16:31:48.173Z" }, + { url = "https://files.pythonhosted.org/packages/ec/53/08c717e8696b3f243be89278155512a360a13b5a11bfe87a3a417f180c5e/pyahocorasick-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ec6908893dffc271c1f89fe5a0f6ae872c5b7fdfb82ce032185a1fcf02339a60", size = 34160, upload-time = "2026-04-27T16:31:49.287Z" }, + { url = "https://files.pythonhosted.org/packages/5c/11/4464450c9c44719ab47082eda69424de22af51ef68c482f7e8c48a30a727/pyahocorasick-2.3.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:43e79e7f1737e8bd5290ee61bfbbc0af0a44975b8aa719ffbb00e3cd8c5c8e35", size = 113498, upload-time = "2026-04-27T16:31:50.925Z" }, + { url = "https://files.pythonhosted.org/packages/64/e0/398f558e004616411ae6914666f0aa51eb019405ef4f48358e6a9b26bc4d/pyahocorasick-2.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:343c93387146ddef771118cab8fc60e3be1c9c5595b647ad6c898fc940a63e20", size = 114814, upload-time = "2026-04-27T16:31:52.329Z" }, + { url = "https://files.pythonhosted.org/packages/84/dc/a7c78f3fafdee825ab2a69c7aeedc8c3bf1a82f69a710071bbeac3d8be29/pyahocorasick-2.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:648ee2e1dae6753cbe153d610cd8208f3da00e20456d3696de49a7606106afad", size = 116447, upload-time = "2026-04-27T16:31:54.196Z" }, + { url = "https://files.pythonhosted.org/packages/70/99/f028911b158fd9d6ea0c50a99b17b798f4cbb4d14aedf9bc07dcebfd406c/pyahocorasick-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7b52bb618a6d29223470c5518daa59f319cbbca878373dcec3ca89a63759c0e5", size = 117863, upload-time = "2026-04-27T16:31:55.672Z" }, + { url = "https://files.pythonhosted.org/packages/30/75/5d5d377fab5b93462ff22496ac5a09725534ec37217626b0a5480c321e5a/pyahocorasick-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:31c743e80e92f81c390214b69f474945689f0f83db8d9bae7118a4623e5da63d", size = 35244, upload-time = "2026-04-27T16:31:56.813Z" }, + { url = "https://files.pythonhosted.org/packages/00/0b/ce8637d57f122533067e5080cbd54d4698968acd2a16921469c838ee1ae3/pyahocorasick-2.3.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9b87fa566bd71b46407ea8cfd86ddc6c97ba7f20eb29041ce9b5213b111e76be", size = 60047, upload-time = "2026-04-27T16:31:58.019Z" }, + { url = "https://files.pythonhosted.org/packages/63/8d/f98d8caad8bed8dc70b5b406704ca652c5bb59168984424e61732f31de50/pyahocorasick-2.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:523c5460afae4b9228bb9df7571ef23b90ceb3411428beb7df167d696ae054dc", size = 34114, upload-time = "2026-04-27T16:31:59.425Z" }, + { url = "https://files.pythonhosted.org/packages/60/97/b06f783364347a369c86344dbebb194535b7f41bf1df0f42dc4e64e3b655/pyahocorasick-2.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0e59226baf6ffb5acb6f72868ef345a4bd23d2a30ef08a9e1bf51043ea9b430d", size = 113504, upload-time = "2026-04-27T16:32:00.735Z" }, + { url = "https://files.pythonhosted.org/packages/29/b5/54b057c13eae27ceca51e68e13e1194e4c624d624b0369b571177f390a62/pyahocorasick-2.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7c90328fb64f6d1c24bbf969194f4fe0b3aacbdddadf28ec920b34a524681a54", size = 114564, upload-time = "2026-04-27T16:32:02.184Z" }, + { url = "https://files.pythonhosted.org/packages/79/c1/a0c0ed44ebe2a0e62bebc545158707b9543fa685c384a9af90bb568444cf/pyahocorasick-2.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8b10d29fb3eddf8228e41d285f2e052efddb99b6dd1ed1e0f28f00d0d0570005", size = 116371, upload-time = "2026-04-27T16:32:03.967Z" }, + { url = "https://files.pythonhosted.org/packages/c4/db/d174d6bbc6caa811ac3c3695de28785b36d83ee94aecd461f58e621068fc/pyahocorasick-2.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba7b98de0ff3203e2cd8c27682f6934c0d893cd97e65a45b8478e468d9919c90", size = 117877, upload-time = "2026-04-27T16:32:05.407Z" }, + { url = "https://files.pythonhosted.org/packages/c5/96/37c50ac951bb0260ec38d8d12e5b51587ef1ef4035c279088f2771544b28/pyahocorasick-2.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:4acb11a0a2ff10519465749d22ad70789e9fe7f81dc8fe9957a8868e499e18ab", size = 35987, upload-time = "2026-04-27T16:32:07.08Z" }, +] + [[package]] name = "pyarrow" version = "23.0.0" @@ -3559,6 +3605,13 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0f/8b/4b61d6e13f7108f36910df9ab4b58fd389cc2520d54d81b88660804aad99/torch-2.10.0-2-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:418997cb02d0a0f1497cf6a09f63166f9f5df9f3e16c8a716ab76a72127c714f", size = 79423467, upload-time = "2026-02-10T21:44:48.711Z" }, { url = "https://files.pythonhosted.org/packages/d3/54/a2ba279afcca44bbd320d4e73675b282fcee3d81400ea1b53934efca6462/torch-2.10.0-2-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:13ec4add8c3faaed8d13e0574f5cd4a323c11655546f91fbe6afa77b57423574", size = 79498202, upload-time = "2026-02-10T21:44:52.603Z" }, { url = "https://files.pythonhosted.org/packages/ec/23/2c9fe0c9c27f7f6cb865abcea8a4568f29f00acaeadfc6a37f6801f84cb4/torch-2.10.0-2-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:e521c9f030a3774ed770a9c011751fb47c4d12029a3d6522116e48431f2ff89e", size = 79498254, upload-time = "2026-02-10T21:44:44.095Z" }, + { url = "https://files.pythonhosted.org/packages/16/ee/efbd56687be60ef9af0c9c0ebe106964c07400eade5b0af8902a1d8cd58c/torch-2.10.0-3-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a1ff626b884f8c4e897c4c33782bdacdff842a165fee79817b1dd549fdda1321", size = 915510070, upload-time = "2026-03-11T14:16:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/36/ab/7b562f1808d3f65414cd80a4f7d4bb00979d9355616c034c171249e1a303/torch-2.10.0-3-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:ac5bdcbb074384c66fa160c15b1ead77839e3fe7ed117d667249afce0acabfac", size = 915518691, upload-time = "2026-03-11T14:15:43.147Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7a/abada41517ce0011775f0f4eacc79659bc9bc6c361e6bfe6f7052a6b9363/torch-2.10.0-3-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:98c01b8bb5e3240426dcde1446eed6f40c778091c8544767ef1168fc663a05a6", size = 915622781, upload-time = "2026-03-11T14:17:11.354Z" }, + { url = "https://files.pythonhosted.org/packages/ab/c6/4dfe238342ffdcec5aef1c96c457548762d33c40b45a1ab7033bb26d2ff2/torch-2.10.0-3-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:80b1b5bfe38eb0e9f5ff09f206dcac0a87aadd084230d4a36eea5ec5232c115b", size = 915627275, upload-time = "2026-03-11T14:16:11.325Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f0/72bf18847f58f877a6a8acf60614b14935e2f156d942483af1ffc081aea0/torch-2.10.0-3-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:46b3574d93a2a8134b3f5475cfb98e2eb46771794c57015f6ad1fb795ec25e49", size = 915523474, upload-time = "2026-03-11T14:17:44.422Z" }, + { url = "https://files.pythonhosted.org/packages/f4/39/590742415c3030551944edc2ddc273ea1fdfe8ffb2780992e824f1ebee98/torch-2.10.0-3-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:b1d5e2aba4eb7f8e87fbe04f86442887f9167a35f092afe4c237dfcaaef6e328", size = 915632474, upload-time = "2026-03-11T14:15:13.666Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8e/34949484f764dde5b222b7fe3fede43e4a6f0da9d7f8c370bb617d629ee2/torch-2.10.0-3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:0228d20b06701c05a8f978357f657817a4a63984b0c90745def81c18aedfa591", size = 915523882, upload-time = "2026-03-11T14:14:46.311Z" }, { url = "https://files.pythonhosted.org/packages/0c/1a/c61f36cfd446170ec27b3a4984f072fd06dab6b5d7ce27e11adb35d6c838/torch-2.10.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:5276fa790a666ee8becaffff8acb711922252521b28fbce5db7db5cf9cb2026d", size = 145992962, upload-time = "2026-01-21T16:24:14.04Z" }, { url = "https://files.pythonhosted.org/packages/b5/60/6662535354191e2d1555296045b63e4279e5a9dbad49acf55a5d38655a39/torch-2.10.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:aaf663927bcd490ae971469a624c322202a2a1e68936eb952535ca4cd3b90444", size = 915599237, upload-time = "2026-01-21T16:23:25.497Z" }, { url = "https://files.pythonhosted.org/packages/40/b8/66bbe96f0d79be2b5c697b2e0b187ed792a15c6c4b8904613454651db848/torch-2.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:a4be6a2a190b32ff5c8002a0977a25ea60e64f7ba46b1be37093c141d9c49aeb", size = 113720931, upload-time = "2026-01-21T16:24:23.743Z" },