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

Fix requirements double counting #638

Merged
Show file tree
Hide file tree
Changes from 12 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
66 changes: 33 additions & 33 deletions src/components/Modals/NewCourse/NewCourseModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,16 @@
data-cyId="newCourse-dropdown"
/>
<div v-else class="selected-course" data-cyId="newCourse-selectedCourse">
{{ selectedCourse.subject }} {{ selectedCourse.catalogNbr }}: {{ selectedCourse.titleLong }}
{{ selectedCourse.subject }} {{ selectedCourse.catalogNbr }}:
{{ selectedCourse.titleLong }}
</div>
<div v-if="selectedCourse != null">
<!-- if a course is selected -->
<selected-requirement-editor
:key="courseSelectorKey"
:editMode="editMode"
:selectedRequirementID="selectedRequirementID"
:requirementsThatAllowDoubleCounting="requirementsThatAllowDoubleCounting"
:automaticallyFulfilledRequirements="automaticallyFulfilledRequirements"
:relatedRequirements="relatedRequirements"
:potentialRequirements="selfCheckRequirements"
@on-selected-change="onSelectedChange"
Expand All @@ -48,7 +49,10 @@ import TeleportModal from '@/components/Modals/TeleportModal.vue';
import CourseSelector from '@/components/Modals/NewCourse/CourseSelector.vue';

import store from '@/store';
import { getRelatedUnfulfilledRequirements } from '@/requirements/requirement-frontend-utils';
import {
getRelatedRequirementIdsForCourseOptOut,
getRelatedUnfulfilledRequirements,
} from '@/requirements/requirement-frontend-utils';

export default defineComponent({
components: { CourseSelector, TeleportModal, SelectedRequirementEditor },
Expand All @@ -61,7 +65,7 @@ export default defineComponent({
return {
selectedCourse: null as CornellCourseRosterCourse | null,
selectedRequirementID: '',
requirementsThatAllowDoubleCounting: [] as readonly string[],
automaticallyFulfilledRequirements: [] as readonly string[],
// relatedRequirements : the requirements that don't allow double counting
relatedRequirements: [] as readonly RequirementWithIDSourceType[],
selfCheckRequirements: [] as readonly RequirementWithIDSourceType[],
Expand Down Expand Up @@ -89,38 +93,31 @@ export default defineComponent({
},
getReqsRelatedToCourse(selectedCourse: CornellCourseRosterCourse) {
const {
directlyRelatedRequirements,
relatedRequirements,
selfCheckRequirements,
automaticallyFulfilledRequirements,
} = getRelatedUnfulfilledRequirements(
selectedCourse,
store.state.groupedRequirementFulfillmentReport,
store.state.toggleableRequirementChoices,
store.state.overriddenFulfillmentChoices
store.state.overriddenFulfillmentChoices,
store.state.userRequirementsMap
);
const automaticallyFulfilledRequirementIds = new Set(
automaticallyFulfilledRequirements.map(({ id }) => id)
);

const requirementsThatAllowDoubleCounting: string[] = [];
const relatedRequirements: RequirementWithIDSourceType[] = [];
directlyRelatedRequirements.forEach(it => {
if (it.allowCourseDoubleCounting) {
requirementsThatAllowDoubleCounting.push(it.name);
} else {
relatedRequirements.push(it);
}
});
const selfCheckRequirementsThatDoesNotAllowDoubleCounting: RequirementWithIDSourceType[] = [];
selfCheckRequirements.forEach(it => {
if (it.allowCourseDoubleCounting) {
requirementsThatAllowDoubleCounting.push(it.name);
} else {
selfCheckRequirementsThatDoesNotAllowDoubleCounting.push(it);
}
});

this.requirementsThatAllowDoubleCounting = requirementsThatAllowDoubleCounting;
this.relatedRequirements = relatedRequirements;
this.selfCheckRequirements = selfCheckRequirementsThatDoesNotAllowDoubleCounting;
if (relatedRequirements.length > 0) {
this.selectedRequirementID = relatedRequirements[0].id;
this.automaticallyFulfilledRequirements = automaticallyFulfilledRequirements.map(
({ name }) => name
);
this.relatedRequirements = relatedRequirements.filter(
req => !automaticallyFulfilledRequirementIds.has(req.id)
);
this.selfCheckRequirements = selfCheckRequirements.filter(
req => !automaticallyFulfilledRequirementIds.has(req.id)
);
if (this.relatedRequirements.length > 0) {
this.selectedRequirementID = this.relatedRequirements[0].id;
} else {
this.selectedRequirementID = '';
}
Expand All @@ -135,10 +132,13 @@ export default defineComponent({
addCourse() {
if (this.selectedCourse == null) return;
this.$emit('add-course', this.selectedCourse, {
// Only exclude the selected requirement from opt-out.
optOut: this.relatedRequirements
.filter(it => it.id !== this.selectedRequirementID)
.map(it => it.id),
optOut: getRelatedRequirementIdsForCourseOptOut(
this.selectedCourse.crseId,
this.selectedRequirementID,
store.state.groupedRequirementFulfillmentReport,
store.state.toggleableRequirementChoices,
store.state.userRequirementsMap
),
Comment on lines +135 to +141
Copy link
Collaborator Author

@benjamin-shen benjamin-shen Jan 16, 2022

Choose a reason for hiding this comment

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

This fixes the following issue:
College: ENG
Major: CS
Minor: Human Development

Add HD 1150 as a course through the add course modal / semester. Due to the limitation of the add modal, the user cannot choose more than one non-double countable requirement. However, after adding the course, the dangerous graph should have all non-self-check edges not in the constraint violation.

eg. If you choose Liberal Studies, both minor requirements should be connected in the dangerous graph. If you choose a minor requirement, liberal studies should be connected in both the dangerous and safe graph.

Copy link
Member

@willespencer willespencer Jan 16, 2022

Choose a reason for hiding this comment

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

Tested this and confirmed what you are saying is true!

This isn't ideal on the front-end, as it means that when users choose one of the minor options in the constraint instead of liberal studies, they are still getting liberal studies set to the course. However the new modal will make this flow more clear right? Is there anything we can do in the meantime?

Copy link
Member

Choose a reason for hiding this comment

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

Same issue when adding CS 1110 where adding it to Computing also adds it to Introductory Programming

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This isn't ideal on the front-end, as it means that when users choose one of the minor options in the constraint instead of liberal studies, they are still getting liberal studies set to the course.

I think this is similar to what would happen in the new add flow. There are no conflicts, so we might as well add it. I believe if you choose a non-liberal studies and non-minor requirement, liberal studies would be no longer connected.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I modified the function so that it opted out of all requirementsThatDoNotAllowDoubleCounting. So, there should be no constraint violations resulting from the minor requirements.

It still automatically selects the liberal studies requirement if it doesn't cause a constraint violation, which is a slight disconnect from the UI. I don't think this can be fixed until the new add modal.

// Only include the selected requirement from opt-in.
acknowledgedCheckerWarningOptIn: this.selfCheckRequirements
.filter(it => it.id === this.selectedRequirementID)
Expand Down
10 changes: 5 additions & 5 deletions src/components/Modals/NewCourse/SelectedRequirementEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@
<div>
<div
v-if="
editMode ? requirementsThatAllowDoubleCounting.length > 0 : chosenRequirementText.length > 0
editMode ? automaticallyFulfilledRequirements.length > 0 : chosenRequirementText.length > 0
"
>
<div class="newCourse-title">
This class automatically fulfills the following requirement(s):
</div>
<div class="newCourse-requirements-container">
<div class="newCourse-requirements" data-cyId="newCourse-requirements">
{{ editMode ? requirementsThatAllowDoubleCounting.join(', ') : chosenRequirementText }}
{{ editMode ? automaticallyFulfilledRequirements.join(', ') : chosenRequirementText }}
</div>
</div>
</div>
Expand Down Expand Up @@ -82,7 +82,7 @@ export default defineComponent({
props: {
editMode: { type: Boolean, required: true },
selectedRequirementID: { type: String, required: true },
requirementsThatAllowDoubleCounting: {
automaticallyFulfilledRequirements: {
type: Array as PropType<readonly string[]>,
required: true,
},
Expand All @@ -104,12 +104,12 @@ export default defineComponent({
computed: {
chosenRequirementText(): string {
if (this.selectedRequirementID === '') {
return this.requirementsThatAllowDoubleCounting.join(', ');
return this.automaticallyFulfilledRequirements.join(', ');
}
const chosenRequirementNames = [...this.relatedRequirements, ...this.potentialRequirements]
.filter(it => it.id === this.selectedRequirementID)
.map(it => it.name);
return [...this.requirementsThatAllowDoubleCounting, ...chosenRequirementNames].join(', ');
return [...this.automaticallyFulfilledRequirements, ...chosenRequirementNames].join(', ');
},
// nonAutoRequirements = relatedRequirements + potentialRequirements
// All the requirements that are not automatically associated with the course
Expand Down
21 changes: 14 additions & 7 deletions src/components/Requirements/IncompleteSelfCheck.vue
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ import {
} from '@/global-firestore-data';
import {
canFulfillChecker,
getAllEligibleRelatedRequirementIds,
getRelatedRequirementIdsForCourseOptOut,
} from '@/requirements/requirement-frontend-utils';

import NewSelfCheckCourseModal from '@/components/Modals/NewCourse/NewSelfCheckCourseModal.vue';
Expand Down Expand Up @@ -101,6 +101,8 @@ export default defineComponent({
return;
}

/* TODO @bshen fix .allowCourseDoubleCounting flag
we should allow the constraint violation to be broken, i.e. don't return early */
Copy link
Member

Choose a reason for hiding this comment

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

What does this refer to?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The usage of .allowCourseDoubleCounting in the following lines.

// There are 2 ways we can add the course to the requirement without double counting violations:
// 1. This requirement allows double counting.
// 2. All the already matched requirements allow double counting.
// If both don't hold, we cannot add.

We should change this to allow adding regardless of whether or not the requirement allows double counting, and resolve the constraint violations later (basically remove the early return). This fix should wait until after most of the frontend is implemented.

Copy link
Member

Choose a reason for hiding this comment

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

Can you make an item for this haha

const currentRequirementAllowDoubleCounting =
store.state.userRequirementsMap[this.subReqCourseId]?.allowCourseDoubleCounting;
const allOtherRequirementsAllowDoubleCounting = store.state.safeRequirementFulfillmentGraph
Expand Down Expand Up @@ -139,18 +141,21 @@ export default defineComponent({
},
addExistingCourse(option: string) {
this.showDropdown = false;
updateRequirementChoice(this.selfCheckCourses[option].uniqueID, choice => ({
const { uniqueID, crseId } = this.selfCheckCourses[option];
updateRequirementChoice(uniqueID, choice => ({
...choice,
// Since we edit from a self-check requirement,
// we know it must be `acknowledgedCheckerWarningOptIn`.
acknowledgedCheckerWarningOptIn: Array.from(
new Set([...choice.acknowledgedCheckerWarningOptIn, this.subReqId])
),
// Keep existing behavior of keeping it connected to at most one requirement.
optOut: getAllEligibleRelatedRequirementIds(
this.selfCheckCourses[option].crseId,
optOut: getRelatedRequirementIdsForCourseOptOut(
crseId,
this.subReqId,
store.state.groupedRequirementFulfillmentReport,
store.state.toggleableRequirementChoices
store.state.toggleableRequirementChoices,
store.state.userRequirementsMap
),
}));
},
Expand All @@ -170,10 +175,12 @@ export default defineComponent({
// We also need to opt-out of all requirements without warnings,
// because the user intention is clear that we only want to bind
// the course to this specific requirement.
optOut: getAllEligibleRelatedRequirementIds(
optOut: getRelatedRequirementIdsForCourseOptOut(
newCourse.crseId,
this.subReqId,
store.state.groupedRequirementFulfillmentReport,
store.state.toggleableRequirementChoices
store.state.toggleableRequirementChoices,
store.state.userRequirementsMap
),
}),
this.$gtag
Expand Down
10 changes: 6 additions & 4 deletions src/components/Semester/Semester.vue
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ import {
updateRequirementChoices,
} from '@/global-firestore-data';
import store, { updateSubjectColorData } from '@/store';
import { getAllEligibleRelatedRequirementIds } from '@/requirements/requirement-frontend-utils';
import { getRelatedRequirementIdsForCourseOptOut } from '@/requirements/requirement-frontend-utils';

type ComponentRef = { $el: HTMLDivElement };

Expand Down Expand Up @@ -280,11 +280,13 @@ export default defineComponent({
// the `optOut` field.
// Below, we find all the requirements it can possibly match,
// and only remove the requirementID since that's the one we should keep.
const optOut = getAllEligibleRelatedRequirementIds(
const optOut = getRelatedRequirementIdsForCourseOptOut(
crseId,
requirementID,
store.state.groupedRequirementFulfillmentReport,
store.state.toggleableRequirementChoices
).filter(it => it !== requirementID);
store.state.toggleableRequirementChoices,
store.state.userRequirementsMap
);
choices[uniqueID] = { ...choice, optOut };
});
return choices;
Expand Down
63 changes: 51 additions & 12 deletions src/requirements/requirement-frontend-utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import requirementJson from './typed-requirement-json';
import specialized from './specialize';
import { examCourseIds } from './requirement-exam-mapping';
import { getConstraintViolationsForSingleCourse } from './requirement-constraints-utils';

/**
* A collection of helper functions
Expand Down Expand Up @@ -444,10 +445,12 @@ export function computeFulfillmentCoursesAndStatistics(
};
}

export function getAllEligibleRelatedRequirementIds(
export function getRelatedRequirementIdsForCourseOptOut(
courseId: number,
associatedRequirementId: string,
groupedRequirements: readonly GroupedRequirementFulfillmentReport[],
toggleableRequirementChoices: AppToggleableRequirementChoices
toggleableRequirementChoices: AppToggleableRequirementChoices,
userRequirementsMap: Readonly<Record<string, RequirementWithIDSourceType>>
): readonly string[] {
const requirements = groupedRequirements
.flatMap(it => it.reqs)
Expand All @@ -458,12 +461,33 @@ export function getAllEligibleRelatedRequirementIds(
);
if (spec == null) return [];
const allEligibleCourses = spec.eligibleCourses.flat();
if (allEligibleCourses.includes(courseId) && requirement.checkerWarning == null) {
if (
requirement.id === associatedRequirementId ||
(allEligibleCourses.includes(courseId) && requirement.checkerWarning == null)
) {
return [requirement.id];
}
return [];
});
return requirements;
// only return the requirements that are in a constraint violation
const uniqueId = -1; // dummy unique id
const { courseToRequirementsInConstraintViolations } = getConstraintViolationsForSingleCourse(
{ uniqueId },
requirements,
(reqA, reqB) =>
allowCourseDoubleCountingBetweenRequirements(
userRequirementsMap[reqA],
userRequirementsMap[reqB]
)
);
const optOut = new Set<string>();
courseToRequirementsInConstraintViolations.get(uniqueId)?.forEach(requirementGroup => {
if (requirementGroup.includes(associatedRequirementId)) {
requirementGroup.forEach(requirement => optOut.add(requirement));
}
});
// order does not need to be preserved
return Array.from(optOut).filter(it => it !== associatedRequirementId);
}

export function getRelatedUnfulfilledRequirements(
Expand All @@ -475,13 +499,15 @@ export function getRelatedUnfulfilledRequirements(
}: CornellCourseRosterCourse,
groupedRequirements: readonly GroupedRequirementFulfillmentReport[],
toggleableRequirementChoices: AppToggleableRequirementChoices,
overriddenFulfillmentChoices: FirestoreOverriddenFulfillmentChoices
overriddenFulfillmentChoices: FirestoreOverriddenFulfillmentChoices,
userRequirementsMap: Readonly<Record<string, RequirementWithIDSourceType>>
): {
readonly directlyRelatedRequirements: readonly RequirementWithIDSourceType[];
readonly relatedRequirements: readonly RequirementWithIDSourceType[];
readonly selfCheckRequirements: readonly RequirementWithIDSourceType[];
readonly automaticallyFulfilledRequirements: readonly RequirementWithIDSourceType[];
} {
const code = `${subject} ${catalogNbr}`;
const directlyRelatedRequirements: RequirementWithIDSourceType[] = [];
const relatedRequirements: RequirementWithIDSourceType[] = [];
const selfCheckRequirements: RequirementWithIDSourceType[] = [];
for (let i = 0; i < groupedRequirements.length; i += 1) {
const subreqs = groupedRequirements[i].reqs.filter(
Expand All @@ -501,9 +527,6 @@ export function getRelatedUnfulfilledRequirements(
toggleableRequirementChoices
);
// potential self-check requirements
if (requirementSpec == null && !subRequirement.allowCourseDoubleCounting) {
Copy link
Member

Choose a reason for hiding this comment

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

Why do we not need the second check here anymore?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The function is only called once in NewCourseModal.vue, to generate the automatically fulfilled requirements and the non-automatically fulfilled requirements dropdown. In the past, the if-expression was used to filter out the requirements that allowed double counting which we assumed would always be automatically fulfilled. However, with our reframing of the double counting flag (and in this new implementation), it is filtered later in a similar way.

selfCheckRequirements.push(subRequirement);
}
if (requirementSpec != null) {
const allEligibleCourses = requirementSpec.eligibleCourses.flat();
if (allEligibleCourses.includes(courseId)) {
Expand All @@ -524,14 +547,30 @@ export function getRelatedUnfulfilledRequirements(
);
if (fulfillmentStatisticsWithNewCourse.minCountFulfilled > existingMinCountFulfilled) {
if (subRequirement.checkerWarning == null) {
directlyRelatedRequirements.push(subRequirement);
relatedRequirements.push(subRequirement);
} else {
selfCheckRequirements.push(subRequirement);
}
}
}
} else {
selfCheckRequirements.push(subRequirement);
}
}
}
return { directlyRelatedRequirements, selfCheckRequirements };
const allRequirements = [...relatedRequirements, ...selfCheckRequirements];
const { requirementsThatDoNotAllowDoubleCounting } = getConstraintViolationsForSingleCourse(
{ uniqueId: -1 },
allRequirements.map(({ id }) => id),
(reqA, reqB) =>
allowCourseDoubleCountingBetweenRequirements(
userRequirementsMap[reqA],
userRequirementsMap[reqB]
)
);
const automaticallyFulfilledRequirements = relatedRequirements.filter(
({ id }) => !requirementsThatDoNotAllowDoubleCounting.has(id)
);

return { relatedRequirements, selfCheckRequirements, automaticallyFulfilledRequirements };
}