test: define exported wiki migration contract

This commit is contained in:
msa46 2026-08-02 16:14:40 +02:00
parent 9c318959c8
commit 745554e2fb
5 changed files with 173 additions and 0 deletions

View file

@ -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" }
]
}

View file

@ -9,6 +9,7 @@
"build": "astro build", "build": "astro build",
"preview": "astro preview", "preview": "astro preview",
"check": "astro check && node --test", "check": "astro check && node --test",
"check:migration": "node scripts/check-migration.mjs",
"test": "node --test" "test": "node --test"
}, },
"dependencies": { "dependencies": {

View file

@ -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;
}

75
scripts/lib/migration.mjs Normal file
View file

@ -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,
};
}

View file

@ -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 });
}
});