1
0
mirror of https://github.com/google/comprehensive-rust.git synced 2025-10-09 10:55:26 +02:00

Re-enable the red box to show available space

Way back in #187, I introduced a hacky tool to show the available
space on a slide: it was a `mdbook` plugin which injected the
necessary CSS on each slide. Crude, but it got the job done.

The logic was moved from Python to a real CSS file with associated
JavaScript in #1842. In #1917, the functionality was moved to a
dedicated “instructor menu”, together with functionality for saving
the state of the Playground on each slide.

Unfortunately, the whole thing was disabled in #1935 since I found
that the Playgrounds lost their code with the saving logic. I was also
not 100% happy with dedicating space on each slide for a menu only
used by instructors.

However, I really think we need a tool to let slide editors know about
the available space, so I would like to re-introduce the red box. This
time via a keyboard shortcut to make it easy to toggle as needed.

I’m suggesting enabling this for everyone, with the expectation that
most people won’t find the shortcut and will quickly disable the box
if they do (there is a dedicated button to hide it again).

End-to-end tests have been added for the new functionality.
This commit is contained in:
Martin Geisler
2025-09-27 11:02:39 +02:00
parent 0a734e9f02
commit 5f3010d88a
10 changed files with 317 additions and 109 deletions

View File

@@ -43,9 +43,6 @@ export MDBOOK_OUTPUT__PANDOC__DISABLED=false
mdbook build -d "$dest_dir"
# Disable the redbox button in built versions of the course
echo '// Disabled in published builds, see build.sh' > "${dest_dir}/html/theme/redbox.js"
mv "$dest_dir/pandoc/pdf/comprehensive-rust.pdf" "$dest_dir/html/"
(cd "$dest_dir/exerciser" && zip --recurse-paths ../html/comprehensive-rust-exercises.zip comprehensive-rust-exercises/)

View File

@@ -171,3 +171,86 @@ its tasks correctly.
snippet, treat it as a self-contained program. Do not assume it shares a scope
or context with other snippets in the same file unless the surrounding text
explicitly states otherwise.
## Interacting with the `mdbook` Theme
The `mdbook` theme has several interactive elements. Here's how to interact with
them:
- **Sidebar Toggle:** The sidebar can be opened and closed by clicking the
"hamburger" button in the top-left of the body text. This button has the ID
`sidebar-toggle`. You can use the following JavaScript to toggle the sidebar:
```javascript
const button = document.getElementById("sidebar-toggle");
button.click();
```
## WebdriverIO Testing
This project uses WebdriverIO for browser-based integration tests. Here are some
key findings about the test environment:
### Test Environments
The `tests/` directory contains two primary configurations:
- `npm test` (runs `wdio.conf.ts`): This is the standard for self-contained
integration tests. It uses `@wdio/static-server-service` to create a temporary
web server on port 8080.
- `npm run test-mdbook` (runs `wdio.conf-mdbook.ts`): This is for testing
against a live `mdbook serve` instance, which typically runs on port 3000.
It is important to use the standard `npm test` command for most test development
to ensure the tests are self-contained.
### Writing Stable Tests
Tests can be flaky if they don't correctly handle the asynchronous nature of the
web browser and the test environment's state management.
- **State Leakage Between Tests:** Despite what the WebdriverIO documentation
might suggest, `browser.url()` is not always sufficient to guarantee a clean
slate between tests. Lingering state, such as inline CSS styles applied by
JavaScript, can leak from one test into the next, causing unexpected failures.
The most effective solution found for this project is to add
`await browser.refresh();` to the `beforeEach` hook. This forces a full page
reload that properly clears the old state.
- **Race Conditions with Dynamic Elements:** Many elements in this project are
created dynamically by JavaScript after the initial page load. If a test tries
to access an element immediately after navigation, it may fail because the
script hasn't finished running and the element doesn't exist in the DOM yet.
This creates a race condition. To prevent this, always use
`await element.waitForExist()` to ensure the element is present before trying
to interact with it or assert its state (e.g., `toBeDisplayed()`).
### Handling Redirects
`mdbook` uses a redirect map defined in `book.toml` under the
`[output.html.redirect]` section. When writing tests, it is crucial to use the
final, non-redirecting URL for navigation. Navigating to a URL that is a
redirect will cause the browser to follow it, but this process can strip URL
query parameters, leading to test failures for features that depend on them.
### Running and Debugging Tests
To run a single test file, use the `--spec` flag with the a string matching the
file name:
```bash
npm test -- --spec redbox
```
To check for flakiness, you can repeat a test multiple times using the
`--repeat` flag:
```bash
npm test -- --spec redbox --repeat 100
```
Use `--mochaOpts.grep` to run a single test within a file:
```bash
npm test -- --spec redbox --mochaOpts.grep "should be hidden by default"
```

View File

@@ -60,6 +60,7 @@ urlcolor = "red"
smart-punctuation = true
additional-js = [
"theme/speaker-notes.js",
"theme/redbox.js",
]
additional-css = [
"theme/css/svgbob.css",

View File

@@ -55,6 +55,7 @@ better. Your students are also very welcome to [send us feedback][2]!
[1]: https://github.com/google/comprehensive-rust/discussions/86
[2]: https://github.com/google/comprehensive-rust/discussions/100
[3]: https://github.com/google/comprehensive-rust#building
[red-box]: ?show-red-box=true
<details>
@@ -69,6 +70,11 @@ better. Your students are also very welcome to [send us feedback][2]!
- **Familiarize yourself with `mdbook`:** The course is presented using
`mdbook`. Knowing how to navigate, search, and use its features will make the
presentation smoother.
- **Slice size helper:** Press <kbd>Ctrl</kbd> + <kbd>Alt</kbd> + <kbd>B</kbd>
to toggle a visual guide showing the amount of space available when
presenting. Expect any content outside of the red box to be hidden initially.
Use this as a guide when editing slides. You can also
[enable it via this link][red-box].
### Creating a Good Learning Environment

98
tests/src/redbox.test.ts Normal file
View File

@@ -0,0 +1,98 @@
import { describe, it } from "mocha";
import { expect, browser } from "@wdio/globals";
describe("Red Box", () => {
const redBox = () => $("#aspect-ratio-helper");
const redBoxButton = () => $("#turn-off-red-box");
beforeEach(async () => {
await browser.url("/hello-world.html");
await browser.execute(() => sessionStorage.clear());
// Clear any lingering state (like inline styles) from previous
// tests. Reading https://webdriver.io/docs/api/browser/url,
// this should not be necessary, but tests fail without it.
await browser.refresh();
});
it("should be hidden by default", async () => {
await expect(redBox()).not.toBeDisplayed();
});
describe("Keyboard Shortcut", () => {
it("should show the red box when toggled on", async () => {
await browser.toggleRedBox();
await expect(redBox()).toBeDisplayed();
await expect(redBoxButton()).toBeDisplayed();
});
it("should hide the red box when toggled off", async () => {
// Toggle on first
await browser.toggleRedBox();
await expect(redBox()).toBeDisplayed();
// Then toggle off
await browser.toggleRedBox();
await expect(redBox()).not.toBeDisplayed();
});
});
describe("URL Parameter", () => {
it("should show red box", async () => {
await browser.url("/hello-world.html?show-red-box=true");
await expect(redBox()).toBeDisplayed();
});
it("should override session storage", async () => {
// Set session storage first to ensure the URL parameter takes precedence.
await browser.execute(() => sessionStorage.setItem("showRedBox", "true"));
await browser.url("/hello-world.html?show-red-box=false");
await expect(redBox()).not.toBeDisplayed();
});
});
describe("Hide Button", () => {
it("should hide the red box when clicked", async () => {
await browser.toggleRedBox();
await expect(redBox()).toBeDisplayed();
await (await redBoxButton()).click();
await expect(redBox()).not.toBeDisplayed();
});
});
describe("Session Storage", () => {
it("should persist being shown after a reload", async () => {
await browser.toggleRedBox();
await expect(redBox()).toBeDisplayed();
await browser.refresh();
await expect(redBox()).toBeDisplayed();
});
it("should persist being hidden after a reload", async () => {
await browser.toggleRedBox(); // turn on
await browser.toggleRedBox(); // turn off
await expect(redBox()).not.toBeDisplayed();
// Explicitly check that storage is cleared before reloading
const storage = await browser.execute(() =>
sessionStorage.getItem("showRedBox"),
);
expect(storage).toBeNull();
await browser.refresh();
await expect(redBox()).not.toBeDisplayed();
});
});
describe("Interactions", () => {
it("should be able to be hidden with the keyboard after being shown with the URL", async () => {
await browser.url("/hello-world.html?show-red-box=true");
await expect(redBox()).toBeDisplayed();
await browser.toggleRedBox();
await expect(redBox()).not.toBeDisplayed();
});
});
});

View File

@@ -3,7 +3,17 @@ import { $, expect, browser } from "@wdio/globals";
describe("speaker-notes", () => {
beforeEach(async () => {
await browser.url("/");
await browser.url("/welcome-day-1.html");
await browser.refresh();
});
afterEach(async () => {
const handles = await browser.getWindowHandles();
if (handles.length > 1) {
await browser.switchToWindow(handles[1]);
await browser.closeWindow();
await browser.switchToWindow(handles[0]);
}
});
it("contains summary with heading and button", async () => {
@@ -17,7 +27,7 @@ describe("speaker-notes", () => {
const details$ = await $("details");
const button$ = await $("details summary .pop-out");
await expect(details$).toBeDisplayed();
button$.scrollIntoView();
await button$.scrollIntoView();
await button$.click();
await expect(details$).not.toBeDisplayed();
@@ -28,4 +38,16 @@ describe("speaker-notes", () => {
expect.stringContaining("#speaker-notes-open"),
);
});
it("should not show the red box in the speaker notes window", async () => {
const button$ = await $("details summary .pop-out");
await button$.scrollIntoView();
await button$.click();
const handles = await browser.getWindowHandles();
await browser.switchToWindow(handles[1]);
const redBox = await $("#aspect-ratio-helper");
await expect(redBox).not.toExist();
});
});

View File

@@ -213,8 +213,37 @@ export const config: WebdriverIO.Config = {
* @param {Array.<String>} specs List of spec file paths that are to be run
* @param {object} browser instance of created browser/device session
*/
before: function (capabilities, specs) {
browser.setWindowSize(2560, 1440);
before: async function (capabilities, specs) {
await browser.setWindowSize(2560, 1440);
/**
* Adds a custom `browser.toggleRedBox()` command.
*
* This command is necessary to reliably test the red box toggle
* functionality. A direct `browser.keys()` call proved to be
* flaky, causing intermittent test failures. This custom command
* will wait for the UI to reflect the state change, thus
* eliminating race conditions.
*/
browser.addCommand("toggleRedBox", async function () {
const redBox = await $("#aspect-ratio-helper");
const initialVisibility = await redBox.isDisplayed();
// Perform the toggle action.
await browser.keys(["Control", "Alt", "b"]);
// Wait until the visibility state has changed.
await browser.waitUntil(
async function () {
const currentVisibility = await redBox.isDisplayed();
return currentVisibility !== initialVisibility;
},
{
timeout: 5000,
timeoutMsg: `Red box display state did not toggle after 5s. Initial state: ${initialVisibility}`,
},
);
});
},
/**
* Runs before a WebdriverIO command gets executed.

View File

@@ -8,7 +8,8 @@ div#aspect-ratio-helper {
}
div#aspect-ratio-helper div {
outline: 3px dashed red;
position: relative;
outline: 2em solid rgba(255, 0, 0, 0.2);
margin: 0 auto;
/* At this width, the theme fonts are readable in a 16
person conference room. If the browser is wider, the
@@ -19,6 +20,16 @@ div#aspect-ratio-helper div {
aspect-ratio: 16/8.5;
}
#instructor-menu-list {
margin-left: 55px;
#turn-off-red-box {
position: absolute;
bottom: 10px;
right: 10px;
z-index: 10000;
padding: 10px;
background-color: #f44336;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
pointer-events: auto;
}

View File

@@ -1,71 +0,0 @@
(function handleInstructor() {
function handleInstructorMenu() {
let leftButtons = document.getElementsByClassName("left-buttons")[0];
let instructorMenu = document.createElement("button");
let instructorMenuList = document.createElement("ul");
let redBoxItem = document.createElement("li");
let redBoxButton = document.createElement("button");
let playgroundStateItem = document.createElement("li");
let playgroundStateButton = document.createElement("button");
leftButtons.insertBefore(instructorMenu, leftButtons.lastChild);
leftButtons.insertBefore(instructorMenuList, leftButtons.lastChild);
instructorMenuList.insertBefore(redBoxItem, instructorMenuList.lastChild);
instructorMenuList.insertBefore(
playgroundStateItem,
instructorMenuList.lastChild,
);
redBoxItem.insertBefore(redBoxButton, redBoxItem.lastChild);
playgroundStateItem.insertBefore(
playgroundStateButton,
playgroundStateItem.lastChild,
);
instructorMenu.title = "Utilities for course instructors";
instructorMenu.innerHTML =
'<i class="fa fa-ellipsis-v" aria-hidden="true"></i>';
redBoxButton.innerHTML = "aspect-ratio box";
redBoxButton.title =
"Outline the area that fits on one screen while teaching the course.";
playgroundStateButton.innerHTML = "reset all playgrounds";
playgroundStateButton.title =
"Reset code in all playgrounds to its original value.";
instructorMenu.className = "icon-button";
instructorMenuList.className = "theme-popup";
redBoxButton.className = "theme";
playgroundStateButton.className = "theme";
instructorMenuList.style.display = "none";
instructorMenuList.role = "menu";
redBoxItem.role = "none";
playgroundStateItem.role = "none";
redBoxButton.role = "menuitem";
playgroundStateButton.role = "menuitem";
redBoxButton.id = "redbox";
instructorMenuList.id = "instructor-menu-list";
playgroundStateButton.id = "playground-state";
instructorMenu.addEventListener("click", () => {
if (instructorMenuList.style.display === "none") {
instructorMenuList.style.display = "block";
} else {
instructorMenuList.style.display = "none";
}
});
document.addEventListener("click", (e) => {
if (!instructorMenu.contains(e.target)) {
instructorMenuList.style.display = "none";
}
});
}
handleInstructorMenu();
var redBoxButton = document.getElementById("redbox");
var playgroundStateButton = document.getElementById("playground-state");
redBoxButton.addEventListener("click", () => window.redboxButtonClicked());
playgroundStateButton.addEventListener("click", () =>
window.resetPlaygroundsClicked(),
);
})();

View File

@@ -1,29 +1,61 @@
(function redBoxButton() {
// Create a new div element
var newDiv = document.createElement("div");
// Set the id attribute of the new div
newDiv.id = "aspect-ratio-helper";
// Create a nested div inside the new div
var nestedDiv = document.createElement("div");
// Append the nested div to the new div
newDiv.appendChild(nestedDiv, newDiv.firstChild);
// Get the parent element where you want to append the new div
var parentElement = document.body; // Change this to your desired parent element
// Append the new div to the parent element
parentElement.insertBefore(newDiv, parentElement.firstChild);
//Default hiding the redbox
document.getElementById("aspect-ratio-helper").style.display = "none";
})();
(function () {
if (window.location.hash === "#speaker-notes-open") {
return;
}
//Create a function to button to perform on click action.
function redboxButtonClicked() {
var hideShowButton = document.getElementById("redbox");
if (document.getElementById("aspect-ratio-helper").style.display === "none") {
document.getElementById("aspect-ratio-helper").style.display = "block";
hideShowButton.innerHTML = "aspect-ratio box";
const redBox = document.createElement("div");
redBox.id = "aspect-ratio-helper";
redBox.style.display = "none"; // Initially hidden
const nestedDiv = document.createElement("div");
redBox.appendChild(nestedDiv);
const turnOffButton = document.createElement("button");
turnOffButton.id = "turn-off-red-box";
turnOffButton.textContent = "Hide Red Box";
nestedDiv.appendChild(turnOffButton);
document.body.prepend(redBox);
const storageKey = "showRedBox";
const turnOff = () => {
redBox.style.display = "none";
sessionStorage.removeItem(storageKey);
};
const turnOn = () => {
sessionStorage.setItem(storageKey, "true");
redBox.style.display = "block";
};
const toggleRedBox = () => {
if (redBox.style.display === "none") {
turnOn();
} else {
document.getElementById("aspect-ratio-helper").style.display = "none";
hideShowButton.innerHTML = "aspect-ratio box";
turnOff();
}
};
document.addEventListener("keydown", (event) => {
// Toggle the red box with Ctrl+Alt+B
if (event.ctrlKey && event.altKey && event.key === "b") {
event.preventDefault();
toggleRedBox();
}
window.redboxButtonClicked = redboxButtonClicked;
});
turnOffButton.addEventListener("click", turnOff);
// Check initial state from URL parameter or session storage
const searchParams = new URLSearchParams(window.location.search);
const showRedBoxParam = searchParams.get("show-red-box");
if (showRedBoxParam === "true") {
turnOn();
} else if (showRedBoxParam === "false") {
turnOff();
} else if (sessionStorage.getItem(storageKey) === "true") {
turnOn();
}
})();