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

Refactor LabelImage to separate out answers from userInput into scoringData #1965

Open
wants to merge 40 commits into
base: main
Choose a base branch
from

Conversation

Myranae
Copy link
Contributor

@Myranae Myranae commented Dec 6, 2024

Summary:

This PR updates LabelImage so that answers are no longer available in the userInput object. This allows the scoring function to have both a userInput parameter and a scoringData parameter to keep answers separate from the user's input with the goal of supporting server side scoring.

PerseusLabelImageMarker and MarkerType contained the same properties. As such, I simplified the code a bit and removed PerseusLabelImageMarker.

In addition, several locations were referencing the wrong types, so those were updated to reference the correct ones. Also, new tests were added confirming the output of getUserInput does not contain answers, that scorePerseusItem returns the correct results, and that the widget renders correctly if answers are not present in the JSON blob.

Issue: LEMS-2440

Test plan:

  • Confirm all checks pass
  • Confirm widget still works as expected

@Myranae Myranae self-assigned this Dec 6, 2024
Copy link
Contributor

github-actions bot commented Dec 6, 2024

npm Snapshot: Published

Good news!! We've packaged up the latest commit from this PR (415f32a) and published it to npm. You
can install it using the tag PR1965.

Example:

yarn add @khanacademy/perseus@PR1965

If you are working in Khan Academy's webapp, you can run:

./dev/tools/bump_perseus_version.sh -t PR1965

Copy link
Contributor

github-actions bot commented Dec 6, 2024

Size Change: +77 B (+0.01%)

Total Size: 1.27 MB

Filename Size Change
packages/perseus/dist/es/index.js 416 kB +77 B (+0.02%)
ℹ️ View Unchanged
Filename Size
packages/kas/dist/es/index.js 39 kB
packages/keypad-context/dist/es/index.js 760 B
packages/kmath/dist/es/index.js 4.27 kB
packages/math-input/dist/es/index.js 77.9 kB
packages/math-input/dist/es/strings.js 1.79 kB
packages/perseus-core/dist/es/index.js 1.48 kB
packages/perseus-editor/dist/es/index.js 688 kB
packages/perseus-linter/dist/es/index.js 22.2 kB
packages/perseus/dist/es/strings.js 4.12 kB
packages/pure-markdown/dist/es/index.js 3.66 kB
packages/simple-markdown/dist/es/index.js 12.5 kB

compressed-size-action

Comment on lines 144 to 149
export type PerseusLabelImageScoringData = {
markers: ReadonlyArray<MarkerAnswers>;
};

export type PerseusLabelImageUserInput = {
markers: ReadonlyArray<InteractiveMarkerType>;
markers: ReadonlyArray<Omit<InteractiveMarkerType, "answers">>;
Copy link
Contributor Author

@Myranae Myranae Dec 6, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are the main changes. Changed PerseusLabelImageUserInput so it no longer includes answers in the array. Then I moved the parts of InteractiveMarkerType needed for scoring to PerseusLabelImageScoringData.

Comment on lines -75 to -76
// TODO: there's some weirdness in our types between
// PerseusLabelImageMarker and InteractiveMarkerType
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was able to resolve this by removing PerseusLabelImageMarker

Comment on lines 52 to 60
const combinedData = userInput.markers.map((marker, index) => {
return {
...marker,
answers: scoringData.markers[index].answers,
};
});
const allMarkerData = {markers: combinedData};

for (const marker of userInput.markers) {
for (const marker of allMarkerData.markers) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Data was combined here so that marker can have all the available information as scoreMarker requires an object of InteractiveMarkerType, which includes answers.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No action needed, but my gut feeling is that this is needless misdirection. I kind of feel we should just change scoreMarker:

function scoreMarker(
    userInput: APieceOfUserInputType,
    scoringData: APieceOfScoringDataType,
): InteractiveMarkerScore;

I'm fine punting on this though.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed!

// TODO(LEMS-2544): Investigate impact on scoring; possibly pull out &/or remove rubric parameter.
showRationalesForCurrentlySelectedChoices(rubric: PerseusLabelImageRubric) {
// TODO(LEMS-2544): Investigate impact on scoring; possibly pull out &/or remove scoringData parameter.
showRationalesForCurrentlySelectedChoices(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit of a problem as it requires scoringData on the client-side it seems like. Since this has a TODO associated with it, I left it for now.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a known issue: https://khanacademy.atlassian.net/browse/LEMS-2544

Luckily AX won't use rationales so we can punt on this for now.

@Myranae Myranae marked this pull request as ready for review December 6, 2024 22:46
@khan-actions-bot khan-actions-bot requested a review from a team December 6, 2024 22:46
@khan-actions-bot
Copy link
Contributor

Gerald

Required Reviewers
  • @Khan/perseus for changes to .changeset/many-penguins-hug.md, packages/perseus/src/index.ts, packages/perseus/src/perseus-types.ts, packages/perseus/src/validation.types.ts, packages/perseus-editor/src/widgets/label-image-editor.tsx, packages/perseus/src/widgets/label-image/label-image.tsx, packages/perseus/src/widgets/label-image/score-label-image.test.ts, packages/perseus/src/widgets/label-image/score-label-image.ts, packages/perseus/src/widgets/label-image/types.ts, packages/perseus-editor/src/widgets/__stories__/label-image-editor.stories.tsx, packages/perseus-editor/src/widgets/label-image/marker.tsx, packages/perseus-editor/src/widgets/label-image/question-markers.tsx, packages/perseus-editor/src/widgets/label-image/__stories__/question-markers.stories.tsx

Don't want to be involved in this pull request? Comment #removeme and we won't notify you of further changes.

Copy link
Contributor

@handeyeco handeyeco left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is the right direction. I'm wondering though:

  1. Will LabelImage render if we provide it with PerseusData sans-answers?
  2. Will getUserInput work sans-answers?

Knowing how good you are about manual testing, I'm guessing the answer is "yes." I'd just like to see some automated tests to confirm.

// TODO(LEMS-2544): Investigate impact on scoring; possibly pull out &/or remove rubric parameter.
showRationalesForCurrentlySelectedChoices(rubric: PerseusLabelImageRubric) {
// TODO(LEMS-2544): Investigate impact on scoring; possibly pull out &/or remove scoringData parameter.
showRationalesForCurrentlySelectedChoices(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a known issue: https://khanacademy.atlassian.net/browse/LEMS-2544

Luckily AX won't use rationales so we can punt on this for now.


export type PerseusLabelImageUserInput = {
markers: ReadonlyArray<InteractiveMarkerType>;
markers: ReadonlyArray<Omit<InteractiveMarkerType, "answers">>;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a little higher-risk, but I think we should make this test case pass:

    // label-image.test.ts

    describe("getUserInput", () => {
        it("doesn't include answer in getUserInput", async () => {
            // render component
            const {renderer} = renderQuestion(textQuestion);

            const userInput = renderer.getUserInputMap();

            expect(userInput).toEqual({
                "label-image 1": {
                    markers: [
                        {
                            label: "The fourth unlabeled bar line.",
                            x: 25,
                            y: 17.7,
                        },
                        {
                            label: "The third unlabeled bar line.",
                            x: 25,
                            y: 35.3,
                        },
                        {
                            label: "The second unlabeled bar line.",
                            x: 25,
                            y: 53,
                        },
                        {
                            label: "The first unlabeled bar line.",
                            x: 25,
                            y: 70.3,
                        },
                    ],
                },
            });
        });
    });

I think there should be another set of tests too:

  1. Render a LabelImage widget using ItemData that has no answers (to simulate how it will be on the FE)
  2. Answer the LabelImage (both wrong and right)
  3. Use getUserInput on the Renderer to get the user input
  4. Use scorePerseusItem to score the user input

More than anything, I'm just surprised that this ticket ended up being more of a types change whereas I was thinking it would end up being more of a logic change. Not saying you're wrong, it just makes me worried we're missing something.

@@ -0,0 +1,6 @@
---
"@khanacademy/perseus": minor
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since you changed PerseusTypes in a way that's not backwards compatible, I'd call this a major/breaking change.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated to major. Thanks for pointing this out!

Comment on lines 52 to 60
const combinedData = userInput.markers.map((marker, index) => {
return {
...marker,
answers: scoringData.markers[index].answers,
};
});
const allMarkerData = {markers: combinedData};

for (const marker of userInput.markers) {
for (const marker of allMarkerData.markers) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No action needed, but my gut feeling is that this is needless misdirection. I kind of feel we should just change scoreMarker:

function scoreMarker(
    userInput: APieceOfUserInputType,
    scoringData: APieceOfScoringDataType,
): InteractiveMarkerScore;

I'm fine punting on this though.

@@ -1059,17 +1060,6 @@ export type PerseusLabelImageWidgetOptions = {
static: boolean;
};

export type PerseusLabelImageMarker = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jeremywiebe is particular when it comes to changing perseus-types.ts, I would have him sign off first.

@@ -1,19 +1,21 @@
// Base marker, with the props that are set by the editor.
export type MarkerType = {
// The list of correct answers expected for the marker.
export type MarkerAnswers = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These types feel off to me. I'm wondering if it's actually something like:

// stuff to render
type LabelImageMarker = {
    // Reveal the correctness state of the user selected answers for the marker.
    showCorrectness: "correct" | "incorrect";
    focused: boolean;
    // Translatable Text; The text to show for the marker. Not displayed directly to the user
    label: string;
    // The marker coordinates on the question image as percent of image size.
    x: number;
    y: number;
}

type LabelImageMarkerScoringData = {
    // The list of correct answers expected for the marker. Often only one but can have multiple
    answers: ReadonlyArray<string>;
}

type LabelImageMarkerUserInput = {
    // The user selected list of answers, used to grade the question.
    selected: ReadonlyArray<string>;
}

type LabelImageFullMarker = LabelImageMarker & LabelImageMarkerScoringData & LabelImageMarkerUserInput;

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated! Still need to add recommended tests and do additional manual testing though.

Copy link
Collaborator

@jeremywiebe jeremywiebe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall I like the direction of this PR. One change I'd like to see is that we keep the type defined in perseus-types.ts and then define the ScoringData for it in validation.types.ts.

@@ -1059,17 +1060,6 @@ export type PerseusLabelImageWidgetOptions = {
static: boolean;
};

export type PerseusLabelImageMarker = {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm assuming you got rid of this type because it was a duplicate of what's in the widget? Could you instead, make this type the source of truth? perseus-types.ts is the source of truth for the data that we serialize out of Perseus and I've been working to keep all non-trivial type definitions for widget options here instead of in the widget folders.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Further, because TypeScript types match if they are the same shape, I was thinking about ScoringData and ValidationData for each widget as a matching subset of the widget options in this file.

Copy link
Contributor Author

@Myranae Myranae Dec 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mean use the types in this file (perseus-types.ts) to define the ScoringData and ValidationData types (in validation.types.ts)? Also, with Matthew's suggestion, I ended up modifying the types in the label-image/types.ts file. I'll review what I did and what was here to see if I can make them work together still. Or maybe I can just leave this file alone 😂

Copy link
Member

@catandthemachines catandthemachines left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code looks good to me! Though I would encourage you to get an approval from Matthew/Jeremy as they have more context into the scoring code 😉

@@ -28,7 +31,7 @@ type Props = {
imageWidth: number;
imageHeight: number;
// The list of label markers on the question image.
markers: ReadonlyArray<MarkerType>;
markers: ReadonlyArray<LabelImageMarker & LabelImageMarkerScoringData>;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I notice your creating this union in a lot of files. Can you combine them in the types object so you don't have to keep joining them in every file?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great suggestion! I'll set that up. :)

We are moving scoring data and user input to validation data only
We build the wanted object instead of remove the unneeded property. The removal method was used previously to change as little as possible from the incoming markers object.
Don't want to use the ScoringData type here. Would be widget options type if the parameter returns
…ithout-rubric

# Conflicts:
#	packages/perseus/src/index.ts
Add tests for scoring (invalid, correct, and incorrect), test for rendering without answers, and a test to confirm getUserInput works
Just confirms text from the question has been rendered on the page
@@ -709,4 +714,110 @@ describe("LabelImage", function () {
});
});
});

describe("getUserInput", () => {
it("doesn't include answer in getUserInput from getUserInputMap", () => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 Can we make this test slightly more generic? I think we're less concerned about answer specifically and more concerned about getUserInputMap() returning the right data overall. So if I were to take a stab at naming this test, I would do something like:

Suggested change
it("doesn't include answer in getUserInput from getUserInputMap", () => {
it("should return the current user input on initial render", () => {

Copy link
Contributor Author

@Myranae Myranae Dec 13, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the focus was supposed to be making sure answer was not included, but it's also correct to confirm it's just returning the correct userInput, especially since I didn't write any expect statements specifically checking that answers are absent. I do think it's important to emphasize that the goal is to have answers out of user input, but I guess that will be obvious from the shape of UserInput. Updated :)

});

describe("textWithoutAnswersQuestion", () => {
it("should render a text question without answers", () => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! Great idea for a test as we get ready to start splitting things!!

Comment on lines +308 to +313
return {
markers: this.props.markers.map((marker) => ({
selected: marker.selected,
label: marker.label,
})),
};
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@handeyeco for visibility, Tamara and I re-wrote this function to build a return value by explicitly picking the pieces we need instead of simply return a value out of props. This help stop guarantee we're really only using values in the type and not depending elsewhere on keys that may get stripped when we do the Scoring Data stripping stuff.

Comment on lines 21 to 22
x: 0,
y: 0,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we dropped x and y from this type, didn't we?

Comment on lines 14 to 15
x: 0,
y: 0,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we dropped x and y from this type, didn't we?

markers: [
{
...emptyMarker,
...emptyUserInput,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think once we update this object to only have what's in the user input type, we might not need it. It might be more clear to inline the single selected: [] bit here instead of spreading this object at that point. I'll leave it to you to decide which way you want to go. :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated the tests to remove these empty types.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants