You've already forked comprehensive-rust
mirror of
https://github.com/google/comprehensive-rust.git
synced 2025-10-09 02:42:04 +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:
3
.github/workflows/build.sh
vendored
3
.github/workflows/build.sh
vendored
@@ -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/)
|
||||
|
||||
|
83
GEMINI.md
83
GEMINI.md
@@ -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"
|
||||
```
|
||||
|
@@ -60,6 +60,7 @@ urlcolor = "red"
|
||||
smart-punctuation = true
|
||||
additional-js = [
|
||||
"theme/speaker-notes.js",
|
||||
"theme/redbox.js",
|
||||
]
|
||||
additional-css = [
|
||||
"theme/css/svgbob.css",
|
||||
|
@@ -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
98
tests/src/redbox.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
@@ -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();
|
||||
});
|
||||
});
|
||||
|
@@ -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.
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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(),
|
||||
);
|
||||
})();
|
@@ -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";
|
||||
})();
|
||||
|
||||
//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";
|
||||
} else {
|
||||
document.getElementById("aspect-ratio-helper").style.display = "none";
|
||||
hideShowButton.innerHTML = "aspect-ratio box";
|
||||
(function () {
|
||||
if (window.location.hash === "#speaker-notes-open") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
window.redboxButtonClicked = redboxButtonClicked;
|
||||
|
||||
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 {
|
||||
turnOff();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
// Toggle the red box with Ctrl+Alt+B
|
||||
if (event.ctrlKey && event.altKey && event.key === "b") {
|
||||
event.preventDefault();
|
||||
toggleRedBox();
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
})();
|
||||
|
Reference in New Issue
Block a user