diff --git a/src/main/webapp/app/shared/metis/posting-content/posting-content-part/posting-content-part.components.ts b/src/main/webapp/app/shared/metis/posting-content/posting-content-part/posting-content-part.components.ts index 6c8322045888..a485184bd3df 100644 --- a/src/main/webapp/app/shared/metis/posting-content/posting-content-part/posting-content-part.components.ts +++ b/src/main/webapp/app/shared/metis/posting-content/posting-content-part/posting-content-part.components.ts @@ -76,15 +76,21 @@ export class PostingContentPartComponent implements OnInit { processContent() { if (this.postingContentPart.contentBeforeReference) { this.processedContentBeforeReference = this.escapeNumberedList(this.postingContentPart.contentBeforeReference); + this.processedContentBeforeReference = this.escapeUnorderedList(this.processedContentBeforeReference); } if (this.postingContentPart.contentAfterReference) { this.processedContentAfterReference = this.escapeNumberedList(this.postingContentPart.contentAfterReference); + this.processedContentAfterReference = this.escapeUnorderedList(this.processedContentAfterReference); } } escapeNumberedList(content: string): string { - return content.replace(/^(\s*\d+)\. /gm, '$1\\. '); + return content.replace(/^(\s*\d+)\. /gm, '$1\\. '); + } + + escapeUnorderedList(content: string): string { + return content.replace(/^(- )/gm, '\\$1'); } /** diff --git a/src/main/webapp/app/shared/metis/posting-markdown-editor/posting-markdown-editor.component.ts b/src/main/webapp/app/shared/metis/posting-markdown-editor/posting-markdown-editor.component.ts index 03a87624d52f..c176eed51aa0 100644 --- a/src/main/webapp/app/shared/metis/posting-markdown-editor/posting-markdown-editor.component.ts +++ b/src/main/webapp/app/shared/metis/posting-markdown-editor/posting-markdown-editor.component.ts @@ -15,6 +15,7 @@ import { inject, input, } from '@angular/core'; +import monaco from 'monaco-editor'; import { ViewContainerRef } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { MetisService } from 'app/shared/metis/metis.service'; @@ -122,6 +123,41 @@ export class PostingMarkdownEditorComponent implements OnInit, ControlValueAcces ngAfterViewInit(): void { this.markdownEditor.enableTextFieldMode(); + + const editor = this.markdownEditor.monacoEditor; + if (editor) { + editor.onDidChangeModelContent((event: monaco.editor.IModelContentChangedEvent) => { + const position = editor.getPosition(); + if (!position) { + return; + } + + const model = editor.getModel(); + if (!model) { + return; + } + + const lineContent = model.getLineContent(position.lineNumber).trimStart(); + const hasPrefix = lineContent.startsWith('- ') || /^\s*1\. /.test(lineContent); + if (hasPrefix && event.changes.length === 1 && (event.changes[0].text.startsWith('- ') || event.changes[0].text.startsWith('1. '))) { + return; + } + + if (hasPrefix) { + this.handleKeyDown(model, position.lineNumber); + } + }); + } + } + + private handleKeyDown(model: monaco.editor.ITextModel, lineNumber: number): void { + const lineContent = model.getLineContent(lineNumber).trimStart(); + + if (lineContent.startsWith('- ')) { + this.markdownEditor.handleActionClick(new MouseEvent('click'), this.defaultActions.find((action) => action instanceof BulletedListAction)!); + } else if (/^\d+\. /.test(lineContent)) { + this.markdownEditor.handleActionClick(new MouseEvent('click'), this.defaultActions.find((action) => action instanceof OrderedListAction)!); + } } /** diff --git a/src/main/webapp/app/shared/monaco-editor/model/actions/bulleted-list.action.ts b/src/main/webapp/app/shared/monaco-editor/model/actions/bulleted-list.action.ts index d7bdd9aa1d1e..508f03649209 100644 --- a/src/main/webapp/app/shared/monaco-editor/model/actions/bulleted-list.action.ts +++ b/src/main/webapp/app/shared/monaco-editor/model/actions/bulleted-list.action.ts @@ -1,7 +1,7 @@ import { faListUl } from '@fortawesome/free-solid-svg-icons'; import { ListAction } from './list.action'; -const BULLET_PREFIX = '• '; +const BULLET_PREFIX = '- '; /** * Action used to add or modify a bullet-point list in the text editor. diff --git a/src/main/webapp/app/shared/monaco-editor/model/actions/list.action.ts b/src/main/webapp/app/shared/monaco-editor/model/actions/list.action.ts index 659f4bed285c..5f404a25535e 100644 --- a/src/main/webapp/app/shared/monaco-editor/model/actions/list.action.ts +++ b/src/main/webapp/app/shared/monaco-editor/model/actions/list.action.ts @@ -37,7 +37,7 @@ export abstract class ListAction extends TextEditorAction { */ protected stripAnyListPrefix(line: string): string { const numberedListRegex = /^\s*\d+\.\s+/; - const bulletListRegex = /^\s*[-*+•]\s+/; + const bulletListRegex = /^\s*[-*+]\s+/; if (numberedListRegex.test(line)) { return line.replace(numberedListRegex, ''); @@ -91,10 +91,13 @@ export abstract class ListAction extends TextEditorAction { } if (position.getColumn() === currentLineText.length + 1) { - const lineWithoutPrefix = this.stripAnyListPrefix(currentLineText); const newPrefix = this.getPrefix(1); - const updatedLine = currentLineText.startsWith(newPrefix) ? lineWithoutPrefix : newPrefix + lineWithoutPrefix; + if (currentLineText.startsWith(newPrefix)) { + return; + } + + const updatedLine = newPrefix + currentLineText; editor.replaceTextAtRange( new TextEditorRange(new TextEditorPosition(position.getLineNumber(), 1), new TextEditorPosition(position.getLineNumber(), currentLineText.length + 1)), @@ -110,7 +113,7 @@ export abstract class ListAction extends TextEditorAction { // Determine if all lines have the current prefix let allLinesHaveCurrentPrefix; - if (this.getPrefix(1) != '• ') { + if (this.getPrefix(1) != '- ') { const numberedListRegex = /^\s*\d+\.\s+/; allLinesHaveCurrentPrefix = lines.every((line) => numberedListRegex.test(line)); } else { @@ -124,7 +127,7 @@ export abstract class ListAction extends TextEditorAction { const linesWithoutPrefix = lines.map((line) => this.stripAnyListPrefix(line)); updatedLines = linesWithoutPrefix.map((line, index) => { - const prefix = this.getPrefix(index) != '• ' ? this.getPrefix(index + 1) : this.getPrefix(startLineNumber + index); + const prefix = this.getPrefix(index) != '- ' ? this.getPrefix(index + 1) : this.getPrefix(startLineNumber + index); return prefix + line; }); } @@ -141,7 +144,7 @@ export abstract class ListAction extends TextEditorAction { */ protected hasPrefix(line: string): boolean { const numberedListRegex = /^\s*\d+\.\s+/; - const bulletListRegex = /^\s*[•\-*+]\s+/; + const bulletListRegex = /^\s*[-\-*+]\s+/; return numberedListRegex.test(line) || bulletListRegex.test(line); } @@ -162,9 +165,9 @@ export abstract class ListAction extends TextEditorAction { if (isNumbered) { const match = currentLineText.match(/^\s*(\d+)\.\s+/); const currentNumber = match ? parseInt(match[1], 10) : 0; - nextLinePrefix = `${currentNumber + 1}. `; + nextLinePrefix = `${currentNumber + 1}. `; } else { - nextLinePrefix = '• '; + nextLinePrefix = '- '; } } @@ -187,7 +190,7 @@ export abstract class ListAction extends TextEditorAction { if (position) { const lineNumber = position.getLineNumber(); const lineContent = editor.getLineText(lineNumber); - const linePrefixMatch = lineContent.match(/^\s*(\d+\.\s+|[-*+•]\s+)/); + const linePrefixMatch = lineContent.match(/^\s*(\d+\.\s+|[-*+]\s+)/); if (linePrefixMatch) { const prefixLength = linePrefixMatch[0].length; diff --git a/src/main/webapp/app/shared/monaco-editor/model/actions/ordered-list.action.ts b/src/main/webapp/app/shared/monaco-editor/model/actions/ordered-list.action.ts index 06930260c14b..44bd5ea73027 100644 --- a/src/main/webapp/app/shared/monaco-editor/model/actions/ordered-list.action.ts +++ b/src/main/webapp/app/shared/monaco-editor/model/actions/ordered-list.action.ts @@ -12,7 +12,7 @@ export class OrderedListAction extends ListAction { } public getPrefix(lineNumber: number): string { - const space = lineNumber >= 10 ? ' ' : ' '; + const space = ' '; return `${lineNumber}.${space}`; } diff --git a/src/main/webapp/app/shared/monaco-editor/monaco-editor.component.ts b/src/main/webapp/app/shared/monaco-editor/monaco-editor.component.ts index 73bb9f68f931..288f53cc9c07 100644 --- a/src/main/webapp/app/shared/monaco-editor/monaco-editor.component.ts +++ b/src/main/webapp/app/shared/monaco-editor/monaco-editor.component.ts @@ -118,6 +118,19 @@ export class MonacoEditorComponent implements OnInit, OnDestroy { return convertedWords.join(' '); } + public onDidChangeModelContent(listener: (event: monaco.editor.IModelContentChangedEvent) => void): monaco.IDisposable { + return this._editor.onDidChangeModelContent(listener); + } + + public getModel() { + return this._editor.getModel(); + } + + public getLineContent(lineNumber: number): string { + const model = this._editor.getModel(); + return model ? model.getLineContent(lineNumber) : ''; + } + ngOnInit(): void { const resizeObserver = new ResizeObserver(() => { this._editor.layout(); diff --git a/src/test/javascript/spec/component/shared/metis/posting-content/posting-content-part.component.spec.ts b/src/test/javascript/spec/component/shared/metis/posting-content/posting-content-part.component.spec.ts index 245f54d59a62..5a64278b8145 100644 --- a/src/test/javascript/spec/component/shared/metis/posting-content/posting-content-part.component.spec.ts +++ b/src/test/javascript/spec/component/shared/metis/posting-content/posting-content-part.component.spec.ts @@ -270,4 +270,50 @@ describe('PostingContentPartComponent', () => { expect(outputEmitter).not.toHaveBeenCalled(); }); }); + + describe('Content processing', () => { + it('should process content before and after reference with escaped numbered and unordered lists', () => { + const contentBefore = '1. This is a numbered list\n2. Another item\n- This is an unordered list'; + const contentAfter = '1. Numbered again\n- Unordered again'; + component.postingContentPart = { + contentBeforeReference: contentBefore, + contentAfterReference: contentAfter, + linkToReference: undefined, + queryParams: undefined, + referenceStr: undefined, + } as PostingContentPart; + fixture.detectChanges(); + + component.processContent(); + + expect(component.processedContentBeforeReference).toBe('1\\. This is a numbered list\n2\\. Another item\n\\- This is an unordered list'); + expect(component.processedContentAfterReference).toBe('1\\. Numbered again\n\\- Unordered again'); + }); + + it('should escape numbered lists correctly', () => { + const content = '1. First item\n2. Second item\n3. Third item'; + const escapedContent = component.escapeNumberedList(content); + expect(escapedContent).toBe('1\\. First item\n2\\. Second item\n3\\. Third item'); + }); + + it('should escape unordered lists correctly', () => { + const content = '- First item\n- Second item\n- Third item'; + const escapedContent = component.escapeUnorderedList(content); + expect(escapedContent).toBe('\\- First item\n\\- Second item\n\\- Third item'); + }); + + it('should not escape text without numbered or unordered lists', () => { + const content = 'This is just a paragraph.\nAnother paragraph.'; + const escapedNumbered = component.escapeNumberedList(content); + const escapedUnordered = component.escapeUnorderedList(content); + expect(escapedNumbered).toBe(content); + expect(escapedUnordered).toBe(content); + }); + + it('should handle mixed numbered and unordered lists in content', () => { + const content = '1. Numbered item\n- Unordered item\n2. Another numbered item\n- Another unordered item'; + const escapedContent = component.escapeNumberedList(component.escapeUnorderedList(content)); + expect(escapedContent).toBe('1\\. Numbered item\n\\- Unordered item\n2\\. Another numbered item\n\\- Another unordered item'); + }); + }); }); diff --git a/src/test/javascript/spec/component/shared/metis/postings-markdown-editor/postings-markdown-editor.component.spec.ts b/src/test/javascript/spec/component/shared/metis/postings-markdown-editor/postings-markdown-editor.component.spec.ts index 10a6a49276f9..80ef5cf80a23 100644 --- a/src/test/javascript/spec/component/shared/metis/postings-markdown-editor/postings-markdown-editor.component.spec.ts +++ b/src/test/javascript/spec/component/shared/metis/postings-markdown-editor/postings-markdown-editor.component.spec.ts @@ -37,6 +37,7 @@ import { TextEditorPosition } from 'app/shared/monaco-editor/model/actions/adapt import { BulletedListAction } from 'app/shared/monaco-editor/model/actions/bulleted-list.action'; import { OrderedListAction } from 'app/shared/monaco-editor/model/actions/ordered-list.action'; import { ListAction } from '../../../../../../../main/webapp/app/shared/monaco-editor/model/actions/list.action'; +import monaco from 'monaco-editor'; describe('PostingsMarkdownEditor', () => { let component: PostingMarkdownEditorComponent; @@ -124,6 +125,7 @@ describe('PostingsMarkdownEditor', () => { MockProvider(ChannelService), { provide: Overlay, useValue: mockOverlay }, { provide: OverlayPositionBuilder, useValue: overlayPositionBuilderMock }, + { provide: MarkdownEditorMonacoComponent, useValue: mockMarkdownEditorComponent }, ], declarations: [PostingMarkdownEditorComponent, MockComponent(MarkdownEditorMonacoComponent)], }) @@ -361,15 +363,15 @@ describe('PostingsMarkdownEditor', () => { getLineNumber: () => 1, getColumn: () => 6, } as TextEditorPosition); - mockEditor.getLineText.mockReturnValue('• First line'); + mockEditor.getLineText.mockReturnValue('- First line'); bulletedListAction.run(mockEditor); const { preventDefaultSpy } = simulateKeydownEvent(mockEditor, 'Enter', { shiftKey: true }); expect(preventDefaultSpy).toHaveBeenCalled(); - expect(mockEditor.replaceTextAtRange).toHaveBeenCalledWith(expect.any(TextEditorRange), '\n• '); - expect(mockEditor.setPosition).toHaveBeenCalledWith(new TextEditorPosition(2, 4)); + expect(mockEditor.replaceTextAtRange).toHaveBeenCalledWith(expect.any(TextEditorRange), '\n- '); + expect(mockEditor.setPosition).toHaveBeenCalledWith(new TextEditorPosition(2, 3)); }); it('should handle Cmd+Enter correctly without inserting double line breaks', () => { @@ -379,7 +381,7 @@ describe('PostingsMarkdownEditor', () => { getLineNumber: () => 1, getColumn: () => 6, } as TextEditorPosition); - mockEditor.getLineText.mockReturnValue('• First line'); + mockEditor.getLineText.mockReturnValue('- First line'); bulletedListAction.run(mockEditor); @@ -387,8 +389,8 @@ describe('PostingsMarkdownEditor', () => { expect(preventDefaultSpy).toHaveBeenCalled(); expect(stopPropagationSpy).toHaveBeenCalled(); - expect(mockEditor.replaceTextAtRange).toHaveBeenCalledWith(expect.any(TextEditorRange), '\n• '); - expect(mockEditor.setPosition).toHaveBeenCalledWith(new TextEditorPosition(2, 4)); + expect(mockEditor.replaceTextAtRange).toHaveBeenCalledWith(expect.any(TextEditorRange), '\n- '); + expect(mockEditor.setPosition).toHaveBeenCalledWith(new TextEditorPosition(2, 3)); }); const simulateListAction = (action: TextEditorAction, selectedText: string, expectedText: string, startLineNumber: number = 1) => { @@ -437,14 +439,14 @@ describe('PostingsMarkdownEditor', () => { it('should add bulleted list prefixes correctly', () => { const bulletedListAction = component.defaultActions.find((action: any) => action instanceof BulletedListAction) as BulletedListAction; const selectedText = `First line\nSecond line\nThird line`; - const expectedText = `• First line\n• Second line\n• Third line`; + const expectedText = `- First line\n- Second line\n- Third line`; simulateListAction(bulletedListAction, selectedText, expectedText); }); it('should remove bulleted list prefixes correctly when toggled', () => { const bulletedListAction = component.defaultActions.find((action: any) => action instanceof BulletedListAction) as BulletedListAction; - const selectedText = `• First line\n• Second line\n• Third line`; + const selectedText = `- First line\n- Second line\n- Third line`; const expectedText = `First line\nSecond line\nThird line`; simulateListAction(bulletedListAction, selectedText, expectedText); @@ -453,7 +455,7 @@ describe('PostingsMarkdownEditor', () => { it('should add ordered list prefixes correctly starting from 1', () => { const orderedListAction = component.defaultActions.find((action: any) => action instanceof OrderedListAction) as OrderedListAction; const selectedText = `First line\nSecond line\nThird line`; - const expectedText = `1. First line\n2. Second line\n3. Third line`; + const expectedText = `1. First line\n2. Second line\n3. Third line`; simulateListAction(orderedListAction, selectedText, expectedText); }); @@ -469,8 +471,8 @@ describe('PostingsMarkdownEditor', () => { it('should switch from bulleted list to ordered list correctly', () => { const bulletedListAction = component.defaultActions.find((action: any) => action instanceof BulletedListAction) as BulletedListAction; const orderedListAction = component.defaultActions.find((action: any) => action instanceof OrderedListAction) as OrderedListAction; - const bulletedText = `• First line\n• Second line\n• Third line`; - const expectedOrderedText = `1. First line\n2. Second line\n3. Third line`; + const bulletedText = `- First line\n- Second line\n- Third line`; + const expectedOrderedText = `1. First line\n2. Second line\n3. Third line`; simulateListAction(bulletedListAction, bulletedText, `First line\nSecond line\nThird line`); @@ -482,7 +484,7 @@ describe('PostingsMarkdownEditor', () => { const orderedListAction = component.defaultActions.find((action: any) => action instanceof OrderedListAction) as OrderedListAction; const bulletedListAction = component.defaultActions.find((action: any) => action instanceof BulletedListAction) as BulletedListAction; const orderedText = `1. First line\n2. Second line\n3. Third line`; - const expectedBulletedText = `• First line\n• Second line\n• Third line`; + const expectedBulletedText = `- First line\n- Second line\n- Third line`; simulateListAction(orderedListAction, orderedText, `First line\nSecond line\nThird line`); @@ -493,7 +495,7 @@ describe('PostingsMarkdownEditor', () => { it('should start ordered list numbering from 1 regardless of an inline list', () => { const orderedListAction = component.defaultActions.find((action: any) => action instanceof OrderedListAction) as OrderedListAction; const selectedText = `Some previous text\n1. First line\n2. Second line\n3. Third line`; - const expectedText = `1. Some previous text\n2. First line\n3. Second line\n4. Third line`; + const expectedText = `1. Some previous text\n2. First line\n3. Second line\n4. Third line`; simulateListAction(orderedListAction, selectedText, expectedText); }); @@ -502,8 +504,8 @@ describe('PostingsMarkdownEditor', () => { const bulletedListAction = component.defaultActions.find((action: any) => action instanceof BulletedListAction) as BulletedListAction; const orderedListAction = component.defaultActions.find((action: any) => action instanceof OrderedListAction) as OrderedListAction; - const bulletedText = `• First line\n• Second line\n• Third line`; - const expectedOrderedText = `1. First line\n2. Second line\n3. Third line`; + const bulletedText = `- First line\n- Second line\n- Third line`; + const expectedOrderedText = `1. First line\n2. Second line\n3. Third line`; simulateListAction(bulletedListAction, `First line\nSecond line\nThird line`, bulletedText); @@ -517,13 +519,13 @@ describe('PostingsMarkdownEditor', () => { const initialText = `First line\nSecond line\nThird line`; - const bulletedText = `• First line\n• Second line\n• Third line`; + const bulletedText = `- First line\n- Second line\n- Third line`; simulateListAction(bulletedListAction, initialText, bulletedText); mockEditor.replaceTextAtRange.mockClear(); simulateListAction(bulletedListAction, bulletedText, initialText); - const orderedText = `1. First line\n2. Second line\n3. Third line`; + const orderedText = `1. First line\n2. Second line\n3. Third line`; mockEditor.replaceTextAtRange.mockClear(); simulateListAction(orderedListAction, initialText, orderedText); @@ -537,14 +539,42 @@ describe('PostingsMarkdownEditor', () => { const initialText = `First line\nSecond line\nThird line`; - const bulletedText = `• First line\n• Second line\n• Third line`; + const bulletedText = `- First line\n- Second line\n- Third line`; simulateListAction(bulletedListAction, initialText, bulletedText); - const orderedText = `1. First line\n2. Second line\n3. Third line`; + const orderedText = `1. First line\n2. Second line\n3. Third line`; mockEditor.replaceTextAtRange.mockClear(); simulateListAction(orderedListAction, bulletedText, orderedText); mockEditor.replaceTextAtRange.mockClear(); simulateListAction(bulletedListAction, orderedText, bulletedText); }); + + it('should handle key down event and invoke the correct action', () => { + const bulletedListAction = new BulletedListAction(); + + component.defaultActions = [bulletedListAction]; + + const handleActionClickSpy = jest.spyOn(component.markdownEditor, 'handleActionClick'); + + const mockModel = { + getLineContent: jest.fn().mockReturnValue('- List item'), + } as unknown as monaco.editor.ITextModel; + const mockPosition = { lineNumber: 1 } as monaco.Position; + + (component as any).handleKeyDown(mockModel, mockPosition.lineNumber); + + expect(handleActionClickSpy).toHaveBeenCalledWith(expect.any(MouseEvent), bulletedListAction); + }); + + it('should handle invalid line content gracefully', () => { + const mockModel = { + getLineContent: jest.fn().mockReturnValue(''), + } as unknown as monaco.editor.ITextModel; + const mockPosition = { lineNumber: 1 } as monaco.Position; + const handleActionClickSpy = jest.spyOn(component.markdownEditor, 'handleActionClick'); + + (component as any).handleKeyDown(mockModel, mockPosition.lineNumber); + expect(handleActionClickSpy).not.toHaveBeenCalled(); + }); }); diff --git a/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor-action.integration.spec.ts b/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor-action.integration.spec.ts index 2de7e4afcf89..4551f36513f9 100644 --- a/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor-action.integration.spec.ts +++ b/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor-action.integration.spec.ts @@ -138,7 +138,7 @@ describe('MonacoEditorActionIntegration', () => { const action = new OrderedListAction(); comp.registerAction(action); action.executeInCurrentEditor(); - expect(comp.getText()).toBe('1. '); + expect(comp.getText()).toBe('1. '); }); it.each([1, 2, 3])('Should toggle heading %i on selected line', (headingLevel) => { diff --git a/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor.component.spec.ts b/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor.component.spec.ts index 244e9004ac43..462ba750f969 100644 --- a/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor.component.spec.ts +++ b/src/test/javascript/spec/component/shared/monaco-editor/monaco-editor.component.spec.ts @@ -289,4 +289,42 @@ describe('MonacoEditorComponent', () => { comp.setText(originalText); expect(comp.getText()).toBe(originalText); }); + + it('should register a listener for model content changes', () => { + const listenerStub = jest.fn(); + fixture.detectChanges(); + const disposable = comp.onDidChangeModelContent(listenerStub); + comp.setText(singleLineText); + expect(listenerStub).toHaveBeenCalled(); + disposable.dispose(); + }); + + it('should retrieve the editor model', () => { + fixture.detectChanges(); + comp.setText(singleLineText); + const model = comp.getModel(); + expect(model).not.toBeNull(); + expect(model?.getValue()).toBe(singleLineText); + }); + + it('should get the content of a specific line', () => { + fixture.detectChanges(); + comp.setText(multiLineText); + const lineContent = comp.getLineContent(2); + expect(lineContent).toBe('static void main() {'); + }); + + it('should handle invalid line numbers in getLineContent', () => { + fixture.detectChanges(); + comp.setText(multiLineText); + + // Invalid line numbers + expect(() => comp.getLineContent(0)).toThrow(); + expect(() => comp.getLineContent(-1)).toThrow(); + expect(() => comp.getLineContent(999)).toThrow(); + + // Empty line + comp.setText('line1\n\nline3'); + expect(comp.getLineContent(2)).toBe(''); + }); });