Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[CRT-32] Add assertions to scratch #50

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
Open
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
98 changes: 76 additions & 22 deletions apps/scratch/src/blocks/make-toolbox-xml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,35 @@ const createDictionaryWithAllValuesInfinite = (
): Record<string, number> =>
Object.values(object).reduce((acc, key) => ({ ...acc, [key]: -1 }), {});

export const allowAllBlocks: BlockLimits = {
...createDictionaryWithAllValuesInfinite(MotionOpCode),
...createDictionaryWithAllValuesInfinite(LooksOpCode),
...createDictionaryWithAllValuesInfinite(SoundOpCode),
...createDictionaryWithAllValuesInfinite(EventOpCode),
...createDictionaryWithAllValuesInfinite(ControlOpCode),
...createDictionaryWithAllValuesInfinite(SensingOpCode),
...createDictionaryWithAllValuesInfinite(OperatorsOpCode),
const allowAllMotionBlocks: BlockLimits =
createDictionaryWithAllValuesInfinite(MotionOpCode);

const allowAllLooksBlocks: BlockLimits =
createDictionaryWithAllValuesInfinite(LooksOpCode);

const allowAllSoundBlocks: BlockLimits =
createDictionaryWithAllValuesInfinite(SoundOpCode);

const allowAllEventBlocks: BlockLimits =
createDictionaryWithAllValuesInfinite(EventOpCode);

const allowAllControlBlocks: BlockLimits =
createDictionaryWithAllValuesInfinite(ControlOpCode);

const allowAllSensingBlocks: BlockLimits =
createDictionaryWithAllValuesInfinite(SensingOpCode);

const allowAllOperatorsBlocks: BlockLimits =
createDictionaryWithAllValuesInfinite(OperatorsOpCode);

export const allowAllStandardBlocks: BlockLimits = {
...allowAllMotionBlocks,
...allowAllLooksBlocks,
...allowAllSoundBlocks,
...allowAllEventBlocks,
...allowAllControlBlocks,
...allowAllSensingBlocks,
...allowAllOperatorsBlocks,
variables: true,
customBlocks: true,
};
Expand All @@ -88,6 +109,7 @@ export const allowNoBlocks: BlockLimits = {};
* @param costumeName - The name of the default selected costume dropdown.
* @param backdropName - The name of the default selected backdrop dropdown.
* @param soundName - The name of the default selected sound dropdown.
* @param blockLimits - The number of blocks that are allowed to be used for a given task. Null means all blocks are allowed.
* @returns- a ScratchBlocks-style XML document for the contents of the toolbox.
*/
const makeToolboxXML = function (
Expand All @@ -99,7 +121,7 @@ const makeToolboxXML = function (
costumeName: string = "",
backdropName: string = "",
soundName: string = "",
blockLimits: BlockLimits = allowAllBlocks,
blockLimits: BlockLimits | null = null,
): string {
isStage = isInitialSetup || isStage;
const gap = categorySeparator;
Expand Down Expand Up @@ -127,7 +149,7 @@ const makeToolboxXML = function (
isStage,
targetId,
colors.motion,
blockLimits,
blockLimits ?? allowAllMotionBlocks,
);
const looksXML =
moveCategory("looks") ||
Expand All @@ -138,7 +160,7 @@ const makeToolboxXML = function (
costumeName,
backdropName,
colors.looks,
blockLimits,
blockLimits ?? allowAllLooksBlocks,
);
const soundXML =
moveCategory("sound") ||
Expand All @@ -148,19 +170,25 @@ const makeToolboxXML = function (
targetId,
soundName,
colors.sounds,
blockLimits,
blockLimits ?? allowAllSoundBlocks,
);
const eventsXML =
moveCategory("event") ||
buildEventXml(isInitialSetup, isStage, targetId, colors.event, blockLimits);
buildEventXml(
isInitialSetup,
isStage,
targetId,
colors.event,
blockLimits ?? allowAllEventBlocks,
);
const controlXML =
moveCategory("control") ||
buildControlXml(
isInitialSetup,
isStage,
targetId,
colors.control,
blockLimits,
blockLimits ?? allowAllControlBlocks,
);
const sensingXML =
moveCategory("sensing") ||
Expand All @@ -169,7 +197,7 @@ const makeToolboxXML = function (
isStage,
targetId,
colors.sensing,
blockLimits,
blockLimits ?? allowAllSensingBlocks,
);
const operatorsXML =
moveCategory("operators") ||
Expand All @@ -178,7 +206,7 @@ const makeToolboxXML = function (
isStage,
targetId,
colors.operators,
blockLimits,
blockLimits ?? allowAllOperatorsBlocks,
);
const variablesXML =
moveCategory("data") ||
Expand All @@ -203,20 +231,46 @@ const makeToolboxXML = function (
gap,
operatorsXML,
gap,
blockLimits.variables ? variablesXML : "",
blockLimits == null || blockLimits.variables ? variablesXML : "",
gap,
blockLimits.customBlocks ? myBlocksXML : "",
blockLimits == null || blockLimits.customBlocks ? myBlocksXML : "",
];

for (const extensionCategory of categoriesXML) {
if (blockLimits !== null) {
// find all <block type="{opcode}"> elements and check them against blockLimits
extensionCategory.xml = extensionCategory.xml.replace(
/<block type="([^"]+)">.*?<\/block>/g,
(match, opcode) => {
// if the opcode is not in any of the blockLimits, return the match
if (opcode in blockLimits) {
const limit = blockLimits[opcode];
if (limit === 0) {
// if the limit is 0, don't show the block
return "";
}
} else {
// if the opcode is not in blockLimits, don't show the block
return "";
}

return match;
},
);
}

// check if the xml still contains some <block> elements
if (extensionCategory.xml.includes("<block")) {
// if so, add the category to the toolbox
everything.push(extensionCategory.xml);
}
}

if (!everything.find((xml) => xml.includes("<category"))) {
// if all blocks are disabled, show a message
everything.push(allBlocksAreDisabled);
}

for (const extensionCategory of categoriesXML) {
everything.push(gap, extensionCategory.xml);
}

everything.push(xmlClose);
return everything.join("\n");
};
Expand Down
91 changes: 79 additions & 12 deletions apps/scratch/src/components/TaskConfig.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import React, { FormEvent, useCallback, useEffect } from "react";
import React, { FormEvent, useCallback, useEffect, useState } from "react";

import VM, { ScratchCrtConfig } from "scratch-vm";
import VM from "scratch-vm";
import Modal from "./modal/Modal";
import { allowAllBlocks, allowNoBlocks } from "../blocks/make-toolbox-xml";
import {
allowAllStandardBlocks,
allowNoBlocks,
} from "../blocks/make-toolbox-xml";
import { UpdateBlockToolboxEvent } from "../events/update-block-toolbox";
import { useAssertionsEnabled } from "../hooks/useAssertionsEnabled";
import { ExtensionId } from "../extensions";
import { ScratchCrtConfig } from "../types/scratch-vm-custom";
import { FormattedMessage } from "react-intl";

const TaskConfig = ({
vm,
Expand All @@ -14,6 +21,12 @@ const TaskConfig = ({
isShown?: boolean;
hideModal: () => void;
}) => {
const assertionsEnabled = useAssertionsEnabled(vm);
const [isAssertionsExtensionEnabled, setIsAssertionsExtensionEnabled] =
useState(false);

const [enableAssertions, setEnableAssertions] = useState(false);

const updateConfig = useCallback(
(
e: React.SyntheticEvent,
Expand All @@ -37,9 +50,12 @@ const TaskConfig = ({
[vm],
);

const onAllowAllBlocks = useCallback(
const onAllowAllStandardBlocks = useCallback(
(e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
updateConfig(e, (config) => (config.allowedBlocks = allowAllBlocks));
updateConfig(
e,
(config) => (config.allowedBlocks = allowAllStandardBlocks),
);
},
[updateConfig],
);
Expand All @@ -55,32 +71,83 @@ const TaskConfig = ({
e.preventDefault();
e.stopPropagation();

updateConfig(e, () => {});
updateConfig(e, () => {
// update assertions
if (isAssertionsExtensionEnabled) {
vm.runtime.emit(
enableAssertions ? "ENABLE_ASSERTIONS" : "DISABLE_ASSERTIONS",
Tyratox marked this conversation as resolved.
Show resolved Hide resolved
);
}
});

hideModal();
},
[vm],
[
vm,
isAssertionsExtensionEnabled,
enableAssertions,
updateConfig,
hideModal,
],
);

useEffect(() => {
// every time the modal is opened, load the form values based on the config
setIsAssertionsExtensionEnabled(
vm.extensionManager.isExtensionLoaded(ExtensionId.Assertions),
);

setEnableAssertions(assertionsEnabled);
}, [isShown]);

return (
<Modal isShown={isShown}>
<h1>Task Config</h1>
<h1>
<FormattedMessage
defaultMessage="Task Config"
description="Heading of the task config modal."
id="crt.taskConfig.heading"
/>
</h1>

<form onSubmit={onSubmit} data-testid="task-config-form">
<button
onClick={onAllowAllBlocks}
data-testid="allow-all-blocks-button"
onClick={onAllowAllStandardBlocks}
data-testid="allow-all-standard-blocks-button"
>
Allow all blocks to be used
<FormattedMessage
defaultMessage="Allow all standard blocks to be used"
description="Label shown on the button that, when clicked, allows all standard blocks to be used by students."
id="crt.taskConfig.heading"
/>
</button>
<button onClick={onAllowNoBlocks} data-testid="allow-no-blocks-button">
Disallow any block to be used
<FormattedMessage
defaultMessage="Disallow all blocks"
description="Label shown on the button that, when clicked, disallows all blocks from being used by students."
id="crt.taskConfig.heading"
/>
</button>

{isAssertionsExtensionEnabled && (
<label>
<span>
<FormattedMessage
defaultMessage="Enable assertions simulating a student solving the task."
description="Label shown next to the checkbox that allows a teacher to simulate the assertion mode when editing."
id="crt.taskConfig.heading"
/>
</span>
<input
type="checkbox"
min="0"
checked={enableAssertions}
onChange={(e) => setEnableAssertions(e.target.checked)}
data-testid="enable-assertions-checkbox"
/>
</label>
)}

<input
type="submit"
value="Save"
Expand Down
40 changes: 40 additions & 0 deletions apps/scratch/src/components/assertions-state/AssertionsState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import VM from "scratch-vm";
import styles from "./assertions-state.css";
import icon from "../../extensions/assertions/test-icon-white.svg";
import { useAssertionsEnabled } from "../../hooks/useAssertionsEnabled";
import { useAssertionsState } from "../../hooks/useAssertionsState";
import classNames from "classnames";

const AssertionsState = ({ vm }: { vm: VM }) => {
const assertionsEnabled = useAssertionsEnabled(vm);
const assertionsState = useAssertionsState(vm);

if (!assertionsEnabled) {
return null;
}

const isSuccessful =
assertionsState.total > 0 &&
assertionsState.passed >= assertionsState.total;

return (
<div
className={classNames(
styles.state,
isSuccessful ? styles.success : styles.noSuccess,
)}
data-testid="assertion-state"
>
{assertionsState.total > 0 && (
<span>
<span data-testid="passed">{assertionsState.passed}</span>
{" / "}
<span data-testid="total">{assertionsState.total}</span>
</span>
)}
<img src={icon} />
</div>
);
};

export default AssertionsState;
25 changes: 25 additions & 0 deletions apps/scratch/src/components/assertions-state/assertions-state.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
.state {
padding: 0.25rem;
border-radius: 0.25rem;
background-color: var(--assertions-default-color);
color: #fff;

display: flex;
flex-direction: row;
justify-content: center;

gap: 0.5rem;

img {
height: 20px;
width: auto;
}
}

.success {
background-color: var(--assertions-success-color);
}

.no-success {
background-color: var(--assertions-default-color);
}
Loading