This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-7304-ae1d1d88e6d6a3f3559eb6512a679959da152421 in repository https://gitbox.apache.org/repos/asf/texera.git
commit cd28761ba02f80b9028599f045a1326f7bc54a85 Author: Xinyuan Lin <[email protected]> AuthorDate: Tue Aug 4 17:46:59 2026 -0700 test(frontend): cover the codearea template and breakpoint popup placement (#7304) ### What changes were proposed in this PR? Two workspace editor sub-components whose specs stopped early. | Component | Before | Tests now | |---|---|---| | `codearea-custom-template` | 64.5%, one "should create" test | 6 | | `breakpoint-condition-input` | 63.9%, condition + keyboard paths | 13 | **`CodeareaCustomTemplateComponent`** had a single creation test. Its actual job is keeping one shared, per-operator "is the editor open" flag in sync across three inputs — this component opening or closing the editor, a co-editor opening the same one remotely, and the component being torn down while the editor is still up. The flag lives in `CodeEditorService` keyed by operator id, so a mix-up there silently reopens the wrong operator's editor. Also pinned: `ngOnDestroy` persists the **current** flag rather than a hardcoded false (so a component torn down with its editor up comes back open), and the created editor is handed **this field's** form control rather than a fresh one — otherwise the dialog opens detached from the property it is meant to edit. The co-editor test stubs `getCoeditorOpenedCodeEditorSubject` and builds its own fixture, because the component subscribes in its **constructor** and that getter returns `asObservable()`. Casting the result back to a `Subject` would only work by accident of the current implementation — the same trap that came up in review on the mini-map spec. **`BreakpointConditionInputComponent`**'s popup has no layout of its own; it is positioned by arithmetic over Monaco's reported geometry, and none of `left()`, `top()`, `isVisible` or the css offsets written by `ngOnChanges` was tested. The existing stub supplies distinguishable non-zero values, so every new expectation is a specific number — ``` left() = 30 (rect.left) + 10 (glyphMarginLeft) - 0 (scrollLeft) - 160 (popup width) = -120 top() = 20 (rect.top) + 40 (line bottom) - 5 (scrollTop) = 55 ``` — rather than a zero that jsdom would produce anyway. Also covers both the no-editor and no-line guards, and the horizontal scroll offset that only `left()` reads (so a copy-paste of `top()`'s body would be caught). `code-debugger.component.ts` (20 missed) is deliberately out of scope: its spec already has 16 tests covering the decoration and status-change handlers, and the residue looked like it would need padding rather than real assertions. Said plainly rather than quietly skipped. No production file is touched. ### Any related issues, documentation, discussions? Closes #7301 ### How was this PR tested? ``` npx ng test --watch=false --include="**/codearea-custom-template.component.spec.ts" --include="**/breakpoint-condition-input.component.spec.ts" ``` ``` ✓ .../breakpoint-condition-input.component.spec.ts (13 tests) ✓ .../codearea-custom-template.component.spec.ts (6 tests) Test Files 2 passed (2) ``` `yarn format:ci` passes (prettier-eslint + eslint), which Vitest does not cover on its own — it flagged both files on the first attempt. ### Was this PR authored or co-authored using generative AI tooling? Generated-by: Claude Code (Opus 5) --------- Signed-off-by: Xinyuan Lin <[email protected]> Co-authored-by: Copilot Autofix powered by AI <[email protected]> --- .../breakpoint-condition-input.component.spec.ts | 83 +++++++++++++++ .../codearea-custom-template.component.spec.ts | 116 +++++++++++++++++++++ 2 files changed, 199 insertions(+) diff --git a/frontend/src/app/workspace/component/code-editor-dialog/breakpoint-condition-input/breakpoint-condition-input.component.spec.ts b/frontend/src/app/workspace/component/code-editor-dialog/breakpoint-condition-input/breakpoint-condition-input.component.spec.ts index 2566f86e0e..43d891c2c5 100644 --- a/frontend/src/app/workspace/component/code-editor-dialog/breakpoint-condition-input/breakpoint-condition-input.component.spec.ts +++ b/frontend/src/app/workspace/component/code-editor-dialog/breakpoint-condition-input/breakpoint-condition-input.component.spec.ts @@ -71,6 +71,89 @@ describe("BreakpointConditionInputComponent", () => { component.closeEmitter.emit(); }); + /** + * The popup has no layout of its own: it is positioned by arithmetic over the Monaco editor's + * reported geometry. The stub above supplies distinguishable non-zero values + * (glyphMarginLeft 10, editor rect top 20 / left 30, line bottom 40, scrollTop 5), so every + * expectation below is a specific number rather than a zero that jsdom would produce anyway. + */ + describe("popup placement", () => { + it("offsets left by the glyph margin and the fixed popup width", () => { + // 30 (rect.left) + 10 (glyphMarginLeft) - 0 (scrollLeft) - 160 (popup width) + expect(component.left()).toBe(-120); + }); + + it("places the top at the bottom of the target line, less the scroll offset", () => { + // 20 (rect.top) + 40 (line bottom) - 5 (scrollTop) + expect(component.top()).toBe(55); + }); + + it("subtracts the horizontal scroll offset", () => { + // Only left() reads scrollLeft; a copy-paste of top()'s body would miss this. + component.monacoEditor = { + ...component.monacoEditor, + getScrollLeft: () => 25, + } as unknown as editor.IStandaloneCodeEditor; + + expect(component.left()).toBe(-145); + }); + + it("falls back to 0 rather than throwing when there is no editor yet", () => { + const stub = component.monacoEditor; + component.monacoEditor = undefined as unknown as editor.IStandaloneCodeEditor; + try { + // The guards exist because the popup can be rendered a tick before the editor is attached. + expect(component.left()).toBe(0); + expect(component.top()).toBe(0); + } finally { + // afterEach disposes the editor, so put the stub back. + component.monacoEditor = stub; + } + }); + + it("falls back to 0 for top() when no line is targeted", () => { + component.lineNum = undefined; + expect(component.top()).toBe(0); + // left() does not depend on the line, so it still resolves. + expect(component.left()).toBe(-120); + }); + + it("writes both css offsets when the target line changes", () => { + mockUdfDebugService.getCondition.mockReturnValue("x > 1"); + component.lineNum = 3; + const changes: SimpleChanges = { + lineNum: { currentValue: 3, previousValue: 1, firstChange: false, isFirstChange: () => false }, + }; + + component.ngOnChanges(changes); + + expect(component.topPosition).toBe("55px"); + expect(component.leftPosition).toBe("-120px"); + }); + }); + + describe("visibility", () => { + it("is visible only while a line is targeted", () => { + // The template keys its *ngIf on this, so an inverted getter leaves the popup stuck open. + expect(component.isVisible).toBe(true); + + component.lineNum = undefined; + expect(component.isVisible).toBe(false); + }); + }); + + it("ignores a keypress when no line is targeted", () => { + component.lineNum = undefined; + const emitted = vi.fn(); + component.closeEmitter.subscribe(emitted); + + component.handleEvent(new KeyboardEvent("keydown", { key: "Enter" })); + + // Neither saved nor closed: with no line there is nothing to attach a condition to. + expect(mockUdfDebugService.doUpdateBreakpointCondition).not.toHaveBeenCalled(); + expect(emitted).not.toHaveBeenCalled(); + }); + it("should create the component", () => { expect(component).toBeTruthy(); }); diff --git a/frontend/src/app/workspace/component/codearea-custom-template/codearea-custom-template.component.spec.ts b/frontend/src/app/workspace/component/codearea-custom-template/codearea-custom-template.component.spec.ts index d94217f7c2..6b1a81cc21 100644 --- a/frontend/src/app/workspace/component/codearea-custom-template/codearea-custom-template.component.spec.ts +++ b/frontend/src/app/workspace/component/codearea-custom-template/codearea-custom-template.component.spec.ts @@ -25,6 +25,9 @@ import { OperatorMetadataService } from "../../service/operator-metadata/operato import { StubOperatorMetadataService } from "../../service/operator-metadata/stub-operator-metadata.service"; import { FormControl } from "@angular/forms"; import { commonTestProviders } from "../../../common/testing/test-utils"; +import { CodeEditorService } from "../../service/code-editor/code-editor.service"; +import { CoeditorPresenceService } from "../../service/workflow-graph/model/coeditor-presence.service"; +import { Subject } from "rxjs"; describe("CodeareaCustomTemplateComponent", () => { let component: CodeareaCustomTemplateComponent; @@ -52,4 +55,117 @@ describe("CodeareaCustomTemplateComponent", () => { it("should create", () => { expect(component).toBeTruthy(); }); + + /** + * The component's whole job is to keep one shared, per-operator "is the editor open" flag in + * sync across three inputs: this component's own open/close, the same operator's editor opened + * by a co-editor, and the component being torn down while the editor is still up. The flag lives + * in CodeEditorService keyed by operator id, so a mix-up there silently reopens (or fails to + * reopen) the wrong operator's editor. + * + * `openEditor` is driven directly rather than through the template button: it calls + * `codeEditorService.vc.createComponent`, and `vc` is a ViewContainerRef the real workspace + * supplies. The tests below install a fake one so the component's own bookkeeping is observable + * without standing up a Monaco editor. + */ + describe("editor-open state", () => { + let codeEditorService: CodeEditorService; + let destroyCallbacks: (() => void)[]; + + // The highlighted operator is what getOperatorID reads, and it is the key every + // setEditorState/getEditorState call is scoped by. + function highlightedOperatorId(): string { + return TestBed.inject(WorkflowActionService).getJointGraphWrapper().getCurrentHighlightedOperatorIDs()[0]; + } + + beforeEach(() => { + const workflowActionService = TestBed.inject(WorkflowActionService); + vi.spyOn(workflowActionService.getJointGraphWrapper(), "getCurrentHighlightedOperatorIDs").mockReturnValue([ + "test-operator-id", + ]); + // ngOnInit reads the highlighted operator id; re-run it so operatorID is deterministic for these tests. + component.ngOnInit(); + + codeEditorService = TestBed.inject(CodeEditorService); + destroyCallbacks = []; + // Stand-in for the workspace's ViewContainerRef. Records the onDestroy hook the component + // registers so the close path can be triggered without a real component teardown. + codeEditorService.vc = { + createComponent: () => ({ + instance: {} as any, + onDestroy: (cb: () => void) => destroyCallbacks.push(cb), + destroy: () => destroyCallbacks.forEach(cb => cb()), + }), + } as any; + }); + + it("marks the operator's editor open and hands the field's control to it", () => { + component.openEditor(); + + expect(component.isEditorOpen).toBe(true); + // The created editor must edit THIS field, not a fresh control - otherwise the dialog opens + // detached from the operator property it is meant to edit. + expect(component.componentRef!.instance.formControl).toBe(component.field.formControl); + + let published: boolean | undefined; + codeEditorService.getEditorState(highlightedOperatorId()).subscribe(v => (published = v)); + expect(published).toBe(true); + }); + + it("clears the flag again when the editor component is destroyed", () => { + component.openEditor(); + component.componentRef!.destroy(); + + expect(component.isEditorOpen).toBe(false); + let published: boolean | undefined; + codeEditorService.getEditorState(highlightedOperatorId()).subscribe(v => (published = v)); + expect(published).toBe(false); + }); + + it("opens the editor when a co-editor opens one", () => { + // The constructor subscribes to the co-editor stream, so the stub has to be in place before + // the component exists - hence a fresh fixture here rather than the shared one. Stubbing the + // getter (rather than casting its result back to a Subject) keeps this honest: it returns + // `asObservable()`, so a cast would only work by accident of the current implementation. + const opened = new Subject<{ operatorId: string }>(); + vi.spyOn(TestBed.inject(CoeditorPresenceService), "getCoeditorOpenedCodeEditorSubject").mockReturnValue( + opened.asObservable() + ); + + const remoteFixture = TestBed.createComponent(CodeareaCustomTemplateComponent); + const remoteComponent = remoteFixture.componentInstance; + remoteComponent.field = { props: {}, formControl: new FormControl() } as any; + remoteFixture.detectChanges(); + + expect(remoteComponent.isEditorOpen).toBe(false); + + // A remote open has to bring this client's editor up too - that is what makes the session + // collaborative rather than merely presence-aware. + opened.next({ operatorId: highlightedOperatorId() }); + + expect(remoteComponent.isEditorOpen).toBe(true); + expect(remoteComponent.componentRef).toBeDefined(); + }); + + it("persists the open flag on destroy so a reopened panel restores it", () => { + component.openEditor(); + + component.ngOnDestroy(); + + // ngOnDestroy writes the CURRENT flag rather than a hardcoded false, so a component torn + // down with its editor still up comes back open. + let published: boolean | undefined; + codeEditorService.getEditorState(highlightedOperatorId()).subscribe(v => (published = v)); + expect(published).toBe(true); + }); + + it("tracks an external state change through ngOnInit's subscription", () => { + // Another component sharing the same operator id flips the flag; this one must follow. + codeEditorService.setEditorState(highlightedOperatorId(), true); + expect(component.isEditorOpen).toBe(true); + + codeEditorService.setEditorState(highlightedOperatorId(), false); + expect(component.isEditorOpen).toBe(false); + }); + }); });
