diff --git a/frontend/src/app/workspace/component/code-editor-dialog/code-editor.component.browser.spec.ts b/frontend/src/app/workspace/component/code-editor-dialog/code-editor.component.browser.spec.ts index 677dd54c1fe..fac2b91dac9 100644 --- a/frontend/src/app/workspace/component/code-editor-dialog/code-editor.component.browser.spec.ts +++ b/frontend/src/app/workspace/component/code-editor-dialog/code-editor.component.browser.spec.ts @@ -144,12 +144,26 @@ interface FakeEditor { getScrolledVisiblePosition: ReturnType; createDecorationsCollection: ReturnType; actions: monaco.editor.IActionDescriptor[]; + model: { getLineCount: () => number; tokenization: { forceTokenization: ReturnType } }; } function makeFakeEditor(): FakeEditor { const actions: monaco.editor.IActionDescriptor[] = []; + // The component calls `getModel()` several times per pass (once for the + // MonacoBinding, again for the re-tokenize sweep). A single memoized model + // keeps those views consistent so a test can assert on what the component + // did to it. + const model = { + getValue: () => "x = 1\ny = 2\n", + getValueInRange: () => "x", + onDidChangeContent: () => ({ dispose: () => {} }), + getOffsetAt: () => 0, + getLineCount: () => 3, + tokenization: { forceTokenization: vi.fn() }, + }; return { actions, + model, addAction: vi.fn((action: monaco.editor.IActionDescriptor) => { actions.push(action); return { dispose: vi.fn(), id: action.id, label: action.label }; @@ -157,12 +171,7 @@ function makeFakeEditor(): FakeEditor { updateOptions: vi.fn(), layout: vi.fn(), getSelection: vi.fn(() => new monaco.Selection(1, 1, 1, 5)), - getModel: vi.fn(() => ({ - getValue: () => "x = 1\ny = 2\n", - getValueInRange: () => "x", - onDidChangeContent: () => ({ dispose: () => {} }), - getOffsetAt: () => 0, - })), + getModel: vi.fn(() => model), getScrolledVisiblePosition: vi.fn(() => ({ top: 50, left: 100, height: 18 })), createDecorationsCollection: vi.fn(() => ({ clear: vi.fn() })), }; @@ -357,6 +366,76 @@ describe("CodeEditorComponent (browser)", () => { expect((fixture.componentInstance as any).isMultipleVariables).toBe(false); }); + it("initializeMonacoEditor: leaves the binding unwired when the operator has no shared code", async () => { + const { fixture, editor, c } = makeFixtureWithFakes(); + (c as any).code = undefined; + + fixture.detectChanges(); + await flush(); + + // readOnly still went through, so the subscribe body was entered and then + // bailed out at the missing-code guard rather than never running. + expect(editor.updateOptions).toHaveBeenCalledWith({ readOnly: false }); + expect(monacoBindingCalls).toHaveLength(0); + expect(editor.addAction).not.toHaveBeenCalled(); + }); + + it("initializeMonacoEditor: force-tokenizes every line so the first paint carries syntax colours", async () => { + const { fixture, editor } = makeFixtureWithFakes(); + + fixture.detectChanges(); + await flush(); + + // The stand-in model reports three lines, and Monaco line numbers are + // 1-based and inclusive, so exactly 1, 2, 3 should be swept. + const forceTokenization = editor.model.tokenization.forceTokenization; + expect(forceTokenization.mock.calls.map(call => call[0])).toEqual([1, 2, 3]); + }); + + it("initializeMonacoEditor: replaces the previous binding and detaches the previous listener on re-init", async () => { + const { fixture, c } = makeFixtureWithFakes(); + fixture.detectChanges(); + await flush(); + expect(monacoBindingCalls).toHaveLength(1); + const firstBinding = (c as any).monacoBinding as { destroy: ReturnType }; + const detach = vi.fn(); + (c as any).detachYCodeListener = detach; + + // Re-emitting on the version stream re-runs the editor bring-up that + // ngAfterViewInit subscribed to. + displayVersionStream$.next(false); + await flush(); + + expect(monacoBindingCalls).toHaveLength(2); + expect(firstBinding.destroy).toHaveBeenCalledOnce(); + expect(detach).toHaveBeenCalledOnce(); + }); + + it("initializeDiffEditor: diffs this operator's latest-version code against the shared text", async () => { + const { fixture, c } = makeFixtureWithFakes(); + const sharedText = new Y.Doc().getText("code"); + sharedText.insert(0, "shared editing text"); + (c as any).code = sharedText; + // The decoy operator is listed FIRST, so picking anything other than the + // entry matching `currentOperatorId` surfaces the wrong version. + vi.spyOn(workflowActionService, "getTempWorkflow").mockReturnValue({ + content: { + operators: [ + { operatorID: "a-different-operator", operatorProperties: { code: "some other operator's code" } }, + { operatorID: fixture.componentInstance.currentOperatorId, operatorProperties: { code: "latest version" } }, + ], + }, + } as any); + displayVersionStream$.next(true); + + fixture.detectChanges(); + await flush(); + + const config = editorAppMock.configs[0] as any; + expect(config.codeResources.modified.text).toBe("latest version"); + expect(config.codeResources.original.text).toBe("shared editing text"); + }); + it("onWindowResize: calls editor.layout() through adjustEditorSize", async () => { const { fixture, editor } = makeFixtureWithFakes(); fixture.detectChanges(); diff --git a/frontend/src/app/workspace/component/code-editor-dialog/code-editor.component.spec.ts b/frontend/src/app/workspace/component/code-editor-dialog/code-editor.component.spec.ts index 6c9bd5af889..fd6e403d20d 100644 --- a/frontend/src/app/workspace/component/code-editor-dialog/code-editor.component.spec.ts +++ b/frontend/src/app/workspace/component/code-editor-dialog/code-editor.component.spec.ts @@ -28,11 +28,18 @@ import { mockOperatorMetaData } from "../../service/operator-metadata/mock-opera import { commonTestProviders } from "../../../common/testing/test-utils"; import { OperatorPredicate } from "../../types/workflow-common.interface"; import { OperatorSchema } from "../../types/operator-schema.interface"; -import { of, Subject } from "rxjs"; -import { AIAssistantService } from "../../service/ai-assistant/ai-assistant.service"; +import { BehaviorSubject, defer, of, config as rxjsConfig, Subject } from "rxjs"; +import { + AIAssistantService, + TypeAnnotationResponse, + UnannotatedArgument, +} from "../../service/ai-assistant/ai-assistant.service"; import * as monaco from "monaco-editor"; import { UiUdfParametersSyncService } from "../../service/code-editor/ui-udf-parameters-sync.service"; import { NotificationService } from "../../../common/service/notification/notification.service"; +import { ComponentRef, SecurityContext } from "@angular/core"; +import { DomSanitizer, SafeHtml } from "@angular/platform-browser"; +import { Coeditor } from "../../../common/type/user"; // Operator types that the constructor's language-detection branch must map // to a specific language. `RUDFSource` / `RUDF` -> `r`; the three V2 Python @@ -91,14 +98,99 @@ const buildPredicate = (operatorID: string, operatorType: string): OperatorPredi isDisabled: false, }); +// A plain recording stand-in for `IStandaloneCodeEditor`. The component never +// introspects the editor beyond the handful of methods below, so the private +// editor-consuming methods (`setupAIAssistantActions`, `handleTypeAnnotation`, +// `insertTypeAnnotations`) can be driven directly with this object — no Monaco +// runtime, and no module mocking, which `@angular/build`'s `isolate: false` +// makes unreliable. +// +// The numbers here are deliberately all distinct (`top` 50 vs `left` 100, +// `getOffsetAt` 42 vs the `?? 0` fallback) so that a mutation swapping two of +// them cannot render an identical result. +interface FakeEditor { + actions: monaco.editor.IActionDescriptor[]; + addAction: ReturnType; + getSelection: ReturnType; + getModel: ReturnType; + getScrolledVisiblePosition: ReturnType; + createDecorationsCollection: ReturnType; + layout: ReturnType; +} + +function makeFakeEditor(): FakeEditor { + const actions: monaco.editor.IActionDescriptor[] = []; + return { + actions, + addAction: vi.fn((action: monaco.editor.IActionDescriptor) => { + actions.push(action); + return { dispose: vi.fn(), id: action.id, label: action.label }; + }), + getSelection: vi.fn(() => new monaco.Selection(1, 1, 1, 5)), + getModel: vi.fn(() => ({ + getValue: () => "def f(a, b):\n return a\n", + getValueInRange: () => "a, b", + getOffsetAt: () => 42, + })), + getScrolledVisiblePosition: vi.fn(() => ({ top: 50, left: 100, height: 18 })), + createDecorationsCollection: vi.fn(() => ({ clear: vi.fn() })), + layout: vi.fn(), + }; +} + +const asEditor = (editor: FakeEditor) => editor as unknown as monaco.editor.ICodeEditor; + +// The component reads `getBoundingClientRect()` on the container to decide +// whether to clamp it to the viewport. jsdom always reports an all-zero rect, +// so tests stage their own. +function stubRect(left: number, top: number, right: number, bottom: number): DOMRect { + return { + x: left, + y: top, + left, + top, + right, + bottom, + width: right - left, + height: bottom - top, + toJSON: () => ({}), + } as DOMRect; +} + +// `window.innerWidth` / `innerHeight` are accessor properties in jsdom, so they +// are staged with `defineProperty` and restored from the captured descriptor. +function withViewport(width: number, height: number, run: () => T): T { + const originalWidth = Object.getOwnPropertyDescriptor(window, "innerWidth"); + const originalHeight = Object.getOwnPropertyDescriptor(window, "innerHeight"); + Object.defineProperty(window, "innerWidth", { value: width, configurable: true }); + Object.defineProperty(window, "innerHeight", { value: height, configurable: true }); + try { + return run(); + } finally { + if (originalWidth) Object.defineProperty(window, "innerWidth", originalWidth); + if (originalHeight) Object.defineProperty(window, "innerHeight", originalHeight); + } +} + describe("CodeEditorComponent", () => { let workflowActionService: WorkflowActionService; let uiParametersParseErrors: Subject<{ operatorId: string; message?: string }>; let notificationServiceMock: { error: ReturnType }; + let aiEnabled$: BehaviorSubject; + let getTypeAnnotationsSpy: ReturnType; + let locateUnannotatedSpy: ReturnType; beforeEach(async () => { + // The component persists the dialog geometry per operator id in + // localStorage and restores it in ngAfterViewInit, so a test that resizes + // the container would otherwise leak that geometry into every later + // fixture built from the same predicate. + localStorage.clear(); uiParametersParseErrors = new Subject(); notificationServiceMock = { error: vi.fn() }; + aiEnabled$ = new BehaviorSubject("OpenAI"); + getTypeAnnotationsSpy = vi.fn().mockReturnValue(of({ choices: [{ message: { content: ": int" } }] })); + locateUnannotatedSpy = vi.fn().mockReturnValue(of([])); await TestBed.configureTestingModule({ providers: [ WorkflowActionService, @@ -111,6 +203,17 @@ describe("CodeEditorComponent", () => { }, }, { provide: NotificationService, useValue: notificationServiceMock }, + // The real service resolves its gate over HTTP, which + // HttpClientTestingModule never answers, so `setupAIAssistantActions` + // would hang before registering anything. + { + provide: AIAssistantService, + useValue: { + checkAIAssistantEnabled: () => aiEnabled$, + getTypeAnnotations: getTypeAnnotationsSpy, + locateUnannotated: locateUnannotatedSpy, + }, + }, ...commonTestProviders, ], imports: [CodeEditorComponent, HttpClientTestingModule], @@ -361,4 +464,491 @@ describe("CodeEditorComponent", () => { expect(checked).toBe("OpenAI"); }); }); + + // `getCoeditorCursorStyles` interpolates two awareness-sourced values into a + // `")).toBe(true); + }); + + // One case per alternation in SAFE_CSS_COLOR: short hex, hex-with-alpha, + // and the percent-bearing hsl() form. + (["#abc", "#aabbccdd", "hsl(120, 50%, 50%)"] as const).forEach(color => { + it(`accepts the ${color} colour notation`, () => { + expect(stylesFor("42", color)).toContain(`.yRemoteSelectionHead-42 { border-color: ${color}}`); + }); + }); + + const REJECTED: ReadonlyArray = [ + ["a clientId that is not all digits", "12; }", "#ff00aa"], + ["a clientId longer than ten digits", "123456789012", "#ff00aa"], + ["an empty clientId", "", "#ff00aa"], + ["a missing colour", "12345", ""], + ["a named colour outside the allow-list", "12345", "red"], + ["a url() colour", "12345", "url(https://evil.example/x)"], + ["a colour carrying a style-tag escape", "12345", "#fff}"], + ]; + REJECTED.forEach(([label, clientId, color]) => { + it(`emits no CSS at all for ${label}`, () => { + expect(stylesFor(clientId, color)).toBe(""); + }); + }); + }); + + describe("template", () => { + it("the close button destroys the host component reference", () => { + const fixture = makeFixture(mockJavaUDFPredicate); + const destroy = vi.fn(); + fixture.componentInstance.componentRef = { destroy } as unknown as ComponentRef; + + (fixture.nativeElement as HTMLElement).querySelector("#close-button")!.click(); + + expect(destroy).toHaveBeenCalledOnce(); + }); + + it("the close button is inert when the dialog was created without a component reference", () => { + const fixture = makeFixture(mockJavaUDFPredicate); + expect(fixture.componentInstance.componentRef).toBeUndefined(); + + expect(() => + (fixture.nativeElement as HTMLElement).querySelector("#close-button")!.click() + ).not.toThrow(); + }); + + it("renders one scoped cursor-style block per coeditor", () => { + const fixture = makeFixture(mockJavaUDFPredicate); + fixture.componentInstance.coeditorPresenceService.coeditors = [ + { clientId: "777", color: "rgba(9, 8, 7, 0.8)" } as unknown as Coeditor, + { clientId: "888", color: "#abcdef" } as unknown as Coeditor, + ]; + fixture.detectChanges(); + + const html = (fixture.nativeElement as HTMLElement).innerHTML; + expect(html).toContain("yRemoteSelection-777"); + expect(html).toContain("yRemoteSelection-888"); + }); + + it("renders nothing for a coeditor whose awareness values fail the allow-list", () => { + const fixture = makeFixture(mockJavaUDFPredicate); + fixture.componentInstance.coeditorPresenceService.coeditors = [ + { clientId: "not-a-number", color: "#abcdef" } as unknown as Coeditor, + ]; + fixture.detectChanges(); + + expect((fixture.nativeElement as HTMLElement).innerHTML).not.toContain("yRemoteSelection"); + }); + + it("keeps the suggestion panel out of the DOM until a suggestion is staged", () => { + const fixture = makeFixture(mockJavaUDFPredicate); + expect((fixture.nativeElement as HTMLElement).querySelector("texera-annotation-suggestion")).toBeNull(); + + fixture.componentInstance.showAnnotationSuggestion = true; + fixture.detectChanges(); + + expect((fixture.nativeElement as HTMLElement).querySelector("texera-annotation-suggestion")).not.toBeNull(); + }); + + it("routes the suggestion panel's accept and decline to the matching handlers", () => { + const fixture = makeFixture(mockJavaUDFPredicate); + const component = fixture.componentInstance; + component.showAnnotationSuggestion = true; + fixture.detectChanges(); + const accepted = vi.spyOn(component, "acceptCurrentAnnotation"); + const declined = vi.spyOn(component, "rejectCurrentAnnotation"); + + component.annotationSuggestion.onAccept(); + expect(accepted).toHaveBeenCalledOnce(); + expect(declined).not.toHaveBeenCalled(); + + component.annotationSuggestion.onDecline(); + expect(declined).toHaveBeenCalledOnce(); + expect(accepted).toHaveBeenCalledOnce(); + }); + }); + + // The two context-menu actions are registered against a live Monaco editor, + // which jsdom cannot host. Their `run` callbacks are plain closures over the + // component though, so `setupAIAssistantActions` is driven directly with a + // recording stand-in and the callbacks are invoked off the registration list. + describe("AI assistant actions", () => { + function registerOn(editor: FakeEditor): CodeEditorComponent { + const fixture = makeFixture(mockJavaUDFPredicate); + (fixture.componentInstance as any).setupAIAssistantActions(editor); + return fixture.componentInstance; + } + const runAction = (editor: FakeEditor, id: string) => + editor.actions.find(action => action.id === id)!.run(asEditor(editor)); + + it("registers the per-selection action ahead of the bulk action when the gate reports OpenAI", () => { + const editor = makeFakeEditor(); + registerOn(editor); + expect(editor.actions.map(action => action.id)).toEqual(["type-annotation-action", "all-type-annotation-action"]); + }); + + it("registers only the bulk action when the gate reports a different provider", () => { + aiEnabled$.next("NoAiAssistant"); + const editor = makeFakeEditor(); + registerOn(editor); + expect(editor.actions.map(action => action.id)).toEqual(["all-type-annotation-action"]); + }); + + it("the per-selection action forwards the selected text, its start line, and the whole file", () => { + const editor = makeFakeEditor(); + editor.getSelection.mockReturnValue(new monaco.Selection(3, 2, 4, 9)); + registerOn(editor); + + runAction(editor, "type-annotation-action"); + + // Three distinct arguments — selected text, start line, whole document — + // so transposing any pair changes the call. + expect(getTypeAnnotationsSpy).toHaveBeenCalledWith("a, b", 3, "def f(a, b):\n return a\n"); + }); + + it("the bulk action forwards the selected text and its start line", () => { + const editor = makeFakeEditor(); + editor.getSelection.mockReturnValue(new monaco.Selection(3, 2, 4, 9)); + registerOn(editor); + + runAction(editor, "all-type-annotation-action"); + + expect(locateUnannotatedSpy).toHaveBeenCalledWith("a, b", 3); + }); + + it("both actions bail out when the editor has no model", () => { + const editor = makeFakeEditor(); + editor.getModel.mockReturnValue(null); + registerOn(editor); + + runAction(editor, "type-annotation-action"); + runAction(editor, "all-type-annotation-action"); + + expect(getTypeAnnotationsSpy).not.toHaveBeenCalled(); + expect(locateUnannotatedSpy).not.toHaveBeenCalled(); + }); + + it("both actions bail out when there is no selection", () => { + const editor = makeFakeEditor(); + editor.getSelection.mockReturnValue(null); + registerOn(editor); + + runAction(editor, "type-annotation-action"); + runAction(editor, "all-type-annotation-action"); + + expect(getTypeAnnotationsSpy).not.toHaveBeenCalled(); + expect(locateUnannotatedSpy).not.toHaveBeenCalled(); + }); + + it("the bulk action leaves the multi-variable state untouched when nothing is unannotated", () => { + const editor = makeFakeEditor(); + const component = registerOn(editor); + + runAction(editor, "all-type-annotation-action"); + + expect(locateUnannotatedSpy).toHaveBeenCalledOnce(); + expect((component as any).isMultipleVariables).toBe(false); + expect((component as any).userResponseSubject).toBeUndefined(); + expect(editor.createDecorationsCollection).not.toHaveBeenCalled(); + }); + }); + + // "Add All Type Annotations" walks the unannotated arguments one at a time, + // pausing on each until the user accepts or declines. Every variable inserted + // on a line shifts the columns of the ones after it on that same line, which + // the walker compensates for with a running offset. + describe("bulk annotation walk", () => { + // Two arguments share line 3 (so the second one is offset by the length of + // the accepted suggestion) and a third sits on line 7 (so the offset + // resets). Every column is distinct, so a mistaken offset is visible. + const UNANNOTATED: ReadonlyArray = [ + { name: "alpha", startLine: 3, startColumn: 5, endLine: 3, endColumn: 6 }, + { name: "beta", startLine: 3, startColumn: 10, endLine: 3, endColumn: 11 }, + { name: "gamma", startLine: 7, startColumn: 2, endLine: 7, endColumn: 3 }, + ]; + const rangeTuple = (range: monaco.Range | undefined) => [ + range!.startLineNumber, + range!.startColumn, + range!.endLineNumber, + range!.endColumn, + ]; + + // `acceptCurrentAnnotation` emits the advance and only then clears + // `showAnnotationSuggestion`, so it relies on the annotation lookup being + // asynchronous: the next argument's suggestion has to land after that + // clear, not before it. The real service answers over HTTP, so the stub + // resolves off a promise rather than emitting inline — a synchronous + // `of(...)` here would stall the walk after the first argument. + const respondAsync = () => + defer(() => Promise.resolve({ choices: [{ message: { content: ": int" } }] } as TypeAnnotationResponse)); + const settle = () => new Promise(resolve => setTimeout(resolve, 0)); + + async function startWalk() { + locateUnannotatedSpy.mockReturnValue(of(UNANNOTATED)); + getTypeAnnotationsSpy.mockImplementation(respondAsync); + const fixture = makeFixture(mockJavaUDFPredicate); + const component = fixture.componentInstance; + const editor = makeFakeEditor(); + const insert = vi.fn(); + // `acceptCurrentAnnotation` reaches through the EditorApp for the editor + // and through the YText for the insertion point; both are staged so that + // accepting exercises the real path rather than poking the private + // response subject directly. + const stageEditorApp = () => { + (component as any).editorApp = { + getEditor: () => editor, + dispose: vi.fn().mockResolvedValue(undefined), + }; + }; + stageEditorApp(); + (component as any).code = { insert }; + (component as any).setupAIAssistantActions(editor); + editor.actions.find(action => action.id === "all-type-annotation-action")!.run(asEditor(editor)); + await settle(); + const accept = async () => { + // ngAfterViewInit's own editor bring-up resolves on a later tick and + // reassigns `editorApp`, so it is re-staged immediately before each + // accept (synchronously, so nothing can interleave) to keep the + // insertion point coming from the stand-in editor. + stageEditorApp(); + component.acceptCurrentAnnotation(); + await settle(); + }; + return { component, editor, insert, accept }; + } + + it("offsets a same-line argument by the accepted suggestion and resets on a new line", async () => { + const { component, editor, accept } = await startWalk(); + + // First argument: untouched columns, and the walk is now in flight. + expect((component as any).isMultipleVariables).toBe(true); + expect(component.currentCode).toBe("alpha"); + expect(rangeTuple(component.currentRange)).toEqual([3, 5, 3, 6]); + + await accept(); + + // Second argument shares line 3, so both columns shift by the length of + // the ": int" suggestion accepted a moment ago. + expect(component.currentCode).toBe("beta"); + expect(rangeTuple(component.currentRange)).toEqual([3, 15, 3, 16]); + + await accept(); + + // Third argument is on line 7, so the running offset resets to zero. + expect(component.currentCode).toBe("gamma"); + expect(rangeTuple(component.currentRange)).toEqual([7, 2, 7, 3]); + + expect(editor.createDecorationsCollection).toHaveBeenCalledTimes(3); + }); + + it("tears the walk down once the last argument has been answered", async () => { + const { component, editor, accept } = await startWalk(); + + await accept(); + await accept(); + expect((component as any).isMultipleVariables).toBe(true); + + await accept(); + + expect((component as any).isMultipleVariables).toBe(false); + expect((component as any).userResponseSubject).toBeUndefined(); + // No fourth argument, so no fourth highlight. + expect(editor.createDecorationsCollection).toHaveBeenCalledTimes(3); + }); + + it("highlights each argument as it comes up and clears that highlight on answer", async () => { + const { editor, accept } = await startWalk(); + const highlightOf = (index: number) => + editor.createDecorationsCollection.mock.results[index].value as { clear: ReturnType }; + + const decoration = editor.createDecorationsCollection.mock.calls[0][0][0]; + expect(rangeTuple(decoration.range)).toEqual([3, 5, 3, 6]); + expect(decoration.options.className).toBe("annotation-highlight"); + expect(decoration.options.hoverMessage.value).toBe("Argument without Annotation"); + expect(highlightOf(0).clear).not.toHaveBeenCalled(); + + await accept(); + + expect(highlightOf(0).clear).toHaveBeenCalledOnce(); + expect(highlightOf(1).clear).not.toHaveBeenCalled(); + }); + + it("advances on decline without shifting the next same-line argument", async () => { + const { component } = await startWalk(); + expect(component.currentCode).toBe("alpha"); + + component.rejectCurrentAnnotation(); + await settle(); + + // Nothing was inserted, so the second argument on line 3 keeps its + // original columns — the offset only compensates for accepted text. + expect(component.currentCode).toBe("beta"); + expect(rangeTuple(component.currentRange)).toEqual([3, 10, 3, 11]); + }); + + it("inserts the accepted suggestion at the model offset for the range end", async () => { + const { insert, accept } = await startWalk(); + + await accept(); + + // 42 is the stand-in model's offset; the `?? 0` fallback would give 0. + expect(insert).toHaveBeenCalledWith(42, ": int"); + }); + }); + + describe("handleTypeAnnotation", () => { + it("stages the trimmed suggestion and pushes it into the rendered suggestion panel", () => { + getTypeAnnotationsSpy.mockReturnValue(of({ choices: [{ message: { content: "\n : int \n" } }] })); + const fixture = makeFixture(mockJavaUDFPredicate); + const component = fixture.componentInstance; + // The panel is behind an *ngIf, so it has to be rendered before the + // ViewChild that the component writes into resolves. + component.showAnnotationSuggestion = true; + fixture.detectChanges(); + expect(component.annotationSuggestion).toBeDefined(); + + (component as any).handleTypeAnnotation("a, b", new monaco.Range(2, 3, 2, 8), makeFakeEditor(), 2, "all code"); + + // 50 + 100 and 100 + 100 — top and left stay distinguishable. + expect(component.suggestionTop).toBe(150); + expect(component.suggestionLeft).toBe(200); + expect(component.showAnnotationSuggestion).toBe(true); + expect(component.annotationSuggestion.code).toBe("a, b"); + expect(component.annotationSuggestion.suggestion).toBe(": int"); + expect(component.annotationSuggestion.top).toBe(150); + expect(component.annotationSuggestion.left).toBe(200); + }); + + it("leaves the suggestion offsets alone when the range is scrolled out of view", () => { + const fixture = makeFixture(mockJavaUDFPredicate); + const component = fixture.componentInstance; + const editor = makeFakeEditor(); + editor.getScrolledVisiblePosition.mockReturnValue(null); + + (component as any).handleTypeAnnotation("a, b", new monaco.Range(2, 3, 2, 8), editor, 2, "all code"); + + expect(component.suggestionTop).toBe(0); + expect(component.suggestionLeft).toBe(0); + expect(component.showAnnotationSuggestion).toBe(true); + }); + + const INVALID_RESPONSES: ReadonlyArray = [ + ["carries no choices field", {}], + ["carries an empty choices array", { choices: [] }], + ["carries a choice with no message", { choices: [{}] }], + ["carries a message with empty content", { choices: [{ message: { content: "" } }] }], + ]; + INVALID_RESPONSES.forEach(([label, response]) => { + it(`raises and stages nothing when the AI response ${label}`, async () => { + getTypeAnnotationsSpy.mockReturnValue(of(response as TypeAnnotationResponse)); + const fixture = makeFixture(mockJavaUDFPredicate); + const component = fixture.componentInstance; + + // The throw happens inside an RxJS `next` handler, which RxJS reports + // out-of-band on a timer rather than rethrowing to the caller. Route + // that report into a local sink so the assertion can see it and it + // cannot surface as an unhandled error later in the run. + const reported: unknown[] = []; + const previousHandler = rxjsConfig.onUnhandledError; + rxjsConfig.onUnhandledError = error => reported.push(error); + try { + (component as any).handleTypeAnnotation("a, b", new monaco.Range(2, 3, 2, 8), makeFakeEditor(), 2, "all"); + await new Promise(resolve => setTimeout(resolve, 0)); + } finally { + rxjsConfig.onUnhandledError = previousHandler; + } + + expect(reported.some(error => String(error).includes("does not contain valid message content"))).toBe(true); + expect(component.showAnnotationSuggestion).toBe(false); + expect(component.currentSuggestion).toBe(""); + expect(component.currentRange).toBeUndefined(); + }); + }); + }); + + describe("container geometry", () => { + const containerOf = (fixture: ComponentFixture) => + (fixture.componentInstance as any).containerElement.nativeElement as HTMLElement; + + it("restores the geometry saved under this operator id", () => { + localStorage.setItem(mockJavaUDFPredicate.operatorID, "width: 640px; height: 480px;"); + + const container = containerOf(makeFixture(mockJavaUDFPredicate)); + + expect(container.style.width).toBe("640px"); + expect(container.style.height).toBe("480px"); + }); + + it("writes the geometry back under this operator id on destroy", () => { + const fixture = makeFixture(mockJavaUDFPredicate); + containerOf(fixture).style.cssText = "width: 111px; height: 222px;"; + + fixture.destroy(); + + const saved = localStorage.getItem(mockJavaUDFPredicate.operatorID) ?? ""; + expect(saved).toContain("width: 111px"); + expect(saved).toContain("height: 222px"); + }); + + it("clamps a container that overflows the viewport on both axes", () => { + const fixture = makeFixture(mockJavaUDFPredicate); + const component = fixture.componentInstance; + const container = containerOf(fixture); + container.getBoundingClientRect = () => stubRect(10, 20, 5000, 5000); + const layout = vi.fn(); + (component as any).editorApp = { getEditor: () => ({ layout }), dispose: vi.fn().mockResolvedValue(undefined) }; + + withViewport(800, 600, () => component.onWindowResize()); + + // Width comes off the LEFT edge and the viewport WIDTH, height off the + // TOP edge and the viewport HEIGHT; all four inputs differ, so swapping + // any pair changes the result. + expect(container.style.width).toBe("790px"); + expect(container.style.height).toBe("580px"); + expect(layout).toHaveBeenCalledOnce(); + }); + + it("leaves a container that already fits the viewport untouched", () => { + const fixture = makeFixture(mockJavaUDFPredicate); + const component = fixture.componentInstance; + const container = containerOf(fixture); + container.getBoundingClientRect = () => stubRect(10, 20, 300, 400); + const layout = vi.fn(); + (component as any).editorApp = { getEditor: () => ({ layout }), dispose: vi.fn().mockResolvedValue(undefined) }; + + withViewport(800, 600, () => component.onWindowResize()); + + expect(container.style.width).toBe(""); + expect(container.style.height).toBe(""); + // The relayout is unconditional even when no clamping was needed. + expect(layout).toHaveBeenCalledOnce(); + }); + + it("survives a resize before any editor has been mounted", () => { + const fixture = makeFixture(mockJavaUDFPredicate); + const container = containerOf(fixture); + container.getBoundingClientRect = () => stubRect(10, 20, 5000, 5000); + + expect(() => withViewport(800, 600, () => fixture.componentInstance.onWindowResize())).not.toThrow(); + expect(container.style.width).toBe("790px"); + }); + }); });