diff --git a/curriculum/build-curriculum.js b/curriculum/build-curriculum.js index d177803c207..cd1282b0f5a 100644 --- a/curriculum/build-curriculum.js +++ b/curriculum/build-curriculum.js @@ -11,7 +11,7 @@ const { } = require('./build-superblock'); const { buildCertification } = require('./build-certification'); -const { applyFilters } = require('./utils'); +const { applyFilters, closestFilters } = require('./utils'); const { getContentDir, getLanguageConfig, @@ -310,8 +310,10 @@ async function parseCurriculumStructure(filters) { const superblockList = addBlockStructure( addSuperblockStructure(curriculum.superblocks) ); + const refinedFilters = closestFilters(filters, superblockList); + const fullSuperblockList = applyFilters(superblockList, refinedFilters); return { - fullSuperblockList: applyFilters(superblockList, filters), + fullSuperblockList, certifications: curriculum.certifications }; } diff --git a/curriculum/package.json b/curriculum/package.json index 96607e28dd0..92157c2a6a1 100644 --- a/curriculum/package.json +++ b/curriculum/package.json @@ -43,6 +43,7 @@ "@babel/core": "7.23.7", "@babel/register": "7.23.7", "@types/polka": "^0.5.7", + "@types/string-similarity": "^4.0.2", "@vitest/ui": "^3.2.4", "chai": "4.4.1", "glob": "8.1.0", diff --git a/curriculum/utils.js b/curriculum/utils.js index df0ad1d1a17..ccf12b9ab86 100644 --- a/curriculum/utils.js +++ b/curriculum/utils.js @@ -1,4 +1,7 @@ const path = require('path'); + +const comparison = require('string-similarity'); + const { SuperBlocks, generateSuperBlockList @@ -142,9 +145,37 @@ const applyFilters = createFilterPipeline([ filterByChallengeId ]); +function closestMatch(target, xs) { + return comparison.findBestMatch(target.toLowerCase(), xs).bestMatch.target; +} + +function closestFilters(target, superblocks) { + if (target?.superBlock) { + const superblockNames = superblocks.map(({ name }) => name); + return { + ...target, + superBlock: closestMatch(target.superBlock, superblockNames) + }; + } + + if (target?.block) { + const blocks = superblocks.flatMap(({ blocks }) => + blocks.map(({ dashedName }) => dashedName) + ); + return { + ...target, + block: closestMatch(target.block, blocks) + }; + } + + return target; +} + +exports.closestFilters = closestFilters; +exports.closestMatch = closestMatch; +exports.createSuperOrder = createSuperOrder; exports.filterByBlock = filterByBlock; exports.filterBySuperblock = filterBySuperblock; exports.filterByChallengeId = filterByChallengeId; -exports.createSuperOrder = createSuperOrder; exports.getSuperOrder = getSuperOrder; exports.applyFilters = applyFilters; diff --git a/curriculum/utils.test.ts b/curriculum/utils.test.ts index 6229fbf9679..27c0167f23c 100644 --- a/curriculum/utils.test.ts +++ b/curriculum/utils.test.ts @@ -4,6 +4,8 @@ import { describe, it, expect } from 'vitest'; import { SuperBlocks } from '../shared-dist/config/curriculum'; import { + closestFilters, + closestMatch, createSuperOrder, filterByBlock, filterByChallengeId, @@ -124,157 +126,233 @@ describe('getSuperOrder', () => { }); }); -describe('filterByChallengeId', () => { - it('returns the same superblocks if no challengeId is provided', () => { - const superblocks = [ - { - name: 'superblock-1', - blocks: [{ dashedName: 'block-1', challengeOrder: [{ id: '1' }] }] - }, - { - name: 'superblock-2', - blocks: [{ dashedName: 'block-2', challengeOrder: [{ id: '2' }] }] - } - ]; - expect(filterByChallengeId(superblocks)).toEqual(superblocks); - }); - - it('ignores blocks without the specified challengeId', () => { - const superblocks = [ - { - name: 'superblock-1', - blocks: [ - { dashedName: 'block-1', challengeOrder: [{ id: '1' }] }, - { dashedName: 'block-2', challengeOrder: [{ id: '2' }] } - ] - } - ]; - const filtered = filterByChallengeId(superblocks, { challengeId: '2' }); - expect(filtered).toEqual([ - { - name: 'superblock-1', - blocks: [{ dashedName: 'block-2', challengeOrder: [{ id: '2' }] }] - } - ]); - }); - - it('returns only the specified challenge and its solution challenge', () => { - const superblocks = [ - { - name: 'superblock-1', - blocks: [ - { - dashedName: 'block-1', - challengeOrder: [{ id: '1' }, { id: '2' }, { id: '3' }] - }, - { dashedName: 'block-2', challengeOrder: [{ id: '4' }] } - ] - } - ]; - const filtered = filterByChallengeId(superblocks, { challengeId: '1' }); - expect(filtered).toEqual([ - { - name: 'superblock-1', - blocks: [ - { dashedName: 'block-1', challengeOrder: [{ id: '1' }, { id: '2' }] } - ] - } - ]); - }); - - it('returns only superblocks containing the specified challenge', () => { - const superblocks = [ - { - name: 'superblock-1', - blocks: [ - { dashedName: 'block-1', challengeOrder: [{ id: '1' }] }, - { dashedName: 'block-2', challengeOrder: [{ id: '2' }] } - ] - }, - { - name: 'superblock-2', - blocks: [{ dashedName: 'block-3', challengeOrder: [{ id: '3' }] }] - } - ]; - const filtered = filterByChallengeId(superblocks, { challengeId: '2' }); - expect(filtered).toEqual([ - { - name: 'superblock-1', - blocks: [{ dashedName: 'block-2', challengeOrder: [{ id: '2' }] }] - } - ]); - }); -}); - -describe('filterByBlock', () => { - it('returns the same superblocks if no block is provided', () => { - const superblocks = [ - { - name: 'superblock-1', - blocks: [{ dashedName: 'block-1' }, { dashedName: 'block-2' }] - } - ]; - expect(filterByBlock(superblocks)).toEqual(superblocks); - }); - - it('returns only the specified block', () => { - const superblocks = [ - { - name: 'superblock-1', - blocks: [{ dashedName: 'block-1' }, { dashedName: 'block-2' }] - } - ]; - const filtered = filterByBlock(superblocks, { block: 'block-1' }); - expect(filtered).toEqual([ - { - name: 'superblock-1', - blocks: [{ dashedName: 'block-1' }] - } - ]); - }); - - it('returns an empty array if no blocks match the specified block', () => { - const superblocks = [ - { - name: 'superblock-1', - blocks: [{ dashedName: 'block-1' }, { dashedName: 'block-2' }] - } - ]; - const filtered = filterByBlock(superblocks, { block: 'nonexistent-block' }); - expect(filtered).toEqual([]); - }); -}); - -describe('filterBySuperblock', () => { - it('returns the same superblocks if no superBlock is provided', () => { - const superblocks = [ - { - name: 'superblock-1', - blocks: [{ dashedName: 'block-1' }, { dashedName: 'block-2' }] - } - ]; - expect(filterBySuperblock(superblocks)).toEqual(superblocks); - }); - - it('returns only the specified superblock', () => { - const superblocks = [ - { - name: 'superblock-1', - blocks: [{ dashedName: 'block-1' }, { dashedName: 'block-2' }] - }, - { - name: 'superblock-2', - blocks: [{ dashedName: 'block-3' }] - } - ]; - const filtered = filterBySuperblock(superblocks, { - superBlock: 'superblock-1' +describe('filter utils', () => { + describe('filterByChallengeId', () => { + it('returns the same superblocks if no challengeId is provided', () => { + const superblocks = [ + { + name: 'superblock-1', + blocks: [{ dashedName: 'block-1', challengeOrder: [{ id: '1' }] }] + }, + { + name: 'superblock-2', + blocks: [{ dashedName: 'block-2', challengeOrder: [{ id: '2' }] }] + } + ]; + expect(filterByChallengeId(superblocks)).toEqual(superblocks); + }); + + it('ignores blocks without the specified challengeId', () => { + const superblocks = [ + { + name: 'superblock-1', + blocks: [ + { dashedName: 'block-1', challengeOrder: [{ id: '1' }] }, + { dashedName: 'block-2', challengeOrder: [{ id: '2' }] } + ] + } + ]; + const filtered = filterByChallengeId(superblocks, { challengeId: '2' }); + expect(filtered).toEqual([ + { + name: 'superblock-1', + blocks: [{ dashedName: 'block-2', challengeOrder: [{ id: '2' }] }] + } + ]); + }); + + it('returns only the specified challenge and its solution challenge', () => { + const superblocks = [ + { + name: 'superblock-1', + blocks: [ + { + dashedName: 'block-1', + challengeOrder: [{ id: '1' }, { id: '2' }, { id: '3' }] + }, + { dashedName: 'block-2', challengeOrder: [{ id: '4' }] } + ] + } + ]; + const filtered = filterByChallengeId(superblocks, { challengeId: '1' }); + expect(filtered).toEqual([ + { + name: 'superblock-1', + blocks: [ + { + dashedName: 'block-1', + challengeOrder: [{ id: '1' }, { id: '2' }] + } + ] + } + ]); + }); + + it('returns only superblocks containing the specified challenge', () => { + const superblocks = [ + { + name: 'superblock-1', + blocks: [ + { dashedName: 'block-1', challengeOrder: [{ id: '1' }] }, + { dashedName: 'block-2', challengeOrder: [{ id: '2' }] } + ] + }, + { + name: 'superblock-2', + blocks: [{ dashedName: 'block-3', challengeOrder: [{ id: '3' }] }] + } + ]; + const filtered = filterByChallengeId(superblocks, { challengeId: '2' }); + expect(filtered).toEqual([ + { + name: 'superblock-1', + blocks: [{ dashedName: 'block-2', challengeOrder: [{ id: '2' }] }] + } + ]); + }); + }); + + describe('filterByBlock', () => { + it('returns the same superblocks if no block is provided', () => { + const superblocks = [ + { + name: 'superblock-1', + blocks: [{ dashedName: 'block-1' }, { dashedName: 'block-2' }] + } + ]; + expect(filterByBlock(superblocks)).toEqual(superblocks); + }); + + it('returns only the specified block', () => { + const superblocks = [ + { + name: 'superblock-1', + blocks: [{ dashedName: 'block-1' }, { dashedName: 'block-2' }] + } + ]; + const filtered = filterByBlock(superblocks, { block: 'block-1' }); + expect(filtered).toEqual([ + { + name: 'superblock-1', + blocks: [{ dashedName: 'block-1' }] + } + ]); + }); + + it('returns an empty array if no blocks match the specified block', () => { + const superblocks = [ + { + name: 'superblock-1', + blocks: [{ dashedName: 'block-1' }, { dashedName: 'block-2' }] + } + ]; + const filtered = filterByBlock(superblocks, { + block: 'nonexistent-block' + }); + expect(filtered).toEqual([]); + }); + }); + + describe('filterBySuperblock', () => { + it('returns the same superblocks if no superBlock is provided', () => { + const superblocks = [ + { + name: 'superblock-1', + blocks: [{ dashedName: 'block-1' }, { dashedName: 'block-2' }] + } + ]; + expect(filterBySuperblock(superblocks)).toEqual(superblocks); + }); + + it('returns only the specified superblock', () => { + const superblocks = [ + { + name: 'superblock-1', + blocks: [{ dashedName: 'block-1' }, { dashedName: 'block-2' }] + }, + { + name: 'superblock-2', + blocks: [{ dashedName: 'block-3' }] + } + ]; + const filtered = filterBySuperblock(superblocks, { + superBlock: 'superblock-1' + }); + expect(filtered).toEqual([ + { + name: 'superblock-1', + blocks: [{ dashedName: 'block-1' }, { dashedName: 'block-2' }] + } + ]); + }); + }); + + describe('closestMatch', () => { + it('returns the closest matching element', () => { + const items = [ + 'responsive-web-design', + 'javascript-algorithms-and-data-structures', + 'front-end-development-libraries', + 'data-visualization' + ]; + const input = 'responsiv web design'; + const closest = 'responsive-web-design'; + expect(closestMatch(input, items)).toBe(closest); + }); + + it('ignores case when finding the closest match', () => { + const items = [ + 'responsive-web-design', + 'ReSPonSivE-WeB-DeSiGne', + 'javascript-algorithms-and-data-structures', + 'front-end-development-libraries', + 'data-visualization' + ]; + const input = 'ReSPonSiv WeB DeSiGn'; + const closest = 'responsive-web-design'; + expect(closestMatch(input, items)).toBe(closest); + }); + }); + + describe('closestFilters', () => { + it('returns the closest matching superblock filter', () => { + const superblocks = [ + { + name: 'responsive-web-design', + blocks: [ + { dashedName: 'basic-html-and-html5' }, + { dashedName: 'css-flexbox' } + ] + }, + { + name: 'javascript-algorithms-and-data-structures', + blocks: [{ dashedName: 'basic-javascript' }, { dashedName: 'es6' }] + } + ]; + + expect( + closestFilters({ superBlock: 'responsiv web design' }, superblocks) + ).toEqual({ superBlock: 'responsive-web-design' }); + }); + + it('returns the closest matching block filter', () => { + const superblocks = [ + { + name: 'responsive-web-design', + blocks: [ + { dashedName: 'basic-html-and-html5' }, + { dashedName: 'css-flexbox' } + ] + }, + { + name: 'javascript-algorithms-and-data-structures', + blocks: [{ dashedName: 'basic-javascript' }, { dashedName: 'es6' }] + } + ]; + + expect(closestFilters({ block: 'basic-javascr' }, superblocks)).toEqual({ + block: 'basic-javascript' + }); }); - expect(filtered).toEqual([ - { - name: 'superblock-1', - blocks: [{ dashedName: 'block-1' }, { dashedName: 'block-2' }] - } - ]); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c156074a74..2167fceb061 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -724,6 +724,9 @@ importers: '@types/polka': specifier: ^0.5.7 version: 0.5.7 + '@types/string-similarity': + specifier: ^4.0.2 + version: 4.0.2 '@vitest/ui': specifier: ^3.2.4 version: 3.2.4(vitest@3.2.4) @@ -4823,6 +4826,9 @@ packages: '@types/store@2.0.5': resolution: {integrity: sha512-5NmTKe3GWdOaykzq7no+Ahf6mafJu0oLc9JNhJ3E26+0oFvd6GnksnZQpMXcH526mfG4xDYjFiKzyDL51PzeWQ==} + '@types/string-similarity@4.0.2': + resolution: {integrity: sha512-LkJQ/jsXtCVMK+sKYAmX/8zEq+/46f1PTQw7YtmQwb74jemS1SlNLmARM2Zml9DgdDTWKAtc5L13WorpHPDjDA==} + '@types/superagent@4.1.19': resolution: {integrity: sha512-McM1mlc7PBZpCaw0fw/36uFqo0YeA6m8JqoyE4OfqXsZCIg0hPP2xdE6FM7r6fdprDZHlJwDpydUj1R++93hCA==} @@ -19850,6 +19856,8 @@ snapshots: '@types/store@2.0.5': {} + '@types/string-similarity@4.0.2': {} + '@types/superagent@4.1.19': dependencies: '@types/cookiejar': 2.1.2