diff --git a/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/plagiarism-split-view.component.html b/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/plagiarism-split-view.component.html index 4c6052b563d4..603fae72e0a4 100644 --- a/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/plagiarism-split-view.component.html +++ b/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/plagiarism-split-view.component.html @@ -5,10 +5,24 @@ } @if (isProgrammingOrTextExercise) { - + } } +
+ +
@if (plagiarismComparison) { @if (isModelingExercise) { @@ -20,10 +34,14 @@ } @if (isProgrammingOrTextExercise) { } } diff --git a/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/plagiarism-split-view.component.scss b/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/plagiarism-split-view.component.scss index 4956ee867320..12ac2a9ad4a2 100644 --- a/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/plagiarism-split-view.component.scss +++ b/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/plagiarism-split-view.component.scss @@ -19,3 +19,7 @@ position: relative; } } + +.split-pane-header-color { + background-color: var(--bs-body-bg); +} diff --git a/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/plagiarism-split-view.component.ts b/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/plagiarism-split-view.component.ts index 340d72154ce6..c8e6fe103fa5 100644 --- a/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/plagiarism-split-view.component.ts +++ b/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/plagiarism-split-view.component.ts @@ -1,4 +1,4 @@ -import { AfterViewInit, Component, Directive, ElementRef, Input, OnChanges, OnInit, QueryList, SimpleChanges, ViewChildren } from '@angular/core'; +import { AfterViewInit, Component, Directive, ElementRef, Input, OnChanges, OnDestroy, OnInit, QueryList, SimpleChanges, ViewChildren } from '@angular/core'; import * as Split from 'split.js'; import { Subject } from 'rxjs'; import { PlagiarismComparison } from 'app/exercises/shared/plagiarism/types/PlagiarismComparison'; @@ -10,6 +10,8 @@ import { PlagiarismCasesService } from 'app/course/plagiarism-cases/shared/plagi import { HttpResponse } from '@angular/common/http'; import { SimpleMatch } from 'app/exercises/shared/plagiarism/types/PlagiarismMatch'; import dayjs from 'dayjs/esm'; +import { TextPlagiarismFileElement } from 'app/exercises/shared/plagiarism/types/text/TextPlagiarismFileElement'; +import { IconDefinition, faLock, faUnlock } from '@fortawesome/free-solid-svg-icons'; @Directive({ selector: '[jhiPane]' }) export class SplitPaneDirective { @@ -21,7 +23,7 @@ export class SplitPaneDirective { styleUrls: ['./plagiarism-split-view.component.scss'], templateUrl: './plagiarism-split-view.component.html', }) -export class PlagiarismSplitViewComponent implements AfterViewInit, OnChanges, OnInit { +export class PlagiarismSplitViewComponent implements AfterViewInit, OnChanges, OnInit, OnDestroy { @Input() comparison: PlagiarismComparison; @Input() exercise: Exercise; @Input() splitControlSubject: Subject; @@ -31,6 +33,9 @@ export class PlagiarismSplitViewComponent implements AfterViewInit, OnChanges, O @ViewChildren(SplitPaneDirective) panes!: QueryList; plagiarismComparison: PlagiarismComparison; + fileSelectedSubject = new Subject(); + showFilesSubject = new Subject(); + dropdownHoverSubject = new Subject(); public split: Split.Instance; @@ -39,13 +44,16 @@ export class PlagiarismSplitViewComponent implements AfterViewInit, OnChanges, O public matchesA: Map; public matchesB: Map; + isLockFilesEnabled = false; readonly dayjs = dayjs; + protected readonly faLock: IconDefinition = faLock; + protected readonly faUnlock: IconDefinition = faUnlock; constructor(private plagiarismCasesService: PlagiarismCasesService) {} /** - * Initialize third party libs inside this lifecycle hook. + * Initialize third-party libraries inside this lifecycle hook. */ ngAfterViewInit(): void { const paneElements = this.panes.map((pane: SplitPaneDirective) => pane.elementRef.nativeElement); @@ -84,6 +92,12 @@ export class PlagiarismSplitViewComponent implements AfterViewInit, OnChanges, O } } + ngOnDestroy() { + this.fileSelectedSubject.complete(); + this.showFilesSubject.complete(); + this.dropdownHoverSubject.complete(); + } + /** * Swaps fields of A with fields of B in-place. * More specifically, swaps submissionA with submissionB and startA with startB in matches. @@ -177,4 +191,11 @@ export class PlagiarismSplitViewComponent implements AfterViewInit, OnChanges, O } } } + + /** + * Toggles the state of file locking and emits the new state to the parent component. + */ + toggleLockFiles() { + this.isLockFilesEnabled = !this.isLockFilesEnabled; + } } diff --git a/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/split-pane-header/split-pane-header.component.html b/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/split-pane-header/split-pane-header.component.html index 865d3dfe654c..556a5e8bf3bf 100644 --- a/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/split-pane-header/split-pane-header.component.html +++ b/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/split-pane-header/split-pane-header.component.html @@ -1,5 +1,5 @@
-
+
@if (hasActiveFile()) {
@@ -9,9 +9,15 @@
{{ studentLogin || ('artemisApp.plagiarism.unknownStudent' | artemisTranslate) }}
@if (showFiles) { -
    +
      @for (file of files; track file; let idx = $index) { -
    • +
    • {{ file.file }}
    • } diff --git a/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/split-pane-header/split-pane-header.component.scss b/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/split-pane-header/split-pane-header.component.scss index 7b335e961b86..f4348ef2a551 100644 --- a/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/split-pane-header/split-pane-header.component.scss +++ b/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/split-pane-header/split-pane-header.component.scss @@ -50,3 +50,7 @@ overflow: scroll; } } + +.dropdown-item.hover { + background-color: var(--data-table-dropdown-item-selected-background-color); +} diff --git a/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/split-pane-header/split-pane-header.component.ts b/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/split-pane-header/split-pane-header.component.ts index 773a297e53e5..0a0b3b84d7ea 100644 --- a/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/split-pane-header/split-pane-header.component.ts +++ b/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/split-pane-header/split-pane-header.component.ts @@ -1,5 +1,7 @@ -import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core'; +import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SimpleChanges, input } from '@angular/core'; import { faChevronDown } from '@fortawesome/free-solid-svg-icons'; +import { Subject, Subscription } from 'rxjs'; +import { TextPlagiarismFileElement } from 'app/exercises/shared/plagiarism/types/text/TextPlagiarismFileElement'; /** * A file name that additionally stores if a plagiarism match has been found for it. @@ -14,16 +16,84 @@ export type FileWithHasMatch = { templateUrl: './split-pane-header.component.html', styleUrls: ['./split-pane-header.component.scss'], }) -export class SplitPaneHeaderComponent implements OnChanges { +export class SplitPaneHeaderComponent implements OnChanges, OnInit, OnDestroy { @Input() files: FileWithHasMatch[]; @Input() studentLogin: string; + fileSelectedSubject = input>(); + isLockFilesEnabled = input(); + showFilesSubject = input>(); + dropdownHoverSubject = input>(); + @Output() selectFile = new EventEmitter(); public showFiles = false; public activeFileIndex = 0; + private fileSelectSubscription?: Subscription; + private showFilesSubscription?: Subscription; + private dropdownHoverSubscription?: Subscription; + // Icons faChevronDown = faChevronDown; + hoveredFileIndex: number; + + ngOnInit(): void { + this.subscribeToFileSelection(); + this.subscribeToShowFiles(); + this.subscribeToDropdownHover(); + } + + /** + * subscribes to listening onto file changes in component instance + * @private helper method + */ + private subscribeToFileSelection(): void { + this.fileSelectSubscription = this.fileSelectedSubject()!.subscribe((textPlagiarismElement) => { + if (this.isLockFilesEnabled()) { + this.handleLockedFileSelection(textPlagiarismElement.file, textPlagiarismElement.idx); + } + }); + } + + private handleLockedFileSelection(file: FileWithHasMatch, idx: number): void { + const index = this.files[idx]?.file === file.file ? idx : this.getIndexOf(file); + + if (index >= 0) { + this.handleFileSelect(file, index, false); + } else { + this.showFiles = false; + } + } + + /** + * subscribes to listening onto dropdown toggle in component instance + * @private helper method + */ + private subscribeToShowFiles(): void { + this.showFilesSubscription = this.showFilesSubject()?.subscribe((showFiles) => { + if (this.isLockFilesEnabled()! || (!this.isLockFilesEnabled()! && !showFiles)) { + this.toggleShowFiles(false, showFiles); + } + }); + } + + /** + * subscribes to listening onto mouse enter changes in dropdown in component instance + * @private helper method + */ + private subscribeToDropdownHover(): void { + this.dropdownHoverSubscription = this.dropdownHoverSubject()?.subscribe((textPlagiarismElement) => { + if (this.isLockFilesEnabled()) { + this.handleDropdownHover(textPlagiarismElement.file, textPlagiarismElement.idx); + } + }); + } + + private handleDropdownHover(file: FileWithHasMatch, idx: number): void { + const index = this.files[idx]?.file === file.file ? idx : this.getIndexOf(file); + + this.hoveredFileIndex = index >= 0 ? index : -1; + } ngOnChanges(changes: SimpleChanges) { if (changes.files) { @@ -37,6 +107,12 @@ export class SplitPaneHeaderComponent implements OnChanges { } } + ngOnDestroy(): void { + this.fileSelectSubscription?.unsubscribe(); + this.showFilesSubscription?.unsubscribe(); + this.dropdownHoverSubscription?.unsubscribe(); + } + hasActiveFile(): boolean { return this.hasFiles() && this.activeFileIndex < this.files.length; } @@ -45,19 +121,54 @@ export class SplitPaneHeaderComponent implements OnChanges { return this.files[this.activeFileIndex].file; } - handleFileSelect(file: FileWithHasMatch, idx: number): void { + /** + * handles selection of file from dropdown, propagates change to fileslectionsubject component for lock sync + * @param file to be selected + * @param idx index of the file from the dropdown + * @param propagateChanges propagate changes to listeners subscribed to fileSelectedSubject + */ + handleFileSelect(file: FileWithHasMatch, idx: number, propagateChanges: boolean): void { + if (propagateChanges) { + this.fileSelectedSubject()!.next({ idx: idx, file: file }); + file.hasMatch = true; + } this.activeFileIndex = idx; this.showFiles = false; this.selectFile.emit(file.file); } hasFiles(): boolean { - return this.files && this.files.length > 0; + return !!this.files?.length; } - toggleShowFiles(): void { + /** + * Toggles the dropdown visibility and optionally propagates changes to the parent component. + * @param showFiles Optional toggle status; if undefined, the status will be toggled. + * @param propagateChanges Whether to propagate the change to the parent component. + */ + toggleShowFiles(propagateChanges: boolean, showFiles?: boolean): void { if (this.hasFiles()) { - this.showFiles = !this.showFiles; + this.showFiles = showFiles !== undefined ? showFiles : !this.showFiles; + + if (propagateChanges) { + this.showFilesSubject()!.next(this.showFiles); + } } } + + triggerMouseEnter(file: FileWithHasMatch, idx: number) { + const subject = this.dropdownHoverSubject(); + if (subject) { + subject.next({ idx, file }); + } + } + + /** + * gets index of the file if it exists + * @param file The file to look up. + * @returns index if found, -1 otherwise + */ + private getIndexOf(file: FileWithHasMatch): number { + return this.files.findIndex((f) => f.file === file.file); + } } diff --git a/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/text-submission-viewer/text-submission-viewer.component.html b/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/text-submission-viewer/text-submission-viewer.component.html index e1becc774b87..00cf96ec5037 100644 --- a/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/text-submission-viewer/text-submission-viewer.component.html +++ b/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/text-submission-viewer/text-submission-viewer.component.html @@ -1,4 +1,12 @@ - + @if (cannotLoadFiles) {
      diff --git a/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/text-submission-viewer/text-submission-viewer.component.ts b/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/text-submission-viewer/text-submission-viewer.component.ts index 9cd4b201f571..edf51ca92de9 100644 --- a/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/text-submission-viewer/text-submission-viewer.component.ts +++ b/src/main/webapp/app/exercises/shared/plagiarism/plagiarism-split-view/text-submission-viewer/text-submission-viewer.component.ts @@ -12,6 +12,8 @@ import { FileWithHasMatch } from 'app/exercises/shared/plagiarism/plagiarism-spl import { escape } from 'lodash-es'; import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'; import { TEXT_FILE_EXTENSIONS } from 'app/shared/constants/file-extensions.constants'; +import { Subject } from 'rxjs'; +import { TextPlagiarismFileElement } from 'app/exercises/shared/plagiarism/types/text/TextPlagiarismFileElement'; type FilesWithType = { [p: string]: FileType }; @@ -26,6 +28,10 @@ export class TextSubmissionViewerComponent implements OnChanges { @Input() matches: Map; @Input() plagiarismSubmission: PlagiarismSubmission; @Input() hideContent: boolean; + @Input() fileSelectedSubject!: Subject; + @Input() isLockFilesEnabled: boolean; + @Input() showFilesSubject!: Subject; + @Input() dropdownHoverSubject!: Subject; /** * Name of the currently selected file. diff --git a/src/main/webapp/app/exercises/shared/plagiarism/types/text/TextPlagiarismFileElement.ts b/src/main/webapp/app/exercises/shared/plagiarism/types/text/TextPlagiarismFileElement.ts new file mode 100644 index 000000000000..5780762bd07c --- /dev/null +++ b/src/main/webapp/app/exercises/shared/plagiarism/types/text/TextPlagiarismFileElement.ts @@ -0,0 +1,6 @@ +import { FileWithHasMatch } from 'app/exercises/shared/plagiarism/plagiarism-split-view/split-pane-header/split-pane-header.component'; + +export interface TextPlagiarismFileElement { + idx: number; + file: FileWithHasMatch; +} diff --git a/src/test/javascript/spec/component/plagiarism/plagiarism-header.component.spec.ts b/src/test/javascript/spec/component/plagiarism/plagiarism-header.component.spec.ts index c38202b631ec..a010211dc85c 100644 --- a/src/test/javascript/spec/component/plagiarism/plagiarism-header.component.spec.ts +++ b/src/test/javascript/spec/component/plagiarism/plagiarism-header.component.spec.ts @@ -13,6 +13,8 @@ import { HttpResponse } from '@angular/common/http'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { MockNgbModalService } from '../../helpers/mocks/service/mock-ngb-modal.service'; import { ButtonComponent } from 'app/shared/components/button.component'; +import { MockDirective } from 'ng-mocks'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; describe('Plagiarism Header Component', () => { let comp: PlagiarismHeaderComponent; @@ -22,7 +24,7 @@ describe('Plagiarism Header Component', () => { beforeEach(() => { TestBed.configureTestingModule({ imports: [ArtemisTestModule, TranslateTestingModule], - declarations: [PlagiarismHeaderComponent], + declarations: [PlagiarismHeaderComponent, MockDirective(TranslateDirective)], providers: [ { provide: TranslateService, useClass: MockTranslateService }, { provide: NgbModal, useClass: MockNgbModalService }, diff --git a/src/test/javascript/spec/component/plagiarism/plagiarism-split-view.component.spec.ts b/src/test/javascript/spec/component/plagiarism/plagiarism-split-view.component.spec.ts index 4b06a312d72c..e5c160f6ecc4 100644 --- a/src/test/javascript/spec/component/plagiarism/plagiarism-split-view.component.spec.ts +++ b/src/test/javascript/spec/component/plagiarism/plagiarism-split-view.component.spec.ts @@ -170,7 +170,7 @@ describe('Plagiarism Split View Component', () => { }); it('should parse text matches', () => { - jest.spyOn(comp, 'mapMatchesToElements').mockReturnValue(new Map()); + jest.spyOn(comp, 'mapMatchesToElements').mockReturnValue(new Map()); const matches: PlagiarismMatch[] = [ { startA: 0, startB: 0, length: 5 }, diff --git a/src/test/javascript/spec/component/plagiarism/split-pane-header.component.spec.ts b/src/test/javascript/spec/component/plagiarism/split-pane-header.component.spec.ts index 31953315113a..d4540192235f 100644 --- a/src/test/javascript/spec/component/plagiarism/split-pane-header.component.spec.ts +++ b/src/test/javascript/spec/component/plagiarism/split-pane-header.component.spec.ts @@ -10,10 +10,16 @@ import { PlagiarismDetailsComponent } from 'app/exercises/shared/plagiarism/plag import { PlagiarismRunDetailsComponent } from 'app/exercises/shared/plagiarism/plagiarism-run-details/plagiarism-run-details.component'; import { PlagiarismSidebarComponent } from 'app/exercises/shared/plagiarism/plagiarism-sidebar/plagiarism-sidebar.component'; import { MockComponent, MockDirective, MockPipe } from 'ng-mocks'; +import { TextPlagiarismFileElement } from 'app/exercises/shared/plagiarism/types/text/TextPlagiarismFileElement'; +import { Subject } from 'rxjs'; +import { TextSubmissionViewerComponent } from 'app/exercises/shared/plagiarism/plagiarism-split-view/text-submission-viewer/text-submission-viewer.component'; +import { By } from '@angular/platform-browser'; describe('SplitPaneHeaderComponent', () => { - let comp: SplitPaneHeaderComponent; - let fixture: ComponentFixture; + let comp1: SplitPaneHeaderComponent; + let comp2: SplitPaneHeaderComponent; + let fixture1: ComponentFixture; + let fixture2: ComponentFixture; const files = [ { file: 'src/Main.java', hasMatch: true }, @@ -31,92 +37,231 @@ describe('SplitPaneHeaderComponent', () => { MockComponent(PlagiarismDetailsComponent), MockComponent(PlagiarismRunDetailsComponent), MockComponent(PlagiarismSidebarComponent), + MockComponent(TextSubmissionViewerComponent), ], providers: [{ provide: TranslateService, useClass: MockTranslateService }], }) .compileComponents() .then(() => { - fixture = TestBed.createComponent(SplitPaneHeaderComponent); - comp = fixture.componentInstance; + const fileSelectedSubject = new Subject(); + const showFilesSubject = new Subject(); + const dropdownHoverSubject = new Subject(); + fixture1 = TestBed.createComponent(SplitPaneHeaderComponent); + comp1 = fixture1.componentInstance; + fixture2 = TestBed.createComponent(SplitPaneHeaderComponent); + comp2 = fixture2.componentInstance; - comp.files = []; - comp.studentLogin = 'ts10abc'; - comp.selectFile = new EventEmitter(); + comp1.files = []; + comp1.studentLogin = 'ts10abc'; + comp1.selectFile = new EventEmitter(); + fixture1.componentRef.setInput('fileSelectedSubject', fileSelectedSubject); + fixture1.componentRef.setInput('showFilesSubject', showFilesSubject); + fixture1.componentRef.setInput('dropdownHoverSubject', dropdownHoverSubject); + + comp2.files = []; + comp2.studentLogin = 'ts20abc'; + comp2.selectFile = new EventEmitter(); + fixture2.componentRef.setInput('fileSelectedSubject', fileSelectedSubject); + fixture2.componentRef.setInput('showFilesSubject', showFilesSubject); + fixture2.componentRef.setInput('dropdownHoverSubject', dropdownHoverSubject); }); }); - it('resets the active file index on change', () => { - comp.activeFileIndex = 1; + comp1.activeFileIndex = 1; - comp.ngOnChanges({ + comp1.ngOnChanges({ files: { currentValue: files } as SimpleChange, }); - expect(comp.activeFileIndex).toBe(0); + expect(comp1.activeFileIndex).toBe(0); }); it('selects the first file on change', () => { - comp.files = files; - jest.spyOn(comp.selectFile, 'emit'); + comp1.files = files; + jest.spyOn(comp1.selectFile, 'emit'); - comp.ngOnChanges({ + comp1.ngOnChanges({ files: { currentValue: files } as SimpleChange, }); - expect(comp.selectFile.emit).toHaveBeenCalledOnce(); - expect(comp.selectFile.emit).toHaveBeenCalledWith(files[0].file); + expect(comp1.selectFile.emit).toHaveBeenCalledOnce(); + expect(comp1.selectFile.emit).toHaveBeenCalledWith(files[0].file); }); it('does not find an active file', () => { - const activeFile = comp.hasActiveFile(); + const activeFile = comp1.hasActiveFile(); expect(activeFile).toBeFalse(); }); it('returns the active file', () => { - comp.files = files; - const activeFile = comp.getActiveFile(); + comp1.files = files; + const activeFile = comp1.getActiveFile(); expect(activeFile).toBe(files[0].file); }); it('handles selection of a file', () => { const idx = 1; - comp.showFiles = true; - jest.spyOn(comp.selectFile, 'emit'); + comp1.showFiles = true; + jest.spyOn(comp1.selectFile, 'emit'); - comp.handleFileSelect(files[idx], idx); + comp1.handleFileSelect(files[idx], idx, true); - expect(comp.activeFileIndex).toBe(idx); - expect(comp.showFiles).toBeFalse(); - expect(comp.selectFile.emit).toHaveBeenCalledOnce(); - expect(comp.selectFile.emit).toHaveBeenCalledWith(files[idx].file); + expect(comp1.activeFileIndex).toBe(idx); + expect(comp1.showFiles).toBeFalse(); + expect(comp1.selectFile.emit).toHaveBeenCalledOnce(); + expect(comp1.selectFile.emit).toHaveBeenCalledWith(files[idx].file); }); it('has no files', () => { - expect(comp.hasFiles()).toBeFalse(); + expect(comp1.hasFiles()).toBeFalse(); }); it('has files', () => { - comp.files = files; + comp1.files = files; - expect(comp.hasFiles()).toBeTrue(); + expect(comp1.hasFiles()).toBeTrue(); }); it('toggles "show files"', () => { - comp.showFiles = false; - comp.files = files; + comp1.showFiles = false; + comp1.files = files; - comp.toggleShowFiles(); + comp1.toggleShowFiles(false); - expect(comp.showFiles).toBeTrue(); + expect(comp1.showFiles).toBeTrue(); }); it('does not toggle "show files"', () => { - comp.showFiles = false; + comp1.showFiles = false; + + comp1.toggleShowFiles(false); + + expect(comp1.showFiles).toBeFalse(); + }); + + it('should emit selected file through fileSelectedSubject', () => { + const selectedFile = { idx: 0, file: files[0] }; + let emittedFile: TextPlagiarismFileElement | undefined; + comp1.fileSelectedSubject()!.subscribe((file) => { + emittedFile = file; + }); + + comp1.fileSelectedSubject()!.next(selectedFile); + + // Assert + expect(emittedFile).toBe(selectedFile); + }); + + it('should sync file selection when lockFilesEnabled true', () => { + const idx = 0; + const selectedFile = { idx: idx, file: files[idx] }; + const lockFilesEnabled = true; + + comp1.files = files; + comp2.files = files; + comp1.showFiles = true; + comp2.showFiles = true; + fixture1.componentRef.setInput('isLockFilesEnabled', lockFilesEnabled); + fixture2.componentRef.setInput('isLockFilesEnabled', lockFilesEnabled); + + const handleFileSelectWithoutPropagationSpy = jest.spyOn(comp2, 'handleFileSelect'); + + fixture1.detectChanges(); + fixture2.detectChanges(); + + comp1.handleFileSelect(selectedFile.file, selectedFile.idx, true); + + fixture1.detectChanges(); + fixture2.detectChanges(); + + expect(handleFileSelectWithoutPropagationSpy).toHaveBeenCalledExactlyOnceWith(selectedFile.file, selectedFile.idx, false); + }); + + it('should not sync file selection when lockFilesEnabled false', () => { + const idx = 0; + const selectedFile = { idx: idx, file: files[idx] }; + const lockFilesEnabled = false; + + fixture1.componentRef.setInput('isLockFilesEnabled', lockFilesEnabled); + fixture2.componentRef.setInput('isLockFilesEnabled', lockFilesEnabled); + + const handleFileSelect = jest.spyOn(comp1, 'handleFileSelect'); + const handleFileSelectWithoutPropagationSpy = jest.spyOn(comp2, 'handleFileSelect'); + + fixture1.detectChanges(); + fixture2.detectChanges(); + comp1.handleFileSelect(selectedFile.file, selectedFile.idx, true); + fixture1.detectChanges(); + fixture2.detectChanges(); + + expect(handleFileSelectWithoutPropagationSpy).toHaveBeenCalledTimes(0); + expect(handleFileSelect).toHaveBeenCalledOnce(); + }); + + it('should trigger dropdown hover subject on mouseenter on the first file element', () => { + comp1.files = files; + comp1.showFiles = true; + + fixture1.detectChanges(); + + const fileList = fixture1.debugElement.query(By.css('.split-pane-header-files')); + expect(fileList).toBeTruthy(); + + const fileItems = fileList.queryAll(By.css('.split-pane-header-file')); + expect(fileItems.length).toBeGreaterThan(0); + + const firstFileItem = fileItems[0]; + const triggerMouseEnterSpy = jest.spyOn(comp1 as any, 'triggerMouseEnter'); + + firstFileItem.triggerEventHandler('mouseenter', null); + expect(triggerMouseEnterSpy).toHaveBeenCalledWith(comp1.files[0], 0); + }); + + it('should create dropdownHoverSubject on ngOnInit', () => { + // Arrange + const mockFile = files[0]; + const mockIdx = 0; + + comp1.files = files; + fixture1.componentRef.setInput('isLockFilesEnabled', true); + jest.spyOn(comp1 as any, 'handleDropdownHover'); + + comp1.ngOnInit(); + comp1.dropdownHoverSubject()!.next({ file: mockFile, idx: mockIdx }); + + expect(comp1['handleDropdownHover']).toHaveBeenCalledWith(mockFile, mockIdx); + expect(comp1.hoveredFileIndex).toBe(mockIdx); + }); + + it('should update showFiles when hasFiles returns true', () => { + comp1.hasFiles = jest.fn().mockReturnValue(true); + const initialShowFiles = comp1.showFiles; + + comp1.toggleShowFiles(false); + + expect(comp1.showFiles).not.toBe(initialShowFiles); + }); + + it('should not update showFiles when hasFiles returns false', () => { + comp1.hasFiles = jest.fn().mockReturnValue(false); + const initialShowFiles = comp1.showFiles; + + comp1.toggleShowFiles(false); + + expect(comp1.showFiles).toBe(initialShowFiles); + }); + + it('should set hoveredFileIdx to -1 if file does not match and getIndexOf returns -1', () => { + const mockFile = { file: 'testFile', hasMatch: true }; + const mockIdx = files.length; + + comp1.files = files; + jest.spyOn(comp1 as any, 'getIndexOf').mockReturnValue(-1); - comp.toggleShowFiles(); + (comp1 as any).handleDropdownHover(mockFile, mockIdx); - expect(comp.showFiles).toBeFalse(); + expect(comp1.hoveredFileIndex).toBe(-1); }); });