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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/medcat-trainer_ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -285,13 +285,15 @@ jobs:
tags: cogstacksystems/medcat-trainer:test
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=cogstacksystems/medcat-trainer:buildcache
cache-to: type=registry,ref=cogstacksystems/medcat-trainer:buildcache,mode=max

- name: Run Django Tests
run: |
# run tests using the built image
docker run --rm cogstacksystems/medcat-trainer:test /home/.venv/bin/python manage.py test

- name: Prune stale Docker build cache
run: docker system prune --all --force --volumes

- name: Push Docker MedCATtrainer image
id: docker_build_push
uses: docker/build-push-action@v7
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,10 @@ export default {
formattedText () {
if (this.loading || !this.text || !this.ents) { return '' }
if (this.ents.length === 0) {
let text = this.text.replace('&', '&amp').replace('<', '&gt').replace('>', '&gt')
text = text === 'nan' ? '' : text
// Must escape for vue3-runtime-template: raw '<' / '>' / '&' are compiled as Vue
// template HTML and throw (e.g. compiler-24 missing end tag), which can leave the
// annotator unable to render subsequent documents.
const text = this.text === 'nan' ? '' : _.escape(this.text)
return this.addAnnos ? `<div @contextmenu.prevent.stop="showCtxMenu($event)">${text}</div>` : `<div>${text}</div>`
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -387,5 +387,54 @@ describe('ClinicalText.vue', () => {
const formattedText = vm.formattedText
expect(formattedText).toContain('@contextmenu.prevent.stop')
})

it('escapes HTML-like clinical markup when there are no annotations', async () => {
const { compile } = await import('@vue/compiler-dom')
const text = '18 >[ No Service Recorded ] [ LLAMS MDT ] status <unknown> a & b'
const wrapper = mount(ClinicalText, {
props: {
...defaultProps,
text,
ents: []
},
global: {
stubs: ['v-overlay', 'v-progress-circular', 'v-runtime-template', 'vue-simple-context-menu']
}
})

const formattedText = (wrapper.vm as any).formattedText
expect(formattedText).toContain('&gt;')
expect(formattedText).toContain('&lt;unknown&gt;')
expect(formattedText).toContain('&amp;')
expect(formattedText).not.toContain('<unknown>')
expect(() => compile(formattedText)).not.toThrow()
})

it('escapes HTML-like markup in unannotated segments when annotations exist', async () => {
const { compile } = await import('@vue/compiler-dom')
const text = 'before <tag> Sample after >[ note ]'
const ents = [{
id: 1,
start_ind: 13,
end_ind: 19,
assignedValues: { 'Concept Anno': 'Correct' },
manually_created: false
}]
const wrapper = mount(ClinicalText, {
props: {
...defaultProps,
text,
ents
},
global: {
stubs: ['v-overlay', 'v-progress-circular', 'v-runtime-template', 'vue-simple-context-menu']
}
})

const formattedText = (wrapper.vm as any).formattedText
expect(formattedText).toContain('&lt;tag&gt;')
expect(formattedText).toContain('&gt;')
expect(() => compile(formattedText)).not.toThrow()
})
})

Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ describe('TrainAnnotations.vue fetchData', () => {
if (url.startsWith('/api/project-annotate-entities/')) {
return Promise.reject({ response: { status: 500, data: { message: 'Database unavailable' } } })
}
return new Promise(() => {})
return new Promise(() => { })
})

wrapper.vm.fetchData()
Expand All @@ -68,7 +68,7 @@ describe('TrainAnnotations.vue fetchData', () => {
if (url.startsWith('/api/project-annotate-entities/')) {
return Promise.reject({ response: { status: 401, data: { detail: 'Invalid token.' } } })
}
return new Promise(() => {})
return new Promise(() => { })
})

wrapper.vm.fetchData()
Expand All @@ -85,7 +85,7 @@ describe('TrainAnnotations.vue fetchEntities', () => {
return Promise.reject({ response: { data: { message: 'Invalid token.' } } })
}
// Stall created() lifecycle requests so they don't interfere with the test.
return new Promise(() => {})
return new Promise(() => { })
})

wrapper.vm.project = project
Expand Down Expand Up @@ -134,7 +134,7 @@ describe('TrainAnnotations.vue fetchEntities', () => {
if (url.startsWith('/api/annotated-entities/')) {
return Promise.resolve({ data: { results: [], previous: null, next: null } })
}
return new Promise(() => {})
return new Promise(() => { })
})

wrapper.vm.fetchData()
Expand All @@ -157,7 +157,7 @@ describe('TrainAnnotations.vue fetchEntities', () => {
}
})
}
return new Promise(() => {})
return new Promise(() => { })
})

wrapper.vm.project = project
Expand All @@ -173,8 +173,36 @@ describe('TrainAnnotations.vue fetchEntities', () => {
expect(wrapper.vm.currentEnt.id).toBe(10)
})

it('resets annotation pagination state when switching documents', async () => {
const { wrapper, mockGet } = mountView((url) => {
if (url.startsWith('/api/annotated-entities/')) {
return Promise.resolve({
data: { results: [], previous: null, next: null }
})
}
return new Promise(() => { })
})

wrapper.vm.project = { ...project, prepared_documents: [123, 456] }
wrapper.vm.currentDoc = { id: 123, text: 'first doc' }
wrapper.vm.ents = [{ id: 1, start_ind: 0, end_ind: 1, assignedValues: {} }]
wrapper.vm.nextEntSetUrl = '/api/annotated-entities/?project=1&document=123&page=2'
wrapper.vm.loadingMsg = null

wrapper.vm.loadDoc({ id: 456, text: 'second doc with <tag> markup' })
await flushPromises()

expect(wrapper.vm.currentDoc.id).toBe(456)
expect(wrapper.vm.loadingMsg).toBeNull()
// Must request the new document, not continue the previous page URL.
const entCalls = mockGet.mock.calls.filter(c => String(c[0]).startsWith('/api/annotated-entities/'))
expect(entCalls.some(c => String(c[0]).includes('document=456'))).toBe(true)
expect(entCalls.some(c => String(c[0]).includes('page=2'))).toBe(false)

})

it('does not load a document when the auth cookie is missing', async () => {
const { wrapper, mockGet } = mountView(() => new Promise(() => {}), {})
const { wrapper, mockGet } = mountView(() => new Promise(() => { }), {})
mockGet.mockClear()

wrapper.vm.project = project
Expand All @@ -183,5 +211,6 @@ describe('TrainAnnotations.vue fetchEntities', () => {

expect(mockGet).not.toHaveBeenCalled()
expect(wrapper.vm.currentDoc).toBeNull()

})
})
Original file line number Diff line number Diff line change
Expand Up @@ -529,7 +529,12 @@ export default {
}
})
}
// Clear prior doc annotation state so we never render the new text with old ents
// (or continue paginating the previous document's annotated-entities URL).
this.currentEnt = null
this.ents = null
this.nextEntSetUrl = null
this.loadingMsg = 'Loading document...'
this.prepareDoc()
},
prepareDoc () {
Expand Down Expand Up @@ -620,7 +625,8 @@ export default {
this.nextEntSetUrl = null
this.loadingMsg = null
this.errors.modal = true
this.errors.message = 'Failed to load document annotations. Please try again by refreshing the page.'
this.errors.message = `Failed to load document annotations for project ID ${this.projectId || ''}${this.currentDoc && this.currentDoc.id ? ', document ID ' + this.currentDoc.id : ''}. Please try again by refreshing the page. If the problem persists, please contact your administrator and provide them with this page's URL: ${window.location.href}`

if (err.response) {
this.errors.message = err.response.data?.message || this.errors.message
this.errors.description = err.response.data?.description || ''
Expand Down
Loading