test: define exported wiki migration contract
This commit is contained in:
parent
9c318959c8
commit
745554e2fb
5 changed files with 173 additions and 0 deletions
35
docs/migration-manifest.json
Normal file
35
docs/migration-manifest.json
Normal 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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
17
scripts/check-migration.mjs
Normal file
17
scripts/check-migration.mjs
Normal 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
75
scripts/lib/migration.mjs
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
45
tests/migration-manifest.test.mjs
Normal file
45
tests/migration-manifest.test.mjs
Normal 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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue