From cd4f77bbc08f06f7feec654fbb2ca805749a2db0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ari=20Perkki=C3=B6?= Date: Thu, 3 Oct 2024 12:29:42 +0300 Subject: [PATCH 1/4] fix(astro): correct error message when chapter not found --- .../single-part-chapter-and-lesson.json | 61 ++++++++ ...gle-part-chapter-and-multiple-lessons.json | 141 ++++++++++++++++++ .../astro/src/default/utils/content.spec.ts | 106 +++++++++++++ packages/astro/src/default/utils/content.ts | 4 +- 4 files changed, 310 insertions(+), 2 deletions(-) create mode 100644 packages/astro/src/default/utils/__snapshots__/single-part-chapter-and-lesson.json create mode 100644 packages/astro/src/default/utils/__snapshots__/single-part-chapter-and-multiple-lessons.json create mode 100644 packages/astro/src/default/utils/content.spec.ts diff --git a/packages/astro/src/default/utils/__snapshots__/single-part-chapter-and-lesson.json b/packages/astro/src/default/utils/__snapshots__/single-part-chapter-and-lesson.json new file mode 100644 index 000000000..6ce2dd637 --- /dev/null +++ b/packages/astro/src/default/utils/__snapshots__/single-part-chapter-and-lesson.json @@ -0,0 +1,61 @@ +{ + "parts": { + "1-part": { + "id": "1-part", + "order": 0, + "data": { + "type": "part", + "title": "Basics" + }, + "slug": "part-slug", + "chapters": { + "1-chapter": { + "id": "1-chapter", + "order": 0, + "data": { + "title": "The first chapter in part 1", + "type": "chapter" + }, + "slug": "chapter-slug", + "lessons": { + "1-lesson": { + "data": { + "type": "lesson", + "title": "Welcome to TutorialKit", + "template": "default", + "i18n": { + "mocked": "default localization" + }, + "openInStackBlitz": true + }, + "id": "1-lesson", + "filepath": "1-part/1-chapter/1-lesson/content.md", + "order": 0, + "part": { + "id": "1-part", + "title": "Basics" + }, + "chapter": { + "id": "1-chapter", + "title": "The first chapter in part 1" + }, + "Markdown": "Markdown for tutorial", + "slug": "lesson-slug", + "files": [ + "1-part-1-chapter-1-lesson-files.json", + [] + ], + "solution": [ + "1-part-1-chapter-1-lesson-solution.json", + [] + ] + } + }, + "firstLessonId": "1-lesson" + } + }, + "firstChapterId": "1-chapter" + } + }, + "firstPartId": "1-part" +} \ No newline at end of file diff --git a/packages/astro/src/default/utils/__snapshots__/single-part-chapter-and-multiple-lessons.json b/packages/astro/src/default/utils/__snapshots__/single-part-chapter-and-multiple-lessons.json new file mode 100644 index 000000000..605f61555 --- /dev/null +++ b/packages/astro/src/default/utils/__snapshots__/single-part-chapter-and-multiple-lessons.json @@ -0,0 +1,141 @@ +{ + "parts": { + "1-part": { + "id": "1-part", + "order": 0, + "data": { + "type": "part", + "title": "Basics" + }, + "slug": "part-slug", + "chapters": { + "1-chapter": { + "id": "1-chapter", + "order": 0, + "data": { + "title": "The first chapter in part 1", + "type": "chapter" + }, + "slug": "chapter-slug", + "lessons": { + "1-first": { + "data": { + "type": "lesson", + "title": "Welcome to TutorialKit", + "template": "default", + "i18n": { + "mocked": "default localization" + }, + "openInStackBlitz": true + }, + "id": "1-first", + "filepath": "1-part/1-chapter/1-first/content.md", + "order": 0, + "part": { + "id": "1-part", + "title": "Basics" + }, + "chapter": { + "id": "1-chapter", + "title": "The first chapter in part 1" + }, + "Markdown": "Markdown for tutorial", + "slug": "lesson-slug", + "files": [ + "1-part-1-chapter-1-first-files.json", + [] + ], + "solution": [ + "1-part-1-chapter-1-first-solution.json", + [] + ], + "next": { + "title": "Welcome to TutorialKit", + "href": "/part-slug/chapter-slug/lesson-slug" + } + }, + "2-second": { + "data": { + "type": "lesson", + "title": "Welcome to TutorialKit", + "template": "default", + "i18n": { + "mocked": "default localization" + }, + "openInStackBlitz": true + }, + "id": "2-second", + "filepath": "1-part/1-chapter/2-second/content.md", + "order": 1, + "part": { + "id": "1-part", + "title": "Basics" + }, + "chapter": { + "id": "1-chapter", + "title": "The first chapter in part 1" + }, + "Markdown": "Markdown for tutorial", + "slug": "lesson-slug", + "files": [ + "1-part-1-chapter-2-second-files.json", + [] + ], + "solution": [ + "1-part-1-chapter-2-second-solution.json", + [] + ], + "prev": { + "title": "Welcome to TutorialKit", + "href": "/part-slug/chapter-slug/lesson-slug" + }, + "next": { + "title": "Welcome to TutorialKit", + "href": "/part-slug/chapter-slug/lesson-slug" + } + }, + "3-third": { + "data": { + "type": "lesson", + "title": "Welcome to TutorialKit", + "template": "default", + "i18n": { + "mocked": "default localization" + }, + "openInStackBlitz": true + }, + "id": "3-third", + "filepath": "1-part/1-chapter/3-third/content.md", + "order": 2, + "part": { + "id": "1-part", + "title": "Basics" + }, + "chapter": { + "id": "1-chapter", + "title": "The first chapter in part 1" + }, + "Markdown": "Markdown for tutorial", + "slug": "lesson-slug", + "files": [ + "1-part-1-chapter-3-third-files.json", + [] + ], + "solution": [ + "1-part-1-chapter-3-third-solution.json", + [] + ], + "prev": { + "title": "Welcome to TutorialKit", + "href": "/part-slug/chapter-slug/lesson-slug" + } + } + }, + "firstLessonId": "1-first" + } + }, + "firstChapterId": "1-chapter" + } + }, + "firstPartId": "1-part" +} \ No newline at end of file diff --git a/packages/astro/src/default/utils/content.spec.ts b/packages/astro/src/default/utils/content.spec.ts new file mode 100644 index 000000000..277490c2a --- /dev/null +++ b/packages/astro/src/default/utils/content.spec.ts @@ -0,0 +1,106 @@ +import * as content from 'astro:content'; +import { expect, test, vi, type TaskContext } from 'vitest'; +import { getTutorial, type CollectionEntryTutorial } from './content'; + +const getCollection = vi.mocked(content.getCollection); +vi.mock('astro:content', () => ({ getCollection: vi.fn() })); + +// mock DEFAULT_LOCALIZATION so that we don't need to update test results everytime new keys are added there +vi.mock(import('@tutorialkit/types'), async (importOriginal) => ({ + ...(await importOriginal()), + DEFAULT_LOCALIZATION: { mocked: 'default localization' } as any, +})); + +expect.addSnapshotSerializer({ + serialize: (val) => JSON.stringify(val, null, 2), + test: (value) => !(value instanceof Error), +}); + +test('single part, chapter and lesson', async (ctx) => { + getCollection.mockReturnValueOnce([ + { id: 'meta.md', ...tutorial }, + { id: '1-part/meta.md', ...part }, + { id: '1-part/1-chapter/meta.md', ...chapter }, + { id: '1-part/1-chapter/1-lesson/content.md', ...lesson }, + ]); + + const collection = await getTutorial(); + await expect(collection).toMatchFileSnapshot(snapshotName(ctx)); +}); + +test('single part, chapter and multiple lessons', async (ctx) => { + getCollection.mockReturnValueOnce([ + { id: 'meta.md', ...tutorial }, + { id: '1-part/meta.md', ...part }, + { id: '1-part/1-chapter/meta.md', ...chapter }, + + // 3 lessons + { id: '1-part/1-chapter/1-first/content.md', ...lesson }, + { id: '1-part/1-chapter/2-second/content.md', ...lesson }, + { id: '1-part/1-chapter/3-third/content.md', ...lesson }, + ]); + + const collection = await getTutorial(); + + const lessons = collection.parts['1-part'].chapters['1-chapter'].lessons; + expect(Object.keys(lessons)).toHaveLength(3); + + await expect(collection).toMatchFileSnapshot(snapshotName(ctx)); +}); + +test('throws when part not found', async () => { + getCollection.mockReturnValueOnce([ + { id: 'meta.md', ...tutorial }, + { id: '2-part/meta.md', ...part }, + { id: '1-part/1-chapter/meta.md', ...chapter }, + { id: '1-part/1-chapter/1-first/content.md', ...lesson }, + ]); + + await expect(getTutorial).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: Could not find part '1-part']`); +}); + +test('throws when chapter not found', async () => { + getCollection.mockReturnValueOnce([ + { id: 'meta.md', ...tutorial }, + { id: '1-part/meta.md', ...part }, + { id: '1-part/2-chapter/meta.md', ...chapter }, + { id: '1-part/1-chapter/1-first/content.md', ...lesson }, + ]); + + await expect(getTutorial).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: Could not find chapter '1-chapter']`); +}); + +const tutorial: Omit = { + slug: 'tutorial-slug', + body: 'Hello world', + collection: 'tutorial', + data: { type: 'tutorial' }, +}; + +const part: Omit = { + slug: 'part-slug', + body: 'Hello world', + collection: 'tutorial', + data: { type: 'part', title: 'Basics' }, +}; + +const chapter: Omit = { + slug: 'chapter-slug', + body: 'body here', + collection: 'tutorial', + data: { title: 'The first chapter in part 1', type: 'chapter' }, +}; + +const lesson: Omit = { + slug: 'lesson-slug', + body: 'body here', + collection: 'tutorial', + data: { type: 'lesson', title: 'Welcome to TutorialKit' }, + render: () => ({ Content: 'Markdown for tutorial' }) as unknown as ReturnType, +}; + +function snapshotName(ctx: TaskContext) { + const testName = ctx.task.name.replaceAll(',', '').replaceAll(' ', '-'); + + return `__snapshots__/${testName}.json`; +} diff --git a/packages/astro/src/default/utils/content.ts b/packages/astro/src/default/utils/content.ts index 46e19fe95..b1665ba5b 100644 --- a/packages/astro/src/default/utils/content.ts +++ b/packages/astro/src/default/utils/content.ts @@ -58,7 +58,7 @@ export async function getTutorial(): Promise { } if (!_tutorial.parts[partId].chapters[chapterId]) { - throw new Error(`Could not find chapter '${partId}'`); + throw new Error(`Could not find chapter '${chapterId}'`); } const { Content } = await entry.render(); @@ -321,7 +321,7 @@ function getSlug(entry: CollectionEntryTutorial) { return slug; } -interface CollectionEntryTutorial { +export interface CollectionEntryTutorial { id: string; slug: string; body: string; From 99f0e5a9b4421136cb5d840e8bb547f1d7305551 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ari=20Perkki=C3=B6?= Date: Thu, 3 Oct 2024 12:35:26 +0300 Subject: [PATCH 2/4] build: exclude snapshots --- packages/astro/scripts/build.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/astro/scripts/build.js b/packages/astro/scripts/build.js index 9e9bf5774..c0695a082 100644 --- a/packages/astro/scripts/build.js +++ b/packages/astro/scripts/build.js @@ -67,7 +67,7 @@ async function copyDefaultFolder() { // copy default folder unmodified, without test files await cp(src, dist, { recursive: true, - filter: (filename) => !filename.endsWith('.spec.ts'), + filter: (filename) => !filename.endsWith('.spec.ts') && !filename.includes('__snapshots__'), }); if (isWatch) { From cba3be5e7540a6f6934612de3030d181ded7fe20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ari=20Perkki=C3=B6?= Date: Fri, 4 Oct 2024 16:40:58 +0300 Subject: [PATCH 3/4] test: more test cases for getTutorial --- .../utils/__snapshots__/multiple-parts.json | 189 ++++++++++++++++++ .../single-part-multiple-chapters.json | 165 +++++++++++++++ .../astro/src/default/utils/content.spec.ts | 153 +++++++++++++- 3 files changed, 499 insertions(+), 8 deletions(-) create mode 100644 packages/astro/src/default/utils/__snapshots__/multiple-parts.json create mode 100644 packages/astro/src/default/utils/__snapshots__/single-part-multiple-chapters.json diff --git a/packages/astro/src/default/utils/__snapshots__/multiple-parts.json b/packages/astro/src/default/utils/__snapshots__/multiple-parts.json new file mode 100644 index 000000000..3ccc38cbf --- /dev/null +++ b/packages/astro/src/default/utils/__snapshots__/multiple-parts.json @@ -0,0 +1,189 @@ +{ + "parts": { + "1-part": { + "id": "1-part", + "order": 0, + "data": { + "type": "part", + "title": "Basics" + }, + "slug": "part-slug", + "chapters": { + "1-chapter": { + "id": "1-chapter", + "order": 0, + "data": { + "title": "The first chapter in part 1", + "type": "chapter" + }, + "slug": "chapter-slug", + "lessons": { + "1-first": { + "data": { + "type": "lesson", + "title": "Welcome to TutorialKit", + "template": "default", + "i18n": { + "mocked": "default localization" + }, + "openInStackBlitz": true + }, + "id": "1-first", + "filepath": "1-part/1-chapter/1-first/content.md", + "order": 0, + "part": { + "id": "1-part", + "title": "Basics" + }, + "chapter": { + "id": "1-chapter", + "title": "The first chapter in part 1" + }, + "Markdown": "Markdown for tutorial", + "slug": "lesson-slug", + "files": [ + "1-part-1-chapter-1-first-files.json", + [] + ], + "solution": [ + "1-part-1-chapter-1-first-solution.json", + [] + ], + "next": { + "title": "Welcome to TutorialKit", + "href": "/part-slug/chapter-slug/lesson-slug" + } + } + }, + "firstLessonId": "1-first" + } + }, + "firstChapterId": "1-chapter" + }, + "2-part": { + "id": "2-part", + "order": 1, + "data": { + "type": "part", + "title": "Basics" + }, + "slug": "part-slug", + "chapters": { + "2-chapter": { + "id": "2-chapter", + "order": 0, + "data": { + "title": "The first chapter in part 1", + "type": "chapter" + }, + "slug": "chapter-slug", + "lessons": { + "1-second": { + "data": { + "type": "lesson", + "title": "Welcome to TutorialKit", + "template": "default", + "i18n": { + "mocked": "default localization" + }, + "openInStackBlitz": true + }, + "id": "1-second", + "filepath": "2-part/2-chapter/1-second/content.md", + "order": 0, + "part": { + "id": "2-part", + "title": "Basics" + }, + "chapter": { + "id": "2-chapter", + "title": "The first chapter in part 1" + }, + "Markdown": "Markdown for tutorial", + "slug": "lesson-slug", + "files": [ + "2-part-2-chapter-1-second-files.json", + [] + ], + "solution": [ + "2-part-2-chapter-1-second-solution.json", + [] + ], + "prev": { + "title": "Welcome to TutorialKit", + "href": "/part-slug/chapter-slug/lesson-slug" + }, + "next": { + "title": "Welcome to TutorialKit", + "href": "/part-slug/chapter-slug/lesson-slug" + } + } + }, + "firstLessonId": "1-second" + } + }, + "firstChapterId": "2-chapter" + }, + "3-part": { + "id": "3-part", + "order": 2, + "data": { + "type": "part", + "title": "Basics" + }, + "slug": "part-slug", + "chapters": { + "3-chapter": { + "id": "3-chapter", + "order": 0, + "data": { + "title": "The first chapter in part 1", + "type": "chapter" + }, + "slug": "chapter-slug", + "lessons": { + "1-third": { + "data": { + "type": "lesson", + "title": "Welcome to TutorialKit", + "template": "default", + "i18n": { + "mocked": "default localization" + }, + "openInStackBlitz": true + }, + "id": "1-third", + "filepath": "3-part/3-chapter/1-third/content.md", + "order": 0, + "part": { + "id": "3-part", + "title": "Basics" + }, + "chapter": { + "id": "3-chapter", + "title": "The first chapter in part 1" + }, + "Markdown": "Markdown for tutorial", + "slug": "lesson-slug", + "files": [ + "3-part-3-chapter-1-third-files.json", + [] + ], + "solution": [ + "3-part-3-chapter-1-third-solution.json", + [] + ], + "prev": { + "title": "Welcome to TutorialKit", + "href": "/part-slug/chapter-slug/lesson-slug" + } + } + }, + "firstLessonId": "1-third" + } + }, + "firstChapterId": "3-chapter" + } + }, + "firstPartId": "1-part" +} \ No newline at end of file diff --git a/packages/astro/src/default/utils/__snapshots__/single-part-multiple-chapters.json b/packages/astro/src/default/utils/__snapshots__/single-part-multiple-chapters.json new file mode 100644 index 000000000..b6d2cf7b2 --- /dev/null +++ b/packages/astro/src/default/utils/__snapshots__/single-part-multiple-chapters.json @@ -0,0 +1,165 @@ +{ + "parts": { + "1-part": { + "id": "1-part", + "order": 0, + "data": { + "type": "part", + "title": "Basics" + }, + "slug": "part-slug", + "chapters": { + "1-chapter": { + "id": "1-chapter", + "order": 0, + "data": { + "title": "The first chapter in part 1", + "type": "chapter" + }, + "slug": "chapter-slug", + "lessons": { + "1-first": { + "data": { + "type": "lesson", + "title": "Welcome to TutorialKit", + "template": "default", + "i18n": { + "mocked": "default localization" + }, + "openInStackBlitz": true + }, + "id": "1-first", + "filepath": "1-part/1-chapter/1-first/content.md", + "order": 0, + "part": { + "id": "1-part", + "title": "Basics" + }, + "chapter": { + "id": "1-chapter", + "title": "The first chapter in part 1" + }, + "Markdown": "Markdown for tutorial", + "slug": "lesson-slug", + "files": [ + "1-part-1-chapter-1-first-files.json", + [] + ], + "solution": [ + "1-part-1-chapter-1-first-solution.json", + [] + ], + "next": { + "title": "Welcome to TutorialKit", + "href": "/part-slug/chapter-slug/lesson-slug" + } + } + }, + "firstLessonId": "1-first" + }, + "2-chapter": { + "id": "2-chapter", + "order": 1, + "data": { + "title": "The first chapter in part 1", + "type": "chapter" + }, + "slug": "chapter-slug", + "lessons": { + "1-second": { + "data": { + "type": "lesson", + "title": "Welcome to TutorialKit", + "template": "default", + "i18n": { + "mocked": "default localization" + }, + "openInStackBlitz": true + }, + "id": "1-second", + "filepath": "1-part/2-chapter/1-second/content.md", + "order": 0, + "part": { + "id": "1-part", + "title": "Basics" + }, + "chapter": { + "id": "2-chapter", + "title": "The first chapter in part 1" + }, + "Markdown": "Markdown for tutorial", + "slug": "lesson-slug", + "files": [ + "1-part-2-chapter-1-second-files.json", + [] + ], + "solution": [ + "1-part-2-chapter-1-second-solution.json", + [] + ], + "prev": { + "title": "Welcome to TutorialKit", + "href": "/part-slug/chapter-slug/lesson-slug" + }, + "next": { + "title": "Welcome to TutorialKit", + "href": "/part-slug/chapter-slug/lesson-slug" + } + } + }, + "firstLessonId": "1-second" + }, + "3-chapter": { + "id": "3-chapter", + "order": 2, + "data": { + "title": "The first chapter in part 1", + "type": "chapter" + }, + "slug": "chapter-slug", + "lessons": { + "1-third": { + "data": { + "type": "lesson", + "title": "Welcome to TutorialKit", + "template": "default", + "i18n": { + "mocked": "default localization" + }, + "openInStackBlitz": true + }, + "id": "1-third", + "filepath": "1-part/3-chapter/1-third/content.md", + "order": 0, + "part": { + "id": "1-part", + "title": "Basics" + }, + "chapter": { + "id": "3-chapter", + "title": "The first chapter in part 1" + }, + "Markdown": "Markdown for tutorial", + "slug": "lesson-slug", + "files": [ + "1-part-3-chapter-1-third-files.json", + [] + ], + "solution": [ + "1-part-3-chapter-1-third-solution.json", + [] + ], + "prev": { + "title": "Welcome to TutorialKit", + "href": "/part-slug/chapter-slug/lesson-slug" + } + } + }, + "firstLessonId": "1-third" + } + }, + "firstChapterId": "1-chapter" + } + }, + "firstPartId": "1-part" +} \ No newline at end of file diff --git a/packages/astro/src/default/utils/content.spec.ts b/packages/astro/src/default/utils/content.spec.ts index 277490c2a..cc3a6a876 100644 --- a/packages/astro/src/default/utils/content.spec.ts +++ b/packages/astro/src/default/utils/content.spec.ts @@ -48,6 +48,143 @@ test('single part, chapter and multiple lessons', async (ctx) => { await expect(collection).toMatchFileSnapshot(snapshotName(ctx)); }); +test('single part, multiple chapters', async (ctx) => { + getCollection.mockReturnValueOnce([ + { id: 'meta.md', ...tutorial }, + { id: '1-part/meta.md', ...part }, + + // 3 chapters + { id: '1-part/1-chapter/meta.md', ...chapter }, + { id: '1-part/2-chapter/meta.md', ...chapter }, + { id: '1-part/3-chapter/meta.md', ...chapter }, + + { id: '1-part/1-chapter/1-first/content.md', ...lesson }, + { id: '1-part/2-chapter/1-second/content.md', ...lesson }, + { id: '1-part/3-chapter/1-third/content.md', ...lesson }, + ]); + + const collection = await getTutorial(); + + const chapters = collection.parts['1-part'].chapters; + expect(Object.keys(chapters)).toHaveLength(3); + + await expect(collection).toMatchFileSnapshot(snapshotName(ctx)); +}); + +test('multiple parts', async (ctx) => { + getCollection.mockReturnValueOnce([ + { id: 'meta.md', ...tutorial }, + + // 3 parts + { id: '1-part/meta.md', ...part }, + { id: '2-part/meta.md', ...part }, + { id: '3-part/meta.md', ...part }, + + { id: '1-part/1-chapter/meta.md', ...chapter }, + { id: '2-part/2-chapter/meta.md', ...chapter }, + { id: '3-part/3-chapter/meta.md', ...chapter }, + + { id: '1-part/1-chapter/1-first/content.md', ...lesson }, + { id: '2-part/2-chapter/1-second/content.md', ...lesson }, + { id: '3-part/3-chapter/1-third/content.md', ...lesson }, + ]); + + const collection = await getTutorial(); + expect(Object.keys(collection.parts)).toHaveLength(3); + + await expect(collection).toMatchFileSnapshot(snapshotName(ctx)); +}); + +test('lesson inherits metadata from tutorial', async () => { + const data: CollectionEntryTutorial['data'] = { + type: 'tutorial', + autoReload: true, + editor: { fileTree: { allowEdits: ['some-pattern/**'] } }, + editPageLink: 'example-link', + filesystem: { watch: true }, + focus: '/index.js', + i18n: { confirmationText: 'example' }, + mainCommand: 'ls', + prepareCommands: ['npm i'], + openInStackBlitz: { projectTitle: 'example' }, + previews: ['8080', '3000'], + template: 'vite', + terminal: { panels: ['output', 'terminal'] }, + }; + + getCollection.mockReturnValueOnce([ + { id: 'meta.md', ...tutorial, data }, + { id: '1-part/meta.md', ...part }, + { id: '1-part/1-chapter/meta.md', ...chapter }, + { id: '1-part/1-chapter/1-lesson/content.md', ...lesson }, + ]); + + const collection = await getTutorial(); + const { data: lessonData } = collection.parts['1-part'].chapters['1-chapter'].lessons['1-lesson']; + + expect(lessonData).toStrictEqual({ ...data, type: 'lesson', title: lesson.data.title }); +}); + +test('lesson inherits metadata cascaded form all higher levels', async () => { + getCollection.mockReturnValueOnce([ + { + id: 'meta.md', + ...tutorial, + data: { + ...tutorial.data, + editPageLink: 'edit link from tutorial', + focus: 'this should be overwritten', + mainCommand: 'this should be overwritten', + template: 'this should be overwritten', + }, + }, + { + id: '1-part/meta.md', + ...part, + data: { + ...part.data, + focus: 'focus from part', + mainCommand: 'this should be overwritten', + template: 'this should be overwritten', + }, + }, + { + id: '1-part/1-chapter/meta.md', + ...chapter, + data: { + ...chapter.data, + mainCommand: 'main command from chapter', + template: 'this should be overwritten', + }, + }, + { + id: '1-part/1-chapter/1-lesson/content.md', + ...lesson, + data: { ...lesson.data, template: 'template from lesson' }, + }, + ]); + + const collection = await getTutorial(); + const { data: lessonData } = collection.parts['1-part'].chapters['1-chapter'].lessons['1-lesson']; + + expect(lessonData.editPageLink).toBe('edit link from tutorial'); + expect(lessonData.focus).toBe('focus from part'); + expect(lessonData.mainCommand).toBe('main command from chapter'); + expect(lessonData.template).toBe('template from lesson'); +}); + +test('throws when tutorial metadata not found', async () => { + getCollection.mockReturnValueOnce([ + { id: '1-part/meta.md', ...part }, + { id: '1-part/1-chapter/meta.md', ...chapter }, + { id: '1-part/1-chapter/1-first/content.md', ...lesson }, + ]); + + await expect(getTutorial).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Could not find tutorial 'meta.md' file]`, + ); +}); + test('throws when part not found', async () => { getCollection.mockReturnValueOnce([ { id: 'meta.md', ...tutorial }, @@ -70,34 +207,34 @@ test('throws when chapter not found', async () => { await expect(getTutorial).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: Could not find chapter '1-chapter']`); }); -const tutorial: Omit = { +const tutorial = { slug: 'tutorial-slug', body: 'Hello world', collection: 'tutorial', data: { type: 'tutorial' }, -}; +} satisfies Omit; -const part: Omit = { +const part = { slug: 'part-slug', body: 'Hello world', collection: 'tutorial', data: { type: 'part', title: 'Basics' }, -}; +} satisfies Omit; -const chapter: Omit = { +const chapter = { slug: 'chapter-slug', body: 'body here', collection: 'tutorial', data: { title: 'The first chapter in part 1', type: 'chapter' }, -}; +} satisfies Omit; -const lesson: Omit = { +const lesson = { slug: 'lesson-slug', body: 'body here', collection: 'tutorial', data: { type: 'lesson', title: 'Welcome to TutorialKit' }, render: () => ({ Content: 'Markdown for tutorial' }) as unknown as ReturnType, -}; +} satisfies Omit; function snapshotName(ctx: TaskContext) { const testName = ctx.task.name.replaceAll(',', '').replaceAll(' ', '-'); From dabe5a5877bdf71c1a20fd87f749f75c58674a24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ari=20Perkki=C3=B6?= Date: Mon, 7 Oct 2024 10:03:59 +0300 Subject: [PATCH 4/4] test: test cases for order --- .../astro/src/default/utils/content.spec.ts | 325 ++++++++++++------ 1 file changed, 222 insertions(+), 103 deletions(-) diff --git a/packages/astro/src/default/utils/content.spec.ts b/packages/astro/src/default/utils/content.spec.ts index cc3a6a876..d4b39e994 100644 --- a/packages/astro/src/default/utils/content.spec.ts +++ b/packages/astro/src/default/utils/content.spec.ts @@ -1,8 +1,8 @@ import * as content from 'astro:content'; -import { expect, test, vi, type TaskContext } from 'vitest'; +import { describe, expect, test, vi, type TaskContext } from 'vitest'; import { getTutorial, type CollectionEntryTutorial } from './content'; -const getCollection = vi.mocked(content.getCollection); +const getCollection = vi.mocked<() => Omit[]>(content.getCollection); vi.mock('astro:content', () => ({ getCollection: vi.fn() })); // mock DEFAULT_LOCALIZATION so that we don't need to update test results everytime new keys are added there @@ -95,116 +95,235 @@ test('multiple parts', async (ctx) => { await expect(collection).toMatchFileSnapshot(snapshotName(ctx)); }); -test('lesson inherits metadata from tutorial', async () => { - const data: CollectionEntryTutorial['data'] = { - type: 'tutorial', - autoReload: true, - editor: { fileTree: { allowEdits: ['some-pattern/**'] } }, - editPageLink: 'example-link', - filesystem: { watch: true }, - focus: '/index.js', - i18n: { confirmationText: 'example' }, - mainCommand: 'ls', - prepareCommands: ['npm i'], - openInStackBlitz: { projectTitle: 'example' }, - previews: ['8080', '3000'], - template: 'vite', - terminal: { panels: ['output', 'terminal'] }, - }; - - getCollection.mockReturnValueOnce([ - { id: 'meta.md', ...tutorial, data }, - { id: '1-part/meta.md', ...part }, - { id: '1-part/1-chapter/meta.md', ...chapter }, - { id: '1-part/1-chapter/1-lesson/content.md', ...lesson }, - ]); - - const collection = await getTutorial(); - const { data: lessonData } = collection.parts['1-part'].chapters['1-chapter'].lessons['1-lesson']; - - expect(lessonData).toStrictEqual({ ...data, type: 'lesson', title: lesson.data.title }); -}); - -test('lesson inherits metadata cascaded form all higher levels', async () => { - getCollection.mockReturnValueOnce([ - { - id: 'meta.md', - ...tutorial, - data: { - ...tutorial.data, - editPageLink: 'edit link from tutorial', - focus: 'this should be overwritten', - mainCommand: 'this should be overwritten', - template: 'this should be overwritten', +describe('metadata inheriting', () => { + test('lesson inherits metadata from tutorial', async () => { + const data: CollectionEntryTutorial['data'] = { + type: 'tutorial', + autoReload: true, + editor: { fileTree: { allowEdits: ['some-pattern/**'] } }, + editPageLink: 'example-link', + filesystem: { watch: true }, + focus: '/index.js', + i18n: { confirmationText: 'example' }, + mainCommand: 'ls', + prepareCommands: ['npm i'], + openInStackBlitz: { projectTitle: 'example' }, + previews: ['8080', '3000'], + template: 'vite', + terminal: { panels: ['output', 'terminal'] }, + }; + + getCollection.mockReturnValueOnce([ + { id: 'meta.md', ...tutorial, data }, + { id: '1-part/meta.md', ...part }, + { id: '1-part/1-chapter/meta.md', ...chapter }, + { id: '1-part/1-chapter/1-lesson/content.md', ...lesson }, + ]); + + const collection = await getTutorial(); + const { data: lessonData } = collection.parts['1-part'].chapters['1-chapter'].lessons['1-lesson']; + + expect(lessonData).toStrictEqual({ ...data, type: 'lesson', title: lesson.data.title }); + }); + + test('lesson inherits metadata cascaded from all higher levels', async () => { + getCollection.mockReturnValueOnce([ + { + id: 'meta.md', + ...tutorial, + data: { + ...tutorial.data, + editPageLink: 'edit link from tutorial', + focus: 'this should be overwritten', + mainCommand: 'this should be overwritten', + template: 'this should be overwritten', + }, }, - }, - { - id: '1-part/meta.md', - ...part, - data: { - ...part.data, - focus: 'focus from part', - mainCommand: 'this should be overwritten', - template: 'this should be overwritten', + { + id: '1-part/meta.md', + ...part, + data: { + ...part.data, + focus: 'focus from part', + mainCommand: 'this should be overwritten', + template: 'this should be overwritten', + }, }, - }, - { - id: '1-part/1-chapter/meta.md', - ...chapter, - data: { - ...chapter.data, - mainCommand: 'main command from chapter', - template: 'this should be overwritten', + { + id: '1-part/1-chapter/meta.md', + ...chapter, + data: { + ...chapter.data, + mainCommand: 'main command from chapter', + template: 'this should be overwritten', + }, }, - }, - { - id: '1-part/1-chapter/1-lesson/content.md', - ...lesson, - data: { ...lesson.data, template: 'template from lesson' }, - }, - ]); + { + id: '1-part/1-chapter/1-lesson/content.md', + ...lesson, + data: { ...lesson.data, template: 'template from lesson' }, + }, + ]); - const collection = await getTutorial(); - const { data: lessonData } = collection.parts['1-part'].chapters['1-chapter'].lessons['1-lesson']; + const collection = await getTutorial(); + const { data: lessonData } = collection.parts['1-part'].chapters['1-chapter'].lessons['1-lesson']; - expect(lessonData.editPageLink).toBe('edit link from tutorial'); - expect(lessonData.focus).toBe('focus from part'); - expect(lessonData.mainCommand).toBe('main command from chapter'); - expect(lessonData.template).toBe('template from lesson'); + expect(lessonData.editPageLink).toBe('edit link from tutorial'); + expect(lessonData.focus).toBe('focus from part'); + expect(lessonData.mainCommand).toBe('main command from chapter'); + expect(lessonData.template).toBe('template from lesson'); + }); }); -test('throws when tutorial metadata not found', async () => { - getCollection.mockReturnValueOnce([ - { id: '1-part/meta.md', ...part }, - { id: '1-part/1-chapter/meta.md', ...chapter }, - { id: '1-part/1-chapter/1-first/content.md', ...lesson }, - ]); - - await expect(getTutorial).rejects.toThrowErrorMatchingInlineSnapshot( - `[Error: Could not find tutorial 'meta.md' file]`, - ); -}); - -test('throws when part not found', async () => { - getCollection.mockReturnValueOnce([ - { id: 'meta.md', ...tutorial }, - { id: '2-part/meta.md', ...part }, - { id: '1-part/1-chapter/meta.md', ...chapter }, - { id: '1-part/1-chapter/1-first/content.md', ...lesson }, - ]); - - await expect(getTutorial).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: Could not find part '1-part']`); +describe('ordering', () => { + test('parts are ordered by default', async () => { + getCollection.mockReturnValueOnce([ + { id: 'meta.md', ...tutorial }, + { id: '2-part/meta.md', ...part }, + { id: '3-part/meta.md', ...part }, + { id: '1-part/meta.md', ...part }, + ]); + + const collection = await getTutorial(); + const parts = collection.parts; + + expect(parts['1-part'].order).toBe(0); + expect(parts['2-part'].order).toBe(1); + expect(parts['3-part'].order).toBe(2); + }); + + test('parts are ordered by metadata', async () => { + getCollection.mockReturnValueOnce([ + { + id: 'meta.md', + ...tutorial, + data: { ...tutorial.data, parts: ['3-part', '1-part', '2-part'] }, + }, + { id: '2-part/meta.md', ...part }, + { id: '3-part/meta.md', ...part }, + { id: '1-part/meta.md', ...part }, + ]); + + const collection = await getTutorial(); + const parts = collection.parts; + + expect(parts['3-part'].order).toBe(0); + expect(parts['1-part'].order).toBe(1); + expect(parts['2-part'].order).toBe(2); + }); + + test('chapters are ordered by default', async () => { + getCollection.mockReturnValueOnce([ + { id: 'meta.md', ...tutorial }, + { id: '1-part/meta.md', ...part }, + { id: '1-part/3-chapter/meta.md', ...chapter }, + { id: '1-part/1-chapter/meta.md', ...chapter }, + { id: '1-part/2-chapter/meta.md', ...chapter }, + ]); + + const collection = await getTutorial(); + const chapters = collection.parts['1-part'].chapters; + + expect(chapters['1-chapter'].order).toBe(0); + expect(chapters['2-chapter'].order).toBe(1); + expect(chapters['3-chapter'].order).toBe(2); + }); + + test('chapters are ordered by metadata', async () => { + getCollection.mockReturnValueOnce([ + { id: 'meta.md', ...tutorial }, + { id: '1-part/meta.md', ...part, data: { ...part.data, chapters: ['3-chapter', '1-chapter', '2-chapter'] } }, + { id: '1-part/1-chapter/meta.md', ...chapter }, + { id: '1-part/2-chapter/meta.md', ...chapter }, + { id: '1-part/3-chapter/meta.md', ...chapter }, + ]); + + const collection = await getTutorial(); + const chapters = collection.parts['1-part'].chapters; + + expect(chapters['3-chapter'].order).toBe(0); + expect(chapters['1-chapter'].order).toBe(1); + expect(chapters['2-chapter'].order).toBe(2); + }); + + test('lessons are ordered by default', async () => { + getCollection.mockReturnValueOnce([ + { id: 'meta.md', ...tutorial }, + { id: '1-part/meta.md', ...part }, + { id: '1-part/1-chapter/meta.md', ...chapter }, + { id: '1-part/1-chapter/2-lesson/meta.md', ...lesson }, + { id: '1-part/1-chapter/3-lesson/meta.md', ...lesson }, + { id: '1-part/1-chapter/1-lesson/meta.md', ...lesson }, + ]); + + const collection = await getTutorial(); + const lessons = collection.parts['1-part'].chapters['1-chapter'].lessons; + + expect(lessons['1-lesson'].order).toBe(0); + expect(lessons['2-lesson'].order).toBe(1); + expect(lessons['3-lesson'].order).toBe(2); + }); + + test('lessons are ordered by metadata', async () => { + getCollection.mockReturnValueOnce([ + { id: 'meta.md', ...tutorial }, + { id: '1-part/meta.md', ...part }, + { + id: '1-part/1-chapter/meta.md', + ...chapter, + data: { + ...chapter.data, + lessons: ['3-lesson', '1-lesson', '2-lesson'], + }, + }, + { id: '1-part/1-chapter/2-lesson/meta.md', ...lesson }, + { id: '1-part/1-chapter/3-lesson/meta.md', ...lesson }, + { id: '1-part/1-chapter/1-lesson/meta.md', ...lesson }, + ]); + + const collection = await getTutorial(); + const lessons = collection.parts['1-part'].chapters['1-chapter'].lessons; + + expect(lessons['3-lesson'].order).toBe(0); + expect(lessons['1-lesson'].order).toBe(1); + expect(lessons['2-lesson'].order).toBe(2); + }); }); -test('throws when chapter not found', async () => { - getCollection.mockReturnValueOnce([ - { id: 'meta.md', ...tutorial }, - { id: '1-part/meta.md', ...part }, - { id: '1-part/2-chapter/meta.md', ...chapter }, - { id: '1-part/1-chapter/1-first/content.md', ...lesson }, - ]); - - await expect(getTutorial).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: Could not find chapter '1-chapter']`); +describe('missing parts', () => { + test('throws when tutorial metadata not found', async () => { + getCollection.mockReturnValueOnce([ + { id: '1-part/meta.md', ...part }, + { id: '1-part/1-chapter/meta.md', ...chapter }, + { id: '1-part/1-chapter/1-first/content.md', ...lesson }, + ]); + + await expect(getTutorial).rejects.toThrowErrorMatchingInlineSnapshot( + `[Error: Could not find tutorial 'meta.md' file]`, + ); + }); + + test('throws when part not found', async () => { + getCollection.mockReturnValueOnce([ + { id: 'meta.md', ...tutorial }, + { id: '2-part/meta.md', ...part }, + { id: '1-part/1-chapter/meta.md', ...chapter }, + { id: '1-part/1-chapter/1-first/content.md', ...lesson }, + ]); + + await expect(getTutorial).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: Could not find part '1-part']`); + }); + + test('throws when chapter not found', async () => { + getCollection.mockReturnValueOnce([ + { id: 'meta.md', ...tutorial }, + { id: '1-part/meta.md', ...part }, + { id: '1-part/2-chapter/meta.md', ...chapter }, + { id: '1-part/1-chapter/1-first/content.md', ...lesson }, + ]); + + await expect(getTutorial).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: Could not find chapter '1-chapter']`); + }); }); const tutorial = {