From 745554e2fb53f3c5b5e716ddfea2ed67f061ad10 Mon Sep 17 00:00:00 2001 From: msa46 Date: Sun, 2 Aug 2026 16:14:40 +0200 Subject: [PATCH] test: define exported wiki migration contract --- docs/migration-manifest.json | 35 +++++++++++++++ package.json | 1 + scripts/check-migration.mjs | 17 +++++++ scripts/lib/migration.mjs | 75 +++++++++++++++++++++++++++++++ tests/migration-manifest.test.mjs | 45 +++++++++++++++++++ 5 files changed, 173 insertions(+) create mode 100644 docs/migration-manifest.json create mode 100644 scripts/check-migration.mjs create mode 100644 scripts/lib/migration.mjs create mode 100644 tests/migration-manifest.test.mjs diff --git a/docs/migration-manifest.json b/docs/migration-manifest.json new file mode 100644 index 0000000..d11808f --- /dev/null +++ b/docs/migration-manifest.json @@ -0,0 +1,35 @@ +{ + "version": 1, + "sourceRoot": "/Volumes/S/Incognito/DokuWiki Export", + "entries": [ + { "source": "pages/start.txt", "destination": "src/content/docs/index.mdx", "mode": "page" }, + { "source": "pages/study.txt", "destination": "src/content/docs/index.mdx", "mode": "merge" }, + { "source": "pages/study/msv_incognito.txt", "destination": "src/content/docs/about-incognito.md", "mode": "page" }, + { "source": "pages/study/bachelor.txt", "destination": "src/content/docs/bachelor/index.md", "mode": "page" }, + { "source": "pages/study/bachelor/year_1.txt", "destination": "src/content/docs/bachelor/year-1/index.md", "mode": "page" }, + { "source": "pages/study/bachelor/year_1/project_1-1.txt", "destination": "src/content/docs/bachelor/year-1/project-1-1.md", "mode": "page" }, + { "source": "pages/study/bachelor/year_1/project_1-2.txt", "destination": "src/content/docs/bachelor/year-1/project-1-2.md", "mode": "page" }, + { "source": "pages/study/bachelor/year_2.txt", "destination": "src/content/docs/bachelor/year-2/index.md", "mode": "page" }, + { "source": "pages/study/bachelor/year_2/honours_programme.txt", "destination": "src/content/docs/bachelor/year-2/honours-programme.md", "mode": "page" }, + { "source": "pages/study/bachelor/year_2/project_2-1.txt", "destination": "src/content/docs/bachelor/year-2/project-2-1.md", "mode": "page" }, + { "source": "pages/study/bachelor/year_2/project_2-2.txt", "destination": "src/content/docs/bachelor/year-2/project-2-2.md", "mode": "page" }, + { "source": "pages/study/bachelor/year_3.txt", "destination": "src/content/docs/bachelor/year-3/index.md", "mode": "page" }, + { "source": "pages/study/bachelor/year_3/bachelors_thesis.txt", "destination": "src/content/docs/bachelor/year-3/bachelors-thesis.md", "mode": "page" }, + { "source": "pages/study/bachelor/year_3/honours_programme.txt", "destination": "src/content/docs/bachelor/year-3/honours-programme.md", "mode": "page" }, + { "source": "pages/study/bachelor/year_3/project_3-1.txt", "destination": "src/content/docs/bachelor/year-3/project-3-1.md", "mode": "page" }, + { "source": "pages/study/bachelor/year_3/study_abroad.txt", "destination": "src/content/docs/bachelor/year-3/study-abroad.md", "mode": "page" }, + { "source": "pages/study/master_ai.txt", "destination": "src/content/docs/master-ai/index.md", "mode": "page" }, + { "source": "pages/study/master_ai/year_1.txt", "destination": "src/content/docs/master-ai/year-1/index.md", "mode": "page" }, + { "source": "pages/study/master_ai/year_1/project_mai_1.txt", "destination": "src/content/docs/master-ai/year-1/research-project-1.md", "mode": "page" }, + { "source": "pages/study/master_ai/year_1/project_mai_2.txt", "destination": "src/content/docs/master-ai/year-1/research-project-2.md", "mode": "page" }, + { "source": "pages/study/master_ai/year_2.txt", "destination": "src/content/docs/master-ai/year-2/index.md", "mode": "page" }, + { "source": "pages/study/master_dsdm.txt", "destination": "src/content/docs/master-dsdm/index.md", "mode": "page" }, + { "source": "pages/study/master_dsdm/year_1.txt", "destination": "src/content/docs/master-dsdm/year-1/index.md", "mode": "page" }, + { "source": "pages/study/master_dsdm/year_2.txt", "destination": "src/content/docs/master-dsdm/year-2/index.md", "mode": "page" }, + { "source": "pages/study/useful_information.txt", "destination": "src/content/docs/useful-information/index.md", "mode": "page" }, + { "source": "pages/study/useful_information/pages/dke_locations.txt", "destination": "src/content/docs/useful-information/dke-locations.md", "mode": "page" }, + { "source": "pages/study/useful_information/pages/handy_locations.txt", "destination": "src/content/docs/useful-information/handy-locations.md", "mode": "page" }, + { "source": "pages/study/useful_information/pages/it_services.txt", "destination": "src/content/docs/useful-information/it-services.md", "mode": "page" }, + { "source": "pages/study/useful_information/pages/laptop_buy_advice.txt", "destination": "src/content/docs/useful-information/laptop-buying-advice.md", "mode": "page" } + ] +} diff --git a/package.json b/package.json index e30f018..a4ca49b 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "build": "astro build", "preview": "astro preview", "check": "astro check && node --test", + "check:migration": "node scripts/check-migration.mjs", "test": "node --test" }, "dependencies": { diff --git a/scripts/check-migration.mjs b/scripts/check-migration.mjs new file mode 100644 index 0000000..8aef7f7 --- /dev/null +++ b/scripts/check-migration.mjs @@ -0,0 +1,17 @@ +import { resolve } from 'node:path'; +import { checkDestinations, loadManifest, validateManifest } from './lib/migration.mjs'; + +const allowMissing = process.argv.slice(2).includes('--allow-missing'); +const manifest = await loadManifest('docs/migration-manifest.json'); +const { entries } = validateManifest(manifest); +const result = await checkDestinations({ entries }, resolve('.')); + +console.log(`Migration manifest accounts for ${result.total} source pages.`); +for (const destination of result.missing) { + console.log(`Missing destination: ${destination}`); +} + +if (result.missing.length > 0 && !allowMissing) { + console.error(`Migration check failed: ${result.missing.length} destination file(s) are missing.`); + process.exitCode = 1; +} diff --git a/scripts/lib/migration.mjs b/scripts/lib/migration.mjs new file mode 100644 index 0000000..8a5af51 --- /dev/null +++ b/scripts/lib/migration.mjs @@ -0,0 +1,75 @@ +import { readFile } from 'node:fs/promises'; +import { access } from 'node:fs/promises'; +import { join } from 'node:path'; + +const SOURCE_COUNT = 29; +const DOCS_ROOT = 'src/content/docs/'; + +export async function loadManifest(path) { + return JSON.parse(await readFile(path, 'utf8')); +} + +export function validateManifest(manifest) { + if (!Array.isArray(manifest?.entries)) { + throw new TypeError('Migration manifest entries must be an array.'); + } + + if (manifest.entries.length !== SOURCE_COUNT) { + throw new Error(`Migration manifest must contain exactly ${SOURCE_COUNT} entries.`); + } + + const sources = new Set(); + const destinations = new Set(); + + for (const entry of manifest.entries) { + if (!entry || typeof entry !== 'object') { + throw new TypeError('Each migration manifest entry must be an object.'); + } + + const { source, destination, mode } = entry; + if (typeof source !== 'string' || typeof destination !== 'string') { + throw new TypeError('Each migration manifest entry needs string source and destination paths.'); + } + if (!['page', 'merge'].includes(mode)) { + throw new TypeError('Each migration manifest entry mode must be page or merge.'); + } + if (source.startsWith('pages/wiki/')) { + throw new Error(`Generic DokuWiki source is excluded: ${source}`); + } + if (!(source === 'pages/start.txt' || source === 'pages/study.txt' || source.startsWith('pages/study/'))) { + throw new Error(`Source is outside the selected migration scope: ${source}`); + } + if (!destination.startsWith(DOCS_ROOT)) { + throw new Error(`Destination is outside ${DOCS_ROOT}: ${destination}`); + } + if (sources.has(source)) { + throw new Error(`Duplicate migration source: ${source}`); + } + if (destinations.has(destination) && mode !== 'merge') { + throw new Error(`Duplicate destination requires a later merge entry: ${destination}`); + } + + sources.add(source); + destinations.add(destination); + } + + return { entries: manifest.entries }; +} + +export async function checkDestinations(manifest, root) { + const destinations = [...new Set(manifest.entries.map(({ destination }) => destination))]; + const missing = []; + + for (const destination of destinations) { + try { + await access(join(root, destination)); + } catch { + missing.push(destination); + } + } + + return { + total: manifest.entries.length, + missing, + }; +} diff --git a/tests/migration-manifest.test.mjs b/tests/migration-manifest.test.mjs new file mode 100644 index 0000000..4f2a39c --- /dev/null +++ b/tests/migration-manifest.test.mjs @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { access, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { loadManifest, validateManifest, checkDestinations } from '../scripts/lib/migration.mjs'; + +test('manifest accounts for exactly 29 unique source pages', async () => { + const manifest = await loadManifest('docs/migration-manifest.json'); + const result = validateManifest(manifest); + assert.equal(result.entries.length, 29); + assert.equal(new Set(result.entries.map(({ source }) => source)).size, 29); +}); + +test('generic DokuWiki pages are excluded', async () => { + const manifest = await loadManifest('docs/migration-manifest.json'); + assert.equal(manifest.entries.some(({ source }) => source.startsWith('pages/wiki/')), false); +}); + +test('validation rejects duplicate destinations unless the later entry is a merge', async () => { + const manifest = await loadManifest('docs/migration-manifest.json'); + manifest.entries[1].mode = 'page'; + + assert.throws(() => validateManifest(manifest), /Duplicate destination/); +}); + +test('destination check reports only files that have not been migrated', async () => { + const root = await mkdir(join(tmpdir(), `migration-${process.pid}-${Date.now()}`), { recursive: true }); + const existing = join(root, 'src/content/docs/present.md'); + await mkdir(join(root, 'src/content/docs'), { recursive: true }); + await writeFile(existing, '# Present\n'); + + try { + const result = await checkDestinations({ + entries: [ + { destination: 'src/content/docs/present.md' }, + { destination: 'src/content/docs/missing.md' }, + ], + }, root); + await access(existing); + assert.deepEqual(result.missing, ['src/content/docs/missing.md']); + } finally { + await rm(root, { recursive: true, force: true }); + } +});