Add lessons Phase 5: grading scales with nested levels

Custom grading scales with ordered levels (value, label, numeric score,
color). Supports one-default-per-store constraint, deep create with
nested levels, lookup endpoint for dropdowns, and search/pagination.
12 new tests (76 total lessons tests).
This commit is contained in:
Ryan Moon
2026-03-30 09:36:48 -05:00
parent 73360cd478
commit 31f661ff4f
8 changed files with 396 additions and 2 deletions

View File

@@ -939,4 +939,133 @@ suite('Lessons', { tags: ['lessons'] }, (t) => {
t.assert.status(res, 200)
t.assert.ok(res.data.data.every((s: any) => s.status === 'scheduled'))
})
// ─── Grading Scales: CRUD ───
t.test('creates a grading scale with nested levels', { tags: ['grading-scales', 'create'] }, async () => {
const res = await t.api.post('/v1/grading-scales', {
name: 'Standard Letter',
description: 'Traditional letter grades',
isDefault: true,
levels: [
{ value: 'A+', label: 'Excellent Plus', numericValue: 97, colorHex: '#4CAF50', sortOrder: 1 },
{ value: 'A', label: 'Excellent', numericValue: 93, colorHex: '#4CAF50', sortOrder: 2 },
{ value: 'A-', label: 'Excellent Minus', numericValue: 90, colorHex: '#8BC34A', sortOrder: 3 },
{ value: 'B+', label: 'Good Plus', numericValue: 87, colorHex: '#CDDC39', sortOrder: 4 },
{ value: 'B', label: 'Good', numericValue: 83, colorHex: '#CDDC39', sortOrder: 5 },
{ value: 'F', label: 'Fail', numericValue: 0, colorHex: '#F44336', sortOrder: 10 },
],
})
t.assert.status(res, 201)
t.assert.equal(res.data.name, 'Standard Letter')
t.assert.equal(res.data.isDefault, true)
t.assert.equal(res.data.levels.length, 6)
t.assert.equal(res.data.levels[0].value, 'A+')
t.assert.equal(res.data.levels[0].numericValue, 97)
})
t.test('creates a progress scale', { tags: ['grading-scales', 'create'] }, async () => {
const res = await t.api.post('/v1/grading-scales', {
name: 'Progress',
levels: [
{ value: 'Mastered', label: 'Skill fully mastered', numericValue: 100, colorHex: '#4CAF50', sortOrder: 1 },
{ value: 'Proficient', label: 'Near mastery', numericValue: 75, colorHex: '#8BC34A', sortOrder: 2 },
{ value: 'Developing', label: 'Making progress', numericValue: 50, colorHex: '#FFC107', sortOrder: 3 },
{ value: 'Beginning', label: 'Just started', numericValue: 25, colorHex: '#FF9800', sortOrder: 4 },
],
})
t.assert.status(res, 201)
t.assert.equal(res.data.levels.length, 4)
})
t.test('rejects scale without levels', { tags: ['grading-scales', 'create', 'validation'] }, async () => {
const res = await t.api.post('/v1/grading-scales', { name: 'Empty Scale', levels: [] })
t.assert.status(res, 400)
})
t.test('rejects scale without name', { tags: ['grading-scales', 'create', 'validation'] }, async () => {
const res = await t.api.post('/v1/grading-scales', { levels: [{ value: 'A', label: 'A', numericValue: 90, sortOrder: 1 }] })
t.assert.status(res, 400)
})
t.test('gets grading scale with levels', { tags: ['grading-scales', 'read'] }, async () => {
const created = await t.api.post('/v1/grading-scales', {
name: 'Get Test Scale',
levels: [
{ value: 'Pass', label: 'Passed', numericValue: 70, sortOrder: 1 },
{ value: 'Fail', label: 'Failed', numericValue: 0, sortOrder: 2 },
],
})
const res = await t.api.get(`/v1/grading-scales/${created.data.id}`)
t.assert.status(res, 200)
t.assert.equal(res.data.name, 'Get Test Scale')
t.assert.equal(res.data.levels.length, 2)
t.assert.equal(res.data.levels[0].value, 'Pass')
})
t.test('returns 404 for missing grading scale', { tags: ['grading-scales', 'read'] }, async () => {
const res = await t.api.get('/v1/grading-scales/a0000000-0000-0000-0000-999999999999')
t.assert.status(res, 404)
})
t.test('lists grading scales with pagination', { tags: ['grading-scales', 'read', 'pagination'] }, async () => {
const res = await t.api.get('/v1/grading-scales', { limit: 100 })
t.assert.status(res, 200)
t.assert.ok(res.data.data.length >= 2)
t.assert.ok(res.data.pagination)
})
t.test('searches grading scales by name', { tags: ['grading-scales', 'search'] }, async () => {
const res = await t.api.get('/v1/grading-scales', { q: 'Progress' })
t.assert.status(res, 200)
t.assert.ok(res.data.data.some((s: any) => s.name === 'Progress'))
})
t.test('lists all scales with levels (lookup endpoint)', { tags: ['grading-scales', 'read'] }, async () => {
const res = await t.api.get('/v1/grading-scales/all')
t.assert.status(res, 200)
t.assert.ok(Array.isArray(res.data))
t.assert.ok(res.data.length >= 2)
t.assert.ok(res.data[0].levels, 'each scale should include levels')
})
t.test('updates a grading scale', { tags: ['grading-scales', 'update'] }, async () => {
const created = await t.api.post('/v1/grading-scales', {
name: 'Before Update Scale',
levels: [{ value: 'A', label: 'Grade A', numericValue: 90, sortOrder: 1 }],
})
const res = await t.api.patch(`/v1/grading-scales/${created.data.id}`, { name: 'After Update Scale' })
t.assert.status(res, 200)
t.assert.equal(res.data.name, 'After Update Scale')
})
t.test('setting new default unsets previous default', { tags: ['grading-scales', 'update'] }, async () => {
// First scale was created with isDefault: true
const newDefault = await t.api.post('/v1/grading-scales', {
name: 'New Default Scale',
isDefault: true,
levels: [{ value: 'OK', label: 'Okay', numericValue: 70, sortOrder: 1 }],
})
t.assert.equal(newDefault.data.isDefault, true)
// Check all scales - only one should be default
const allScales = await t.api.get('/v1/grading-scales/all')
const defaults = allScales.data.filter((s: any) => s.isDefault === true)
t.assert.equal(defaults.length, 1, 'only one scale should be default')
t.assert.equal(defaults[0].id, newDefault.data.id)
})
t.test('soft-deletes a grading scale', { tags: ['grading-scales', 'delete'] }, async () => {
const created = await t.api.post('/v1/grading-scales', {
name: 'To Delete Scale',
levels: [{ value: 'X', label: 'Delete Me', numericValue: 0, sortOrder: 1 }],
})
const res = await t.api.del(`/v1/grading-scales/${created.data.id}`)
t.assert.status(res, 200)
t.assert.equal(res.data.isActive, false)
const list = await t.api.get('/v1/grading-scales', { q: 'To Delete Scale', limit: 100 })
t.assert.equal(list.data.data.length, 0)
})
})