diff --git a/buildSrc/src/main/groovy/GwtTools.groovy b/buildSrc/src/main/groovy/GwtTools.groovy index 51cd7d4fee5..64517239f87 100644 --- a/buildSrc/src/main/groovy/GwtTools.groovy +++ b/buildSrc/src/main/groovy/GwtTools.groovy @@ -1,6 +1,7 @@ import de.esoco.gwt.gradle.GwtLibPlugin import de.esoco.gwt.gradle.GwtPlugin import de.esoco.gwt.gradle.extension.GwtExtension +import de.esoco.gwt.gradle.task.GwtCheckTask import de.esoco.gwt.gradle.task.GwtCompileTask import groovy.transform.CompileStatic import org.gradle.api.Project @@ -36,6 +37,13 @@ class GwtTools { GwtCompileTask gwtc -> applyModuleSettings p, gwtc, module,description } + // This GWT plugin will fail if tests are run after compilation, instead + // we suppress running the test at all, and ensure that it doesn't check + // if it even can be run until after compile finishes. + p.tasks.withType(GwtCheckTask).configureEach {t -> + t.mustRunAfter(p.tasks.withType(GwtCompileTask)) + t.onlyIf { false } + } return ext } @@ -70,8 +78,6 @@ class GwtTools { gwtDev && gwtc.doFirst { gwtc.logger.quiet('Running in gwt dev mode; saving source to {}/dh/src', extras) } - - p.tasks.findByName('gwtCheck')?.enabled = false } static void applyDefaults(Project p, GwtExtension gwt, boolean compile = false) { @@ -110,31 +116,13 @@ class GwtTools { maxHeapSize = "1024m" minHeapSize = "512m" } - - gwt.dev.with { - /** The ip address of the code server. */ - bindAddress = "127.0.0.1" - /** The port where the code server will run. */ - port = 9876 - /** Specifies Java source level ("1.6", "1.7"). - sourceLevel = "1.8" - /** The level of logging detail (ERROR, WARN, INFO, TRACE, DEBUG, SPAM, ALL) */ - logLevel = "INFO" - /** Emit extra information allow chrome dev tools to display Java identifiers in many placesinstead of JavaScript functions. (NONE, ONLY_METHOD_NAME, ABBREVIATED, FULL) */ - methodNameDisplayMode = "NONE" - /** Where to write output files */ - war = warPath -// extraArgs = ["-firstArgument", "-secondArgument"] - } - - } } static void addGeneratedSources(Project project, GwtCompileTask gwtc) { if (project.configurations.getByName(JavaPlugin.ANNOTATION_PROCESSOR_CONFIGURATION_NAME).dependencies) { (gwtc.src as ConfigurableFileCollection).from( - (project.tasks.getByName(JavaPlugin.COMPILE_JAVA_TASK_NAME) as JavaCompile).options.annotationProcessorGeneratedSourcesDirectory + (project.tasks.getByName(JavaPlugin.COMPILE_JAVA_TASK_NAME) as JavaCompile).options.generatedSourceOutputDirectory ) } project.configurations.getByName(JavaPlugin.COMPILE_CLASSPATH_CONFIGURATION_NAME).allDependencies.withType(ProjectDependency)*.dependencyProject*.each { diff --git a/docker/registry/selenium/build.gradle b/docker/registry/selenium/build.gradle new file mode 100644 index 00000000000..a9bb1568cd2 --- /dev/null +++ b/docker/registry/selenium/build.gradle @@ -0,0 +1,3 @@ +plugins { + id 'io.deephaven.project.register' +} diff --git a/docker/registry/selenium/gradle.properties b/docker/registry/selenium/gradle.properties new file mode 100644 index 00000000000..168b04db3dc --- /dev/null +++ b/docker/registry/selenium/gradle.properties @@ -0,0 +1,3 @@ +io.deephaven.project.ProjectType=DOCKER_REGISTRY +deephaven.registry.imageName=selenium/standalone-firefox:4.16.1-20231219 +deephaven.registry.imageId=selenium/standalone-firefox@sha256:a405fe92b3ce5d7eb31a07e1f99be3d628fdc0e5bdc81febd8dc11786edef024 diff --git a/gradle/web-gwt-test.gradle b/gradle/web-gwt-test.gradle deleted file mode 100644 index cb941530adc..00000000000 --- a/gradle/web-gwt-test.gradle +++ /dev/null @@ -1,33 +0,0 @@ - -configurations { - testImplementation.extendsFrom junit -} - -// for now, all gwt testing will be manual, since we need to have full -// integration testing w/ running servers to be able to use selenium -// (and htmlunit has a bug where it cannot handle Promises). -// This currently defaults to true, and we'll change the default later, -// once we can hook up the rest of the integration testing framework. -boolean manualGwt = findProperty('gwtMode') != 'auto' -boolean testPort = findProperty('gwtTestPort') -String testServer = findProperty('dhTestServer') ?: 'ws://localhost:8123/socket' -String testDir = "$buildDir/testGwt" -task 'gwtTest', type: Test, { - Test t -> - t.inputs.files(sourceSets.test.output.files) -// t.classpath = configurations.testRuntime - t.systemProperties = [ 'gwt.args': "${manualGwt ? '-runStyle Manual:1' : ''} ${testPort ? /-port $testPort/ : ''} -war $testDir/war -setProperty dhTestServer=$testServer -ea -style PRETTY -generateJsInteropExports", - 'gwt.persistentunitcachedir': "$testDir/unitCache", - 'dhTestServer': testServer - ] - t.include '**/*TestSuite.class' - t.useJUnit() - t.maxHeapSize = '2G' - t.scanForTestClasses = false - // never mark task as uptodate when using manual mode - t.outputs.upToDateWhen { !manualGwt } -} - -test { - exclude '**/*TestGwt.class', '**/*TestSuite.class' -} diff --git a/web/client-api/client-api.gradle b/web/client-api/client-api.gradle index 7409b44b81a..deae2f32c9c 100644 --- a/web/client-api/client-api.gradle +++ b/web/client-api/client-api.gradle @@ -1,13 +1,22 @@ +import com.bmuschko.gradle.docker.tasks.container.DockerCreateContainer +import com.bmuschko.gradle.docker.tasks.container.DockerRemoveContainer +import com.bmuschko.gradle.docker.tasks.container.DockerStartContainer +import io.deephaven.tools.docker.WaitForHealthyContainer + plugins { id 'io.deephaven.project.register' + id 'io.deephaven.deephaven-in-docker' } +evaluationDependsOn(Docker.registryProject('selenium')) + apply from: "$rootDir/gradle/web-client.gradle" configurations { js dts typescriptDoclet + testImplementation.extendsFrom junit } dependencies { @@ -20,6 +29,8 @@ dependencies { implementation 'com.vertispan.nio:gwt-nio:1.0-alpha-1' js project(path: ':proto:raw-js-openapi', configuration: 'js') + + testImplementation 'org.seleniumhq.selenium:selenium-remote-driver:4.16.1' } Classpaths.inheritElemental(project, 'elemental2-core', 'implementation') Classpaths.inheritElemental(project, 'elemental2-promise', 'implementation') @@ -57,6 +68,101 @@ artifacts { } } -project.tasks.getByName('quick').dependsOn project.tasks.withType(de.esoco.gwt.gradle.task.GwtCompileTask) +def gwtUnitTest = tasks.register('gwtUnitTest', Test) { t -> + t.systemProperties = [ + 'gwt.args': ['-runStyle HtmlUnit', + '-ea', + '-style PRETTY', + "-war ${layout.buildDirectory.dir('unitTest-war').get().asFile.absolutePath}" + ].join(' '), + 'gwt.persistentunitcachedir': layout.buildDirectory.dir('unitTest-unitCache').get().asFile.absolutePath, + ] + t.include '**/ClientUnitTestSuite.class' + t.useJUnit() + t.scanForTestClasses = false +} + +// start a grpc-api server +String randomSuffix = UUID.randomUUID().toString(); +deephavenDocker { + envVars.set([ + 'START_OPTS':'-Xmx512m -DAuthHandlers=io.deephaven.auth.AnonymousAuthenticationHandler' + ]) + containerName.set "dh-server-for-js-${randomSuffix}" + networkName.set "js-test-network-${randomSuffix}" +} + +def seleniumContainerId = "selenium-${randomSuffix}" +def seleniumPort +if (!hasProperty('selenium.port')) { + seleniumPort = '4444' +} else { + seleniumPort = project.getProperty('selenium.port') +} + +def createSelenium = tasks.register('createSelenium', DockerCreateContainer) { t -> + t.dependsOn(Docker.registryTask(project, 'selenium'), deephavenDocker.startTask) + t.targetImageId('deephaven/selenium:local-build') + t.containerName.set(seleniumContainerId) + // Advised by the selenium documentation + t.hostConfig.shmSize.set(2L * 1024 * 1024 * 1024) -apply from: "$rootDir/gradle/web-gwt-test.gradle" + // Add our own healthcheck to confirm the container starts fully + t.healthCheck.cmd.set(['curl http://localhost:4444/wd/hub/status || exit 1']) + + // This provides a hostname that can be referenced from inside the docker container to access the host + // OS, and connect to the test server. + t.hostConfig.extraHosts.add('host.docker.internal:host-gateway') + t.hostConfig.portBindings.set(["$seleniumPort:4444"]) + t.hostConfig.network.set(deephavenDocker.networkName.get()) +} +def startSelenium = tasks.register('startSelenium', DockerStartContainer) {t -> + t.dependsOn(createSelenium) + t.containerId.set(seleniumContainerId) +} +def seleniumHealthy = project.tasks.register('seleniumHealthy', WaitForHealthyContainer) { task -> + task.dependsOn startSelenium + + task.awaitStatusTimeout.set 120 + task.checkInterval.set 100 + + task.containerId.set(seleniumContainerId) +} +def stopSelenium = project.tasks.register('stopSelenium', DockerRemoveContainer) { task -> + task.dependsOn startSelenium + task.targetContainerId seleniumContainerId + task.force.set true + task.removeVolumes.set true +} + +def gwtIntegrationTest = tasks.register('gwtIntegrationTest', Test) { t -> + t.dependsOn(deephavenDocker.portTask, seleniumHealthy) + t.finalizedBy(deephavenDocker.endTask, stopSelenium) + doFirst { + def webdriverUrl = "http://localhost:${seleniumPort}/" + t.systemProperty('gwt.args', ["-runStyle io.deephaven.web.junit.RunStyleRemoteWebDriver:${webdriverUrl}?firefox", + '-ea', + '-style PRETTY', + "-setProperty dh.server=http://${deephavenDocker.containerName.get()}:10000", + "-war ${layout.buildDirectory.dir('integrationTest-war').get().asFile.absolutePath}" + ].join(' ')) + t.classpath += tasks.getByName('gwtCompile').src + } + t.finalizedBy(deephavenDocker.endTask) + t.systemProperties = [ + 'gwt.persistentunitcachedir':layout.buildDirectory.dir('integrationTest-unitCache').get().asFile.absolutePath, + 'webdriver.test.host':'host.docker.internal', + ] + t.include '**/ClientIntegrationTestSuite.class' + t.useJUnit() + t.scanForTestClasses = false +} + +tasks.named('check').configure { + dependsOn(gwtUnitTest, gwtIntegrationTest) +} + +test { + // Configure jvm-only tests to not run any GWT-only tests + exclude '**/*TestGwt.class', '**/*TestSuite.class' +} diff --git a/web/client-api/src/main/java/io/deephaven/web/client/api/JsTable.java b/web/client-api/src/main/java/io/deephaven/web/client/api/JsTable.java index f7f78fad0ea..c7ef12a3943 100644 --- a/web/client-api/src/main/java/io/deephaven/web/client/api/JsTable.java +++ b/web/client-api/src/main/java/io/deephaven/web/client/api/JsTable.java @@ -45,6 +45,7 @@ import io.deephaven.web.client.api.batch.TableConfig; import io.deephaven.web.client.api.console.JsVariableType; import io.deephaven.web.client.api.filter.FilterCondition; +import io.deephaven.web.client.api.filter.FilterValue; import io.deephaven.web.client.api.input.JsInputTable; import io.deephaven.web.client.api.lifecycle.HasLifecycle; import io.deephaven.web.client.api.state.StateCache; @@ -71,6 +72,8 @@ import io.deephaven.web.shared.fu.JsProvider; import io.deephaven.web.shared.fu.JsRunnable; import io.deephaven.web.shared.fu.RemoverFn; +import javaemul.internal.annotations.DoNotAutobox; +import jsinterop.annotations.JsIgnore; import jsinterop.annotations.JsMethod; import jsinterop.annotations.JsNullable; import jsinterop.annotations.JsOptional; @@ -594,6 +597,11 @@ public JsArray applyFilter(FilterCondition[] filter) { @TsUnion @JsType(name = "?", namespace = JsPackage.GLOBAL, isNative = true) public interface CustomColumnArgUnionType { + @JsOverlay + static CustomColumnArgUnionType of(@DoNotAutobox Object value) { + return Js.cast(value); + } + @JsOverlay default boolean isString() { return (Object) this instanceof String; @@ -1762,8 +1770,8 @@ public void handleSnapshot(TableTicket handle, TableSnapshot snapshot) { viewportRows.size()); } - - protected void processSnapshot() { + @JsIgnore + public void processSnapshot() { try { if (debounce == null) { JsLog.debug("Skipping snapshot b/c debounce is null"); diff --git a/web/client-api/src/test/java/io/deephaven/web/ClientIntegrationTestSuite.java b/web/client-api/src/test/java/io/deephaven/web/ClientIntegrationTestSuite.java new file mode 100644 index 00000000000..c0d2811e93b --- /dev/null +++ b/web/client-api/src/test/java/io/deephaven/web/ClientIntegrationTestSuite.java @@ -0,0 +1,30 @@ +package io.deephaven.web; + +import com.google.gwt.junit.tools.GWTTestSuite; +import io.deephaven.web.client.api.NullValueTestGwt; +import io.deephaven.web.client.api.subscription.ConcurrentTableTestGwt; +import io.deephaven.web.client.api.TableManipulationTestGwt; +import io.deephaven.web.client.api.subscription.ViewportTestGwt; +import junit.framework.Test; +import junit.framework.TestSuite; + +public class ClientIntegrationTestSuite extends GWTTestSuite { + public static Test suite() { + TestSuite suite = new TestSuite("Deephaven JS API Integration Test Suite"); + + // This test doesn't actually talk to the server, but it requires the dh-internal library be available. + // Disabled for now, we don't have good toString on the FilterCondition/FilterValue types. + // suite.addTestSuite(FilterConditionTestGwt.class); + + // Actual integration tests + suite.addTestSuite(ViewportTestGwt.class); + suite.addTestSuite(TableManipulationTestGwt.class); + suite.addTestSuite(ConcurrentTableTestGwt.class); + suite.addTestSuite(NullValueTestGwt.class); + + // Unfinished: + // suite.addTestSuite(TotalsTableTestGwt.class); + + return suite; + } +} diff --git a/web/client-api/src/test/java/io/deephaven/web/ApiTestSuite.java b/web/client-api/src/test/java/io/deephaven/web/ClientUnitTestSuite.java similarity index 67% rename from web/client-api/src/test/java/io/deephaven/web/ApiTestSuite.java rename to web/client-api/src/test/java/io/deephaven/web/ClientUnitTestSuite.java index 84f65261967..b8824afe125 100644 --- a/web/client-api/src/test/java/io/deephaven/web/ApiTestSuite.java +++ b/web/client-api/src/test/java/io/deephaven/web/ClientUnitTestSuite.java @@ -4,17 +4,17 @@ package io.deephaven.web; import com.google.gwt.junit.tools.GWTTestSuite; -import io.deephaven.web.client.api.filter.FilterConditionTestGwt; import io.deephaven.web.client.api.i18n.JsDateTimeFormatTestGwt; import io.deephaven.web.client.api.i18n.JsNumberFormatTestGwt; import junit.framework.Test; import junit.framework.TestSuite; -public class ApiTestSuite extends GWTTestSuite { +/** + * Tests that require a browser environment to run, but do not require the server. + */ +public class ClientUnitTestSuite extends GWTTestSuite { public static Test suite() { - TestSuite suite = new TestSuite("Deephaven Web API Test Suite"); - suite.addTestSuite(FilterConditionTestGwt.class); - + TestSuite suite = new TestSuite("Deephaven JS API Unit Test Suite"); suite.addTestSuite(JsDateTimeFormatTestGwt.class); suite.addTestSuite(JsNumberFormatTestGwt.class); return suite; diff --git a/web/client-api/src/test/java/io/deephaven/web/DeephavenIntegrationTest.gwt.xml b/web/client-api/src/test/java/io/deephaven/web/DeephavenIntegrationTest.gwt.xml new file mode 100644 index 00000000000..fab73ebdec1 --- /dev/null +++ b/web/client-api/src/test/java/io/deephaven/web/DeephavenIntegrationTest.gwt.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/web/client-api/src/test/java/io/deephaven/web/DeephavenUnitTest.gwt.xml b/web/client-api/src/test/java/io/deephaven/web/DeephavenUnitTest.gwt.xml new file mode 100644 index 00000000000..20dec64fd92 --- /dev/null +++ b/web/client-api/src/test/java/io/deephaven/web/DeephavenUnitTest.gwt.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/web/client-api/src/test/java/io/deephaven/web/client/api/AbstractAsyncGwtTestCase.java b/web/client-api/src/test/java/io/deephaven/web/client/api/AbstractAsyncGwtTestCase.java new file mode 100644 index 00000000000..e074863b57c --- /dev/null +++ b/web/client-api/src/test/java/io/deephaven/web/client/api/AbstractAsyncGwtTestCase.java @@ -0,0 +1,388 @@ +package io.deephaven.web.client.api; + +import com.google.gwt.core.client.JavaScriptException; +import com.google.gwt.junit.client.GWTTestCase; +import elemental2.core.JsArray; +import elemental2.core.JsError; +import elemental2.core.JsString; +import elemental2.dom.CustomEvent; +import elemental2.dom.DomGlobal; +import elemental2.promise.IThenable; +import elemental2.promise.Promise; +import io.deephaven.web.client.api.subscription.ViewportData; +import io.deephaven.web.client.fu.CancellablePromise; +import io.deephaven.web.client.ide.IdeSession; +import io.deephaven.web.shared.fu.JsRunnable; +import io.deephaven.web.shared.fu.RemoverFn; +import jsinterop.annotations.JsMethod; +import jsinterop.annotations.JsPackage; +import jsinterop.annotations.JsProperty; +import jsinterop.base.Js; +import jsinterop.base.JsPropertyMap; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.function.BooleanSupplier; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Predicate; + +import static elemental2.dom.DomGlobal.console; + +public abstract class AbstractAsyncGwtTestCase extends GWTTestCase { + @JsMethod(namespace = JsPackage.GLOBAL) + private static native Object eval(String code); + + private static Promise> importScript(String moduleName) { + return (Promise>) eval("import('" + moduleName + "')"); + } + + private static Promise importDhInternal() { + return importScript(localServer + "/jsapi/dh-internal.js") + .then(module -> { + Js.asPropertyMap(DomGlobal.window).set("dhinternal", module.get("dhinternal")); + return Promise.resolve((Void) null); + }); + } + + public static final String localServer = System.getProperty("dh.server", "http://localhost:10000"); + + public static class TableSourceBuilder { + private final List pythonScripts = new ArrayList<>(); + + public TableSourceBuilder script(String script) { + pythonScripts.add(script); + return this; + } + + public TableSourceBuilder script(String tableName, String python) { + pythonScripts.add(tableName + "=" + python); + + return this; + } + } + + /** + * Set this to a value higher than 1 to get more time to run debugger without timeouts failing. + */ + protected static final int TIMEOUT_SCALE = Integer.parseInt(System.getProperty("test.timeout.scale", "1")); + public static final double DELTA = 0.0001; + + public JsString toJsString(String k) { + return Js.cast(k); + } + + public JsArray toJsString(String... k) { + return Js.cast(k); + } + + @JsProperty(name = "log", namespace = "console") + private static native elemental2.core.Function getLog(); + + static IThenable.ThenOnFulfilledCallbackFn logOnSuccess(Object... rest) { + return value -> { + // GWT will puke if we have varargs being sent to varargs; + // so we go JS on it and just grab the function to apply + getLog().apply(null, rest); + return Promise.resolve(value); + }; + } + + static Promise log(Object... rest) { + getLog().apply(null, rest); + return Promise.resolve(rest); + } + + JsRunnable assertEventNotCalled( + HasEventHandling handling, + String... events) { + RemoverFn[] undos = new RemoverFn[events.length]; + for (int i = 0; i < events.length; i++) { + final String ev = events[i]; + undos[i] = handling.addEventListener(ev, e -> { + log("Did not expect", ev, "but fired event", e); + report("Expected " + ev + " to not be called; detail: " + (e.detail)); + }); + } + return () -> { + for (RemoverFn undo : undos) { + undo.remove(); + } + + }; + } + + static IThenable.ThenOnFulfilledCallbackFn run(JsRunnable allow) { + return t -> { + allow.run(); + return Promise.resolve(t); + }; + } + + static Promise expectFailure(Promise state, T value) { + return state.then(val -> Promise.reject("Failed"), + error -> Promise.resolve(value)); + } + + /** + * Imports the webpack content, including protobuf types. Does not connect to the server. + */ + protected Promise setupDhInternal() { + delayTestFinish(504); + return importDhInternal(); + } + + /** + * Connects and authenticates to the configured server and runs the specified scripts. + */ + protected Promise connect(TableSourceBuilder tables) { + // start by delaying test finish by 1.0s so we fail fast in cases where we aren't set up right + delayTestFinish(1007); + return importDhInternal().then(module -> { + CoreClient coreClient = new CoreClient(localServer, null); + return coreClient.login(JsPropertyMap.of("type", CoreClient.LOGIN_TYPE_ANONYMOUS)) + .then(ignore -> coreClient.getAsIdeConnection()) + .then(ide -> { + delayTestFinish(501); + return ide.getConsoleTypes().then(consoleTypes -> { + delayTestFinish(502); + CancellablePromise ideSession = ide.startSession(consoleTypes.getAt(0)); + return ideSession.then(session -> { + + if (consoleTypes.includes("python")) { + return runAllScriptsInOrder(ideSession, session, tables.pythonScripts); + } + throw new IllegalStateException("Unsupported script type " + consoleTypes); + }); + }); + }); + }); + } + + private Promise runAllScriptsInOrder(CancellablePromise ideSession, IdeSession session, + List code) { + Promise result = ideSession; + for (int i = 0; i < code.size(); i++) { + final int index = i; + result = result.then(ignore -> { + delayTestFinish(4000 + index); + + return session.runCode(code.get(index)); + }).then(r -> { + if (r.getError() != null) { + return Promise.reject(r.getError()); + } + return ideSession; + }); + } + return result; + } + + public IThenable.ThenOnFulfilledCallbackFn table(String tableName) { + return session -> session.getTable(tableName, null); + } + + /** + * Utility method to report Promise errors to the unit test framework + */ + protected Promise report(Object error) { + if (error instanceof String) { + reportUncaughtException(new RuntimeException((String) error)); + } else if (error instanceof Throwable) { + reportUncaughtException((Throwable) error); + } + if (error instanceof JsError) { + reportUncaughtException(new JavaScriptException(error)); + } else { + reportUncaughtException(new RuntimeException(error.toString())); + } + // keep failing down the chain in case someone else cares + return Promise.reject(error); + } + + protected Promise finish(Object input) { + finishTest(); + return Promise.resolve(input); + } + + /** + * Helper method to add a listener to the promise of a table, and ensure that an update is received with the + * expected number of items, within the specified timeout. + * + * Prereq: have already requested a viewport on that table + */ + protected Promise assertUpdateReceived(Promise tablePromise, int count, int timeoutInMillis) { + return tablePromise.then(table -> assertUpdateReceived(table, count, timeoutInMillis)); + } + + /** + * Helper method to add a listener to a table, and ensure that an update is received with the expected number of + * items, within the specified timeout. + * + * Prereq: have already requested a viewport on that table. Remember to request that within the same event loop, so + * that there isn't a data race and the update gets missed. + */ + protected Promise assertUpdateReceived(JsTable table, int count, int timeoutInMillis) { + return assertUpdateReceived(table, viewportData -> assertEquals(count, viewportData.getRows().length), + timeoutInMillis); + } + + protected Promise assertUpdateReceived(JsTable table, Consumer check, int timeoutInMillis) { + return Promise.race(this.waitForEvent(table, JsTable.EVENT_UPDATED, e -> { + ViewportData viewportData = e.detail; + check.accept(viewportData); + }, timeoutInMillis), + table.nextEvent(JsTable.EVENT_REQUEST_FAILED, (double) timeoutInMillis).then(Promise::reject)) + .then(ignore -> Promise.resolve(table)); + } + + protected IThenable.ThenOnFulfilledCallbackFn delayFinish(int timeout) { + return table -> { + delayTestFinish(timeout); + return Promise.resolve(table); + }; + } + + protected IThenable.ThenOnFulfilledCallbackFn waitForTick(int timeout) { + return table -> waitForEvent(table, JsTable.EVENT_SIZECHANGED, ignored -> { + }, timeout); + } + + protected IThenable.ThenOnFulfilledCallbackFn waitForTickTwice(int timeout) { + return table -> { + // wait for two ticks... one from setting the viewport, and then another for whenever the table actually + // ticks. + // (if these happen out of order, they will still be very close) + return waitForEvent(table, JsTable.EVENT_SIZECHANGED, ignored -> { + }, timeout) + .then(t -> waitForEvent(table, JsTable.EVENT_SIZECHANGED, ignored -> { + }, timeout)); + }; + } + + protected Promise waitForEventWhere(V evented, String eventName, + Predicate> check, int timeout) { + // note that this roughly reimplements the 'kill timer' so this can be run in parallel with itself or other + // similar steps + return new Promise<>((resolve, reject) -> { + boolean[] complete = {false}; + console.log("adding " + eventName + " listener ", evented); + // apparent compiler bug, review in gwt 2.9 + RemoverFn unsub = Js.uncheckedCast(evented) + .addEventListener(eventName, e -> { + if (complete[0]) { + return;// already done, but timeout hasn't cleared us yet + } + console.log("event ", e, " observed ", eventName, " for ", evented); + try { + if (check.test(e)) { + complete[0] = true; + resolve.onInvoke(evented); + } + } catch (Throwable ex) { + reject.onInvoke(ex); + } + }); + DomGlobal.setTimeout(p0 -> { + unsub.remove(); + if (!complete[0]) { + reject.onInvoke("Failed to complete in " + timeout + "ms " + evented); + } + complete[0] = true; + // complete already handled + }, timeout * TIMEOUT_SCALE + 13); + + }); + } + + protected Promise waitForEvent(V evented, String eventName, + Consumer> check, int timeout) { + return this.waitForEventWhere(evented, eventName, e -> { + check.accept(e); + return true; + }, timeout); + } + + + protected static Promise promiseAllThen(T then, IThenable... promises) { + return Promise.all(promises).then(items -> Promise.resolve(then)); + } + + protected IThenable waitFor(BooleanSupplier predicate, int checkInterval, int timeout, T result) { + return new Promise<>((resolve, reject) -> { + schedule(predicate, checkInterval, () -> resolve.onInvoke(result), + () -> reject.onInvoke("timeout of " + timeout + " exceeded"), timeout); + }); + } + + protected IThenable.ThenOnFulfilledCallbackFn waitFor(int millis) { + return result -> new Promise<>((resolve, reject) -> { + DomGlobal.setTimeout(p -> resolve.onInvoke(result), millis); + }); + } + + protected IThenable.ThenOnFulfilledCallbackFn waitForEvent(T table, + String eventName, int millis) { + return result -> new Promise<>((resolve, reject) -> { + boolean[] success = {false}; + table.addEventListenerOneShot(eventName, e -> { + success[0] = true; + resolve.onInvoke(table); + }); + DomGlobal.setTimeout(p -> { + if (!success[0]) { + reject.onInvoke("Waited " + millis + "ms"); + } + }, millis); + }); + } + + private void schedule(BooleanSupplier predicate, int checkInterval, Runnable complete, Runnable fail, int timeout) { + if (timeout <= 0) { + fail.run(); + } + if (predicate.getAsBoolean()) { + complete.run(); + } + DomGlobal.setTimeout(ignore -> { + if (predicate.getAsBoolean()) { + complete.run(); + } else { + schedule(predicate, checkInterval, complete, fail, (timeout * TIMEOUT_SCALE) - checkInterval); + } + }, checkInterval); + } + + protected Promise assertNextViewportIs(JsTable table, Function column, + String[] expected) { + return assertUpdateReceived(table, viewportData -> { + String[] actual = Js.uncheckedCast(getColumnData(viewportData, column.apply(table))); + assertTrue("Expected " + Arrays.toString(expected) + ", found " + Arrays.toString(actual) + " in table " + + table + " at state " + table.state(), Arrays.equals(expected, actual)); + }, 2000); + } + + protected Object getColumnData(ViewportData viewportData, Column a) { + return viewportData.getRows().map((r, index, all) -> r.get(a)); + } + + protected Promise assertNextViewportIs(JsTable table, double... expected) { + return assertUpdateReceived(table, viewportData -> { + double[] actual = Js.uncheckedCast(getColumnData(viewportData, table.findColumn("I"))); + assertTrue("Expected " + Arrays.toString(expected) + ", found " + Arrays.toString(actual) + " in table " + + table, Arrays.equals(expected, actual)); + }, 2000); + } + + public static List filterColumns(JsTable table, JsPredicate filter) { + List matches = new ArrayList<>(); + table.getColumns().forEach((c, i, arr) -> { + if (filter.test(c)) { + matches.add(c); + } + return null; + }); + return matches; + } +} diff --git a/web/client-api/src/test/java/io/deephaven/web/client/api/JsPredicate.java b/web/client-api/src/test/java/io/deephaven/web/client/api/JsPredicate.java new file mode 100644 index 00000000000..927bec52f09 --- /dev/null +++ b/web/client-api/src/test/java/io/deephaven/web/client/api/JsPredicate.java @@ -0,0 +1,11 @@ +package io.deephaven.web.client.api; + +import jsinterop.annotations.JsFunction; + +@JsFunction +@FunctionalInterface +public interface JsPredicate { + + @SuppressWarnings("unusable-by-js") + boolean test(I input); +} diff --git a/web/client-api/src/test/java/io/deephaven/web/client/api/NullValueTestGwt.java b/web/client-api/src/test/java/io/deephaven/web/client/api/NullValueTestGwt.java new file mode 100644 index 00000000000..6c2e8da138a --- /dev/null +++ b/web/client-api/src/test/java/io/deephaven/web/client/api/NullValueTestGwt.java @@ -0,0 +1,94 @@ +package io.deephaven.web.client.api; + +import com.google.gwt.junit.DoNotRunWith; +import com.google.gwt.junit.Platform; +import elemental2.core.JsArray; +import elemental2.promise.Promise; +import io.deephaven.web.client.api.subscription.ViewportRow; + +import java.math.BigDecimal; +import java.math.BigInteger; + +@DoNotRunWith(Platform.HtmlUnitBug) +public class NullValueTestGwt extends AbstractAsyncGwtTestCase { + private final TableSourceBuilder tables = new TableSourceBuilder() + .script("from deephaven import empty_table") + .script("nulltable", "empty_table(2).update([\n" + + " \"MyInt=i==0?null:i\",\n" + + " \"MyLong=i==0?null:(long)i\",\n" + + " \"MyDouble=i==0?null:(double)i\",\n" + + " \"MyShort=i==0?null:(short)i\",\n" + + " \"MyFloat=i==0?null:(float)i\",\n" + + " \"MyChar=i==0?null:(char)i\",\n" + + " \"MyByte=i==0?null:(byte)i\",\n" + + " \"MyBoolean=i==0?null:true\",\n" + + " \"MyString=i==0?null:``+i\",\n" + + " \"MyDate=i==0?null:epochNanosToInstant(i)\",\n" + + " \"MyBigInteger=i==0?null:java.math.BigInteger.valueOf(i)\",\n" + + " \"MyBigDecimal=i==0?null:java.math.BigDecimal.valueOf(i, 4)\"\n" + + "])"); + + public void testNullTable() { + connect(tables) + .then(table("nulltable")) + .then(table -> { + delayTestFinish(5000); + + assertEquals(2., table.getSize(), 0); + assertEquals(2., table.getTotalSize(), 0); + + return Promise.resolve(table); + }) + .then(table -> { + assertEquals("int", table.findColumn("MyInt").getType()); + assertEquals("long", table.findColumn("MyLong").getType()); + assertEquals("double", table.findColumn("MyDouble").getType()); + assertEquals("short", table.findColumn("MyShort").getType()); + assertEquals("float", table.findColumn("MyFloat").getType()); + assertEquals("char", table.findColumn("MyChar").getType()); + assertEquals("byte", table.findColumn("MyByte").getType()); + assertEquals("java.lang.Boolean", table.findColumn("MyBoolean").getType()); + assertEquals("java.lang.String", table.findColumn("MyString").getType()); + assertEquals("java.time.Instant", table.findColumn("MyDate").getType()); + assertEquals("java.math.BigInteger", table.findColumn("MyBigInteger").getType()); + assertEquals("java.math.BigDecimal", table.findColumn("MyBigDecimal").getType()); + + return Promise.resolve(table); + }) + .then(table -> { + table.setViewport(0, 1, null); + return assertUpdateReceived(table, viewport -> { + JsArray rows = viewport.getRows(); + ViewportRow nullRow = rows.getAt(0); + + JsArray columns = table.getColumns(); + for (int i = 0; i < columns.length; i++) { + assertEquals(null, nullRow.get(columns.getAt(i))); + } + + ViewportRow valueRow = rows.getAt(1); + assertEquals(1, valueRow.get(table.findColumn("MyInt")).asInt()); + assertEquals((long) 1, + valueRow.get(table.findColumn("MyLong")).cast().getWrapped()); + assertEquals((double) 1, valueRow.get(table.findColumn("MyDouble")).asDouble()); + assertEquals((short) 1, valueRow.get(table.findColumn("MyShort")).asShort()); + assertEquals((float) 1., valueRow.get(table.findColumn("MyFloat")).asFloat()); + assertEquals((char) 1, valueRow.get(table.findColumn("MyChar")).asChar()); + assertEquals((byte) 1, valueRow.get(table.findColumn("MyByte")).asByte()); + assertEquals(true, valueRow.get(table.findColumn("MyBoolean")).asBoolean()); + assertEquals("1", valueRow.get(table.findColumn("MyString")).asString()); + assertEquals(1, valueRow.get(table.findColumn("MyDate")).cast().getWrapped()); + assertEquals(BigInteger.ONE, + valueRow.get(table.findColumn("MyBigInteger")).cast().getWrapped()); + assertEquals(BigDecimal.valueOf(1, 4), + valueRow.get(table.findColumn("MyBigDecimal")).cast().getWrapped()); + }, 1000); + }) + .then(this::finish).catch_(this::report); + } + + @Override + public String getModuleName() { + return "io.deephaven.web.DeephavenIntegrationTest"; + } +} diff --git a/web/client-api/src/test/java/io/deephaven/web/client/api/TableManipulationTestGwt.java b/web/client-api/src/test/java/io/deephaven/web/client/api/TableManipulationTestGwt.java new file mode 100644 index 00000000000..48577053859 --- /dev/null +++ b/web/client-api/src/test/java/io/deephaven/web/client/api/TableManipulationTestGwt.java @@ -0,0 +1,605 @@ +package io.deephaven.web.client.api; + +import com.google.gwt.junit.DoNotRunWith; +import com.google.gwt.junit.Platform; +import elemental2.core.JsArray; +import elemental2.promise.IThenable; +import elemental2.promise.Promise; +import io.deephaven.web.client.api.filter.FilterCondition; +import io.deephaven.web.client.api.filter.FilterValue; +import jsinterop.base.Js; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +@DoNotRunWith(Platform.HtmlUnitBug) +public class TableManipulationTestGwt extends AbstractAsyncGwtTestCase { + private final TableSourceBuilder tables = new TableSourceBuilder() + .script("from deephaven import empty_table") + .script("truthtable", + "empty_table(8).update([\"I=i\", \"four=(int)(i/4)%2\", \"two=(int)(i/2)%2\", \"one=i%2\"])") + .script("strings", "empty_table(1).update([\"str=`abcdefg`\", \"nostr=(String)null\"])") + .script("threedays", + "empty_table(3).update([\"I=i\", \"Timestamp=now() - (i * 24 * 60 * 60 * 1000 * 1000000l)\"])"); + + public void testChangingFilters() { + connect(tables) + .then(table("truthtable")) + .then(table -> { + delayTestFinish(5000); + // filter one column + table.applyFilter( + new FilterCondition[] {table.findColumn("one").filter().eq(FilterValue.ofNumber(0.0))}); + + // before setting viewport, test the size of the table + return waitForEvent(table, JsTable.EVENT_SIZECHANGED, e -> { + assertEquals(4., table.getSize(), 0); + assertEquals(8., table.getTotalSize(), 0); + }, 2014); + }) + .then(table -> { + // then set the viewport, confirm we get those items back + table.setViewport(0, 7, null); + return assertNextViewportIs(table, 0, 2, 4, 6); + }) + .then(table -> { + // filter another too + table.applyFilter(new FilterCondition[] { + table.findColumn("one").filter().eq(FilterValue.ofNumber(0.0)), + table.findColumn("two").filter().eq(FilterValue.ofNumber(1.0)), + }); + table.setViewport(0, 7, null); + return assertNextViewportIs(table, 2, 6); + }) + .then(table -> { + // check full table size from the last filter + assertEquals(2., table.getSize(), 0); + + + // remove the first filter + table.applyFilter(new FilterCondition[] { + table.findColumn("two").filter().eq(FilterValue.ofNumber(1.0)), + }); + table.setViewport(0, 7, null); + return assertNextViewportIs(table, 2, 3, 6, 7); + }) + .then(table -> { + // check full table size from the last filter + assertEquals(4., table.getSize(), 0); + + // clear all filters + table.applyFilter(new FilterCondition[] {}); + table.setViewport(0, 7, null); + return assertNextViewportIs(table, 0, 1, 2, 3, 4, 5, 6, 7); + }) + .then(table -> { + // check full table size from the last filter + assertEquals(8., table.getSize(), 0); + assertEquals(8., table.getTotalSize(), 0); + + return Promise.resolve(table); + }) + + .then(this::finish).catch_(this::report); + } + + public void testStackingSorts() { + connect(tables) + .then(table("truthtable")) + .then(table -> { + delayTestFinish(5000); + // simple sort + table.applySort(new Sort[] {table.findColumn("one").sort().asc()}); + table.setViewport(0, 7, null); + return assertNextViewportIs(table, 0, 2, 4, 6, 1, 3, 5, 7); + }) + .then(table -> { + // check full table size from the last operation + assertEquals(8., table.getSize(), 0); + assertEquals(8., table.getTotalSize(), 0); + + // toggle it + table.applySort(new Sort[] {table.findColumn("one").sort().desc()}); + table.setViewport(0, 7, null); + return assertNextViewportIs(table, 1, 3, 5, 7, 0, 2, 4, 6); + }) + .then(table -> { + // check full table size from the last operation + assertEquals(8., table.getSize(), 0); + + // add another sort + table.applySort(new Sort[] { + table.findColumn("one").sort().desc(), + table.findColumn("two").sort().asc(), + }); + table.setViewport(0, 7, null); + return assertNextViewportIs(table, 1, 5, 3, 7, 0, 4, 2, 6); + }) + .then(table -> { + // toggle second sort + table.applySort(new Sort[] { + table.findColumn("one").sort().desc(), + table.findColumn("two").sort().desc(), + }); + table.setViewport(0, 7, null); + return assertNextViewportIs(table, 3, 7, 1, 5, 2, 6, 0, 4); + }) + .then(table -> { + // remove first sort + table.applySort(new Sort[] { + table.findColumn("two").sort().desc(), + }); + table.setViewport(0, 7, null); + return assertNextViewportIs(table, 2, 3, 6, 7, 0, 1, 4, 5); + }) + .then(table -> { + // clear all sorts + table.applySort(new Sort[] {}); + table.setViewport(0, 7, null); + return assertNextViewportIs(table, 0, 1, 2, 3, 4, 5, 6, 7); + }) + .then(this::finish).catch_(this::report); + } + + public void testSerialFilterAndSort() { + connect(tables) + .then(table("truthtable")) + .then(table -> { + delayTestFinish(5000); + // first, filter, as soon as that is resolved on the server, sort + table.applyFilter(new FilterCondition[] { + table.findColumn("two").filter().eq(FilterValue.ofNumber(0.0)) + }); + // no viewport, since we're going to make another change + return waitForEvent(table, JsTable.EVENT_FILTERCHANGED, e -> { + }, 2015); + }) + .then(table -> { + table.applySort(new Sort[] { + table.findColumn("one").sort().desc() + }); + // set a viewport once this is complete + table.setViewport(0, 6, null); + return waitForEvent(table, JsTable.EVENT_SORTCHANGED, e -> { + }, 1000); + }) + .then(table -> { + return assertNextViewportIs(table, 1, 5, 0, 4); + })// this looks confusing, remember it sorts "one" descending, and is a stable sort + .then(table -> { + // check full table size from the last filter + assertEquals(4., table.getSize(), 0); + + return Promise.resolve(table); + }) + .then(this::finish).catch_(this::report); + } + + + public void testRapidFilterAndSort() { + connect(tables) + .then(table("truthtable")) + .then(table -> { + delayTestFinish(5000); + // filter and sort, and set a viewport, wait for results + table.applyFilter(new FilterCondition[] { + table.findColumn("two").filter().eq(FilterValue.ofNumber(0.0)) + }); + table.applySort(new Sort[] { + table.findColumn("one").sort().desc() + }); + table.setViewport(0, 7, null); + return assertNextViewportIs(table, 1, 5, 0, 4); + }) + .then(table -> { + // replace sort then replace filter then set viewport, wait for results + table.applyFilter(new FilterCondition[] { + table.findColumn("one").filter().eq(FilterValue.ofNumber(0.0)) + }); + table.applySort(new Sort[] { + table.findColumn("two").sort().asc() + }); + table.setViewport(0, 7, null); + return assertNextViewportIs(table, 0, 4, 2, 6); + }) + .then(table -> { + // remove both then re-add both in reverse order, wait for results + table.applySort(new Sort[] { + table.findColumn("two").sort().asc() + }); + table.applyFilter(new FilterCondition[] { + table.findColumn("one").filter().eq(FilterValue.ofNumber(0.0)) + }); + table.setViewport(0, 7, null); + return assertNextViewportIs(table, 0, 4, 2, 6); + }) + .then(this::finish).catch_(this::report); + } + + public void testSwappingFilterAndSort() { + connect(tables) + .then(table("truthtable")) + .then(table -> { + delayTestFinish(5000); + // filter one way, set a sort, then change the filter back again before setting a viewport + table.applyFilter(new FilterCondition[] { + table.findColumn("one").filter().eq(FilterValue.ofNumber(1.0)) + }); + table.applySort(new Sort[] { + table.findColumn("two").sort().asc() + }); + table.applyFilter(new FilterCondition[] { + table.findColumn("one").filter().eq(FilterValue.ofNumber(0.0)) + }); + table.setViewport(0, 7, null); + return assertNextViewportIs(table, 0, 4, 2, 6); + }) + .then(this::finish).catch_(this::report); + } + + // TODO: https://deephaven.atlassian.net/browse/DH-11196 + public void ignore_testClearingFilter() { + connect(tables) + .then(table("truthtable")) + .then(table -> { + delayTestFinish(5000); + table.applyFilter(new FilterCondition[] { + table.findColumn("I").filter().lessThan(FilterValue.ofNumber(3.5)) + }); + table.setViewport(0, 7, null); + return assertNextViewportIs(table, 0, 1, 2, 3); + }) + .then(table -> { + table.applyFilter(new FilterCondition[0]); + table.setViewport(0, 7, null); + return assertNextViewportIs(table, 0, 1, 2, 3, 4, 5, 6, 7); + }) + .then(this::finish).catch_(this::report); + } + + public void testFilterOutAllItems() { + connect(tables) + .then(table("truthtable")) + .then(table -> { + delayTestFinish(2012); + table.applyFilter(new FilterCondition[] { + table.findColumn("one").filter().eq(FilterValue.ofNumber(100.0)) + }); + table.setViewport(0, 7, null); + return assertNextViewportIs(table); + }) + .then(table -> { + // check full table size from the last filter + assertEquals(0., table.getSize(), 0); + + return Promise.resolve(table); + }) + .then(this::finish).catch_(this::report); + } + + public void testReplaceSort() { + connect(tables) + .then(table("truthtable")) + .then(table -> { + delayTestFinish(5000); + // sort, get results, remove sort, get results + table.applySort(new Sort[] { + table.findColumn("I").sort().desc() + }); + table.setViewport(0, 7, null); + + return assertNextViewportIs(table, 7, 6, 5, 4, 3, 2, 1, 0); + }) + .then(table -> { + table.applySort(new Sort[] {}); + table.setViewport(0, 7, null); + + return assertNextViewportIs(table, 0, 1, 2, 3, 4, 5, 6, 7); + }) + // .then(table -> { + // //sort, set viewport, then right away switch sort + // table.applySort(new Sort[]{ + // table.findColumn("one").sort().desc() + // }); + // table.setViewport(0, 7, null); + // table.applySort(new Sort[]{ + // table.findColumn("one").sort().asc() + // }); + // table.setViewport(0, 7, null); + // + // return assertNextViewportIs(table, 0, 2, 4, 6, 1, 3, 5, 7); + // }) + .then(table -> { + // sort, set viewport, wait a moment, then do the same + table.applySort(new Sort[] { + table.findColumn("one").sort().desc() + }); + table.setViewport(0, 7, null); + return Promise.resolve(table); + }) + .then(table -> { + table.applySort(new Sort[] { + table.findColumn("one").sort().asc() + }); + table.setViewport(0, 7, null); + return assertNextViewportIs(table, 0, 2, 4, 6, 1, 3, 5, 7); + }) + .then(this::finish).catch_(this::report); + } + + public void testChangingColumns() { + connect(tables) + .then(table("truthtable")) + .then(table -> { + delayTestFinish(5000); + + table.applyCustomColumns(JsArray.of(JsTable.CustomColumnArgUnionType.of("a=\"\" + I"))); + table.setViewport(0, 7, null); + + return assertNextViewportIs(table, t -> t.findColumn("a"), + new String[] {"0", "1", "2", "3", "4", "5", "6", "7"}); + }) + .then(table -> { + + table.applyCustomColumns(JsArray.of( + JsTable.CustomColumnArgUnionType.of("a=\"\" + I"), + JsTable.CustomColumnArgUnionType.of("b=\"x\" + I"))); + table.setViewport(0, 7, null); + + @SuppressWarnings("unchecked") + Promise assertion = Promise.all(new IThenable[] { + assertNextViewportIs(table, t -> t.findColumn("a"), + new String[] {"0", "1", "2", "3", "4", "5", "6", "7"}), + assertNextViewportIs(table, t -> t.findColumn("b"), + new String[] {"x0", "x1", "x2", "x3", "x4", "x5", "x6", "x7"}) + }); + // we don't want the array of results, just the success/failure and the same table we were working + // with before + return assertion.then(ignore -> Promise.resolve(table)); + }) + .then(table -> { + + table.applyCustomColumns(JsArray.of(JsTable.CustomColumnArgUnionType.of("b=\"x\" + I"))); + table.setViewport(0, 7, null); + + return assertUpdateReceived(table, viewportData -> { + // make sure we see the one column, but not the other + String[] expected = new String[] {"x0", "x1", "x2", "x3", "x4", "x5", "x6", "x7"}; + String[] actual = Js.uncheckedCast(getColumnData(viewportData, table.findColumn("b"))); + assertTrue("Expected " + Arrays.toString(expected) + ", found " + Arrays.toString(actual) + + " in table " + table, Arrays.equals(expected, actual)); + for (int i = 0; i < table.getColumns().length; i++) { + assertFalse("a".equals(table.getColumns().getAt(i).getName())); + } + assertEquals(5, viewportData.getColumns().length); + }, 2001); + }) + .then(table -> { + + table.applyCustomColumns(new JsArray<>()); + table.setViewport(0, 7, null); + + return assertUpdateReceived(table, viewportData -> { + // verify the absence of "a" and "b" in data and table + for (int i = 0; i < table.getColumns().length; i++) { + Column col = table.getColumns().getAt(i); + assertFalse("a".equals(col.getName())); + assertFalse("b".equals(col.getName())); + } + assertEquals(4, viewportData.getColumns().length); + }, 2002); + }) + .then(this::finish).catch_(this::report); + } + + // TODO: https://deephaven.atlassian.net/browse/DH-11196 + public void ignore_testColumnsFiltersAndSorts() { + connect(tables) + .then(table("truthtable")) + .then(table -> { + delayTestFinish(10000); + // add a column + table.applyCustomColumns(JsArray.of(JsTable.CustomColumnArgUnionType.of("a=\"\" + I"))); + table.setViewport(0, 7, null); + + return assertNextViewportIs(table, t -> t.findColumn("a"), + new String[] {"0", "1", "2", "3", "4", "5", "6", "7"}); + }) + .then(table -> { + delayTestFinish(10000); + // apply a filter + table.applyFilter(new FilterCondition[] { + table.findColumn("two").filter().eq(FilterValue.ofNumber(0.0)) + }); + table.setViewport(0, 7, null); + + return assertNextViewportIs(table, t -> t.findColumn("a"), new String[] {"0", "1", "4", "5"}); + }) + .then(table -> { + delayTestFinish(10000); + // apply a sort + table.applySort(new Sort[] { + table.findColumn("one").sort().desc() + }); + table.setViewport(0, 7, null); + + return assertNextViewportIs(table, t -> t.findColumn("a"), new String[] {"1", "5", "0", "4"}); + }) + .then(table -> { + delayTestFinish(10000); + // remove the column + table.applyCustomColumns(new JsArray<>()); + table.setViewport(0, 7, null); + + @SuppressWarnings("unchecked") + Promise assertion = Promise.all(new IThenable[] { + assertUpdateReceived(table, viewportData -> { + // make sure we don't see the column + for (int i = 0; i < table.getColumns().length; i++) { + assertFalse("a".equals(table.getColumns().getAt(i).getName())); + } + assertEquals(4, viewportData.getColumns().length); + }, 2001), + assertNextViewportIs(table, 1, 5, 0, 4) + }); + return assertion.then(ignore -> Promise.resolve(table)); + }) + .then(table -> { + delayTestFinish(10000); + // put the column back + table.applyCustomColumns(JsArray.of(JsTable.CustomColumnArgUnionType.of("a=\"\" + I"))); + table.setViewport(0, 7, null); + + return assertNextViewportIs(table, t -> t.findColumn("a"), new String[] {"1", "5", "0", "4"}); + }) + .then(table -> { + delayTestFinish(10000); + // remove the filter (column and sort, no filter) + table.applyFilter(new FilterCondition[] {}); + table.setViewport(0, 7, null); + + return assertNextViewportIs(table, t -> t.findColumn("a"), + new String[] {"1", "3", "5", "7", "0", "2", "4", "6"}); + }) + .then(table -> { + delayTestFinish(10000); + // remove the column (sorted, no column) + table.applyCustomColumns(new JsArray<>()); + table.setViewport(0, 7, null); + + return assertNextViewportIs(table, 1, 3, 5, 7, 0, 2, 4, 6); + }) + .then(this::finish).catch_(this::report); + + } + + public void testCustomColumnsReferencingOtherCustomColumns() { + connect(tables) + .then(table("truthtable")) + .then(table -> { + delayTestFinish(2001); + table.applyCustomColumns(JsArray.of(JsTable.CustomColumnArgUnionType.of("y=`z`"), + JsTable.CustomColumnArgUnionType.of("z=y"))); + table.setViewport(0, 7, null); + + String[] expected = {"z", "z", "z", "z", "z", "z", "z", "z"}; + // noinspection unchecked + return Promise.all(new IThenable[] { + assertNextViewportIs(table, t -> t.findColumn("y"), expected), + assertNextViewportIs(table, t -> t.findColumn("z"), expected) + }); + }).then(this::finish).catch_(this::report); + } + + public void testDateTimeInFilters() { + List dates = new ArrayList<>(); + connect(tables) + .then(table("threedays")) + .then(table -> { + delayTestFinish(2002); + // grab the three days in the db + table.setViewport(0, 2, null); + + return assertUpdateReceived(table, viewportData -> { + viewportData.getRows().forEach((row, index, all) -> { + dates.add(row.get(table.findColumn("Timestamp")).cast()); + return null; + }); + }, 2003); + }) + .then(table -> { + // take the first date, filter it out, confirm we see the other two + table.applyFilter(new FilterCondition[] { + table.findColumn("Timestamp").filter() + .notIn(new FilterValue[] { + FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(dates.get(0)))}) + }); + table.setViewport(0, 2, null); + return assertUpdateReceived(table, viewportData -> { + assertEquals(2, viewportData.getRows().length); + // this looks shady with the toString(), but they are both LongWrapper values + assertEquals(dates.get(1).toString(), + viewportData.getRows().getAt(0).get(table.findColumn("Timestamp")).toString()); + assertEquals(dates.get(2).toString(), + viewportData.getRows().getAt(1).get(table.findColumn("Timestamp")).toString()); + }, 2004); + }) + .then(table -> { + // take the first date, filter for it, confirm we see only it + table.applyFilter(new FilterCondition[] { + table.findColumn("Timestamp").filter() + .in(new FilterValue[] { + FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(dates.get(0)))}) + }); + table.setViewport(0, 2, null); + return assertUpdateReceived(table, viewportData -> { + assertEquals(1, viewportData.getRows().length); + assertEquals(dates.get(0).toString(), + viewportData.getRows().getAt(0).get(table.findColumn("Timestamp")).toString()); + + }, 2005); + }) + .then(this::finish).catch_(this::report); + } + + public void testIcaseEqualFilters() { + connect(tables) + .then(table("strings")) + .then(table -> { + delayTestFinish(5000); + // ==icase with match + table.applyFilter(new FilterCondition[] { + table.findColumn("str").filter().eqIgnoreCase(FilterValue.ofString("ABCdefg")) + }); + table.setViewport(0, 0, null); + return assertUpdateReceived(table, 1, 2006); + }) + .then(table -> { + // ==icase with no match + table.applyFilter(new FilterCondition[] { + table.findColumn("str").filter().eqIgnoreCase(FilterValue.ofString("xyz")) + }); + table.setViewport(0, 0, null); + return assertUpdateReceived(table, 0, 2007); + }) + .then(table -> { + // !=icase with match (i.e. don't show anything) + table.applyFilter(new FilterCondition[] { + table.findColumn("str").filter().notEqIgnoreCase(FilterValue.ofString("ABCdefg")) + }); + table.setViewport(0, 0, null); + return assertUpdateReceived(table, 0, 2008); + }) + .then(table -> { + // !=icase with no match (i.e. show row) + table.applyFilter(new FilterCondition[] { + table.findColumn("nostr").filter().notEqIgnoreCase(FilterValue.ofString("ABC")) + }); + table.setViewport(0, 0, null); + return assertUpdateReceived(table, 1, 2009); + }) + .then(table -> { + // 0=icase with value check against null (i.e. find nothing) + table.applyFilter(new FilterCondition[] { + table.findColumn("nostr").filter().eqIgnoreCase(FilterValue.ofString("ABC")) + }); + table.setViewport(0, 0, null); + return assertUpdateReceived(table, 0, 2010); + }) + .then(table -> { + // !=icase with value check against null (i.e. find a row) + table.applyFilter(new FilterCondition[] { + table.findColumn("nostr").filter().notEqIgnoreCase(FilterValue.ofString("ABC")) + }); + table.setViewport(0, 0, null); + return assertUpdateReceived(table, 1, 2011); + }) + .then(this::finish).catch_(this::report); + } + + @Override + public String getModuleName() { + return "io.deephaven.web.DeephavenIntegrationTest"; + } +} diff --git a/web/client-api/src/test/java/io/deephaven/web/client/api/TotalsTableTestGwt.java b/web/client-api/src/test/java/io/deephaven/web/client/api/TotalsTableTestGwt.java new file mode 100644 index 00000000000..4c8000f0d51 --- /dev/null +++ b/web/client-api/src/test/java/io/deephaven/web/client/api/TotalsTableTestGwt.java @@ -0,0 +1,452 @@ +package io.deephaven.web.client.api; + +import elemental2.core.JsArray; +import elemental2.core.JsString; +import elemental2.dom.CustomEvent; +import elemental2.promise.IThenable; +import elemental2.promise.Promise; +import io.deephaven.web.client.api.filter.FilterCondition; +import io.deephaven.web.client.api.filter.FilterValue; +import io.deephaven.web.client.api.subscription.ViewportData; +import jsinterop.base.Js; + +import java.util.function.Consumer; + +public class TotalsTableTestGwt extends AbstractAsyncGwtTestCase { + private final TableSourceBuilder tables = new TableSourceBuilder() + .script("from deephaven import empty_table") + .script("strings", "empty_table(1).update([\"str=`abcdefg`\", \"nostr=(String)null\"])") + .script("hasTotals", + "empty_table(5).update_view([(\"I = (double)i\", \"J = (double) i * i\", \"K = (double) i % 2\")])" + + + ".with_attributes({'TotalsTable':'false,false,Count;J=Min:Avg,K=Skip,;'})"); + + public void testQueryDefinedConfigs() { + connect(tables) + .then(session -> { + session.getTable("strings", true) + .then(table1 -> { + // make sure the config is null, since it wasn't defined in the config + assertNull(table1.getTotalsTableConfig()); + + // check that we get a totals table back, even though it won't have any columns + // noinspection unchecked + return (Promise) Promise.all(new Promise[] { + table1.getTotalsTable(null).then(totals1 -> { + assertEquals(0, totals1.getColumns().length); + assertEquals(1, totals1.getSize(), DELTA); + return Promise.resolve(totals1); + }), + table1.getGrandTotalsTable(null).then(totals11 -> { + assertEquals(0, totals11.getColumns().length); + assertEquals(1, totals11.getSize(), DELTA); + return Promise.resolve(totals11); + }), + }); + }); + return Promise.resolve(session); + }) + .then(table("hasTotals")) + .then(table -> { + // make sure the config is null, since it wasn't defined in the config + assertNotNull(table.getTotalsTableConfig()); + assertEquals("Count", table.getTotalsTableConfig().defaultOperation); + assertTrue(table.getTotalsTableConfig().operationMap.has("K")); + assertEquals(1, table.getTotalsTableConfig().operationMap.get("K").length); + assertEquals(Js.cast("Skip"), table.getTotalsTableConfig().operationMap.get("K").getAt(0)); + + assertTrue(table.getTotalsTableConfig().operationMap.has("J")); + assertEquals(2, table.getTotalsTableConfig().operationMap.get("J").length); + assertEquals(Js.cast("Min"), table.getTotalsTableConfig().operationMap.get("J").getAt(0)); + assertEquals(Js.cast("Avg"), table.getTotalsTableConfig().operationMap.get("J").getAt(1)); + + assertFalse(table.getTotalsTableConfig().operationMap.has("I")); + + // check that we get a totals table back, even though it won't have any columns + // noinspection unchecked + return Promise.all((Promise[]) new Promise[] { + table.getTotalsTable(null) + .then(totals -> { + assertEquals(3, totals.getColumns().length); + assertEquals(1, totals.getSize(), DELTA); + totals.setViewport(0, 100, null, null); + + return waitForEvent(totals, JsTable.EVENT_UPDATED, + checkTotals(totals, 5, 6., 0, "a1"), 2508); + }), + table.getGrandTotalsTable(null) + .then(totals -> { + assertEquals(3, totals.getColumns().length); + assertEquals(1, totals.getSize(), DELTA); + totals.setViewport(0, 100, null, null); + + return waitForEvent(totals, JsTable.EVENT_UPDATED, + checkTotals(totals, 5, 6.0, 0., "a2"), 2509); + }) + }); + }) + .then(this::finish).catch_(this::report); + } + + // TODO: https://deephaven.atlassian.net/browse/DH-11196 + public void ignore_testTotalsOnFilteredTable() { + JsTotalsTable[] totalTables = {null, null}; + Promise[] totalPromises = {null, null}; + connect(tables) + .then(table("hasTotals")) + .then(table -> { + delayTestFinish(8000); + table.applyFilter(new FilterCondition[] { + table.findColumn("K").filter().eq(FilterValue.ofNumber(0.0)) + }); + table.setViewport(0, 100, null);// not strictly required, but part of the normal usage + + return waitForEvent(table, JsTable.EVENT_FILTERCHANGED, 2001).onInvoke(table); + }) + .then(table -> promiseAllThen(table, + table.getTotalsTable(null) + .then(totals -> { + totalTables[0] = totals; + assertEquals(3, totals.getColumns().length); + assertEquals(1, totals.getSize(), DELTA); + totals.setViewport(0, 100, null, null); + + // confirm the normal totals match the filtered data + return waitForEvent(totals, JsTable.EVENT_UPDATED, + checkTotals(totals, 3, 6.666666, 0.0, "a1"), 2501); + }), + table.getGrandTotalsTable(null) + .then(totals -> { + totalTables[1] = totals; + assertEquals(3, totals.getColumns().length); + assertEquals(1, totals.getSize(), DELTA); + totals.setViewport(0, 100, null, null); + + // confirm the grand totals are unchanged + return waitForEvent(totals, JsTable.EVENT_UPDATED, + checkTotals(totals, 5, 6., 0., "a2"), 2502); + }))) + .then(table -> { + // Now, change the filter on the original table, and expect the totals tables to automatically + // update. + + table.applyFilter(new FilterCondition[] { + table.findColumn("K").filter().eq(FilterValue.ofNumber(1.0)) + }); + table.setViewport(0, 100, null);// not strictly required, but part of the normal usage + + return promiseAllThen(table, + waitForEvent(table, JsTable.EVENT_FILTERCHANGED, 2002).onInvoke(table), + totalPromises[0] = waitForEvent(totalTables[0], JsTable.EVENT_UPDATED, + checkTotals(totalTables[0], 2, 5, 1, "b1"), 2503), + totalPromises[1] = waitForEvent(totalTables[1], JsTable.EVENT_UPDATED, + checkTotals(totalTables[1], 5, 6, 0, "b2"), 2504)); + }) + .then(table -> { + // forcibly disconnect the worker and test that the total table come back up, and respond to + // re-filtering. + table.getConnection().forceReconnect(); + return Promise.resolve(table); + }) + .then(table -> waitForEvent(table, JsTable.EVENT_RECONNECT, 5001).onInvoke(table)) + .then(table -> promiseAllThen(table, + waitForEvent(totalTables[0], JsTable.EVENT_UPDATED, + checkTotals(totalTables[0], 2, 5, 1, "c1"), 7505), + waitForEvent(totalTables[1], JsTable.EVENT_UPDATED, + checkTotals(totalTables[1], 5, 6, 0, "c2"), 7506))) + .then(table -> { + // Now... refilter the original table, and assert that the totals tables update. + table.applyFilter(new FilterCondition[] { + table.findColumn("K").filter().eq(FilterValue.ofNumber(0.0)) + }); + table.setViewport(0, 100, null);// not strictly required, but part of the normal usage + + return promiseAllThen(table, + waitForEvent(table, JsTable.EVENT_FILTERCHANGED, 2003).onInvoke(table), + waitForEvent(totalTables[0], JsTable.EVENT_UPDATED, + checkTotals(totalTables[0], 3, 6.666666, 0.0, "d1"), 2507), + waitForEvent(totalTables[1], JsTable.EVENT_UPDATED, + checkTotals(totalTables[1], 5, 6., 0., "d2"), 2508)); + }) + .then(this::finish).catch_(this::report); + } + + public void testClosingTotalsWhileClearingFilter() { + JsTotalsTable[] totalTables = {null}; + connect(tables) + .then(table("hasTotals")) + .then(table -> { + delayTestFinish(8000); + table.applyFilter(new FilterCondition[] { + table.findColumn("K").filter().eq(FilterValue.ofNumber(0.0)) + }); + table.setViewport(0, 100, null);// not strictly required, but part of the normal usage + + return waitForEvent(table, JsTable.EVENT_UPDATED, 2001).onInvoke(table); + }) + .then(table -> table.getTotalsTable(null) + .then(totals -> { + totalTables[0] = totals; + return Promise.resolve(table); + })) + .then(table -> { + // Now, clear the filter on the original table, and close the totals table at the same time + table.applyFilter(new FilterCondition[] {}); + // Close the table in the same step as applying the filter. Should not throw an error. + totalTables[0].close(); + table.setViewport(0, 100, null);// not strictly required, but part of the normal usage + + return waitForEvent(table, JsTable.EVENT_UPDATED, 2002).onInvoke(table); + }) + .then(this::finish).catch_(this::report); + } + + // TODO: https://deephaven.atlassian.net/browse/DH-11196 + public void ignore_testFilteringTotalsTable() { + JsTotalsTable[] totalTables = {null, null}; + Promise[] totalPromises = {null, null}; + connect(tables) + .then(table("hasTotals")) + .then(table -> { + delayTestFinish(8000); + /* + * Here is the base table: I J K 0 0 0 1 1 1 2 4 0 // we are going to remove this row, to test + * source table filtering. 3 9 1 4 16 0 + */ + table.applyFilter(new FilterCondition[] { + table.findColumn("J").filter().notEq(FilterValue.ofNumber(4.0)) + }); + table.setViewport(0, 100, null);// not strictly required, but part of the normal usage + + return waitForEvent(table, JsTable.EVENT_FILTERCHANGED, 2001).onInvoke(table); + }) + .then(table -> { + JsTotalsTableConfig config = new JsTotalsTableConfig(); + // group by K so we can do some filtering on the output of the tables + config.groupBy.push("K"); + // copy over the rest of the operations set on the server, so we can reuse some assertion logic + config.operationMap.set("J", + Js.uncheckedCast(new JsString[] {toJsString("Avg"), toJsString("Min")})); + // config.operationMap.set("K", Js.uncheckedCast(new JsString[]{ toJsString("Skip")})); + config.defaultOperation = "Count"; + return promiseAllThen(table, + table.getTotalsTable(config) + .then(totals -> { + totalTables[0] = totals; + assertEquals(4, totals.getColumns().length); + assertEquals(2, totals.getSize(), DELTA); + totals.setViewport(0, 100, null, null); + + // confirm the normal totals match the filtered data + return waitForEvent(totals, JsTable.EVENT_UPDATED, checkTotals(totals, "a1", + TotalsResults.of(2, 2, 8, 0.0), + TotalsResults.of(2, 2, 5, 1.0)), 2501); + }), + table.getGrandTotalsTable(config) + .then(totals -> { + totalTables[1] = totals; + assertEquals(4, totals.getColumns().length); + assertEquals(2, totals.getSize(), DELTA); + totals.setViewport(0, 100, null, null); + + // confirm the grand totals include the missing row... + return waitForEvent(totals, JsTable.EVENT_UPDATED, checkTotals(totals, "a2", + TotalsResults.of(3, 3, 6.66666, 0.0), + TotalsResults.of(2, 2, 5, 1.0)), 2502); + })); + }) + .then(table -> { + // Now, apply a filter to each totals tables... + + totalTables[0].applyFilter(new FilterCondition[] { + // we'll use notEq here, so that changing a filter in the source table causes this filter + // to remove nothing (instead of remove everything). + totalTables[0].findColumn("J__Avg").filter().notEq(FilterValue.ofNumber(8.0)) + }); + totalTables[1].applyFilter(new FilterCondition[] { + totalTables[1].findColumn("J__Avg").filter().eq(FilterValue.ofNumber(5.0)) + }); + totalTables[0].setViewport(0, 100, null, null); + totalTables[1].setViewport(0, 100, null, null); + + return promiseAllThen(table, + totalPromises[0] = waitForEvent(totalTables[0], JsTable.EVENT_UPDATED, + checkTotals(totalTables[0], "b1", TotalsResults.of(2, 2, 5, 1)), 2503), + totalPromises[1] = waitForEvent(totalTables[1], JsTable.EVENT_UPDATED, + checkTotals(totalTables[1], "b2", TotalsResults.of(2, 2, 5, 1)), 2504)); + }) + .then(table -> { + // forcibly disconnect the worker and test that the total table come back up, and respond to + // re-filtering. + table.getConnection().forceReconnect(); + return Promise.resolve(table); + }) + .then(table -> waitForEvent(table, JsTable.EVENT_RECONNECT, 5001).onInvoke(table)) + .then(table -> promiseAllThen(table, + totalPromises[0] = waitForEvent(totalTables[0], JsTable.EVENT_UPDATED, + checkTotals(totalTables[0], "c1", TotalsResults.of(2, 2, 5, 1)), 2505), + totalPromises[1] = waitForEvent(totalTables[1], JsTable.EVENT_UPDATED, + checkTotals(totalTables[1], "c2", TotalsResults.of(2, 2, 5, 1)), 2506))) + .then(table -> { + // Now... refilter the original table, and assert that the totals tables update. + table.applyFilter(new FilterCondition[] { + table.findColumn("J").filter().notEq(FilterValue.ofNumber(9.0)) + // the != filters on the regular totals will no longer remove anything w/ updated source + // filter.... + // but grand totals will ignore this, and still be filtered + }); + table.setViewport(0, 100, null);// not strictly required, but part of the normal usage + + return promiseAllThen(table, + waitForEvent(table, JsTable.EVENT_FILTERCHANGED, 5503).onInvoke(table), + waitForEvent(totalTables[0], JsTable.EVENT_UPDATED, checkTotals(totalTables[1], "d1", + TotalsResults.of(3, 3, 6.666666, 0.0), + TotalsResults.of(1, 1, 1, 1.0)), 2507), + totalPromises[1] = waitForEvent(totalTables[1], JsTable.EVENT_UPDATED, + checkTotals(totalTables[1], "d2", TotalsResults.of(2, 2, 5, 1)), 2508)); + }) + .then(this::finish).catch_(this::report); + } + + public void testGroupedTotals() { + connect(tables) + .then(session -> { + delayFinish(2000); + return session.getTable("hasTotals", true); + }) + .then(table -> { + // take the existing config and group by K + JsTotalsTableConfig config = table.getTotalsTableConfig(); + config.groupBy = new JsArray<>("K"); + + // use the same check for both totals and grand totals tables + IThenable.ThenOnFulfilledCallbackFn checkForBothTotalsTables = + (JsTotalsTable totals) -> { + assertEquals(4, totals.getColumns().length); + assertEquals(2, totals.getSize(), DELTA); + totals.setViewport(0, 100, null, null); + + // confirm the grand totals are unchanged + return waitForEvent(totals, JsTable.EVENT_UPDATED, update -> { + ViewportData viewportData = (ViewportData) update.detail; + + // 2 rows (one for k=0, one for k=1) + assertEquals(2, viewportData.getRows().length); + // 4 columns (3 agg'd, and the grouped column) + assertEquals(4, viewportData.getColumns().length); + + // k=0 row + assertEquals(0, + viewportData.getRows().getAt(0).get(totals.findColumn("K")).asInt()); + assertEquals(3, viewportData.getRows().getAt(0).get(totals.findColumn("I")) + .cast().getWrapped()); + assertEquals(6.666666, + viewportData.getRows().getAt(0).get(totals.findColumn("J__Avg")).asDouble(), + DELTA); + assertEquals(0.0, viewportData.getRows().getAt(0).get(totals.findColumn("J__Min")) + .asDouble()); + + // k=1 row + assertEquals(1, + viewportData.getRows().getAt(1).get(totals.findColumn("K")).asInt()); + assertEquals(2, viewportData.getRows().getAt(1).get(totals.findColumn("I")) + .cast().getWrapped()); + assertEquals(5.0, + viewportData.getRows().getAt(1).get(totals.findColumn("J__Avg")).asDouble(), + DELTA); + assertEquals(1.0, viewportData.getRows().getAt(1).get(totals.findColumn("J__Min")) + .asDouble()); + }, 1500); + }; + + // noinspection unchecked + return Promise.all((Promise[]) new Promise[] { + table.getTotalsTable(config).then(checkForBothTotalsTables), + table.getGrandTotalsTable(config).then(checkForBothTotalsTables) + }); + }) + .then(this::finish).catch_(this::report); + } + + private static class TotalsResults { + int k; + long i; + double avg; + double min; + + public TotalsResults(int k, long i, double avg, double min) { + this.k = k; + this.i = i; + this.avg = avg; + this.min = min; + } + + static TotalsResults of(int k, long i, double avg, double min) { + return new TotalsResults(k, i, avg, min); + } + } + + private Consumer checkTotals( + JsTotalsTable totals, + long i, + double avg, + double min, + String messages) { + String ext = messages; + return update -> { + ViewportData viewportData = (ViewportData) update.detail; + + assertEquals(1, viewportData.getRows().length); + assertEquals(3, viewportData.getColumns().length); + + assertEquals("I" + ext, i, + viewportData.getRows().getAt(0).get(totals.findColumn("I")).cast().getWrapped()); + assertEquals("J_Avg" + ext, avg, + viewportData.getRows().getAt(0).get(totals.findColumn("J__Avg")).asDouble(), 0.0001); + assertEquals("J_Min" + ext, min, + viewportData.getRows().getAt(0).get(totals.findColumn("J__Min")).asDouble(), 0.0001); + + }; + } + + private Consumer checkTotals( + JsTotalsTable totals, + String messages, + TotalsResults... expected) { + String ext = messages; + return update -> { + ViewportData viewportData = (ViewportData) update.detail; + + assertEquals("Viewport data rows", expected.length, viewportData.getRows().length); + assertEquals("Viewport columns", 4, viewportData.getColumns().length); + + for (int ind = 0; ind < expected.length; ind++) { + final TotalsResults result = expected[ind]; + assertEquals("K" + ext, result.k, + viewportData.getRows().getAt(ind).get(totals.findColumn("K")).cast().getWrapped()); + assertEquals("I" + ext, result.i, + viewportData.getRows().getAt(ind).get(totals.findColumn("I")).cast().getWrapped()); + assertEquals("J_Avg" + ext, result.avg, + viewportData.getRows().getAt(ind).get(totals.findColumn("J__Avg")).asDouble(), 0.0001); + assertEquals("J_Min" + ext, result.min, + viewportData.getRows().getAt(ind).get(totals.findColumn("J__Min")).asDouble(), 0.0001); + } + + }; + } + + /** + * Specialized waitForEvent since JsTotalsTable isn't a HasEventHandling subtype, and doesnt make sense to shoehorn + * it in just for tests. + */ + private Promise waitForEvent(JsTotalsTable table, String eventName, Consumer check, + int timeout) { + return waitForEvent(table.getWrappedTable(), eventName, check::accept, timeout) + .then(t -> Promise.resolve(table)); + } + + @Override + public String getModuleName() { + return "io.deephaven.web.DeephavenIntegrationTest"; + } +} diff --git a/web/client-api/src/test/java/io/deephaven/web/client/api/filter/FilterConditionTestGwt.java b/web/client-api/src/test/java/io/deephaven/web/client/api/filter/FilterConditionTestGwt.java index 14296bf4223..27aa370f501 100644 --- a/web/client-api/src/test/java/io/deephaven/web/client/api/filter/FilterConditionTestGwt.java +++ b/web/client-api/src/test/java/io/deephaven/web/client/api/filter/FilterConditionTestGwt.java @@ -4,17 +4,18 @@ package io.deephaven.web.client.api.filter; import com.google.gwt.junit.client.GWTTestCase; +import io.deephaven.web.client.api.AbstractAsyncGwtTestCase; import io.deephaven.web.client.api.Column; /** * Tests basic construction of filter condition instances from simple tables. This does not fully end-to-end test the * filter, just the API around the simple AST we use, especially validation. */ -public class FilterConditionTestGwt extends GWTTestCase { +public class FilterConditionTestGwt extends AbstractAsyncGwtTestCase { @Override public String getModuleName() { - return "io.deephaven.web.DhApiDev"; + return "io.deephaven.web.DeephavenIntegrationTest"; } private Column getColumn() { @@ -26,88 +27,106 @@ private FilterValue[] arr(FilterValue filterValue) { } public void testCreateSimpleFilters() { - Column c = getColumn(); - - assertEquals("ColumnName == (ignore case) 1", - c.filter().eqIgnoreCase(FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(1))).toString()); - assertEquals("ColumnName != (ignore case) 1", - c.filter().notEqIgnoreCase(FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(1))).toString()); - - assertEquals("ColumnName == 1", - c.filter().eq(FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(1))).toString()); - assertEquals("ColumnName != 1", - c.filter().notEq(FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(1))).toString()); - assertEquals("ColumnName > 1", - c.filter().greaterThan(FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(1))).toString()); - assertEquals("ColumnName < 1", - c.filter().lessThan(FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(1))).toString()); - assertEquals("ColumnName >= 1", - c.filter().greaterThanOrEqualTo(FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(1))).toString()); - assertEquals("ColumnName <= 1", - c.filter().lessThanOrEqualTo(FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(1))).toString()); - - assertEquals("ColumnName in 1", - c.filter().in(arr(FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(1)))).toString()); - assertEquals("ColumnName not in 1", - c.filter().notIn(arr(FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(1)))).toString()); - assertEquals("ColumnName icase in 1", - c.filter().inIgnoreCase(arr(FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(1)))).toString()); - assertEquals("ColumnName icase not in 1", - c.filter().notInIgnoreCase(arr(FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(1)))).toString()); - - assertEquals("ColumnName == true", c.filter().isTrue().toString()); - assertEquals("ColumnName == false", c.filter().isFalse().toString()); - assertEquals("isNull(ColumnName)", c.filter().isNull().toString()); - - assertEquals("ColumnName.foo1()", c.filter().invoke("foo1").toString()); - assertEquals("ColumnName.foo2(1)", - c.filter().invoke("foo2", FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(1))).toString()); - assertEquals("ColumnName.foo3(1, 2, \"three\")", - c.filter() - .invoke("foo3", FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(1)), - FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(2)), - FilterValue.ofString("three")) - .toString()); - - assertEquals("foo4()", FilterCondition.invoke("foo4").toString()); - assertEquals("foo5(1)", - FilterCondition.invoke("foo5", FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(1))).toString()); - assertEquals("foo6(1, 2, \"three\")", - FilterCondition - .invoke("foo6", FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(1)), - FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(2)), - FilterValue.ofString("three")) - .toString()); + setupDhInternal().then(ignored -> { + + Column c = getColumn(); + + assertEquals("ColumnName == (ignore case) 1", + c.filter().eqIgnoreCase(FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(1))).toString()); + assertEquals("ColumnName != (ignore case) 1", + c.filter().notEqIgnoreCase(FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(1))).toString()); + + assertEquals("ColumnName == 1", + c.filter().eq(FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(1))).toString()); + assertEquals("ColumnName != 1", + c.filter().notEq(FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(1))).toString()); + assertEquals("ColumnName > 1", + c.filter().greaterThan(FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(1))).toString()); + assertEquals("ColumnName < 1", + c.filter().lessThan(FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(1))).toString()); + assertEquals("ColumnName >= 1", + c.filter().greaterThanOrEqualTo(FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(1))) + .toString()); + assertEquals("ColumnName <= 1", + c.filter().lessThanOrEqualTo(FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(1))) + .toString()); + + assertEquals("ColumnName in 1", + c.filter().in(arr(FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(1)))).toString()); + assertEquals("ColumnName not in 1", + c.filter().notIn(arr(FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(1)))).toString()); + assertEquals("ColumnName icase in 1", + c.filter().inIgnoreCase(arr(FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(1)))) + .toString()); + assertEquals("ColumnName icase not in 1", + c.filter().notInIgnoreCase(arr(FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(1)))) + .toString()); + + assertEquals("ColumnName == true", c.filter().isTrue().toString()); + assertEquals("ColumnName == false", c.filter().isFalse().toString()); + assertEquals("isNull(ColumnName)", c.filter().isNull().toString()); + + assertEquals("ColumnName.foo1()", c.filter().invoke("foo1").toString()); + assertEquals("ColumnName.foo2(1)", + c.filter().invoke("foo2", FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(1))).toString()); + assertEquals("ColumnName.foo3(1, 2, \"three\")", + c.filter() + .invoke("foo3", FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(1)), + FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(2)), + FilterValue.ofString("three")) + .toString()); + + assertEquals("foo4()", FilterCondition.invoke("foo4").toString()); + assertEquals("foo5(1)", + FilterCondition.invoke("foo5", FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(1))) + .toString()); + assertEquals("foo6(1, 2, \"three\")", + FilterCondition + .invoke("foo6", FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(1)), + FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(2)), + FilterValue.ofString("three")) + .toString()); + finishTest(); + return null; + }) + .then(this::finish).catch_(this::report); } public void testCreateCombinedFilters() { - Column c = getColumn(); - - // individual AND - assertEquals("(ColumnName == 1 && ColumnName != 2)", - c.filter().eq(FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(1))) - .and(c.filter().notEq(FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(2)))).toString()); - - // individual OR - assertEquals("(ColumnName == 1 || ColumnName != 2)", - c.filter().eq(FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(1))) - .or(c.filter().notEq(FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(2)))).toString()); - - // individual NOT - assertEquals("!(ColumnName == 1)", - c.filter().eq(FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(1))).not().toString()); - - // nested/combined - assertEquals("(ColumnName == 1 && !((ColumnName == 2 || ColumnName == 3 || ColumnName == 4)))", - c.filter().eq(FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(1))).and( - c.filter().eq(FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(2))) - .or( - c.filter().eq(FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(3))), - c.filter().eq(FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(4)))) - .not()) - .toString() - - ); + setupDhInternal().then(ignored -> { + + Column c = getColumn(); + + // individual AND + assertEquals("(ColumnName == 1 && ColumnName != 2)", + c.filter().eq(FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(1))) + .and(c.filter().notEq(FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(2)))) + .toString()); + + // individual OR + assertEquals("(ColumnName == 1 || ColumnName != 2)", + c.filter().eq(FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(1))) + .or(c.filter().notEq(FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(2)))) + .toString()); + + // individual NOT + assertEquals("!(ColumnName == 1)", + c.filter().eq(FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(1))).not().toString()); + + // nested/combined + assertEquals("(ColumnName == 1 && !((ColumnName == 2 || ColumnName == 3 || ColumnName == 4)))", + c.filter().eq(FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(1))).and( + c.filter().eq(FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(2))) + .or( + c.filter().eq(FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(3))), + c.filter().eq(FilterValue.ofNumber(FilterValue.OfNumberUnionParam.of(4)))) + .not()) + .toString()); + finishTest(); + return null; + }) + .then(this::finish).catch_(this::report); + } } diff --git a/web/client-api/src/test/java/io/deephaven/web/client/api/i18n/JsDateTimeFormatTestGwt.java b/web/client-api/src/test/java/io/deephaven/web/client/api/i18n/JsDateTimeFormatTestGwt.java index abe3f477692..804b7a9ccd0 100644 --- a/web/client-api/src/test/java/io/deephaven/web/client/api/i18n/JsDateTimeFormatTestGwt.java +++ b/web/client-api/src/test/java/io/deephaven/web/client/api/i18n/JsDateTimeFormatTestGwt.java @@ -90,6 +90,6 @@ private long assertRoundTrip(String formatString, String input) { @Override public String getModuleName() { - return "io.deephaven.web.DhApiDev"; + return "io.deephaven.web.DeephavenUnitTest"; } } diff --git a/web/client-api/src/test/java/io/deephaven/web/client/api/i18n/JsNumberFormatTestGwt.java b/web/client-api/src/test/java/io/deephaven/web/client/api/i18n/JsNumberFormatTestGwt.java index fe95c725609..657255826a5 100644 --- a/web/client-api/src/test/java/io/deephaven/web/client/api/i18n/JsNumberFormatTestGwt.java +++ b/web/client-api/src/test/java/io/deephaven/web/client/api/i18n/JsNumberFormatTestGwt.java @@ -70,6 +70,6 @@ public void testLongFormat() { @Override public String getModuleName() { - return "io.deephaven.web.DhApiDev"; + return "io.deephaven.web.DeephavenUnitTest"; } } diff --git a/web/client-api/src/test/java/io/deephaven/web/client/api/subscription/ConcurrentTableTestGwt.java b/web/client-api/src/test/java/io/deephaven/web/client/api/subscription/ConcurrentTableTestGwt.java new file mode 100644 index 00000000000..87a3212a5d1 --- /dev/null +++ b/web/client-api/src/test/java/io/deephaven/web/client/api/subscription/ConcurrentTableTestGwt.java @@ -0,0 +1,95 @@ +package io.deephaven.web.client.api.subscription; + +import com.google.gwt.junit.DoNotRunWith; +import com.google.gwt.junit.Platform; +import elemental2.core.JsArray; +import elemental2.promise.IThenable; +import elemental2.promise.Promise; +import io.deephaven.web.client.api.AbstractAsyncGwtTestCase; +import io.deephaven.web.client.api.Column; +import io.deephaven.web.client.api.JsTable; +import io.deephaven.web.client.api.filter.FilterCondition; +import jsinterop.base.Js; + +import static elemental2.dom.DomGlobal.console; + +@DoNotRunWith(Platform.HtmlUnitBug) +public class ConcurrentTableTestGwt extends AbstractAsyncGwtTestCase { + + private final TableSourceBuilder tables = new TableSourceBuilder() + .script("from deephaven import time_table") + .script("from datetime import datetime, timedelta") + .script("updates", + "time_table(period=\"PT00:00:01\", start_time=datetime.now() - timedelta(minutes=1)).update_view(\"condition= (i%2 == 0)\").sort_descending(\"Timestamp\")"); + + /** + * Take a table, get a viewport on it, copy it, filter the copy, get a viewport on the copy, ensure updates arrive + * on both copies. + * + * Assumes a table exists called `updates` with more than 20 items, where each page gets ticked at least once per + * second, and there is a column `condition` which is `true` for half and `false` for half. + */ + public void testOldCopyKeepsGettingUpdates() { + connect(tables) + .then(table("updates")) + .then(table -> { + // assign a viewport + table.setViewport(0, 9, null); + console.log("viewport set"); + + // within the usual update interval, expect to see an update (added 50% to ensure we get it) + delayTestFinish(2002); + return assertUpdateReceived(table, 10, 1500); + }).then(table -> { + // copy the table, apply a filter to it + console.log("applying filter"); + Promise filteredCopy = table.copy(true).then(toFilter -> { + Column conditionColumn = + toFilter.getColumns().find((c, p1, p2) -> c.getName().equals("condition")); + toFilter.applyFilter(new FilterCondition[] {conditionColumn.filter().isTrue()}); + toFilter.setViewport(0, 9, null); + return new Promise<>((resolve, reject) -> { + toFilter.addEventListener(JsTable.EVENT_FILTERCHANGED, e -> { + resolve.onInvoke(toFilter); + }); + }); + }); + + console.log("testing results"); + // confirm all are getting updates - higher timeout for filtered table, since half the items are + // skipped, 2s tick now + delayTestFinish(3501); + // noinspection unchecked + return Promise.all(new IThenable[] { + assertUpdateReceived(filteredCopy, 10, 3001), + assertUpdateReceived(table, 10, 2501) + }); + }).then(this::finish).catch_(this::report); + } + + public void testTwoIdenticalTablesWithDifferentViewports() { + connect(tables) + .then(delayFinish(2007)) + .then(table("updates")) + .then(table -> { + return Promise.all(new Promise[] {Promise.resolve(table), table.copy(true)}); + }) + .then(result -> { + JsArray array = Js.uncheckedCast(result); + array.getAt(0).setViewport(0, 9, null); + array.getAt(1).setViewport(10, 24, null); + delayTestFinish(3007); + // noinspection unchecked + return Promise.all(new Promise[] { + assertUpdateReceived(array.getAt(0), 10, 2007), + assertUpdateReceived(array.getAt(1), 15, 2008) + }); + }) + .then(this::finish).catch_(this::report); + } + + @Override + public String getModuleName() { + return "io.deephaven.web.DeephavenIntegrationTest"; + } +} diff --git a/web/client-api/src/test/java/io/deephaven/web/client/api/subscription/ViewportTestGwt.java b/web/client-api/src/test/java/io/deephaven/web/client/api/subscription/ViewportTestGwt.java new file mode 100644 index 00000000000..dae893b34d5 --- /dev/null +++ b/web/client-api/src/test/java/io/deephaven/web/client/api/subscription/ViewportTestGwt.java @@ -0,0 +1,518 @@ +package io.deephaven.web.client.api.subscription; + +import elemental2.core.JsArray; +import elemental2.dom.CustomEvent; +import elemental2.dom.DomGlobal; +import elemental2.promise.IThenable; +import elemental2.promise.Promise; +import io.deephaven.web.client.api.AbstractAsyncGwtTestCase; +import io.deephaven.web.client.api.Column; +import io.deephaven.web.client.api.HasEventHandling; +import io.deephaven.web.client.api.JsTable; +import io.deephaven.web.client.api.filter.FilterCondition; +import io.deephaven.web.client.api.filter.FilterValue; +import io.deephaven.web.shared.fu.RemoverFn; +import jsinterop.base.Js; + +import java.util.Objects; + +import static elemental2.dom.DomGlobal.console; + +/** + * Assumes two tables, ticking every 2 seconds: + * + * growingForward = db.timeTable("00:00:01").update("I=i", "J=i*i", "K=0") growingBackward = + * growingForward.sortDescending("Timestamp") blinkOne = db.timeTable("00:00:01").update("I=i", + * "J=1").lastBy("J").where("I%2 != 0") + * + * And another static one: + * + * staticTable = emptyTable(100).update("I=i") + */ +public class ViewportTestGwt extends AbstractAsyncGwtTestCase { + + private final TableSourceBuilder tables = new TableSourceBuilder() + .script("from deephaven import empty_table, time_table") + .script("staticTable", "empty_table(100).update(\"I=i\")") + .script("from datetime import datetime, timedelta") + .script("growingForward", + "time_table(period=\"PT00:00:01\", start_time=datetime.now() - timedelta(minutes=1)).update([\"I=i\", \"J=i*i\", \"K=0\"])") + .script("growingBackward", "growingForward.sort_descending(\"Timestamp\")") + .script("blinkOne", + "time_table(\"PT00:00:01\").update([\"I=i\", \"J=1\"]).last_by(by=\"J\").where(\"I%2 != 0\")"); + + public void testViewportOnStaticTable() { + connect(tables) + .then(table("staticTable")) + .then(table -> { + delayTestFinish(5000); + + int size = (int) table.getSize(); + int lastRow = size - 1; + table.setViewport(0, lastRow, null); + return assertUpdateReceived(table, size, 2500); + }) + .then(table -> { + // table has 100 rows, go through each page of 25, make sure the offset and length is sane + table.setViewport(0, 24, null); + return assertUpdateReceived(table, viewport -> { + assertEquals(0, (long) viewport.getOffset()); + assertEquals(25, viewport.getRows().length); + }, 1000); + }) + .then(table -> { + table.setViewport(25, 49, null); + return assertUpdateReceived(table, viewport -> { + assertEquals(25, (long) viewport.getOffset()); + assertEquals(25, viewport.getRows().length); + }, 1001); + }) + .then(table -> { + table.setViewport(50, 74, null); + return assertUpdateReceived(table, viewport -> { + assertEquals(50, (long) viewport.getOffset()); + assertEquals(25, viewport.getRows().length); + }, 1002); + }) + .then(table -> { + table.setViewport(75, 99, null); + return assertUpdateReceived(table, viewport -> { + assertEquals(75, (long) viewport.getOffset()); + assertEquals(25, viewport.getRows().length); + }, 1003); + }) + .then(this::finish).catch_(this::report); + } + + // TODO: https://deephaven.atlassian.net/browse/DH-11196 + public void ignore_testViewportOnGrowingTable() { + connect(tables) + .then(table("growingForward")) + .then(waitForTick(2200)) + .then(delayFinish(25_000)) + .then(table -> { + // set viewport to actual table size, check that all items are present + int size = (int) table.getSize(); + int lastRow = size - 1; + table.setViewport(0, lastRow, null); + return assertUpdateReceived(table, size, 1500); + }) + .then(waitForTick(2201)) + .then(table -> { + // set viewport size to be larger than range of items, check only size items are present + int size = (int) table.getSize(); + table.setViewport(0, size, null); + return assertUpdateReceived(table, size, 1501); + }) + .then(waitForTick(2202)) + .then(table -> { + table.setViewport(1, 2, null); + // start with the last visible item, showing more than one item, should only see one item at first, + // but we'll tick forward to see more + int size = (int) table.getSize(); + int lastRow = size - 1; + table.setViewport(lastRow, lastRow + 99, null); + return assertUpdateReceived(table, 1, 1502); + }) + .then(waitForTick(2203)) + .then(table -> { + // wait for the size to tick once, verify that the current viewport size reflects that + double size = table.getSize(); + double lastRow = size - 1; + table.setViewport(size, lastRow + 9, null); + return waitFor(() -> table.getSize() == size + 1, 100, 3000, table) + .then(waitForEvent(table, JsTable.EVENT_SIZECHANGED, 2510)) + .then(JsTable::getViewportData) + .then(viewportData -> { + assertEquals(2, viewportData.getRows().length); + // switch back to table for next promise + return Promise.resolve(table); + }); + }) + .then(this::finish).catch_(this::report); + } + + public void testViewportOnUpdatingTable() { + connect(tables) + .then(table("growingBackward")) + .then(table -> { + delayTestFinish(4000); + // set up a viewport, and watch it show up, and tick once + table.setViewport(0, 9, null); + return assertUpdateReceived(table, viewportData -> { + }, 1004); + }) + .then(table -> { + return assertUpdateReceived(table, viewportData -> { + }, 2000); + }) + .then(this::finish).catch_(this::report); + } + + private static int indexOf(JsArray array, T object) { + return indexOf(array.asList().toArray(), object); + } + + private static int indexOf(Object[] array, T object) { + for (int i = 0; i < array.length; i++) { + Object t = array[i]; + if (Objects.equals(t, object)) { + return i; + } + } + + return -1; + } + + public void testViewportSubsetOfColumns() { + connect(tables) + .then(table("growingBackward")) + .then(table -> { + delayTestFinish(8000); + table.setViewport(0, 0, Js.uncheckedCast(new Column[] {table.findColumn("I")})); + + return assertUpdateReceived(table, viewport -> { + assertEquals(1, viewport.getColumns().length); + assertEquals(0, indexOf(viewport.getColumns(), table.findColumn("I"))); + + assertEquals(1, viewport.getRows().length); + assertNotNull(viewport.getRows().getAt(0).get(table.findColumn("I"))); + assertThrowsException(() -> viewport.getRows().getAt(0).get(table.findColumn("J"))); + assertThrowsException(() -> viewport.getRows().getAt(0).get(table.findColumn("K"))); + + }, 2501); + }) + .then(table -> { + // don't change viewport, test the same thing again, make sure deltas behave too + return assertUpdateReceived(table, viewport -> { + assertEquals(1, viewport.getColumns().length); + assertEquals(0, indexOf(viewport.getColumns(), table.findColumn("I"))); + + assertEquals(1, viewport.getRows().length); + assertNotNull(viewport.getRows().getAt(0).get(table.findColumn("I"))); + assertThrowsException(() -> viewport.getRows().getAt(0).get(table.findColumn("J"))); + assertThrowsException(() -> viewport.getRows().getAt(0).get(table.findColumn("K"))); + + }, 2000); + }) + .then(table -> { + table.setViewport(0, 0, Js.uncheckedCast(new Column[] {table.findColumn("J")})); + + return assertUpdateReceived(table, viewport -> { + assertEquals(1, viewport.getColumns().length); + assertEquals(0, indexOf(viewport.getColumns(), table.findColumn("J"))); + + assertEquals(1, viewport.getRows().length); + assertThrowsException(() -> viewport.getRows().getAt(0).get(table.findColumn("I"))); + assertNotNull(viewport.getRows().getAt(0).get(table.findColumn("J"))); + assertThrowsException(() -> viewport.getRows().getAt(0).get(table.findColumn("K"))); + + }, 2502); + }) + .then(table -> { + table.setViewport(0, 0, Js.uncheckedCast(new Column[] {table.findColumn("K")})); + + return assertUpdateReceived(table, viewport -> { + assertEquals(1, viewport.getColumns().length); + assertEquals(0, indexOf(viewport.getColumns(), table.findColumn("K"))); + + assertEquals(1, viewport.getRows().length); + assertThrowsException(() -> viewport.getRows().getAt(0).get(table.findColumn("I"))); + assertThrowsException(() -> viewport.getRows().getAt(0).get(table.findColumn("J"))); + assertNotNull(viewport.getRows().getAt(0).get(table.findColumn("K"))); + + }, 2503); + }) + .then(table -> { + table.setViewport(0, 0, Js.uncheckedCast(new Column[] { + table.findColumn("J"), + table.findColumn("K") + })); + + return assertUpdateReceived(table, viewport -> { + assertEquals(2, viewport.getColumns().length); + assertEquals(0, indexOf(viewport.getColumns(), table.findColumn("J"))); + assertEquals(1, indexOf(viewport.getColumns(), table.findColumn("K"))); + + assertEquals(1, viewport.getRows().length); + assertThrowsException(() -> viewport.getRows().getAt(0).get(table.findColumn("I"))); + assertNotNull(viewport.getRows().getAt(0).get(table.findColumn("J"))); + assertNotNull(viewport.getRows().getAt(0).get(table.findColumn("K"))); + + }, 2504); + }) + .then(table -> { + table.setViewport(0, 0, Js.uncheckedCast(new Column[] { + table.findColumn("J"), + table.findColumn("Timestamp"), + table.findColumn("I"), + table.findColumn("K") + })); + + return assertUpdateReceived(table, viewport -> { + assertEquals(4, viewport.getColumns().length); + assertEquals(0, indexOf(viewport.getColumns(), table.findColumn("Timestamp"))); + assertEquals(1, indexOf(viewport.getColumns(), table.findColumn("I"))); + assertEquals(2, indexOf(viewport.getColumns(), table.findColumn("J"))); + assertEquals(3, indexOf(viewport.getColumns(), table.findColumn("K"))); + + assertEquals(1, viewport.getRows().length); + assertNotNull(viewport.getRows().getAt(0).get(table.findColumn("Timestamp"))); + assertNotNull(viewport.getRows().getAt(0).get(table.findColumn("I"))); + assertNotNull(viewport.getRows().getAt(0).get(table.findColumn("J"))); + assertNotNull(viewport.getRows().getAt(0).get(table.findColumn("K"))); + + }, 2505); + }) + .then(table -> { + table.setViewport(0, 0, null); + + return assertUpdateReceived(table, viewport -> { + assertEquals(4, viewport.getColumns().length); + assertEquals(0, indexOf(viewport.getColumns(), table.findColumn("Timestamp"))); + assertEquals(1, indexOf(viewport.getColumns(), table.findColumn("I"))); + assertEquals(2, indexOf(viewport.getColumns(), table.findColumn("J"))); + assertEquals(3, indexOf(viewport.getColumns(), table.findColumn("K"))); + + assertEquals(1, viewport.getRows().length); + assertNotNull(viewport.getRows().getAt(0).get(table.findColumn("Timestamp"))); + assertNotNull(viewport.getRows().getAt(0).get(table.findColumn("I"))); + assertNotNull(viewport.getRows().getAt(0).get(table.findColumn("J"))); + assertNotNull(viewport.getRows().getAt(0).get(table.findColumn("K"))); + + }, 2506); + }) + .then(this::finish).catch_(this::report); + } + + // TODO: https://deephaven.atlassian.net/browse/DH-11196 + public void ignore_testEmptyTableWithViewport() { + // confirm that when the viewport is set on an empty table that we get exactly one update event + connect(tables) + .then(table("staticTable")) + .then(table -> { + delayTestFinish(10000); + console.log("size", table.getSize()); + // change the filter, set a viewport, assert that sizechanged and update both happen once + table.applyFilter(new FilterCondition[] { + FilterValue.ofBoolean(false).isTrue() + }); + table.setViewport(0, 100, null); + return Promise.all(new IThenable[] { + // when IDS-2113 is fixed, restore this stronger assertion + // assertEventFiresOnce(table, JsTable.EVENT_UPDATED, 1000) + waitForEvent(table, JsTable.EVENT_UPDATED, ignore -> { + }, 2011), + assertEventFiresOnce(table, JsTable.EVENT_SIZECHANGED, 1005) + }).then(ignore -> Promise.resolve(table)); + }) + .then(table -> { + // reset the filter, wait for back to normal + table.applyFilter(new FilterCondition[0]); + table.setViewport(0, 100, null); + return assertUpdateReceived(table, ignore -> { + }, 1006); + }) + .then(table -> { + // change the filter, don't set a viewport, assert only size changes + table.applyFilter(new FilterCondition[] { + FilterValue.ofBoolean(false).isTrue() + }); + return assertEventFiresOnce(table, JsTable.EVENT_SIZECHANGED, 1007); + }) + .then(table -> { + // set a viewport, assert that update fires and no size change + table.setViewport(0, 100, null); + // when IDS-2113 is fixed, restore this stronger assertion + // return assertEventFiresOnce(table, JsTable.EVENT_UPDATED, 1000); + return waitForEvent(table, JsTable.EVENT_UPDATED, ignore -> { + }, 2012); + }) + .then(this::finish).catch_(this::report); + } + + public void testViewportOutOfRangeOfTable() { + // confirm that when the viewport is set beyond the range of the table that we get exactly one update event + connect(tables) + .then(table("staticTable")) + .then(table -> { + table.setViewport(100, 104, null); + + return Promise.all(new IThenable[] { + // when IDS-2113 is fixed, restore this stronger assertion + // assertEventFiresOnce(table, JsTable.EVENT_UPDATED, 1000) + waitForEvent(table, JsTable.EVENT_UPDATED, ignore -> { + }, 2013) + }).then(ignore -> Promise.resolve(table)); + }) + .then(this::finish).catch_(this::report); + + } + + public void testRapidChangingViewport() { + connect(tables) + .then(table("staticTable")) + .then(table -> { + delayTestFinish(5000); + // test running both synchronously + table.setViewport(0, 10, null); + table.setViewport(5, 14, null); + return assertUpdateReceived(table, viewport -> { + assertEquals(5, (int) viewport.getOffset()); + assertEquals(10, (int) viewport.getRows().length); + }, 1008); + }) + .then(table -> { + // test changing the viewport over a microtask (anyone in the web api getting clever with batching?) + table.setViewport(0, 10, null); + return Promise.resolve((Object) null).then(ignore -> Promise.resolve(table)); + }) + .then(table -> { + table.setViewport(6, 14, null); + return assertUpdateReceived(table, viewport -> { + assertEquals(6, (int) viewport.getOffset()); + assertEquals(9, (int) viewport.getRows().length); + }, 1009); + }) + .then(table -> { + table.setViewport(0, 10, null); + return Promise.resolve(table); + }) + // test again over a 4ms delay, minimum task delay + .then(waitFor(4)) + .then(table -> { + table.setViewport(7, 17, null); + return assertUpdateReceived(table, ignored -> { + }, 1010) + .then(waitFor(JsTable.DEBOUNCE_TIME * 2)) + .then(t -> { + // force the debounce to be processed + t.processSnapshot(); + t.getViewportData().then(vp -> { + // assertEquals(7, (int) vp.getOffset()); + assertEquals(11, (int) vp.getRows().length); + return Promise.resolve(vp); + }); + return Promise.resolve(t); + }); + }) + .then(this::finish).catch_(this::report); + } + + public void testViewportWithNoInitialItems() { + // The bug exposed by this case is that a snapshot might start initially empty, but then get + // a delta to make it non-empty. This test goes further, and waits until it is empty again, + // and then cycles back to non-empty once more to make sure all the transitions are tested + connect(tables) + .then(table("blinkOne")) + .then(table -> { + delayTestFinish(20_000); + + // first run, assume all columns + return helperForViewportWithNoInitialItems(table, null, table.getColumns()); + }).then(table -> { + // second, specify only one column to ensure that it is respected + Column i = table.findColumn("I"); + return helperForViewportWithNoInitialItems(table, new Column[] {i}, new JsArray<>(i)); + }) + .then(this::finish).catch_(this::report); + } + + private IThenable helperForViewportWithNoInitialItems(JsTable t, Column[] requestColumns, + JsArray expectedColumns) { + // wait until zero rows are present, so we can set the viewport and get a zero-row "snapshot" + return waitFor(() -> t.getSize() == 0, 100, 2000, t) + .then(table -> { + // set up the viewport to only watch for the first row, then wait until zero rows + table.setViewport(0, 0, Js.uncheckedCast(requestColumns)); + + // viewport should come back quickly showing no data, and all columns + return assertUpdateReceived(table, emptyViewport -> { + assertEquals(0, emptyViewport.getRows().length); + assertEquals(expectedColumns.length, emptyViewport.getColumns().length); + }, 1501); + }) + .then(table -> { + // wait for the next tick, where we get the "first" row added, confirm that the viewport + // data is sane + return waitForEventWhere(table, "updated", (CustomEvent e) -> { + ViewportData viewport = e.detail; + if (viewport.getRows().length != 1) { + return false; // wrong data, wait for another event + } + assertEquals(expectedColumns.length, viewport.getColumns().length); + for (int i = 0; i < viewport.getColumns().length; i++) { + final Column c = viewport.getColumns().getAt(i); + assertNotNull(viewport.getRows().getAt(0).get(c)); + } + return true; + }, 2508); + }) + .then(table -> { + // again wait for the table to go back to zero items, make sure it makes sense + return waitForEventWhere(table, "updated", (CustomEvent e) -> { + ViewportData emptyViewport = (ViewportData) e.detail; + if (emptyViewport.getRows().length != 0) { + return false; // wrong data, wait for another event + } + assertEquals(expectedColumns.length, emptyViewport.getColumns().length); + return true; + }, 2503); + }) + .then(table -> { + // one more tick later, we'll see the item back again + return waitForEventWhere(table, "updated", (CustomEvent e) -> { + ViewportData viewport = (ViewportData) e.detail; + if (viewport.getRows().length != 1) { + return false; // wrong data, wait for another event + } + assertEquals(expectedColumns.length, viewport.getColumns().length); + for (int i = 0; i < viewport.getColumns().length; i++) { + final Column c = viewport.getColumns().getAt(i); + assertNotNull(viewport.getRows().getAt(0).get(c)); + } + return true; + }, 2511); + }); + } + + private Promise assertEventFiresOnce(T eventSource, String eventName, + int intervalInMilliseconds) { + return new Promise<>((resolve, reject) -> { + int[] runCount = {0}; + console.log("adding " + eventName + " listener " + eventSource); + // apparent compiler bug, review in gwt 2.9 + RemoverFn unsub = Js.uncheckedCast(eventSource) + .addEventListener(eventName, e -> { + runCount[0]++; + console.log(eventName + " event observed " + eventSource + ", #" + runCount[0]); + if (runCount[0] > 1) { + reject.onInvoke("Event " + eventName + " fired " + runCount[0] + " times"); + } + }); + DomGlobal.setTimeout(p0 -> { + unsub.remove(); + if (runCount[0] == 1) { + resolve.onInvoke(eventSource); + } else { + reject.onInvoke("Event " + eventName + " fired " + runCount[0] + " times"); + } + }, intervalInMilliseconds); + }); + } + + private void assertThrowsException(Runnable r) { + try { + r.run(); + fail("Expected exception"); + } catch (Exception ignore) { + // expected + } + } + + @Override + public String getModuleName() { + return "io.deephaven.web.DeephavenIntegrationTest"; + } +} + diff --git a/web/client-api/src/test/java/io/deephaven/web/junit/RunStyleRemoteWebDriver.java b/web/client-api/src/test/java/io/deephaven/web/junit/RunStyleRemoteWebDriver.java new file mode 100644 index 00000000000..0a33c37f459 --- /dev/null +++ b/web/client-api/src/test/java/io/deephaven/web/junit/RunStyleRemoteWebDriver.java @@ -0,0 +1,129 @@ +package io.deephaven.web.junit; + +import com.google.gwt.core.ext.TreeLogger; +import com.google.gwt.core.ext.UnableToCompleteException; +import com.google.gwt.junit.JUnitShell; +import com.google.gwt.junit.RunStyle; +import org.openqa.selenium.remote.DesiredCapabilities; +import org.openqa.selenium.remote.RemoteWebDriver; + +import java.net.Inet6Address; +import java.net.InetAddress; +import java.net.MalformedURLException; +import java.net.URL; +import java.net.UnknownHostException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * RunStyle implementation to delegate to Selenium RemoteWebDriver implementations. Simplified version of implementation + * found in gwt-core. + */ +public class RunStyleRemoteWebDriver extends RunStyle { + private final List browsers = new ArrayList<>(); + private final Thread keepalive; + + public RunStyleRemoteWebDriver(JUnitShell shell) { + super(shell); + + keepalive = new Thread(() -> { + while (true) { + try { + Thread.sleep(1000); + } catch (InterruptedException e) { + break; + } + for (RemoteWebDriver browser : browsers) { + // As in RunStyleSelenium, simple way to poll the browser and ensure it is still alive, even if + // not actively being used at the moment. + browser.getTitle(); + } + } + }); + keepalive.setDaemon(true); + } + + + @Override + public final int initialize(String args) { + URL remoteWebDriverUrl; + if (args == null || args.isEmpty()) { + getLogger().log(TreeLogger.ERROR, + "RemoteWebDriver runstyle requires a parameter of the form protocol://hostname:port?browser1[,browser2]"); + return -1; + } + String[] parts = args.split("\\?"); + String url = parts[0]; + try { + remoteWebDriverUrl = new URL(url); + if (remoteWebDriverUrl.getPath().isEmpty() + || (remoteWebDriverUrl.getPath().equals("/") && !url.endsWith("/"))) { + getLogger().log(TreeLogger.INFO, "No path specified in webdriver remote url, using default of /wd/hub"); + remoteWebDriverUrl = new URL(url + "/wd/hub"); + } + } catch (MalformedURLException e1) { + getLogger().log(TreeLogger.ERROR, e1.getMessage(), e1); + return -1; + } + + String[] browserNames = parts[1].split(","); + for (String browserName : browserNames) { + DesiredCapabilities capabilities = new DesiredCapabilities(); + capabilities.setBrowserName(browserName); + + try { + RemoteWebDriver wd = new RemoteWebDriver(remoteWebDriverUrl, capabilities); + browsers.add(wd); + } catch (Exception exception) { + getLogger().log(TreeLogger.ERROR, "Failed to find desired browser", exception); + return -1; + } + } + + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + keepalive.interrupt(); + for (RemoteWebDriver browser : browsers) { + try { + browser.close(); + } catch (Exception ignored) { + // ignore, we're shutting down, continue shutting down others + } + } + })); + return browsers.size(); + } + + @Override + public void launchModule(String moduleName) throws UnableToCompleteException { + // Since WebDriver.get is blocking, start the keepalive thread first + keepalive.start(); + + // Starts each browser to run the tests at the url specified by JUnit+GWT. + for (RemoteWebDriver browser : browsers) { + browser.get(shell.getModuleUrl(moduleName)); + } + } + + /** + * Workaround until GWT's + * JUnitShell handles IPv6 addresses correctly. + */ + public String getLocalHostName() { + String host = System.getProperty("webdriver.test.host"); + if (host != null) { + return host; + } + InetAddress a; + try { + a = InetAddress.getLocalHost(); + } catch (UnknownHostException e) { + throw new RuntimeException("Unable to determine my ip address", e); + } + if (a instanceof Inet6Address) { + return "[" + a.getHostAddress() + "]"; + } else { + return a.getHostAddress(); + } + } +}