import { CONCEPT_TAUGHT_TWICE, CYCLE_FOUND, REQUIRES_FORWARD, REQUIRES_UNKNOWN, SECTION_UNDECLARED, SECTION_UNTAUGHT, TRACE_UNKNOWN, } from "#configuration/strings/coverage.strings"; import type { ContentGraph, GraphContext, GraphFinding, GraphSection } from "#types/coverage.types"; const orderedSections = function orderedSections(graph: ContentGraph, context: GraphContext): string[] { const declared = Object.keys(graph.sections); const built = context.payloadSections.get(graph.page) ?? []; const order = built.filter((section) => declared.includes(section)); return [...order, ...declared.filter((section) => !order.includes(section))]; }; const teachersOf = function teachersOf(graph: ContentGraph): Map { const teachers = new Map(); for (const [section, held] of Object.entries(graph.sections)) { for (const concept of held.teaches) { teachers.set(concept, [...(teachers.get(concept) ?? []), section]); } } return teachers; }; const finding = function finding(page: string, section: string, message: string): GraphFinding { return { message, page, section }; }; const shapeFindings = function shapeFindings(graph: ContentGraph, context: GraphContext): GraphFinding[] { const findings: GraphFinding[] = []; for (const section of context.payloadSections.get(graph.page) ?? []) { if (!(section in graph.sections)) { findings.push(finding(graph.page, section, SECTION_UNDECLARED)); } } for (const [section, held] of Object.entries(graph.sections)) { if (held.teaches.length === 0 && held.narrative !== true) { findings.push(finding(graph.page, section, SECTION_UNTAUGHT)); } for (const trace of held.traces) { if (!context.known.has(trace)) { findings.push(finding(graph.page, section, `${TRACE_UNKNOWN}${trace}`)); } } } return findings; }; const requirementMessage = function requirementMessage( teacher: string | undefined, required: string, order: readonly string[], index: number, ): string | null { if (teacher === undefined) { return `${REQUIRES_UNKNOWN}${required}`; } return order.indexOf(teacher) >= index ? `${REQUIRES_FORWARD}${required}` : null; }; const localRequirements = function localRequirements( held: GraphSection | undefined, teachers: ReadonlyMap, context: GraphContext, ): readonly string[] { return (held?.requires ?? []).filter((required) => teachers.has(required) || !context.taught.has(required)); }; const requirementFindings = function requirementFindings(graph: ContentGraph, context: GraphContext): GraphFinding[] { const findings: GraphFinding[] = []; const teachers = teachersOf(graph); for (const [concept, sections] of teachers) { if (sections.length > 1) { findings.push(finding(graph.page, sections.join(", "), `${CONCEPT_TAUGHT_TWICE}${concept}`)); } } const order = orderedSections(graph, context); for (const [index, section] of order.entries()) { for (const required of localRequirements(graph.sections[section], teachers, context)) { const message = requirementMessage(teachers.get(required)?.[0], required, order, index); if (message !== null) { findings.push(finding(graph.page, section, message)); } } } return findings; }; const cyclesIn = function cyclesIn(edges: ReadonlyMap): string[] { const visiting = new Set(); const done = new Set(); const cycles: string[] = []; const visit = function visit(node: string): void { if (done.has(node)) { return; } if (visiting.has(node)) { cycles.push(node); return; } visiting.add(node); for (const next of edges.get(node) ?? []) { visit(next); } visiting.delete(node); done.add(node); }; for (const node of edges.keys()) { visit(node); } return cycles; }; const cycleFindings = function cycleFindings(graph: ContentGraph): GraphFinding[] { const teachers = teachersOf(graph); const edges = new Map(); for (const [section, held] of Object.entries(graph.sections)) { edges.set( section, held.requires.flatMap((concept) => teachers.get(concept) ?? []), ); } return cyclesIn(edges).map((section) => finding(graph.page, section, CYCLE_FOUND)); }; const NODE_MARK = "#"; const nodeOf = function nodeOf(page: string, section: string): string { return page + NODE_MARK + section; }; export const taughtAcross = function taughtAcross(graphs: readonly ContentGraph[]): ReadonlySet { return new Set(graphs.flatMap((graph) => Object.values(graph.sections).flatMap((held) => held.teaches))); }; export const validateUnion = function validateUnion(graphs: readonly ContentGraph[]): GraphFinding[] { const teachers = new Map(); for (const graph of graphs) { for (const [section, held] of Object.entries(graph.sections)) { for (const concept of held.teaches) { teachers.set(concept, [...(teachers.get(concept) ?? []), nodeOf(graph.page, section)]); } } } const edges = new Map(); for (const graph of graphs) { for (const [section, held] of Object.entries(graph.sections)) { edges.set( nodeOf(graph.page, section), held.requires.flatMap((concept) => teachers.get(concept) ?? []), ); } } return cyclesIn(edges).map((node) => { const cut = node.indexOf(NODE_MARK); return finding(node.slice(0, cut), node.slice(cut + NODE_MARK.length), CYCLE_FOUND); }); }; export const validateGraph = function validateGraph(graph: ContentGraph, context: GraphContext): GraphFinding[] { return [...shapeFindings(graph, context), ...requirementFindings(graph, context), ...cycleFindings(graph)]; }; export const untaughtOf = function untaughtOf(graphs: readonly ContentGraph[], slugs: readonly string[]): string[] { const traced = new Set(graphs.flatMap((graph) => Object.values(graph.sections).flatMap((held) => held.traces))); return [...new Set(slugs)].filter((slug) => !traced.has(slug)); }; export const conceptsTaughtAcross = function conceptsTaughtAcross(graphs: readonly ContentGraph[]): GraphFinding[] { const seen = new Map(); const findings: GraphFinding[] = []; for (const graph of graphs) { for (const concept of teachersOf(graph).keys()) { const other = seen.get(concept); if (other !== undefined && other !== graph.page) { findings.push(finding(graph.page, other, `${CONCEPT_TAUGHT_TWICE}${concept}`)); } seen.set(concept, graph.page); } } return findings; };