Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@

<!--
Tests are the usual answer. If you changed the algorithm, say what the oracle
test does with it. If you changed something visual, a screenshot is the
honest evidence — nothing automated covers the JavaFX views.
test does with it. If you changed something visual, the view tests will say
whether it is still built and still readable, but only a screenshot says
whether it looks right.
-->

- [ ] `./gradlew test` passes
- [ ] `npm test` in `web/` passes, if you touched the browser version
- [ ] `npm test` and `npm run test:browser` in `web/` pass, if you touched the
browser version
- [ ] Changing one engine and not the other? The equivalence fixtures will
say so — see `web/README.md`

Expand Down
34 changes: 31 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ jobs:

- uses: gradle/actions/setup-gradle@v6

# No display is needed: the engine and the tree layout hold no JavaFX
# reference, and nothing here starts the toolkit.
- run: ./gradlew test --no-daemon
# The view tests build real controls, and JavaFX needs a display for
# that even when no window is shown.
- run: xvfb-run -a ./gradlew test --no-daemon

- if: failure()
uses: actions/upload-artifact@v7
Expand All @@ -54,3 +54,31 @@ jobs:
# one implementation and not the other fails here.
- run: npm test
working-directory: web

browser:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- uses: actions/setup-node@v7
with:
node-version: '22'
cache: npm
cache-dependency-path: web/package-lock.json

- run: npm ci
working-directory: web
- run: npx playwright install --with-deps chromium
working-directory: web

# The demo in a real browser, against the page as it is published. This
# is the only thing here that can see layout and colour: the unit tests
# run against a simulated DOM, which has neither.
- run: npm run test:browser
working-directory: web

- if: failure()
uses: actions/upload-artifact@v7
with:
name: playwright-report
path: web/playwright-report
4 changes: 4 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ jobs:
working-directory: web
- run: npm test
working-directory: web
- run: npx playwright install --with-deps chromium
working-directory: web
- run: npm run test:browser
working-directory: web
- run: npm run build
working-directory: web

Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ jobs:
cache-dependency-path: web/package-lock.json

# Nothing is released that does not pass, in either implementation.
- run: ./gradlew test --no-daemon
# The view tests build real controls, which needs a display.
- run: xvfb-run -a ./gradlew test --no-daemon
- run: npm ci
working-directory: web
- run: npm test
Expand Down
26 changes: 21 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,28 @@ When you add a test, check it can fail. Break the thing it covers on purpose
and watch it go red. A test that passes the moment you write it has told you
nothing — that is how the fixture problem above went unnoticed.

## What has no automated coverage
## Testing what is drawn

The JavaFX views. Nothing checks them, and a button once went missing in a
rebuild and stayed missing until someone read the README. If you change the
desktop UI, run it and attach a screenshot. The browser version does have view
tests, in `web/test/render.test.ts`.
Both front ends are covered, in the only ways each can be.

The JavaFX views are built for real in `src/test/java/esu/algorithm/ui/`, on
the JavaFX thread and with the stylesheets applied, but without showing a
window. Those tests check what gets built and how it is styled: that every
control exists, that a tree box carries exactly one state, that every word can
be read against what is behind it. `FxTest` has the harness.

The demo is checked twice. `web/test/` runs it against a simulated DOM, which
is fast but has no layout engine and no stylesheets. `web/browser/` runs the
published page in real Chromium, which is the only thing here that can see
size, position and colour.

What none of this can tell you is whether the result looks *good*. A screenshot
still answers that, and it is worth attaching one to a pull request that
changes anything visual.

Every visual bug this project has shipped was a code path whose only consumer
was a picture, so when you add one, break it on purpose and watch the test go
red before you trust it.

## Style

Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,11 @@ Two other things are worth testing and are: that a step reached by replay is
identical to one reached by stepping, and that the tree layout puts parents over
their children without overlapping anything.

Both front ends are covered too. The JavaFX views are built for real, styled,
and inspected without showing a window. The demo is run in Chromium against the
published page, which is the only thing here that can see size, position and
colour.

## License

MIT — see [LICENSE](LICENSE).
138 changes: 138 additions & 0 deletions src/test/java/esu/algorithm/ui/EsuAppTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package esu.algorithm.ui;

import java.util.ArrayList;
import java.util.List;
import javafx.scene.Node;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.control.ButtonBase;
import javafx.scene.control.Menu;
import javafx.scene.control.MenuBar;
import javafx.scene.control.MenuItem;
import javafx.stage.Stage;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

/**
* That the window is built with everything it is supposed to have.
*
* Saving the results once disappeared during a rebuild and stayed gone until
* someone happened to read the README: nothing referred to it, so nothing
* broke. A control that quietly stops being built is invisible to every test
* that does not look at the window itself.
*/
public class EsuAppTest extends FxTest {

/** Build the real window, off screen, and hand back its scene. */
private Scene window() {
return onFxThread(() -> {
Stage stage = new Stage();
new EsuApp().start(stage);
Scene scene = stage.getScene();
// Built and styled is all this needs; showing it is not.
scene.getRoot().applyCss();
scene.getRoot().layout();
stage.close();
return scene;
});
}

private static void collect(Node node, List<Node> found) {
found.add(node);
if (node instanceof Parent parent) {
for (Node child : parent.getChildrenUnmodifiable()) {
collect(child, found);
}
}
}

private List<String> buttonLabels(Scene scene) {
List<Node> all = new ArrayList<>();
onFxThread(() -> {
collect(scene.getRoot(), all);
return null;
});
List<String> labels = new ArrayList<>();
for (Node node : all) {
if (node instanceof ButtonBase button && button.getText() != null
&& !button.getText().isBlank()) {
labels.add(button.getText());
}
}
return labels;
}

private List<String> menuItems(Scene scene) {
List<Node> all = new ArrayList<>();
onFxThread(() -> {
collect(scene.getRoot(), all);
return null;
});
List<String> items = new ArrayList<>();
for (Node node : all) {
if (node instanceof MenuBar bar) {
for (Menu menu : bar.getMenus()) {
for (MenuItem item : menu.getItems()) {
if (item.getText() != null) {
items.add(item.getText());
}
}
}
}
}
return items;
}

@Test
public void offersEveryControlTheReadmeSaysItHas() {
List<String> buttons = buttonLabels(window());

for (String expected : List.of("Open graph", "Random", "Save results",
"Fit", "Play")) {
assertTrue(buttons.contains(expected),
"no control labelled '" + expected + "'; found " + buttons);
}
}

@Test
public void offersAPillForEverySubgraphSizeItSupports() {
List<String> buttons = buttonLabels(window());

for (int size = EsuApp.MIN_SUBGRAPH_SIZE;
size <= EsuApp.MAX_SUBGRAPH_SIZE; size++) {
assertTrue(buttons.contains(String.valueOf(size)),
"no pill for subgraph size " + size);
}
}

@Test
public void offersTheMenusAndTheirShortcuts() {
Scene scene = window();

List<String> items = menuItems(scene);
for (String expected : List.of("Show step history", "Show shapes found",
"Play or pause", "Next step", "Previous step")) {
assertTrue(items.contains(expected),
"no menu item '" + expected + "'; found " + items);
}
}

@Test
public void startsWithNothingToActOnDisabled() {
Scene scene = window();
List<Node> all = new ArrayList<>();
onFxThread(() -> {
collect(scene.getRoot(), all);
return null;
});

// Until a graph is open, stepping through a search makes no sense.
for (Node node : all) {
if (node instanceof ButtonBase button
&& "Play".equals(button.getText())) {
assertTrue(button.isDisabled(),
"Play is live before a graph is loaded");
}
}
}
}
107 changes: 107 additions & 0 deletions src/test/java/esu/algorithm/ui/FxTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package esu.algorithm.ui;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Supplier;
import javafx.application.Platform;
import javafx.scene.Parent;
import javafx.scene.Scene;
import static org.junit.jupiter.api.Assertions.*;

/**
* Class FxTest
*
* Enough of JavaFX to build real controls and look at them, without showing a
* window or pulling in a UI-driving framework.
*
* The views had no automated coverage at all, and every visual bug this
* project shipped reached a person before it reached a test. What those bugs
* had in common was not a mishandled click: it was the wrong nodes being
* built, or built with the wrong style. That is what this can see.
*
* What it cannot see is whether the result looks good. Screenshots still
* answer that.
*/
public abstract class FxTest {

private static boolean started;

/** Starts the toolkit once for the whole run. */
protected static synchronized void startToolkit() {
if (started) {
return;
}
CountDownLatch ready = new CountDownLatch(1);
try {
Platform.startup(ready::countDown);
} catch (IllegalStateException alreadyRunning) {
ready.countDown();
}
try {
assertTrue(ready.await(30, TimeUnit.SECONDS),
"the JavaFX toolkit did not start");
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
fail("interrupted waiting for the toolkit");
}
Platform.setImplicitExit(false);
started = true;
}

/**
* Run something on the JavaFX thread and hand back what it made.
*
* @param <T> what is being built
* @param build builds it, on the right thread
* @return the result
*/
protected static <T> T onFxThread(Supplier<T> build) {
startToolkit();
AtomicReference<T> result = new AtomicReference<>();
AtomicReference<Throwable> failure = new AtomicReference<>();
CountDownLatch done = new CountDownLatch(1);

Platform.runLater(() -> {
try {
result.set(build.get());
} catch (Throwable thrown) {
failure.set(thrown);
} finally {
done.countDown();
}
});
try {
assertTrue(done.await(30, TimeUnit.SECONDS),
"nothing came back from the JavaFX thread");
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
fail("interrupted waiting for the JavaFX thread");
}
if (failure.get() != null) {
throw new AssertionError("failed on the JavaFX thread", failure.get());
}
return result.get();
}

/**
* Put a control in a scene and resolve its styles, so that what the tests
* read is what a user would have seen.
*
* @param root the control under test
* @param dark true to layer the dark stylesheet over the base one
* @return the same control, styled and laid out
*/
protected static Parent style(Parent root, boolean dark) {
Scene scene = new Scene(root, 900, 700);
scene.getStylesheets().add(
FxTest.class.getResource("esu.css").toExternalForm());
if (dark) {
scene.getStylesheets().add(
FxTest.class.getResource("dark.css").toExternalForm());
}
root.applyCss();
root.layout();
return root;
}
}
Loading
Loading