diff --git a/medcat-trainer/webapp/api/api/utils.py b/medcat-trainer/webapp/api/api/utils.py
index 483370d48..f88cb5a9c 100644
--- a/medcat-trainer/webapp/api/api/utils.py
+++ b/medcat-trainer/webapp/api/api/utils.py
@@ -155,14 +155,6 @@ def get_create_cdb_infos(cdb, concept, cui, cui_info_prop, code_prop, desc_prop,
return model_clazz.objects.filter(code__in=codes)
-def _remove_overlap(project, document, start, end):
- anns = AnnotatedEntity.objects.filter(project=project, document=document)
-
- for ann in anns:
- if (start <= ann.start_ind <= end) or (start <= ann.end_ind <= end):
- logger.debug("Removed %s ", str(ann))
- ann.delete()
-
def create_annotation(source_val: str, selection_occurrence_index: int, cui: str, user: User,
project: ProjectAnnotateEntities, document, cat: CAT):
@@ -180,9 +172,8 @@ def create_annotation(source_val: str, selection_occurrence_index: int, cui: str
start = all_occurrences_start_idxs[selection_occurrence_index]
if start is not None and len(source_val) > 0 and len(cui) > 0:
- # Remove overlaps
+ # Allow overlapping annotations - removed overlap constraint
end = start + len(source_val)
- _remove_overlap(project, document, start, end)
cnt = Entity.objects.filter(label=cui).count()
if cnt == 0:
diff --git a/medcat-trainer/webapp/frontend/src/components/common/ClinicalText.vue b/medcat-trainer/webapp/frontend/src/components/common/ClinicalText.vue
index 27ac7013c..667c80c86 100644
--- a/medcat-trainer/webapp/frontend/src/components/common/ClinicalText.vue
+++ b/medcat-trainer/webapp/frontend/src/components/common/ClinicalText.vue
@@ -78,48 +78,152 @@ export default {
return this.addAnnos ? `
${text}
` : `${text}
`
}
+ // Sort entities by start_ind, then by end_ind (longer first for same start)
+ // Preserve original index for click handlers
+ const sortedEnts = this.ents.map((ent, origIdx) => ({ ent, origIdx })).sort((a, b) => {
+ if (a.ent.start_ind !== b.ent.start_ind) {
+ return a.ent.start_ind - b.ent.start_ind
+ }
+ return b.ent.end_ind - a.ent.end_ind // Longer spans first when same start
+ })
+
const taskHighlightDefault = 'highlight-task-default'
- let formattedText = ''
- let start = 0
let timeout = 0
- for (let i = 0; i < this.ents.length; i++) {
- // highlight the span with default
- let highlightText = this.text.slice(this.ents[i].start_ind, this.ents[i].end_ind)
+ // Create events for start and end of each annotation
+ const events = []
+ sortedEnts.forEach((entData, i) => {
+ const ent = entData.ent
+ const origIdx = entData.origIdx
+ events.push({ pos: ent.start_ind, type: 'start', entIndex: i, origIndex: origIdx, ent: ent })
+ events.push({ pos: ent.end_ind, type: 'end', entIndex: i, origIndex: origIdx, ent: ent })
+ })
+ events.sort((a, b) => {
+ if (a.pos !== b.pos) {
+ return a.pos - b.pos
+ }
+ // At same position, process starts before ends
+ if (a.type !== b.type) {
+ return a.type === 'start' ? -1 : 1
+ }
+ return 0
+ })
+
+ let formattedText = ''
+ let currentPos = 0
+ const activeEnts = [] // Stack of active entities (ordered by when they were opened)
+
+ // Helper function to get style class for an entity
+ const getStyleClass = (ent, origIndex) => {
let styleClass = taskHighlightDefault
- if (this.ents[i].assignedValues[this.taskName] !== null) {
- let btnIndex = this.taskValues.indexOf(this.ents[i].assignedValues[this.taskName])
+ if (ent.assignedValues[this.taskName] !== null) {
+ let btnIndex = this.taskValues.indexOf(ent.assignedValues[this.taskName])
styleClass = `highlight-task-${btnIndex}`
}
- if (this.ents[i].id === this.currentRelStartEnt.id) {
+ if (ent.id === this.currentRelStartEnt.id) {
styleClass += ' current-rel-start'
- } else if (this.ents[i].id === this.currentRelEndEnt.id) {
+ } else if (ent.id === this.currentRelEndEnt.id) {
styleClass += ' current-rel-end'
}
- styleClass = this.ents[i] === this.currentEnt ? `${styleClass} highlight-task-selected` : styleClass
- timeout = this.ents[i] === this.currentEnt && i === 0 ? 500 : timeout
+ if (ent === this.currentEnt) {
+ styleClass += ' highlight-task-selected'
+ timeout = origIndex === 0 ? 500 : timeout
+ }
+ return styleClass
+ }
+
+ // Helper function to build opening span tag
+ const buildOpenSpan = (ent, origIndex) => {
+ const styleClass = getStyleClass(ent, origIndex)
+ return ``
+ }
+ // Helper function to build closing span tag with optional remove button
+ const buildCloseSpan = (ent, origIndex, isInnermost) => {
let removeButtonEl = ''
- if (this.ents[i].manually_created) {
- removeButtonEl = ``
+ if (isInnermost && ent.manually_created) {
+ removeButtonEl = ``
}
- let spanText = `${_.escape(highlightText)}${removeButtonEl}`
-
- let precedingText = _.escape(this.text.slice(start, this.ents[i].start_ind))
- precedingText = precedingText.length !== 0 ? precedingText : ' '
- start = this.ents[i].end_ind
- formattedText += precedingText + spanText
- if (i === this.ents.length - 1) {
- formattedText += this.text.slice(start, this.text.length)
+ return `${removeButtonEl}`
+ }
+
+ for (const event of events) {
+ // Handle start events first (before adding text)
+ if (event.type === 'start') {
+ // Add any text up to this point
+ if (event.pos > currentPos) {
+ const textSegment = this.text.slice(currentPos, event.pos)
+ if (textSegment.length > 0) {
+ formattedText += _.escape(textSegment)
+ }
+ currentPos = event.pos
+ }
+ // Open the span for this annotation
+ formattedText += buildOpenSpan(event.ent, event.origIndex)
+ activeEnts.push({ entIndex: event.entIndex, origIndex: event.origIndex, ent: event.ent })
+ } else if (event.type === 'end') {
+ // Close the span (in reverse order to maintain nesting)
+ const index = activeEnts.findIndex(ae => ae.entIndex === event.entIndex)
+ if (index !== -1) {
+ // If this is not the innermost span, we need to handle overlapping text
+ if (index < activeEnts.length - 1) {
+ // Add text up to the end position while all spans are still active
+ // This text is inside all active spans including this one
+ if (event.pos > currentPos) {
+ const textSegment = this.text.slice(currentPos, event.pos)
+ if (textSegment.length > 0) {
+ formattedText += _.escape(textSegment)
+ }
+ currentPos = event.pos
+ }
+ // Close all inner spans (from innermost to the one after this)
+ // Don't add remove buttons here - these are temporary closes for nesting
+ // We'll add remove buttons only when we reach the actual end position
+ for (let j = activeEnts.length - 1; j > index; j--) {
+ const innerData = activeEnts[j]
+ // Always pass false here - these are temporary closes, not final ends
+ formattedText += buildCloseSpan(innerData.ent, innerData.origIndex, false)
+ }
+ // Close this span (temporary close, no remove button)
+ formattedText += buildCloseSpan(event.ent, event.origIndex, false)
+ // Reopen inner spans (in the same order) so text after this position is inside them
+ for (let j = index + 1; j < activeEnts.length; j++) {
+ const innerData = activeEnts[j]
+ formattedText += buildOpenSpan(innerData.ent, innerData.origIndex)
+ }
+ } else {
+ // This is the innermost span at its final end position
+ // Add text then close it with remove button if needed
+ if (event.pos > currentPos) {
+ const textSegment = this.text.slice(currentPos, event.pos)
+ if (textSegment.length > 0) {
+ formattedText += _.escape(textSegment)
+ }
+ currentPos = event.pos
+ }
+ // Only add remove button when closing at the actual end position
+ formattedText += buildCloseSpan(event.ent, event.origIndex, true)
+ }
+ activeEnts.splice(index, 1)
+ }
}
}
- // escape '<' '>' that may be interpreted as start/end tags, escape inserted span tags.
- // formattedText = formattedText
- // .replace(/<(?!\/?span|font-awesome-icon)/g, '<')
- // .replace(/(?/g, '>')
+ // Add remaining text after all events
+ if (currentPos < this.text.length) {
+ const textSegment = this.text.slice(currentPos)
+ if (textSegment.length > 0) {
+ formattedText += _.escape(textSegment)
+ }
+ // Close any remaining active spans (in reverse order)
+ for (let j = activeEnts.length - 1; j >= 0; j--) {
+ const activeData = activeEnts[j]
+ const isInnermost = j === activeEnts.length - 1
+ formattedText += buildCloseSpan(activeData.ent, activeData.origIndex, isInnermost)
+ }
+ }
formattedText = this.addAnnos ? `${formattedText}
` : `${formattedText}
`
this.scrollIntoView(timeout)
@@ -238,18 +342,64 @@ export default {
box-shadow: 0px -2px 3px 2px rgba(0, 0, 0, 0.2);
padding: 25px;
white-space: pre-wrap;
+ line-height: 1.6; // Base line height for normal text
+
+ // Increase line height when there are 3 or more nested underlines
+ // to prevent underlines from overlapping with next line
+ [class^="highlight-task-"] [class^="highlight-task-"] [class^="highlight-task-"] {
+ line-height: 2.2; // Increased line height for 3+ levels of nesting
+ padding-bottom: 4px; // Extra padding to push next line down
+ display: inline-block; // Ensure padding applies
+ }
+
+ // Also handle when default is deeply nested
+ .highlight-task-default [class^="highlight-task-"] [class^="highlight-task-"] {
+ line-height: 2.2;
+ padding-bottom: 4px;
+ display: inline-block;
+ }
}
.highlight-task-default {
- background: lightgrey;
- border: 1px solid lightgrey;
- border-radius: 3px;
+ text-decoration: underline;
+ text-decoration-color: lightgrey;
+ text-decoration-thickness: 3px;
+ text-underline-offset: 3px; // Moved down 1px to avoid descender breaks
cursor: pointer;
+
+ // Stack underlines when nested - each nested level gets a larger offset with clear spacing
+ [class^="highlight-task-"] {
+ text-underline-offset: 7px; // Second level underline (4px spacing from first, moved down 1px)
+ }
+
+ [class^="highlight-task-"] [class^="highlight-task-"] {
+ text-underline-offset: 11px; // Third level underline (4px spacing from second, moved down 1px)
+ // Increase line height for 3+ levels to prevent overlap with next line
+ line-height: 2.2;
+ padding-bottom: 4px;
+ display: inline-block;
+ }
+
+ [class^="highlight-task-"] [class^="highlight-task-"] [class^="highlight-task-"] {
+ text-underline-offset: 15px; // Fourth level underline (4px spacing from third, moved down 1px)
+ // Further increase line height for 4+ levels
+ line-height: 2.4;
+ padding-bottom: 6px;
+ display: inline-block;
+ }
}
.highlight-task-selected {
- font-weight: bold;
- font-size: 1.15rem;
+ // Background highlight is applied via the specific highlight-task-{i} class
+ // This ensures the background color matches the state color
+ text-decoration-thickness: 4px;
+}
+
+// Selected state for default (unvalidated) annotations
+.highlight-task-default.highlight-task-selected {
+ background-color: rgba(211, 211, 211, 0.3); // Light grey background for selected default
+ padding: 1px 2px;
+ border-radius: 2px;
}
.current-rel-start {
diff --git a/medcat-trainer/webapp/frontend/src/styles/_common.scss b/medcat-trainer/webapp/frontend/src/styles/_common.scss
index b1c7ed09d..889b6ddf6 100644
--- a/medcat-trainer/webapp/frontend/src/styles/_common.scss
+++ b/medcat-trainer/webapp/frontend/src/styles/_common.scss
@@ -46,31 +46,62 @@ $blur-radius: 10px;
.title {
padding: 5px 15px;
font-size: 14pt;
- box-shadow: 0 5px 5px -5px rgba(0,0,0,0.2);
+ box-shadow: 0 5px 5px -5px rgba(0, 0, 0, 0.2);
color: black;
height: $title-height;
}
@each $i, $col in $task-colors {
.highlight-task-#{$i} {
- background-color: $col;
- border-radius: 3px;
+ text-decoration: underline;
+ text-decoration-color: $col;
+ text-decoration-thickness: 3px;
+ text-underline-offset: 3px; // Moved down 1px to avoid descender breaks
cursor: pointer;
- border: 1px solid $col;
- color: white;
+ color: inherit; // Keep original text color
+
+ // When selected, add background highlight with state color
+ &.highlight-task-selected {
+ background-color: $col;
+ padding: 1px 2px;
+ border-radius: 2px;
+ color: white;
+ }
+
+ // Stack underlines when nested - each nested level gets a larger offset with clear spacing
+ [class^="highlight-task-"] {
+ text-underline-offset: 7px; // Second level underline (4px spacing from first, moved down 1px)
+ }
+
+ [class^="highlight-task-"] [class^="highlight-task-"] {
+ text-underline-offset: 11px; // Third level underline (4px spacing from second, moved down 1px)
+ // Increase line height for 3+ levels to prevent overlap with next line
+ line-height: 2.2;
+ padding-bottom: 4px;
+ display: inline-block;
+ }
+
+ [class^="highlight-task-"] [class^="highlight-task-"] [class^="highlight-task-"] {
+ text-underline-offset: 15px; // Fourth level underline (4px spacing from third, moved down 1px)
+ // Further increase line height for 4+ levels
+ line-height: 2.4;
+ padding-bottom: 6px;
+ display: inline-block;
+ }
}
}
-.alert-enter-active, .alert-leave-active {
+.alert-enter-active,
+.alert-leave-active {
transition: opacity .5s;
}
-.alert-enter, .alert-leave-to {
+.alert-enter,
+.alert-leave-to {
opacity: 0;
}
.overlay-message {
padding-left: 10px;
opacity: 0.5;
-}
-
+}
\ No newline at end of file
diff --git a/medcat-trainer/webapp/frontend/src/tests/components/ClinicalText.spec.ts b/medcat-trainer/webapp/frontend/src/tests/components/ClinicalText.spec.ts
new file mode 100644
index 000000000..2253b3647
--- /dev/null
+++ b/medcat-trainer/webapp/frontend/src/tests/components/ClinicalText.spec.ts
@@ -0,0 +1,391 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { mount } from '@vue/test-utils'
+import ClinicalText from '@/components/common/ClinicalText.vue'
+
+describe('ClinicalText.vue', () => {
+ beforeEach(() => {
+ // Mock scrollIntoView
+ Element.prototype.scrollIntoView = vi.fn()
+ })
+
+ afterEach(() => {
+ vi.clearAllMocks()
+ })
+
+ const defaultProps = {
+ text: 'Sample clinical text',
+ taskName: 'Concept Anno',
+ taskValues: ['Correct', 'Deleted', 'Killed', 'Alternative', 'Irrelevant'],
+ ents: [],
+ loading: null,
+ addAnnos: false
+ }
+
+ it('renders empty text when loading', () => {
+ const wrapper = mount(ClinicalText, {
+ props: {
+ ...defaultProps,
+ loading: 'Loading...'
+ },
+ global: {
+ stubs: ['v-overlay', 'v-progress-circular', 'v-runtime-template', 'vue-simple-context-menu']
+ }
+ })
+ expect(wrapper.find('.clinical-note').exists()).toBe(false)
+ })
+
+ it('renders plain text when no annotations', () => {
+ const wrapper = mount(ClinicalText, {
+ props: defaultProps,
+ global: {
+ stubs: ['v-overlay', 'v-progress-circular', 'v-runtime-template', 'vue-simple-context-menu']
+ }
+ })
+ expect(wrapper.find('.clinical-note').exists()).toBe(true)
+ })
+
+ it('renders text with single annotation', () => {
+ const ents = [{
+ id: 1,
+ start_ind: 0,
+ end_ind: 6,
+ assignedValues: { 'Concept Anno': 'Correct' },
+ manually_created: false
+ }]
+ const wrapper = mount(ClinicalText, {
+ props: {
+ ...defaultProps,
+ text: 'Sample clinical text',
+ ents
+ },
+ global: {
+ stubs: ['v-overlay', 'v-progress-circular', 'v-runtime-template', 'vue-simple-context-menu']
+ }
+ })
+ expect(wrapper.find('.clinical-note').exists()).toBe(true)
+ })
+
+ it('renders text with overlapping annotations', () => {
+ const ents = [
+ {
+ id: 1,
+ start_ind: 0,
+ end_ind: 8,
+ assignedValues: { 'Concept Anno': 'Correct' },
+ manually_created: false
+ },
+ {
+ id: 2,
+ start_ind: 3,
+ end_ind: 12,
+ assignedValues: { 'Concept Anno': 'Deleted' },
+ manually_created: true
+ }
+ ]
+ const wrapper = mount(ClinicalText, {
+ props: {
+ ...defaultProps,
+ text: 'SPECIMEN(S) SUBM',
+ ents
+ },
+ global: {
+ stubs: ['v-overlay', 'v-progress-circular', 'v-runtime-template', 'vue-simple-context-menu']
+ }
+ })
+ expect(wrapper.find('.clinical-note').exists()).toBe(true)
+ })
+
+ it('emits select:concept when annotation is clicked', async () => {
+ const ents = [{
+ id: 1,
+ start_ind: 0,
+ end_ind: 6,
+ assignedValues: { 'Concept Anno': 'Correct' },
+ manually_created: false
+ }]
+ const wrapper = mount(ClinicalText, {
+ props: {
+ ...defaultProps,
+ text: 'Sample clinical text',
+ ents
+ },
+ global: {
+ stubs: ['v-overlay', 'v-progress-circular', 'v-runtime-template', 'vue-simple-context-menu']
+ }
+ })
+
+ // Get the component instance to call the method directly
+ const vm = wrapper.vm as any
+ vm.selectEnt(0)
+
+ expect(wrapper.emitted('select:concept')).toBeTruthy()
+ expect(wrapper.emitted('select:concept')?.[0]).toEqual([0])
+ })
+
+ it('emits remove:newAnno when remove button is clicked', async () => {
+ const ents = [{
+ id: 1,
+ start_ind: 0,
+ end_ind: 6,
+ assignedValues: { 'Concept Anno': 'Correct' },
+ manually_created: true
+ }]
+ const wrapper = mount(ClinicalText, {
+ props: {
+ ...defaultProps,
+ text: 'Sample clinical text',
+ ents
+ },
+ global: {
+ stubs: ['v-overlay', 'v-progress-circular', 'v-runtime-template', 'vue-simple-context-menu']
+ }
+ })
+
+ const vm = wrapper.vm as any
+ vm.removeNewAnno(0)
+
+ expect(wrapper.emitted('remove:newAnno')).toBeTruthy()
+ expect(wrapper.emitted('remove:newAnno')?.[0]).toEqual([0])
+ })
+
+ it('applies selected class to currentEnt', () => {
+ const currentEnt = {
+ id: 1,
+ start_ind: 0,
+ end_ind: 6,
+ assignedValues: { 'Concept Anno': 'Correct' },
+ manually_created: false
+ }
+ const ents = [currentEnt]
+ const wrapper = mount(ClinicalText, {
+ props: {
+ ...defaultProps,
+ text: 'Sample clinical text',
+ ents,
+ currentEnt
+ },
+ global: {
+ stubs: ['v-overlay', 'v-progress-circular', 'v-runtime-template', 'vue-simple-context-menu']
+ }
+ })
+ expect(wrapper.find('.clinical-note').exists()).toBe(true)
+ })
+
+ it('handles multiple overlapping annotations correctly', () => {
+ const ents = [
+ {
+ id: 1,
+ start_ind: 0,
+ end_ind: 8,
+ assignedValues: { 'Concept Anno': 'Correct' },
+ manually_created: false
+ },
+ {
+ id: 2,
+ start_ind: 3,
+ end_ind: 12,
+ assignedValues: { 'Concept Anno': 'Deleted' },
+ manually_created: true
+ },
+ {
+ id: 3,
+ start_ind: 5,
+ end_ind: 10,
+ assignedValues: { 'Concept Anno': 'Killed' },
+ manually_created: false
+ }
+ ]
+ const wrapper = mount(ClinicalText, {
+ props: {
+ ...defaultProps,
+ text: 'SPECIMEN(S) SUBM',
+ ents
+ },
+ global: {
+ stubs: ['v-overlay', 'v-progress-circular', 'v-runtime-template', 'vue-simple-context-menu']
+ }
+ })
+ expect(wrapper.find('.clinical-note').exists()).toBe(true)
+ })
+
+ it('only adds one remove button per manually created annotation', () => {
+ const ents = [
+ {
+ id: 1,
+ start_ind: 0,
+ end_ind: 8,
+ assignedValues: { 'Concept Anno': 'Correct' },
+ manually_created: false
+ },
+ {
+ id: 2,
+ start_ind: 3,
+ end_ind: 12,
+ assignedValues: { 'Concept Anno': 'Deleted' },
+ manually_created: true
+ }
+ ]
+ const wrapper = mount(ClinicalText, {
+ props: {
+ ...defaultProps,
+ text: 'SPECIMEN(S) SUBM',
+ ents
+ },
+ global: {
+ stubs: ['v-overlay', 'v-progress-circular', 'v-runtime-template', 'vue-simple-context-menu']
+ }
+ })
+
+ // Check that formattedText contains the remove button only once
+ const vm = wrapper.vm as any
+ const formattedText = vm.formattedText
+ const removeButtonMatches = (formattedText.match(/remove-new-anno/g) || []).length
+ expect(removeButtonMatches).toBe(1) // Only one remove button for the manually created annotation
+ })
+
+ it('handles empty text gracefully', () => {
+ const wrapper = mount(ClinicalText, {
+ props: {
+ ...defaultProps,
+ text: ''
+ },
+ global: {
+ stubs: ['v-overlay', 'v-progress-circular', 'v-runtime-template', 'vue-simple-context-menu']
+ }
+ })
+ expect(wrapper.find('.clinical-note').exists()).toBe(true)
+ })
+
+ it('handles null ents gracefully', () => {
+ const wrapper = mount(ClinicalText, {
+ props: {
+ ...defaultProps,
+ ents: null as any
+ },
+ global: {
+ stubs: ['v-overlay', 'v-progress-circular', 'v-runtime-template', 'vue-simple-context-menu']
+ }
+ })
+ // When ents is null, formattedText returns empty string but the div still renders
+ expect(wrapper.find('.clinical-note').exists()).toBe(true)
+ const vm = wrapper.vm as any
+ expect(vm.formattedText).toBe('')
+ })
+
+ it('applies correct task value classes', () => {
+ const ents = [
+ {
+ id: 1,
+ start_ind: 0,
+ end_ind: 6,
+ assignedValues: { 'Concept Anno': 'Correct' },
+ manually_created: false
+ },
+ {
+ id: 2,
+ start_ind: 7,
+ end_ind: 13,
+ assignedValues: { 'Concept Anno': 'Deleted' },
+ manually_created: false
+ }
+ ]
+ const wrapper = mount(ClinicalText, {
+ props: {
+ ...defaultProps,
+ text: 'Sample clinical text',
+ ents
+ },
+ global: {
+ stubs: ['v-overlay', 'v-progress-circular', 'v-runtime-template', 'vue-simple-context-menu']
+ }
+ })
+
+ const vm = wrapper.vm as any
+ const formattedText = vm.formattedText
+ // Check that highlight-task-0 (Correct) and highlight-task-1 (Deleted) classes are present
+ expect(formattedText).toContain('highlight-task-0')
+ expect(formattedText).toContain('highlight-task-1')
+ })
+
+ it('handles annotations that start at the same position', () => {
+ const ents = [
+ {
+ id: 1,
+ start_ind: 0,
+ end_ind: 6,
+ assignedValues: { 'Concept Anno': 'Correct' },
+ manually_created: false
+ },
+ {
+ id: 2,
+ start_ind: 0,
+ end_ind: 10,
+ assignedValues: { 'Concept Anno': 'Deleted' },
+ manually_created: false
+ }
+ ]
+ const wrapper = mount(ClinicalText, {
+ props: {
+ ...defaultProps,
+ text: 'Sample clinical text',
+ ents
+ },
+ global: {
+ stubs: ['v-overlay', 'v-progress-circular', 'v-runtime-template', 'vue-simple-context-menu']
+ }
+ })
+ expect(wrapper.find('.clinical-note').exists()).toBe(true)
+ })
+
+ it('handles relation start and end entities', () => {
+ const currentRelStartEnt = {
+ id: 1,
+ start_ind: 0,
+ end_ind: 6,
+ assignedValues: { 'Concept Anno': 'Correct' },
+ manually_created: false
+ }
+ const currentRelEndEnt = {
+ id: 2,
+ start_ind: 7,
+ end_ind: 13,
+ assignedValues: { 'Concept Anno': 'Deleted' },
+ manually_created: false
+ }
+ const ents = [currentRelStartEnt, currentRelEndEnt]
+ const wrapper = mount(ClinicalText, {
+ props: {
+ ...defaultProps,
+ text: 'Sample clinical text',
+ ents,
+ currentRelStartEnt,
+ currentRelEndEnt
+ },
+ global: {
+ stubs: ['v-overlay', 'v-progress-circular', 'v-runtime-template', 'vue-simple-context-menu']
+ }
+ })
+
+ const vm = wrapper.vm as any
+ const formattedText = vm.formattedText
+ expect(formattedText).toContain('current-rel-start')
+ expect(formattedText).toContain('current-rel-end')
+ })
+
+ it('handles addAnnos prop correctly', () => {
+ const wrapper = mount(ClinicalText, {
+ props: {
+ ...defaultProps,
+ addAnnos: true
+ },
+ global: {
+ stubs: ['v-overlay', 'v-progress-circular', 'v-runtime-template', 'vue-simple-context-menu']
+ }
+ })
+
+ const vm = wrapper.vm as any
+ const formattedText = vm.formattedText
+ expect(formattedText).toContain('@contextmenu.prevent.stop')
+ })
+})
+