test: add vitest, coverage and mutation gate with availability domain core
Pure logic lives in apps/web/src/domain and must stay at 100% coverage and a 100% mutation score. The first unit is the availability logic for the hero: parsing, resolving and formatting the next free capacity block. Vitest is pinned to 4.1.11 because @stryker-mutator/vitest-runner 10.0.0 runs zero tests per mutant under vitest 5 and reports surviving mutants. ESLint now ignores Stryker, coverage and report output. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,8 @@
|
||||
"build": "next build --webpack",
|
||||
"start": "next start",
|
||||
"lint": "eslint app src scripts video",
|
||||
"test": "echo \"No tests configured\"",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:links": "tsx ./scripts/test-links.ts",
|
||||
"test:file-examples": "tsx ./scripts/test-file-examples-comprehensive.ts",
|
||||
"generate-estimate": "tsx ./scripts/generate-estimate.ts",
|
||||
@@ -21,6 +22,7 @@
|
||||
"video:render:all": "npm run video:render:contact && npm run video:render:button",
|
||||
"pagespeed:test": "npx tsx ./scripts/pagespeed-sitemap.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:mutation": "stryker run",
|
||||
"check:og": "tsx scripts/check-og-images.ts",
|
||||
"check:forms": "tsx scripts/check-forms.ts"
|
||||
},
|
||||
@@ -104,12 +106,16 @@
|
||||
"@mintel/next-utils": "^1.9.0",
|
||||
"@mintel/tsconfig": "^1.9.0",
|
||||
"@next/eslint-plugin-next": "^16.1.6",
|
||||
"@stryker-mutator/core": "^10.0.0",
|
||||
"@stryker-mutator/typescript-checker": "^10.0.0",
|
||||
"@stryker-mutator/vitest-runner": "^10.0.0",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@types/mime-types": "^3.0.1",
|
||||
"@types/node": "^25.0.6",
|
||||
"@types/nodemailer": "^7.0.10",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@vitest/coverage-v8": "^4.1.11",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"cheerio": "^1.1.2",
|
||||
"concurrently": "^9.2.1",
|
||||
@@ -121,7 +127,8 @@
|
||||
"require-extensions": "^0.0.4",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "^8.54.0"
|
||||
"typescript-eslint": "^8.54.0",
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatAvailability,
|
||||
parseAvailabilityConfig,
|
||||
resolveAvailability,
|
||||
} from "./availability";
|
||||
|
||||
describe("parseAvailabilityConfig", () => {
|
||||
it("accepts an ISO date and a positive hour count", () => {
|
||||
expect(
|
||||
parseAvailabilityConfig({ freeFrom: "2026-11-01", hoursPerWeek: 20 }),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
value: { freeFrom: "2026-11-01", hoursPerWeek: 20 },
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts zero hours to express fully booked", () => {
|
||||
expect(
|
||||
parseAvailabilityConfig({ freeFrom: "2026-11-01", hoursPerWeek: 0 }),
|
||||
).toEqual({ ok: true, value: { freeFrom: "2026-11-01", hoursPerWeek: 0 } });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["not a date", "soon"],
|
||||
["a non-ISO format", "01.11.2026"],
|
||||
["an impossible calendar date", "2026-02-30"],
|
||||
["a missing value", undefined],
|
||||
])("rejects %s as invalid-date", (_label, freeFrom) => {
|
||||
expect(parseAvailabilityConfig({ freeFrom, hoursPerWeek: 20 })).toEqual({
|
||||
ok: false,
|
||||
error: "invalid-date",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["text before the date", "x2026-11-01"],
|
||||
["text after the date", "2026-11-01x"],
|
||||
["month zero", "2026-00-10"],
|
||||
["month thirteen", "2026-13-10"],
|
||||
["day zero", "2026-01-00"],
|
||||
["day 32", "2026-01-32"],
|
||||
["29 February in a common year", "2026-02-29"],
|
||||
["29 February in a century year", "1900-02-29"],
|
||||
["31 April", "2026-04-31"],
|
||||
])("rejects %s as invalid-date", (_label, freeFrom) => {
|
||||
expect(parseAvailabilityConfig({ freeFrom, hoursPerWeek: 20 })).toEqual({
|
||||
ok: false,
|
||||
error: "invalid-date",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["29 February in a leap year", "2028-02-29"],
|
||||
["29 February in a 400-year leap year", "2000-02-29"],
|
||||
["31 January in a leap year", "2028-01-31"],
|
||||
])("accepts %s", (_label, freeFrom) => {
|
||||
expect(parseAvailabilityConfig({ freeFrom, hoursPerWeek: 20 }).ok).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts the last day and rejects the day after it, for every month", () => {
|
||||
const lastDays = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
lastDays.forEach((last, index) => {
|
||||
const month = String(index + 1).padStart(2, "0");
|
||||
const accept = parseAvailabilityConfig({
|
||||
freeFrom: `2026-${month}-${String(last).padStart(2, "0")}`,
|
||||
hoursPerWeek: 20,
|
||||
});
|
||||
const reject = parseAvailabilityConfig({
|
||||
freeFrom: `2026-${month}-${String(last + 1).padStart(2, "0")}`,
|
||||
hoursPerWeek: 20,
|
||||
});
|
||||
expect(accept.ok).toBe(true);
|
||||
expect(reject).toEqual({ ok: false, error: "invalid-date" });
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts exactly the number of hours a week has", () => {
|
||||
expect(
|
||||
parseAvailabilityConfig({ freeFrom: "2026-11-01", hoursPerWeek: 168 }).ok,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["negative hours", -1],
|
||||
["fractional hours", 12.5],
|
||||
["more hours than a week has", 169],
|
||||
["a string", "20"],
|
||||
["a missing value", undefined],
|
||||
])("rejects %s as invalid-hours", (_label, hoursPerWeek) => {
|
||||
expect(
|
||||
parseAvailabilityConfig({ freeFrom: "2026-11-01", hoursPerWeek }),
|
||||
).toEqual({ ok: false, error: "invalid-hours" });
|
||||
});
|
||||
|
||||
it("rejects non-object input as invalid-date", () => {
|
||||
expect(parseAvailabilityConfig(null)).toEqual({
|
||||
ok: false,
|
||||
error: "invalid-date",
|
||||
});
|
||||
expect(parseAvailabilityConfig("2026-11-01")).toEqual({
|
||||
ok: false,
|
||||
error: "invalid-date",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAvailability", () => {
|
||||
const config = { freeFrom: "2026-11-01", hoursPerWeek: 20 } as const;
|
||||
|
||||
it("is available now when the free date is in the past", () => {
|
||||
expect(resolveAvailability(config, "2026-12-15")).toEqual({
|
||||
kind: "available-now",
|
||||
hoursPerWeek: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it("is available now on the free date itself", () => {
|
||||
expect(resolveAvailability(config, "2026-11-01")).toEqual({
|
||||
kind: "available-now",
|
||||
hoursPerWeek: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it("is available from the free date while it is still in the future", () => {
|
||||
expect(resolveAvailability(config, "2026-10-31")).toEqual({
|
||||
kind: "available-from",
|
||||
date: "2026-11-01",
|
||||
hoursPerWeek: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it("is booked when zero hours are free, whatever the date", () => {
|
||||
expect(
|
||||
resolveAvailability(
|
||||
{ freeFrom: "2026-01-01", hoursPerWeek: 0 },
|
||||
"2026-12-15",
|
||||
),
|
||||
).toEqual({ kind: "booked" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatAvailability", () => {
|
||||
it("states hours per week when available now", () => {
|
||||
expect(
|
||||
formatAvailability({ kind: "available-now", hoursPerWeek: 20 }),
|
||||
).toBe("Available now · 20 h/week");
|
||||
});
|
||||
|
||||
it("names the next free date in plain words", () => {
|
||||
expect(
|
||||
formatAvailability({
|
||||
kind: "available-from",
|
||||
date: "2026-11-01",
|
||||
hoursPerWeek: 20,
|
||||
}),
|
||||
).toBe("Next free block: 1 Nov 2026 · 20 h/week");
|
||||
});
|
||||
|
||||
it("covers every month name", () => {
|
||||
const names = [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec",
|
||||
];
|
||||
names.forEach((name, index) => {
|
||||
const month = String(index + 1).padStart(2, "0");
|
||||
expect(
|
||||
formatAvailability({
|
||||
kind: "available-from",
|
||||
date: `2027-${month}-09`,
|
||||
hoursPerWeek: 15,
|
||||
}),
|
||||
).toBe(`Next free block: 9 ${name} 2027 · 15 h/week`);
|
||||
});
|
||||
});
|
||||
|
||||
it("says so plainly when fully booked", () => {
|
||||
expect(formatAvailability({ kind: "booked" })).toBe(
|
||||
"Currently booked · next block on request",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
export type AvailabilityConfig = {
|
||||
readonly freeFrom: string;
|
||||
readonly hoursPerWeek: number;
|
||||
};
|
||||
|
||||
export type Availability =
|
||||
| { readonly kind: "available-now"; readonly hoursPerWeek: number }
|
||||
| {
|
||||
readonly kind: "available-from";
|
||||
readonly date: string;
|
||||
readonly hoursPerWeek: number;
|
||||
}
|
||||
| { readonly kind: "booked" };
|
||||
|
||||
export type ParseError = "invalid-date" | "invalid-hours";
|
||||
|
||||
export type Result<T, E> =
|
||||
| { readonly ok: true; readonly value: T }
|
||||
| { readonly ok: false; readonly error: E };
|
||||
|
||||
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
||||
const HOURS_PER_WEEK = 7 * 24;
|
||||
const MONTH_NAMES = [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec",
|
||||
] as const;
|
||||
|
||||
const THIRTY_DAY_MONTHS: ReadonlySet<number> = new Set([4, 6, 9, 11]);
|
||||
|
||||
const isLeapYear = (year: number): boolean =>
|
||||
year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
||||
|
||||
const daysInMonth = (year: number, month: number): number => {
|
||||
if (month === 2) return isLeapYear(year) ? 29 : 28;
|
||||
return THIRTY_DAY_MONTHS.has(month) ? 30 : 31;
|
||||
};
|
||||
|
||||
const isRealIsoDate = (value: unknown): value is string => {
|
||||
if (typeof value !== "string") return false;
|
||||
const match = ISO_DATE.exec(value);
|
||||
if (!match) return false;
|
||||
const [year, month, day] = match.slice(1).map(Number) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
return (
|
||||
month >= 1 && month <= 12 && day >= 1 && day <= daysInMonth(year, month)
|
||||
);
|
||||
};
|
||||
|
||||
const isWeeklyHours = (value: unknown): value is number =>
|
||||
typeof value === "number" &&
|
||||
Number.isInteger(value) &&
|
||||
value >= 0 &&
|
||||
value <= HOURS_PER_WEEK;
|
||||
|
||||
export function parseAvailabilityConfig(
|
||||
raw: unknown,
|
||||
): Result<AvailabilityConfig, ParseError> {
|
||||
const { freeFrom, hoursPerWeek } = (raw ?? {}) as Record<string, unknown>;
|
||||
if (!isRealIsoDate(freeFrom)) return { ok: false, error: "invalid-date" };
|
||||
if (!isWeeklyHours(hoursPerWeek))
|
||||
return { ok: false, error: "invalid-hours" };
|
||||
return { ok: true, value: { freeFrom, hoursPerWeek } };
|
||||
}
|
||||
|
||||
export function resolveAvailability(
|
||||
config: AvailabilityConfig,
|
||||
today: string,
|
||||
): Availability {
|
||||
if (config.hoursPerWeek === 0) return { kind: "booked" };
|
||||
if (config.freeFrom <= today) {
|
||||
return { kind: "available-now", hoursPerWeek: config.hoursPerWeek };
|
||||
}
|
||||
return {
|
||||
kind: "available-from",
|
||||
date: config.freeFrom,
|
||||
hoursPerWeek: config.hoursPerWeek,
|
||||
};
|
||||
}
|
||||
|
||||
const formatDate = (isoDate: string): string => {
|
||||
const [year, month, day] = isoDate.split("-").map(Number) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
];
|
||||
return `${day} ${MONTH_NAMES[month - 1]} ${year}`;
|
||||
};
|
||||
|
||||
export function formatAvailability(availability: Availability): string {
|
||||
switch (availability.kind) {
|
||||
case "available-now":
|
||||
return `Available now · ${availability.hoursPerWeek} h/week`;
|
||||
case "available-from":
|
||||
return `Next free block: ${formatDate(availability.date)} · ${availability.hoursPerWeek} h/week`;
|
||||
case "booked":
|
||||
return "Currently booked · next block on request";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json",
|
||||
"testRunner": "vitest",
|
||||
"checkers": ["typescript"],
|
||||
"tsconfigFile": "tsconfig.json",
|
||||
"mutate": ["src/domain/**/*.ts", "!src/domain/**/*.spec.ts"],
|
||||
"reporters": ["clear-text", "json"],
|
||||
"jsonReporter": {
|
||||
"fileName": "reports/mutation/mutation.json"
|
||||
},
|
||||
"tempDirName": ".stryker-tmp",
|
||||
"cleanTempDir": true,
|
||||
"thresholds": {
|
||||
"high": 100,
|
||||
"low": 100,
|
||||
"break": 100
|
||||
},
|
||||
"plugins": [
|
||||
"@stryker-mutator/vitest-runner",
|
||||
"@stryker-mutator/typescript-checker"
|
||||
],
|
||||
"ignorePatterns": [
|
||||
"cloned-websites",
|
||||
"public",
|
||||
"out",
|
||||
"dist",
|
||||
"storage",
|
||||
"data",
|
||||
"content",
|
||||
"video",
|
||||
"reports",
|
||||
".next",
|
||||
".turbo",
|
||||
"coverage",
|
||||
"backups",
|
||||
"logs",
|
||||
"docker"
|
||||
],
|
||||
"coverageAnalysis": "off",
|
||||
"vitest": {
|
||||
"related": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
const root = fileURLToPath(new URL(".", import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
resolve: { alias: { "@": root } },
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["src/**/*.spec.ts", "tests/unit/**/*.spec.ts"],
|
||||
passWithNoTests: false,
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
// The functional core: pure logic lives in src/domain and must be fully covered.
|
||||
include: ["src/domain/**/*.ts"],
|
||||
exclude: ["src/domain/**/*.spec.ts"],
|
||||
reporter: ["text", "json-summary", "html"],
|
||||
reportsDirectory: "reports/coverage",
|
||||
thresholds: {
|
||||
lines: 100,
|
||||
functions: 100,
|
||||
branches: 100,
|
||||
statements: 100,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -24,6 +24,9 @@ export default tseslint.config(
|
||||
"**/out/**",
|
||||
"**/cloned-websites/**",
|
||||
"**/public/showcase/**",
|
||||
"**/.stryker-tmp/**",
|
||||
"**/reports/**",
|
||||
"**/coverage/**",
|
||||
],
|
||||
},
|
||||
js.configs.recommended,
|
||||
|
||||
Generated
+14163
-6120
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user