diff --git a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/Main.groovy b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/Main.groovy index ac2513bc2dc..026921c06f1 100644 --- a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/Main.groovy +++ b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/Main.groovy @@ -475,7 +475,7 @@ class Main { if (!OSUtils.IS_WINDOWS) { setSpecificHighlighter("/!", SyntaxHighlighter.build(jnanorc, "SH-REPL")) } - addFileHighlight('/nano', '/less', '/slurp', '/load', '/save', *GROOVY_POSIX_CMDS, '/cd') + addFileHighlight('/nano', '/less', '/slurp', '/load', '/save', '/img', *GROOVY_POSIX_CMDS, '/cd') addFileHighlight('/classloader', null, ['-a', '--add']) addExternalHighlighterRefresh(printer::refresh) addExternalHighlighterRefresh(scriptEngine::refresh) diff --git a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/jline/GroovyCommands.groovy b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/jline/GroovyCommands.groovy index 7030a7685ee..47cf60f1c3f 100644 --- a/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/jline/GroovyCommands.groovy +++ b/subprojects/groovy-groovysh/src/main/groovy/org/apache/groovy/groovysh/jline/GroovyCommands.groovy @@ -41,10 +41,25 @@ import org.jline.reader.impl.completer.AggregateCompleter import org.jline.reader.impl.completer.ArgumentCompleter import org.jline.reader.impl.completer.NullCompleter import org.jline.reader.impl.completer.StringsCompleter +import org.jline.terminal.Terminal +import org.jline.terminal.impl.TerminalGraphics +import org.jline.terminal.impl.TerminalGraphicsManager import org.jline.utils.AttributedString +import javax.imageio.ImageIO +import javax.swing.ImageIcon +import javax.swing.JComponent +import javax.swing.JFrame +import javax.swing.JLabel +import javax.swing.SwingUtilities +import javax.swing.WindowConstants +import java.awt.Color import java.awt.Desktop +import java.awt.Dimension +import java.awt.Graphics2D import java.awt.event.ActionListener +import java.awt.image.BufferedImage +import java.awt.image.RenderedImage import java.lang.reflect.Method import java.nio.charset.Charset import java.nio.charset.StandardCharsets @@ -70,6 +85,7 @@ class GroovyCommands extends JlineCommandRegistry implements CommandRegistry { '/console' : new Tuple4<>(this::console, this::defCompleter, this::defCmdDesc, ['launch Groovy console']), '/doc' : new Tuple4<>(this::doc, this::importsCompleter, this::defCmdDesc, ['display documentation']), '/grab' : new Tuple4<>(this::grab, this::grabCompleter, this::grabCmdDesc, ['add maven repository dependencies to classpath']), + '/img' : new Tuple4<>(this::img, this::imgCompleter, this::imgCmdDesc, ['display image inline (Sixel/Kitty/iTerm2 terminals)']), '/classloader' : new Tuple4<>(this::classLoader, this::classloaderCompleter, this::classLoaderCmdDesc, ['display/manage Groovy classLoader data']), '/imports' : new Tuple4<>(this::importsCommand, this::importsCompleter, this::nameDeleteCmdDesc, ['show/delete import statements']), '/vars' : new Tuple4<>(this::varsCommand, this::varsCompleter, this::nameDeleteCmdDesc, ['show/delete variable declarations']), @@ -115,6 +131,12 @@ class GroovyCommands extends JlineCommandRegistry implements CommandRegistry { commands.remove('/grab') } + // /img depends on java.desktop (BufferedImage, ImageIO, Swing fallback). + // It's "static transitive" in JLine's module descriptor, so check at runtime. + if (!ClassUtils.lookFor('javax.imageio.ImageIO')) { + commands.remove('/img') + } + def available = commands.collectEntries { name, tuple -> [name, new CommandMethods((Function)tuple.v1, tuple.v2)] } @@ -214,6 +236,214 @@ class GroovyCommands extends JlineCommandRegistry implements CommandRegistry { return null } + /** + * Displays an image inline using JLine's terminal-graphics support + * (Sixel, Kitty, iTerm2). Falls back to a summary line when the + * terminal doesn't speak any supported protocol; the {@code --gui} + * flag opens a Swing window instead. + * + * @param input parsed command input + * @return always {@code null} + */ + def img(CommandInput input) { + // No fixed arg-count cap: a fully-specified invocation + // /img --width 64 --height 32 --no-preserve-aspect-ratio --gui $img + // is already 7 tokens. The parse loop below validates every flag and + // treats the lone non-option token as the positional, which is the + // useful constraint. + if (maybePrintHelp(input, '/img')) return + try { + Integer width = null + Integer height = null + boolean preserveAspect = true + boolean gui = false + Object positional = null + String positionalLabel = null + for (int i = 0; i < input.args().length; i++) { + String a = input.args()[i] + if (a == null) { + // JLine puts null into args() when a $var reference resolves + // to null — usually because the variable isn't defined yet. + throw new IllegalArgumentException( + '/img: variable reference resolved to null ' + + '(undefined or not yet assigned) — define it first, e.g. ' + + "'panel = ScatterPlot.of(...).canvas().panel()'") + } + if (a == '-w' || a == '--width') { + width = Integer.parseInt(requireArgValue(input, ++i, a)) + } else if (a.startsWith('--width=')) { + width = Integer.parseInt(a.substring('--width='.length())) + } else if (a == '--height') { + height = Integer.parseInt(requireArgValue(input, ++i, a)) + } else if (a.startsWith('--height=')) { + height = Integer.parseInt(a.substring('--height='.length())) + } else if (a == '-p' || a == '--no-preserve-aspect-ratio') { + preserveAspect = false + } else if (a == '-g' || a == '--gui') { + gui = true + } else if (!a.startsWith('-')) { + // Use the resolved value from xargs — for "$myImage" this + // is the variable's value (e.g. a BufferedImage); for a + // plain string ("foo.png") it's the same string. + positional = input.xargs()[i] + positionalLabel = a + } + } + if (positional == null) { + throw new IllegalArgumentException('No image path, URL, or variable provided') + } + BufferedImage image = positional instanceof String + ? loadImage((String) positional) + : coerceToImage(positional, width, height) + if (image == null) { + throw new IllegalArgumentException("Not a recognised image: $positionalLabel") + } + // For raw-pixel inputs (file/URL/BufferedImage/RenderedImage), --width + // and --height are terminal-display dimensions (cells). For inputs + // that *generate* the image from those dims (createBufferedImage / + // toBufferedImage / JComponent paint), the values are already + // consumed as source pixels and must NOT also be passed to the + // terminal opts — otherwise e.g. "--width=600" gets reinterpreted + // as 600 character cells and the chart renders blank/clipped. + boolean dimsConsumedByGeneration = !(positional instanceof String + || positional instanceof BufferedImage + || positional instanceof RenderedImage) + Terminal terminal = input.terminal() + if (gui) { + showInSwing(image, positionalLabel) + } else if (TerminalGraphicsManager.isGraphicsSupported(terminal)) { + def opts = new TerminalGraphics.ImageOptions().preserveAspectRatio(preserveAspect) + if (!dimsConsumedByGeneration) { + if (width != null) opts.width(width) + if (height != null) opts.height(height) + } + TerminalGraphicsManager.displayImage(terminal, image, opts) + // Reset cursor to column 0 of the next line — Sixel/iTerm2/Kitty + // protocols typically leave the cursor at the right edge of + // the image, which would indent the next prompt. + terminal.writer().println() + terminal.writer().flush() + } else { + // Coerce to String — DefaultPrinter renders unknown Object + // (including GString) as a field table; we want a plain line. + String summary = "[image: ${image.width}x${image.height}, $positionalLabel] " + + "(this terminal doesn't support inline images; try Kitty/iTerm2/WezTerm, or use --gui)" + printer.println(summary) + } + } catch (Exception e) { + saveException(e) + } + return null + } + + private static String requireArgValue(CommandInput input, int idx, String flag) { + // Trailing-flag guard for `--width`/`--height` (and friends): without + // this, `/img --width $img` reads past the end of args() and surfaces + // an opaque ArrayIndexOutOfBoundsException via saveException. + if (idx >= input.args().length) { + throw new IllegalArgumentException("/img: missing value for $flag") + } + input.args()[idx] + } + + private BufferedImage loadImage(String pathOrUrl) { + if (pathOrUrl.startsWith('http://') || pathOrUrl.startsWith('https://')) { + return ImageIO.read(URI.create(pathOrUrl).toURL()) + } + Path path = workDir.get().resolve(pathOrUrl) + if (!Files.exists(path)) { + throw new IllegalArgumentException("File not found: $pathOrUrl") + } + ImageIO.read(path.toFile()) + } + + /** + * Converts an arbitrary value into a {@link BufferedImage} for /img. + * Supports: + *
The smoke we test here is "every factory compiles without throwing". + * Driving end-to-end completion (typing a partial line, asserting + * candidates) needs a real LineReader pumping the parser — this unit + * harness can't fake that reliably. Even so, this catches the most + * common breakage: a typo or missing import in a completer factory + * function that would NPE the first time a user hits TAB. + */ +class CompletionTest extends SystemTestSupport { + + @Test + void everyCommandsCompleterFactoryCompilesWithoutThrowing() { + // CommandRegistry.compileCompleters() invokes every per-command + // completer factory function. If any of them throws (typo in a + // class reference, missing JLine import, broken constructor + // chain), this test surfaces it. + def systemCompleter = groovy.compileCompleters() + assert systemCompleter != null + assert !systemCompleter.compiled + systemCompleter.compile() + assert systemCompleter.compiled + } +} diff --git a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/ConsoleCommandTest.groovy b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/ConsoleCommandTest.groovy new file mode 100644 index 00000000000..950834e4020 --- /dev/null +++ b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/ConsoleCommandTest.groovy @@ -0,0 +1,49 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.groovy.groovysh.commands + +import org.junit.jupiter.api.Test + +/** + * Tests for the {@code /console} command. The command launches a Swing + * Groovy console window — we can't (and shouldn't) drive that in CI, but + * the registration and help paths are deterministic. + */ +class ConsoleCommandTest extends SystemTestSupport { + + @Test + void consoleIsRegisteredWhenObjectBrowserOnClasspath() { + // /console is conditionally registered: present iff + // groovy.console.ui.ObjectBrowser is on the classpath. The test + // module pulls groovy-console in transitively, so it should be + // there. If anyone removes that dep without intent, this fails. + assert '/console' in groovy.commandNames() + } + + @Test + void consoleHelpFlagDoesNotLaunchTheConsole() { + // maybePrintHelp short-circuits before `new Console(...)`, so + // `/console --help` is the only way to exercise the command in a + // headless test without opening a frame. Asserts on output growth + // — the help machinery captures via the printer. + int before = printer.output.size() + system.execute('/console --help') + assert printer.output.size() > before + } +} diff --git a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/DocTest.groovy b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/DocTest.groovy new file mode 100644 index 00000000000..142cdd84734 --- /dev/null +++ b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/DocTest.groovy @@ -0,0 +1,53 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.groovy.groovysh.commands + +import org.junit.jupiter.api.Test + +import static groovy.test.GroovyAssert.shouldFail + +/** + * Tests for the {@code /doc} command. The command opens documentation in + * a browser via {@link java.awt.Desktop} when configured, but the early + * exit and error paths (no args, missing config, headless JVM) are + * deterministic and cheap to cover. + */ +class DocTest extends SystemTestSupport { + + @Test + void docWithNoArgsIsNoOp() { + // doc() returns early when xargs is empty; no exception, no output. + system.execute('/doc') + } + + @Test + void docForUnknownTargetSurfacesAClearError() { + // Without a CONSOLE_OPTIONS map, /doc throws IllegalStateException. + // The exact message varies by environment: + // - headless JVM: "Desktop is not supported!" + // - desktop dev box: "No documents configuration!" + // Don't pin to either; just lock in that the failure is targeted + // and not, e.g., an NPE walking through xargs. + def thrown = shouldFail(IllegalStateException) { + system.execute('/doc List') + } + assert thrown.message != null + assert !thrown.message.empty + } +} diff --git a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/GrabTest.groovy b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/GrabTest.groovy index 662b75a890c..49f62ee7a89 100644 --- a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/GrabTest.groovy +++ b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/GrabTest.groovy @@ -22,6 +22,8 @@ import groovy.junit6.plugin.ForkedJvm import org.junit.jupiter.api.Test import org.junit.jupiter.api.condition.EnabledIfSystemProperty +import static groovy.test.GroovyAssert.shouldFail + /** * Tests for the {@code /grab} command — Maven-coordinate dependency * resolution via Grape. The actual artifact-fetching test is forked and @@ -50,4 +52,37 @@ class GrabTest extends SystemTestSupport { assert cls != null assert cls.name == 'org.apache.commons.lang3.StringUtils' } + + @Test + void grabWithMalformedCoordsRaisesAClearError() { + // Coords must be group:module:version (3 colon-separated parts). + // An incomplete spec should fail fast with a targeted message + // rather than reaching the network and timing out. + def thrown = shouldFail(IllegalArgumentException) { + system.execute('/grab org.apache.commons:commons-lang3') + } + assert thrown.message.contains('Invalid command parameter') + assert thrown.message.contains('commons-lang3') + } + + @Test + void grabWithUnknownTwoArgFlagRaisesAClearError() { + // Two-arg form only accepts -v/--verbose. Anything else (here a + // bogus -x) is rejected before any network attempt. + def thrown = shouldFail(IllegalArgumentException) { + system.execute('/grab -x foo:bar:1.0') + } + assert thrown.message.contains('Unknown command parameters') + } + + @Test + void grabListEnumeratesCachedGrapes() { + // /grab --list calls Grape.instance.enumerateGrapes() which is + // local-only (no network); even with an empty cache it returns + // an empty map without throwing. Verifies the --list branch is + // reachable and produces output via the printer. + int before = printer.output.size() + system.execute('/grab --list') + assert printer.output.size() > before + } } diff --git a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/ImgTest.groovy b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/ImgTest.groovy new file mode 100644 index 00000000000..31bb6067ef9 --- /dev/null +++ b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/ImgTest.groovy @@ -0,0 +1,163 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.groovy.groovysh.commands + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir + +import javax.imageio.ImageIO +import javax.swing.JLabel +import java.awt.image.BufferedImage +import java.nio.file.Path + +import static groovy.test.GroovyAssert.shouldFail + +/** + * Smoke tests for the {@code /img} command. Asserts on the fallback path + * (dumb terminal doesn't speak Sixel/Kitty/iTerm2) — the actual ANSI-image + * emission isn't testable through a captured byte stream without a real + * graphics-protocol terminal. + */ +class ImgTest extends SystemTestSupport { + + @TempDir + Path tmp + + private Path writePng(String name, int w, int h) { + Path file = tmp.resolve(name) + BufferedImage image = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB) + ImageIO.write(image, 'png', file.toFile()) + file + } + + @Test + void unsupportedTerminalProducesSummaryLine() { + Path file = writePng('chart.png', 12, 8) + system.execute("/img ${forwardSlashes(file)}") + // Dumb terminal — none of Sixel/Kitty/iTerm2 — falls through to the + // summary-line branch in img(). Assert on dimensions and the file + // identifier; don't pin to exact wording. + def out = printer.output.join() + assert out.contains('12x8') + assert out.contains('chart.png') + } + + @Test + void missingFileSurfacesAClearError() { + // /img saves exceptions via saveException(); JLine's + // AbstractCommandRegistry.invoke rethrows them, so the user sees a + // clear error rather than a silent no-op. + def thrown = shouldFail(IllegalArgumentException) { + system.execute('/img no-such-file.png') + } + assert thrown.message.contains('File not found') + assert thrown.message.contains('no-such-file.png') + } + + @Test + void imgFromBufferedImageVariable() { + // /img $var resolves the engine variable to its value via xargs. + BufferedImage src = new BufferedImage(20, 10, BufferedImage.TYPE_INT_RGB) + engine.put('imgVar', src) + system.execute('/img $imgVar') + def out = printer.output.join() + // Dumb terminal — falls through to the summary, which echoes the + // BufferedImage's actual dimensions. + assert out.contains('20x10') + } + + @Test + void imgFromObjectWithCreateBufferedImage() { + // Duck-typed dispatch: anything with createBufferedImage(int,int) — + // mirrors JFreeChart's signature without us depending on JFreeChart. + engine.put('chart', new ChartLikeForTest()) + system.execute('/img $chart --width=64 --height=32') + def out = printer.output.join() + assert out.contains('64x32') + } + + @Test + void imgFromObjectWithToBufferedImage() { + // Sibling duck-type: anything with toBufferedImage(int,int) — + // mirrors Smile Figure's signature without us depending on Smile. + engine.put('figure', new FigureLikeForTest()) + system.execute('/img $figure --width=72 --height=48') + def out = printer.output.join() + assert out.contains('72x48') + } + + @Test + void imgFromJComponent() { + // JComponent path — laid out and painted into a BufferedImage at the + // requested size. + engine.put('label', new JLabel('hello')) + system.execute('/img $label --width=80 --height=24') + def out = printer.output.join() + assert out.contains('80x24') + } + + @Test + void imgFromUnsupportedTypeErrors() { + engine.put('thing', 42) + def thrown = shouldFail(IllegalArgumentException) { + system.execute('/img $thing') + } + assert thrown.message.contains("don't know how to render") + assert thrown.message.contains('Integer') + } + + @Test + void undefinedVariableProducesClearError() { + // $panel is not defined — JLine resolves it to null and passes null into + // input.args(). Should surface as a friendly message, not a raw NPE on + // startsWith(). + def thrown = shouldFail(IllegalArgumentException) { + system.execute('/img $panel') + } + assert thrown.message.contains('null') + assert !thrown.message.contains('startsWith') + } + + @Test + void trailingWidthFlagWithoutValueProducesClearError() { + // `/img --width` (no value, no positional) used to walk past the end + // of args() and surface as an opaque ArrayIndexOutOfBoundsException + // via saveException. Should now be a targeted IllegalArgumentException + // naming the missing flag. + def thrown = shouldFail(IllegalArgumentException) { + system.execute('/img --width') + } + assert thrown.message.contains('--width') + assert thrown.message.contains('missing value') + } + + /** A test stand-in for JFreeChart-shaped objects. */ + static class ChartLikeForTest { + BufferedImage createBufferedImage(int w, int h) { + new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB) + } + } + + /** A test stand-in for Smile-Figure-shaped objects. */ + static class FigureLikeForTest { + BufferedImage toBufferedImage(int w, int h) { + new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB) + } + } +} diff --git a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/RoundTripTest.groovy b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/RoundTripTest.groovy new file mode 100644 index 00000000000..c30354935b1 --- /dev/null +++ b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/commands/RoundTripTest.groovy @@ -0,0 +1,63 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.groovy.groovysh.commands + +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir + +import java.nio.file.Path + +/** + * End-to-end test that drives several commands in sequence — the kind of + * cross-command regression no single per-command test can catch. + * + *
Distinct from {@link SaveLoadTest}, which exercises {@code /save} → + * {@code engine.reset()} → {@code /load} programmatically: this test + * routes the reset through the {@code /reset} command itself, so the + * full registry-dispatch path is exercised end-to-end. + */ +class RoundTripTest extends SystemTestSupport { + + @TempDir + Path tmp + + @Test + void buildSaveResetLoadRestoresState() { + system.execute('class Probe {}') + system.execute('def doubler(n) { n * 2 }') + system.execute('import java.time.LocalDate') + + Path file = tmp.resolve('session.groovy') + system.execute("/save ${forwardSlashes(file)}") + + // Reset via the registered /reset command (not engine.reset()). + // This is the bit SaveLoadTest doesn't cover. + system.execute('/reset') + assert engine.types.isEmpty() + assert engine.methodNames.isEmpty() + assert engine.imports.isEmpty() + + system.execute("/load ${forwardSlashes(file)}") + + assert engine.types.containsKey('Probe') + assert engine.methodNames.contains('doubler') + assert engine.imports.values().any { it.contains('java.time.LocalDate') } + assert engine.execute('doubler(21)') == 42 + } +} diff --git a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/jline/GroovyEngineTest.groovy b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/jline/GroovyEngineTest.groovy index da853d70ed7..53106994104 100644 --- a/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/jline/GroovyEngineTest.groovy +++ b/subprojects/groovy-groovysh/src/test/groovy/org/apache/groovy/groovysh/jline/GroovyEngineTest.groovy @@ -70,4 +70,60 @@ class GroovyEngineTest { engine.execute('import java.awt.Point') assert engine.imports.values().any { it.contains('java.awt.Point') } } + + @Test + void resetClearsTrackedDefinitionsButLeavesBindingVarsForFreshExecutes() { + // /reset wipes types/methods/imports/snippet-tracked variables; + // it does NOT delete shared/binding variables (those are managed + // by the underlying ScriptEngine and survive). This is contract + // users rely on. + engine.execute('class C {}') + engine.execute('def m(x) { x * 3 }') + engine.put('survivor', 'still here') + + engine.reset() + + assert engine.types.isEmpty() + assert engine.methodNames.isEmpty() + assert engine.imports.isEmpty() + assert engine.hasVariable('survivor') + assert engine.execute('survivor') == 'still here' + } + + @Test + void redefiningATypeReplacesTheTrackedSnippet() { + // The user redefines a class (common in interactive use). The + // engine should keep only one snippet under that name and run + // the latest body — not stack two definitions and produce + // ambiguous behaviour. + engine.execute('class T { String greet() { "first" } }') + engine.execute('class T { String greet() { "second" } }') + assert engine.types.containsKey('T') + assert engine.execute('new T().greet()') == 'second' + } + + @Test + void removeMethodDropsItFromTracking() { + engine.execute('def disposable() { 1 }') + assert engine.methodNames.contains('disposable') + engine.removeMethod('disposable') + assert !engine.methodNames.contains('disposable') + } + + @Test + void removeTypeDropsItFromTracking() { + engine.execute('class Disposable {}') + assert engine.types.containsKey('Disposable') + engine.removeType('Disposable') + assert !engine.types.containsKey('Disposable') + } + + @Test + void closureBindingVariableSurvivesAcrossExecutes() { + // A closure stored in the binding can be invoked by name on a + // later execute — useful for "save a callback, use it later" + // patterns that show up in REPL workflows. + engine.execute('greet = { name -> "hi, $name" }') + assert engine.execute("greet('paul')") == 'hi, paul' + } }