Skip to content
Merged
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
42 changes: 10 additions & 32 deletions frontends/api/src/hooks/learningResources/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type {
LearningResourcesSearchResponse,
} from "../../generated/v1"
import { queryOptions } from "@tanstack/react-query"
import { hasPosition, randomizeGroups } from "./util"

/* List memberships were previously determined in the learningResourcesApi
* from user_list_parents and learning_path_parents on each resource.
Expand All @@ -37,35 +38,6 @@ export const clearListMemberships = (
learning_path_parents: [],
})

const shuffle = ([...arr]) => {
let m = arr.length
while (m) {
const i = Math.floor(Math.random() * m--)
;[arr[m], arr[i]] = [arr[i], arr[m]]
}
return arr
}

const randomizeResults = ([...results]) => {
const resultsByPosition: {
[position: string]: (LearningResource & { position?: string })[] | undefined
} = {}
const randomizedResults: LearningResource[] = []
results.forEach((result) => {
if (!resultsByPosition[result?.position]) {
resultsByPosition[result?.position] = []
}
resultsByPosition[result?.position ?? ""]?.push(result)
})
Object.keys(resultsByPosition)
.sort()
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Explanation: Object keys are strings, and these were stringified numbers, so they were being sorted like 0, 1, 10, 11, 2, 3, 4, ....

This wasn't a visible problem on the homepage because that carusel uses items from every offeror... to get up to 12 items, that carousel only needs positions 0, 1, 2 from all five offerors.

The featured list query is also used on channel pages (We don't query the learning path directly because, at least right now, those are private; only visible to non-staff via /featured endpoint). And on the channel pages, 12 items are shown, so the sorting was getting messed up.

.forEach((position) => {
const shuffled = shuffle(resultsByPosition[position] ?? [])
randomizedResults.push(...shuffled)
})
return randomizedResults
}

const learningResourceKeys = {
root: ["learning_resources"],
// list
Expand Down Expand Up @@ -183,11 +155,17 @@ const learningResourceQueries = {
queryKey: learningResourceKeys.featured(params),
queryFn: () =>
featuredApi.featuredList(params).then((res) => {
const results = res.data.results
const withPosition = results.filter(hasPosition)
if (withPosition.length !== results.length) {
// Should not happen. The featured API always sets position.
console.warn(
"Some featured results are missing position information.",
)
}
return {
...res.data,
results: randomizeResults(
res.data.results.map(clearListMemberships),
),
results: randomizeGroups(withPosition),
}
}),
}),
Expand Down
97 changes: 97 additions & 0 deletions frontends/api/src/hooks/learningResources/util.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { faker } from "@faker-js/faker/locale/en"
import { randomizeGroups, hasPosition } from "./util"

faker.seed(12345) // Seed faker for consistent test results
jest
.spyOn(Math, "random")
.mockImplementation(() => faker.number.float({ min: 0, max: 1 }))

describe("randomizeGroups", () => {
it("should group by position and randomize within groups with duplicates", () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Technically does not test that items are randomized within their groups (though not very testable!)

const items = [
{ id: "a1", position: 1 },
{ id: "a2", position: 1 },
{ id: "b1", position: 12 },
{ id: "b2", position: 12 },
{ id: "c1", position: 2 },
{ id: "c2", position: 2 },
{ id: "d1", position: 3 },
]

const result = randomizeGroups(items)

// Should be grouped by position in numerical order
expect(result[0].position).toBe(1)
expect(result[1].position).toBe(1)
expect(result[2].position).toBe(2)
expect(result[3].position).toBe(2)
expect(result[4].position).toBe(3)
expect(result[5].position).toBe(12)
expect(result[6].position).toBe(12)

// Should contain all items
expect(result).toHaveLength(7)
expect(result.map((item) => item.id).sort()).toEqual([
"a1",
"a2",
"b1",
"b2",
"c1",
"c2",
"d1",
])
})

it("should handle positions greater than 10 correctly (avoid lexicographical sorting)", () => {
const items = [
{ id: "item15", position: 15 },
{ id: "item2", position: 2 },
{ id: "item11", position: 11 },
{ id: "item1", position: 1 },
{ id: "item20", position: 20 },
]

const result = randomizeGroups(items)

// Should be numerically sorted: 1, 2, 11, 15, 20
// NOT lexicographically sorted: 1, 11, 15, 2, 20
const positions = result.map((item) => item.position)
expect(positions).toEqual([1, 2, 11, 15, 20])
})

it("should handle empty array", () => {
const items: Array<{ id: string; position: number }> = []
const result = randomizeGroups(items)
expect(result).toEqual([])
})
})

describe("hasPosition", () => {
it("should return true for objects with non-null position", () => {
const obj = { id: "test", position: 5 }
expect(hasPosition(obj)).toBe(true)
Comment on lines +71 to +72
Copy link
Contributor Author

Choose a reason for hiding this comment

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

copilot really wanted to add this test......

})

it("should return false for objects with null position", () => {
const obj = { id: "test", position: null }
expect(hasPosition(obj)).toBe(false)
})

it("should return true for objects with position 0", () => {
const obj = { id: "test", position: 0 }
expect(hasPosition(obj)).toBe(true)
})

it("should act as a type guard", () => {
const obj: { id: string; position: number | null } = {
id: "test",
position: 5,
}

if (hasPosition(obj)) {
// TypeScript should now know that obj.position is number, not number | null
const position: number = obj.position
expect(position).toBe(5)
}
})
})
58 changes: 58 additions & 0 deletions frontends/api/src/hooks/learningResources/util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
const hasPosition = <T extends { position: number | null }>(
r: T,
): r is T & { position: number } => r.position !== null
Comment on lines +1 to +3
Copy link
Contributor

Choose a reason for hiding this comment

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

Type guard+narrowing... nice!


const shuffle = ([...arr]) => {
let m = arr.length
while (m) {
const i = Math.floor(Math.random() * m--)
;[arr[m], arr[i]] = [arr[i], arr[m]]
}
return arr
}

/**
* Randomize a group of ordered items, where item positions might be duplicated.
* Ordering is preserved between groups, but randomized within groups.
*
* E.g., given the items
* [
* { id: 1, position: 1 },
* { id: 2, position: 1 },
* { id: 3, position: 2 },
* { id: 4, position: 3 },
* { id: 5, position: 3 },
* { id: 6, position: 3 },
* ]
*
* The results would be:
* [
* ...items with position 1 in random order...,
* ...items with position 2 in random order...,
* ...items with position 3 in random order...
* ]
*/
const randomizeGroups = <T extends { position: number }>(results: T[]): T[] => {
const resultsByPosition: {
[position: string]: T[] | undefined
} = {}
const randomizedResults: T[] = []
results.forEach((result) => {
const pos = result?.position
if (!resultsByPosition[pos]) {
resultsByPosition[pos] = []
}
resultsByPosition[pos]?.push(result)
})
Object.keys(resultsByPosition)
.sort(
(a, b) => Number(a) - Number(b), // Sort positions numerically
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 the only functional change vs the old implementation. But I decided to move it to a separate file and have copilot add some tests.

)
.forEach((position) => {
const shuffled = shuffle(resultsByPosition[position] ?? [])
randomizedResults.push(...shuffled)
})
return randomizedResults
}

export { randomizeGroups, hasPosition }
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def add_arguments(self, parser):
"resource_count",
nargs="?",
type=int,
default=10,
default=12,
help="Set the number of courses per featured list (default is 10)",
)

Expand All @@ -44,7 +44,7 @@ def handle(self, *args, **options): # noqa: ARG002
self.stdout.write("Creating featured list for each featured offeror channel")

start = now_in_utc()
resource_count = options.get("resource_count", 10)
resource_count = options["resource_count"]
for offeror in LearningResourceOfferor.objects.all():
self.stdout.write(f"Creating featured list for {offeror.name} channel")

Expand Down
Loading