From 01806003f0bc61307a6619d9658b7af869493c5a Mon Sep 17 00:00:00 2001 From: simonteague6 Date: Sat, 29 Aug 2026 22:59:58 -0500 Subject: [PATCH 1/5] Add frontend test harness and release checks --- .github/workflows/ci.yml | 12 + justfile | 23 +- tests/frontend/harness.mjs | 626 +++++++++++++++++++++++++++++++++++++ 3 files changed, 658 insertions(+), 3 deletions(-) create mode 100644 tests/frontend/harness.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d05ffef..32623e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,3 +39,15 @@ jobs: - name: "Tests" run: just test + + - name: "Frontend Tests" + run: just test-frontend + + - name: "Compile Check" + run: just compile + + - name: "Validate Data" + run: just validate-data + + - name: "Docker Compose Config" + run: just docker-config diff --git a/justfile b/justfile index e28c6b5..8ce1b82 100644 --- a/justfile +++ b/justfile @@ -39,9 +39,10 @@ fix: uvx ruff check --fix . just format +# Release checks: `just lint`, `just format-check`, `uv run pytest`, +# compileall, `uv run tools/validate_data.py`, and `docker compose config`. # THE command before pushing -check: lint format-check test - uv run python -m compileall -q app run.py tools +check: lint format-check test test-frontend compile validate-data docker-config @echo 'All good - ready to push' # Remove caches and build artifacts @@ -52,10 +53,26 @@ clean: rm -f *.pyc *.pyo rm -rf dist build -# Run the test suite +# Run the Python test suite test: uv run pytest +# Run browser-script tests with Node's built-in test runner +test-frontend: + node --test tests/frontend/*.test.mjs + +# Compile Python sources +compile: + uv run python -m compileall -q app run.py tools + +# Validate canonical graph data +validate-data: + uv run tools/validate_data.py + +# Verify the development compose file without starting containers +docker-config: + docker compose config + # Run tests with coverage report coverage: uv run pytest --cov=app --cov-report=term-missing --cov-report=html diff --git a/tests/frontend/harness.mjs b/tests/frontend/harness.mjs new file mode 100644 index 0000000..0406b47 --- /dev/null +++ b/tests/frontend/harness.mjs @@ -0,0 +1,626 @@ +import { readFileSync } from "node:fs"; +import { existsSync } from "node:fs"; +import { resolve, dirname, isAbsolute } from "node:path"; +import { fileURLToPath } from "node:url"; +import vm from "node:vm"; + +const FRONTEND_DIR = dirname(fileURLToPath(import.meta.url)); +const PROJECT_ROOT = resolve(FRONTEND_DIR, "../.."); +const SCRIPT_PATHS = Object.freeze({ + directions: resolve(PROJECT_ROOT, "app/static/js/directions.js"), + map: resolve(PROJECT_ROOT, "app/static/js/map.js"), +}); + +function createEventTarget() { + const listeners = new Map(); + + return { + addEventListener(type, listener) { + const callbacks = listeners.get(type) ?? []; + callbacks.push(listener); + listeners.set(type, callbacks); + }, + + removeEventListener(type, listener) { + const callbacks = listeners.get(type) ?? []; + listeners.set( + type, + callbacks.filter((callback) => callback !== listener), + ); + }, + + dispatchEvent(eventOrType) { + const event = + typeof eventOrType === "string" + ? { type: eventOrType } + : { ...eventOrType }; + if (!event.type) { + throw new TypeError("Events must have a type"); + } + + const callbacks = [...(listeners.get(event.type) ?? [])]; + for (const callback of callbacks) { + callback(event); + } + return true; + }, + + listenerCount(type) { + return (listeners.get(type) ?? []).length; + }, + }; +} + +class FakeElement { + constructor(ownerDocument, tagName) { + this.ownerDocument = ownerDocument; + this.tagName = tagName.toUpperCase(); + this.children = []; + this.parentNode = null; + this.style = {}; + this.className = ""; + this.eventListeners = new Map(); + this._id = ""; + this._innerHTML = ""; + this._textContent = ""; + } + + get id() { + return this._id; + } + + set id(value) { + this.ownerDocument.unregisterId(this); + this._id = String(value); + this.ownerDocument.registerId(this); + } + + get innerHTML() { + return this._innerHTML; + } + + get outerHTML() { + const attributes = [ + this.id && ` id="${this.id}"`, + this.className && ` class="${this.className}"`, + ] + .filter(Boolean) + .join(""); + return `<${this.tagName.toLowerCase()}${attributes}>${this.innerHTML}`; + } + + set innerHTML(value) { + this.clearChildren(); + this._innerHTML = String(value); + this._textContent = ""; + + // The production directions script creates its button through innerHTML. + // This deliberately small parser gives every opening tag a fake node and + // preserves ids/classes needed by tests without pretending to be a browser. + const openingTags = /<([a-z][\w-]*)\b([^>]*)>/gi; + for (const match of this._innerHTML.matchAll(openingTags)) { + const tagName = match[1]; + const child = this.ownerDocument.createElement(tagName); + const id = match[2].match(/\bid=["']([^"']+)["']/i)?.[1]; + const className = match[2].match(/\bclass=["']([^"']+)["']/i)?.[1]; + const text = new RegExp( + `<${tagName}\\b[^>]*>([^<]*)`, + "i", + ).exec(this._innerHTML.slice(match.index))?.[1]; + if (id) child.id = id; + if (className) child.className = className; + if (text?.trim()) child.textContent = text.trim(); + this.appendChild(child); + } + } + + get textContent() { + if (this._textContent) return this._textContent; + return this._innerHTML.replace(/<[^>]*>/g, "").trim(); + } + + set textContent(value) { + this.clearChildren(); + this._innerHTML = ""; + this._textContent = String(value); + } + + appendChild(child) { + if (child.parentNode) { + child.parentNode.removeChild(child); + } + child.parentNode = this; + this.children.push(child); + this.ownerDocument.registerTree(child); + if (!this._innerHTML) { + this._innerHTML = this.children + .map((element) => element.outerHTML) + .join(""); + } + return child; + } + + removeChild(child) { + const index = this.children.indexOf(child); + if (index === -1) return child; + this.children.splice(index, 1); + this.ownerDocument.unregisterTree(child); + child.parentNode = null; + this._innerHTML = this.children + .map((element) => element.outerHTML) + .join(""); + return child; + } + + clearChildren() { + for (const child of [...this.children]) { + this.removeChild(child); + } + } + + addEventListener(type, listener) { + const callbacks = this.eventListeners.get(type) ?? []; + callbacks.push(listener); + this.eventListeners.set(type, callbacks); + } + + removeEventListener(type, listener) { + const callbacks = this.eventListeners.get(type) ?? []; + this.eventListeners.set( + type, + callbacks.filter((callback) => callback !== listener), + ); + } + + dispatchEvent(eventOrType) { + const event = + typeof eventOrType === "string" + ? { type: eventOrType } + : { ...eventOrType }; + if (!event.type) { + throw new TypeError("Events must have a type"); + } + event.target ??= this; + event.currentTarget = this; + + for (const callback of [...(this.eventListeners.get(event.type) ?? [])]) { + callback(event); + } + return true; + } + + click() { + return this.dispatchEvent({ type: "click" }); + } + + querySelector(selector) { + if (selector.startsWith("#")) { + return this.ownerDocument.getElementById(selector.slice(1)); + } + if (selector.startsWith(".")) { + return this.find((element) => + element.className.split(/\s+/).includes(selector.slice(1)), + ); + } + return this.find( + (element) => element.tagName.toLowerCase() === selector.toLowerCase(), + ); + } + + find(predicate) { + for (const child of this.children) { + if (predicate(child)) return child; + const match = child.find(predicate); + if (match) return match; + } + return null; + } +} + +class FakeDocument { + constructor() { + this.createdElements = []; + this.elements = []; + this.elementsById = new Map(); + this.events = createEventTarget(); + this.defaultView = null; + } + + createElement(tagName) { + const element = new FakeElement(this, tagName); + this.createdElements.push(element); + this.elements.push(element); + return element; + } + + installElement(id, tagName = "div") { + const element = new FakeElement(this, tagName); + element.id = id; + this.elements.push(element); + return element; + } + + getElementById(id) { + return this.elementsById.get(id) ?? null; + } + + addEventListener(...args) { + return this.events.addEventListener(...args); + } + + removeEventListener(...args) { + return this.events.removeEventListener(...args); + } + + dispatchEvent(...args) { + return this.events.dispatchEvent(...args); + } + + listenerCount(type) { + return this.events.listenerCount(type); + } + + registerId(element) { + if (element.id) this.elementsById.set(element.id, element); + } + + unregisterId(element) { + if (element.id && this.elementsById.get(element.id) === element) { + this.elementsById.delete(element.id); + } + } + + registerTree(element) { + this.registerId(element); + for (const child of element.children) this.registerTree(child); + } + + unregisterTree(element) { + this.unregisterId(element); + for (const child of element.children) this.unregisterTree(child); + } +} + +function toHostValue(value) { + if (Array.isArray(value)) return Array.from(value, toHostValue); + if (value && typeof value === "object") { + if (Object.getPrototypeOf(value) === Object.prototype) return value; + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [key, toHostValue(entry)]), + ); + } + return value; +} + +function boundsForPoints(points) { + const pointList = Array.isArray(points?.[0]) ? points : [points]; + const latitudes = pointList.map(([latitude]) => latitude); + const longitudes = pointList.map(([, longitude]) => longitude); + const south = Math.min(...latitudes); + const west = Math.min(...longitudes); + const north = Math.max(...latitudes); + const east = Math.max(...longitudes); + + return { + south, + west, + north, + east, + getSouthWest: () => [south, west], + getNorthEast: () => [north, east], + toArray: () => [ + [south, west], + [north, east], + ], + }; +} + +function createLeafletRecorder() { + const maps = []; + const layers = []; + + function createLayer(type, points, options = {}) { + const hostPoints = toHostValue(points); + const layer = { + type, + points: hostPoints, + latlngs: hostPoints, + options: toHostValue(options), + map: null, + addTo(map) { + this.map = map; + map.layers.push(this); + return this; + }, + }; + + if (type === "polyline") { + layer.getBounds = () => boundsForPoints(hostPoints); + } + + layers.push(layer); + return layer; + } + + const leaflet = { + CRS: { Simple: Object.freeze({ name: "Simple" }) }, + + map(container, options = {}) { + const map = { + container, + options: toHostValue(options), + layers: [], + fitBoundsCalls: [], + invalidateSizeCalls: 0, + fitBounds(bounds, fitOptions) { + this.fitBoundsCalls.push({ + bounds: Array.isArray(bounds) ? toHostValue(bounds) : bounds, + options: toHostValue(fitOptions), + }); + return this; + }, + invalidateSize() { + this.invalidateSizeCalls += 1; + return this; + }, + }; + maps.push(map); + return map; + }, + + polyline(points, options) { + return createLayer("polyline", points, options); + }, + + circleMarker(point, options) { + return createLayer("circleMarker", point, options); + }, + }; + + return { leaflet, maps, layers }; +} + +function createConsoleRecorder() { + const messages = { debug: [], info: [], log: [], warn: [], error: [] }; + const console = {}; + + for (const method of Object.keys(messages)) { + console[method] = (...args) => messages[method].push(args); + } + + return { console, messages }; +} + +/** + * Create an isolated browser-like runtime for the two classic frontend scripts. + * The returned object exposes the fake DOM, window, Leaflet recorder, timers, + * and event dispatch helpers used by frontend tests. + */ +export function createBrowserHarness({ + directionSteps, + routeCoordinates, + floorBounds, + windowProperties = {}, + globals = {}, +} = {}) { + const document = new FakeDocument(); + const directionsContainer = document.installElement( + "directions-card-container", + ); + const mapContainer = document.installElement("map"); + const window = createEventTarget(); + const { leaflet, maps, layers } = createLeafletRecorder(); + const { console, messages } = createConsoleRecorder(); + const timers = new Map(); + let nextTimerId = 1; + + Object.assign(window, { + window, + self: window, + document, + L: leaflet, + ...windowProperties, + ...globals, + }); + if (directionSteps !== undefined) { + window.directionSteps = directionSteps; + } + if (routeCoordinates !== undefined) { + window.routeCoordinates = routeCoordinates; + } + if (floorBounds !== undefined) { + window.floorBounds = floorBounds; + } + document.defaultView = window; + + function setTimeoutFake(callback, delay = 0, ...args) { + const id = nextTimerId++; + timers.set(id, { callback, delay, args }); + return id; + } + + function clearTimeoutFake(id) { + timers.delete(id); + } + + function runTimers() { + while (timers.size) { + const [id, timer] = timers.entries().next().value; + timers.delete(id); + timer.callback(...timer.args); + } + } + + const context = vm.createContext({ + window, + document, + L: leaflet, + console, + setTimeout: setTimeoutFake, + clearTimeout: clearTimeoutFake, + ...windowProperties, + ...globals, + }); + if (directionSteps !== undefined) { + context.directionSteps = directionSteps; + } + if (routeCoordinates !== undefined) { + context.routeCoordinates = routeCoordinates; + } + if (floorBounds !== undefined) { + context.floorBounds = floorBounds; + } + + const harness = { + isBrowserHarness: true, + context, + window, + document, + L: leaflet, + leaflet, + console, + consoleMessages: messages, + directionsContainer, + mapContainer, + maps, + layers, + timers, + dispatchDOMContentLoaded() { + document.dispatchEvent({ type: "DOMContentLoaded" }); + return harness; + }, + dispatchWindowEvent(eventOrType) { + window.dispatchEvent(eventOrType); + return harness; + }, + runTimers, + flushTimers: runTimers, + getElementById(id) { + return document.getElementById(id); + }, + getCreatedElements() { + return [...document.createdElements]; + }, + getCreatedLayers() { + return [...layers]; + }, + }; + + return harness; +} + +function isBrowserHarness(value) { + return value?.isBrowserHarness === true; +} + +function resolveScriptPath(scriptPath) { + if (scriptPath instanceof URL) return fileURLToPath(scriptPath); + + const value = String(scriptPath); + if ( + value === "directions" || + value === "directions.js" || + value.endsWith("/directions.js") + ) { + return SCRIPT_PATHS.directions; + } + if (value === "map" || value === "map.js" || value.endsWith("/map.js")) { + return SCRIPT_PATHS.map; + } + + const candidates = isAbsolute(value) + ? [value] + : [resolve(PROJECT_ROOT, value), resolve(FRONTEND_DIR, value)]; + const path = candidates.find((candidate) => existsSync(candidate)); + if (!path) { + throw new Error(`Frontend script not found: ${scriptPath}`); + } + return path; +} + +function prepareLoad(scriptOptions) { + if (isBrowserHarness(scriptOptions)) { + return { harness: scriptOptions, options: {} }; + } + + const options = scriptOptions ?? {}; + const harness = + options.harness ?? + createBrowserHarness({ + directionSteps: options.directionSteps, + routeCoordinates: options.routeCoordinates, + floorBounds: options.floorBounds, + windowProperties: options.windowProperties, + globals: options.globals, + }); + return { harness, options }; +} + +function applyGlobals(harness, options) { + if (options.directionSteps !== undefined) { + harness.window.directionSteps = options.directionSteps; + harness.context.directionSteps = options.directionSteps; + } + if (options.routeCoordinates !== undefined) { + harness.window.routeCoordinates = options.routeCoordinates; + harness.context.routeCoordinates = options.routeCoordinates; + } + if (options.floorBounds !== undefined) { + harness.window.floorBounds = options.floorBounds; + harness.context.floorBounds = options.floorBounds; + } + for (const [name, value] of Object.entries(options.globals ?? {})) { + harness.window[name] = value; + harness.context[name] = value; + } +} + +/** + * Evaluate a classic browser script in a fresh or supplied harness. + * By default the DOMContentLoaded event and queued fake timers are drained. + */ +export function loadScript(scriptPath, scriptOptions = {}) { + const { harness, options } = prepareLoad(scriptOptions); + applyGlobals(harness, options); + + const resolvedPath = resolveScriptPath(scriptPath); + const source = readFileSync(resolvedPath, "utf8"); + vm.runInContext(source, harness.context, { filename: resolvedPath }); + + if (options.dispatchDOMContentLoaded !== false) { + harness.dispatchDOMContentLoaded(); + } + if (options.flushTimers !== false) { + harness.runTimers(); + } + return harness; +} + +export function loadDirectionsScript(scriptOptions = {}) { + return loadScript(SCRIPT_PATHS.directions, scriptOptions); +} + +export function loadMapScript(scriptOptions = {}) { + return loadScript(SCRIPT_PATHS.map, scriptOptions); +} + +export function getCreatedElements(harness) { + if (!isBrowserHarness(harness)) { + throw new TypeError("getCreatedElements expects a browser harness"); + } + return [...harness.document.createdElements]; +} + +export function getCreatedLayers(harness) { + if (!isBrowserHarness(harness)) { + throw new TypeError("getCreatedLayers expects a browser harness"); + } + return [...harness.layers]; +} + +export function getMaps(harness) { + if (!isBrowserHarness(harness)) { + throw new TypeError("getMaps expects a browser harness"); + } + return [...harness.maps]; +} From f4e066e1659ba5bd1eb1845e53552d1a2b16ec39 Mon Sep 17 00:00:00 2001 From: simonteague6 Date: Sat, 29 Aug 2026 23:03:01 -0500 Subject: [PATCH 2/5] Add data entry and CSV importer tests --- tests/test_csv_import.py | 187 +++++++++++++++++++++++++++++++++++++++ tests/test_data_entry.py | 154 ++++++++++++++++++++++++++++++++ 2 files changed, 341 insertions(+) create mode 100644 tests/test_csv_import.py create mode 100644 tests/test_data_entry.py diff --git a/tests/test_csv_import.py b/tests/test_csv_import.py new file mode 100644 index 0000000..a2aaa78 --- /dev/null +++ b/tests/test_csv_import.py @@ -0,0 +1,187 @@ +import json +import sys + +import pytest + +from tools import csv_import + + +def configure_nodes_path(monkeypatch, tmp_path): + path = tmp_path / "data" / "nodes.json" + monkeypatch.setattr(csv_import, "NODES_PATH", path) + return path + + +def test_load_nodes_returns_empty_list_when_file_is_missing(monkeypatch, tmp_path): + configure_nodes_path(monkeypatch, tmp_path) + + assert csv_import.load_nodes() == [] + + +def test_main_imports_new_nodes_and_skips_duplicates(monkeypatch, tmp_path, capsys): + nodes_path = configure_nodes_path(monkeypatch, tmp_path) + nodes_path.parent.mkdir() + nodes_path.write_text(json.dumps([{"id": "existing", "name": "Existing"}])) + csv_path = tmp_path / "rooms.csv" + csv_path.write_text( + "id,name,building,floor,type\n" + "existing,Old Name,BLDG,1,room\n" + "# comment,Ignored,BLDG,1,room\n" + ",Blank,BLDG,1,room\n" + "new,New Room,BLDG,3,room\n" + "new,Duplicate,BLDG,4,room\n", + encoding="utf-8", + ) + monkeypatch.setattr(sys, "argv", ["csv_import.py", str(csv_path)]) + + csv_import.main() + + nodes = json.loads(nodes_path.read_text(encoding="utf-8")) + assert nodes == [ + {"id": "existing", "name": "Existing"}, + { + "id": "new", + "name": "New Room", + "type": "room", + "building": "BLDG", + "coords": [0, 0], + "floor": 3, + }, + ] + output = capsys.readouterr().out + assert "SKIP (already exists): existing" in output + assert "Done: 1 node(s) added, 2 skipped." in output + + +def test_main_uses_default_csv_path_when_no_argument(monkeypatch, tmp_path): + nodes_path = configure_nodes_path(monkeypatch, tmp_path) + nodes_path.parent.mkdir() + csv_path = tmp_path / "default.csv" + csv_path.write_text("id,name,building,floor,type\nroom,Room,BLDG,1,room\n") + monkeypatch.setattr(csv_import, "DEFAULT_CSV", csv_path) + monkeypatch.setattr(sys, "argv", ["csv_import.py"]) + + csv_import.main() + + assert json.loads(nodes_path.read_text(encoding="utf-8"))[0]["id"] == "room" + + +def test_main_ignores_blank_lines_and_comment_or_blank_ids(monkeypatch, tmp_path): + nodes_path = configure_nodes_path(monkeypatch, tmp_path) + nodes_path.parent.mkdir() + csv_path = tmp_path / "rooms.csv" + csv_path.write_text( + "id,name,building,floor,type\n" + "\n" + " # comment,Ignored,BLDG,1,room\n" + " ,No ID,BLDG,1,room\n" + "room,Room,BLDG,1,room\n", + encoding="utf-8", + ) + monkeypatch.setattr(sys, "argv", ["csv_import.py", str(csv_path)]) + + csv_import.main() + + assert json.loads(nodes_path.read_text(encoding="utf-8")) == [ + { + "id": "room", + "name": "Room", + "type": "room", + "building": "BLDG", + "coords": [0, 0], + "floor": 1, + } + ] + + +def test_main_strips_node_fields_from_explicit_csv_path(monkeypatch, tmp_path): + nodes_path = configure_nodes_path(monkeypatch, tmp_path) + nodes_path.parent.mkdir() + csv_path = tmp_path / "rooms.csv" + csv_path.write_text( + "id,name,building,floor,type\n room , Room 101 , BLDG , 2 , classroom \n", + encoding="utf-8", + ) + monkeypatch.setattr(sys, "argv", ["csv_import.py", str(csv_path)]) + + csv_import.main() + + assert json.loads(nodes_path.read_text(encoding="utf-8")) == [ + { + "id": "room", + "name": "Room 101", + "type": "classroom", + "building": "BLDG", + "coords": [0, 0], + "floor": 2, + } + ] + + +def test_main_uses_defaults_for_missing_csv_columns(monkeypatch, tmp_path): + nodes_path = configure_nodes_path(monkeypatch, tmp_path) + nodes_path.parent.mkdir() + csv_path = tmp_path / "rooms.csv" + csv_path.write_text("id\n room_101 \n", encoding="utf-8") + monkeypatch.setattr(sys, "argv", ["csv_import.py", str(csv_path)]) + + csv_import.main() + + assert json.loads(nodes_path.read_text(encoding="utf-8")) == [ + { + "id": "room_101", + "name": "room_101", + "type": "room", + "building": "", + "coords": [0, 0], + "floor": 1, + } + ] + + +def test_load_nodes_raises_for_malformed_json_without_writing(monkeypatch, tmp_path): + nodes_path = configure_nodes_path(monkeypatch, tmp_path) + nodes_path.parent.mkdir() + malformed = '{"nodes":' + nodes_path.write_text(malformed, encoding="utf-8") + + with pytest.raises(json.JSONDecodeError): + csv_import.load_nodes() + + assert nodes_path.read_text(encoding="utf-8") == malformed + + +def test_main_does_not_write_partial_import_when_floor_conversion_fails( + monkeypatch, tmp_path +): + nodes_path = configure_nodes_path(monkeypatch, tmp_path) + nodes_path.parent.mkdir() + existing = [{"id": "existing", "name": "Existing"}] + nodes_path.write_text(json.dumps(existing), encoding="utf-8") + before = nodes_path.read_bytes() + csv_path = tmp_path / "rooms.csv" + csv_path.write_text( + "id,name,building,floor,type\n" + "new,New Room,BLDG,2,room\n" + "broken,Broken Room,BLDG,not-a-floor,room\n", + encoding="utf-8", + ) + monkeypatch.setattr(sys, "argv", ["csv_import.py", str(csv_path)]) + + with pytest.raises(ValueError): + csv_import.main() + + assert nodes_path.read_bytes() == before + assert json.loads(nodes_path.read_text(encoding="utf-8")) == existing + + +def test_main_exits_when_csv_file_is_missing(monkeypatch, tmp_path, capsys): + configure_nodes_path(monkeypatch, tmp_path) + missing = tmp_path / "missing.csv" + monkeypatch.setattr(sys, "argv", ["csv_import.py", str(missing)]) + + with pytest.raises(SystemExit) as exc_info: + csv_import.main() + + assert exc_info.value.code == 1 + assert f"CSV file not found: {missing}" in capsys.readouterr().out diff --git a/tests/test_data_entry.py b/tests/test_data_entry.py new file mode 100644 index 0000000..31a0fd6 --- /dev/null +++ b/tests/test_data_entry.py @@ -0,0 +1,154 @@ +import json + +import pytest + +from app.logic import data_entry + + +def set_data_files(monkeypatch, tmp_path): + nodes_file = tmp_path / "nested" / "nodes.json" + edges_file = tmp_path / "nested" / "edges.json" + monkeypatch.setattr(data_entry, "NODES_FILE", nodes_file) + monkeypatch.setattr(data_entry, "EDGES_FILE", edges_file) + return nodes_file, edges_file + + +def test_load_json_returns_empty_list_for_missing_file(tmp_path): + assert data_entry.load_json(tmp_path / "missing.json") == [] + + +def test_load_json_reads_data_and_reports_malformed_json(tmp_path, capsys): + path = tmp_path / "data.json" + path.write_text('{"valid": true}', encoding="utf-8") + assert data_entry.load_json(path) == {"valid": True} + + path.write_text("not json", encoding="utf-8") + with pytest.raises(json.JSONDecodeError): + data_entry.load_json(path) + assert "Failed to parse JSON file" in capsys.readouterr().out + + +def test_save_json_creates_parent_and_round_trips_data(tmp_path): + path = tmp_path / "new" / "data.json" + expected = [{"id": "room"}] + + data_entry.save_json(path, expected) + + assert json.loads(path.read_text(encoding="utf-8")) == expected + + +def test_add_node_rejects_duplicate_without_prompting_for_details( + monkeypatch, tmp_path, capsys +): + nodes_file, _ = set_data_files(monkeypatch, tmp_path) + existing = [{"id": "room", "name": "Existing"}] + data_entry.save_json(nodes_file, existing) + input_values = iter(["room"]) + monkeypatch.setattr("builtins.input", lambda _prompt: next(input_values)) + + data_entry.add_node() + + assert data_entry.load_json(nodes_file) == existing + assert "already exists" in capsys.readouterr().out + + +def test_add_node_saves_normalized_values(monkeypatch, tmp_path): + nodes_file, _ = set_data_files(monkeypatch, tmp_path) + input_values = iter([" room ", "12.5", "-3", " Room 101 ", " room ", "4"]) + monkeypatch.setattr("builtins.input", lambda _prompt: next(input_values)) + + data_entry.add_node() + + assert data_entry.load_json(nodes_file) == [ + { + "id": "room", + "name": "Room 101", + "type": "room", + "coords": [12.5, -3.0], + "floor": 4, + } + ] + + +@pytest.mark.parametrize( + ("invalid_input_index", "invalid_value"), + [(1, "not-a-number"), (2, "not-a-number"), (5, "not-a-floor")], +) +def test_add_node_does_not_write_when_numeric_input_is_invalid( + monkeypatch, tmp_path, invalid_input_index, invalid_value +): + nodes_file, _ = set_data_files(monkeypatch, tmp_path) + existing = [{"id": "existing", "name": "Existing"}] + data_entry.save_json(nodes_file, existing) + before = nodes_file.read_bytes() + values = ["new", "1", "2", "New room", "room", "1"] + values[invalid_input_index] = invalid_value + input_values = iter(values) + monkeypatch.setattr("builtins.input", lambda _prompt: next(input_values)) + + with pytest.raises(ValueError): + data_entry.add_node() + + assert nodes_file.read_bytes() == before + assert data_entry.load_json(nodes_file) == existing + + +def test_add_edge_rejects_unknown_nodes(monkeypatch, tmp_path, capsys): + nodes_file, edges_file = set_data_files(monkeypatch, tmp_path) + data_entry.save_json(nodes_file, [{"id": "known"}]) + input_values = iter(["known", "missing", "2", "Go there"]) + monkeypatch.setattr("builtins.input", lambda _prompt: next(input_values)) + + data_entry.add_edge() + + assert not edges_file.exists() + assert "missing" in capsys.readouterr().out + + +def test_add_edge_saves_valid_connection(monkeypatch, tmp_path): + nodes_file, edges_file = set_data_files(monkeypatch, tmp_path) + data_entry.save_json(nodes_file, [{"id": "a"}, {"id": "b"}]) + input_values = iter([" a ", " b ", "2.5", " Turn right. "]) + monkeypatch.setattr("builtins.input", lambda _prompt: next(input_values)) + + data_entry.add_edge() + + assert data_entry.load_json(edges_file) == [ + { + "source": "a", + "target": "b", + "weight": 2.5, + "instruction": "Turn right.", + } + ] + + +def test_add_edge_reports_all_unknown_endpoints_without_writing( + monkeypatch, tmp_path, capsys +): + nodes_file, edges_file = set_data_files(monkeypatch, tmp_path) + data_entry.save_json(nodes_file, [{"id": "known"}]) + existing_edges = [{"source": "known", "target": "known", "weight": 1}] + data_entry.save_json(edges_file, existing_edges) + before = edges_file.read_bytes() + input_values = iter(["missing-start", "missing-end", "2", "Go there"]) + monkeypatch.setattr("builtins.input", lambda _prompt: next(input_values)) + + data_entry.add_edge() + + assert edges_file.read_bytes() == before + assert data_entry.load_json(edges_file) == existing_edges + output = capsys.readouterr().out + assert "missing-start" in output + assert "missing-end" in output + + +def test_main_reports_invalid_choice_then_exits(monkeypatch, capsys): + input_values = iter(["not-an-option", "3"]) + monkeypatch.setattr("builtins.input", lambda _prompt: next(input_values)) + + data_entry.main() + + output = capsys.readouterr().out + assert "Invalid option" in output + assert "Exiting..." in output From 79a656d354abc7184b36f981b8143d6049569109 Mon Sep 17 00:00:00 2001 From: simonteague6 Date: Sat, 29 Aug 2026 23:04:38 -0500 Subject: [PATCH 3/5] Add browser frontend behavior tests --- tests/frontend/directions.test.mjs | 77 +++++++++++ tests/frontend/map.test.mjs | 214 +++++++++++++++++++++++++++++ 2 files changed, 291 insertions(+) create mode 100644 tests/frontend/directions.test.mjs create mode 100644 tests/frontend/map.test.mjs diff --git a/tests/frontend/directions.test.mjs b/tests/frontend/directions.test.mjs new file mode 100644 index 0000000..8083726 --- /dev/null +++ b/tests/frontend/directions.test.mjs @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { loadDirectionsScript } from "./harness.mjs"; + +test("directions does nothing when direction steps are missing", () => { + const harness = loadDirectionsScript(); + + assert.equal(harness.directionsContainer.children.length, 0); + assert.equal(harness.directionsContainer.innerHTML, ""); + assert.equal(harness.document.getElementById("next-step-btn"), null); +}); + +test("directions does nothing for an empty step list", () => { + const harness = loadDirectionsScript({ directionSteps: [] }); + + assert.equal(harness.directionsContainer.children.length, 0); + assert.equal(harness.directionsContainer.innerHTML, ""); +}); + +test("directions renders the first step and its ordinary instruction text", () => { + const instruction = "Leave the lobby and turn right toward the elevators."; + const harness = loadDirectionsScript({ directionSteps: [instruction] }); + const card = harness.directionsContainer.children[0]; + const nextButton = harness.getElementById("next-step-btn"); + + assert.equal(harness.directionsContainer.children.length, 1); + assert.equal(card.className, "direction-card active"); + assert.equal(card.children[0].textContent, "Step 1"); + assert.equal(card.children[1].textContent, instruction); + assert.equal(nextButton.textContent, "Next"); + assert.match(harness.directionsContainer.innerHTML, /Step 1/); + assert.match(harness.directionsContainer.innerHTML, /Leave the lobby/); +}); + +test("directions advances through steps in order and then shows arrived", () => { + const harness = loadDirectionsScript({ + directionSteps: [ + "Exit the room and turn left.", + "Continue straight for 20 feet.", + "Turn right at the hallway.", + ], + }); + + const firstButton = harness.getElementById("next-step-btn"); + firstButton.click(); + assert.equal( + harness.directionsContainer.children[0].children[0].textContent, + "Step 2", + ); + assert.equal( + harness.directionsContainer.children[0].children[1].textContent, + "Continue straight for 20 feet.", + ); + + const secondButton = harness.getElementById("next-step-btn"); + secondButton.click(); + assert.equal( + harness.directionsContainer.children[0].children[0].textContent, + "Step 3", + ); + assert.equal( + harness.directionsContainer.children[0].children[1].textContent, + "Turn right at the hallway.", + ); + + const finalButton = harness.getElementById("next-step-btn"); + finalButton.click(); + const arrivedCard = harness.directionsContainer.children[0]; + + assert.equal(arrivedCard.className, "direction-card arrived"); + assert.equal( + harness.directionsContainer.querySelector("h3").textContent, + "You have arrived!", + ); + assert.equal(harness.getElementById("next-step-btn"), null); +}); diff --git a/tests/frontend/map.test.mjs b/tests/frontend/map.test.mjs new file mode 100644 index 0000000..7ce1a97 --- /dev/null +++ b/tests/frontend/map.test.mjs @@ -0,0 +1,214 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; + +import { loadMapScript } from "./harness.mjs"; + +const template = readFileSync( + new URL("../../app/templates/index.html", import.meta.url), + "utf8", +); + +test("map stops with a useful error when template globals are missing", () => { + const cases = [ + { + name: "routeCoordinates", + options: { floorBounds: { width: 30, height: 20 } }, + }, + { name: "floorBounds", options: { routeCoordinates: [{ x: 2, y: 3 }] } }, + ]; + + for (const { name, options } of cases) { + const harness = loadMapScript(options); + + assert.equal(harness.maps.length, 0, `${name} should prevent map setup`); + assert.equal( + harness.layers.length, + 0, + `${name} should prevent layer setup`, + ); + assert.deepEqual(harness.consoleMessages.error, [ + ["routeCoordinates or floorBounds missing from template"], + ]); + } +}); + +test("map template wires data globals before loading the browser scripts", () => { + const directionsDataIndex = template.indexOf("const directionSteps"); + const directionsScriptIndex = template.indexOf("js/directions.js"); + const mapDataIndex = template.indexOf("window.routeCoordinates"); + const mapScriptIndex = template.indexOf("js/map.js"); + + assert.match(template, /id="directions-card-container"/); + assert.match( + template, + /const directionSteps\s*=\s*\{\{\s*steps\s*\|\s*tojson/, + ); + assert.match(template, /id="map"/); + assert.match( + template, + /window\.routeCoordinates\s*=\s*\{\{\s*coordinates\s*\|\s*tojson/, + ); + assert.match( + template, + /window\.floorBounds\s*=\s*\{\{\s*floor_bounds\s*\|\s*tojson/, + ); + assert.ok(directionsDataIndex >= 0); + assert.ok(directionsDataIndex < directionsScriptIndex); + assert.ok(mapDataIndex >= 0); + assert.ok(mapDataIndex < mapScriptIndex); +}); + +test("map uses Simple CRS, configured zoom options, and floor bounds", () => { + const harness = loadMapScript({ + routeCoordinates: [ + { x: 4, y: 2 }, + { x: 28, y: 18 }, + ], + floorBounds: { width: 30, height: 20 }, + }); + const map = harness.maps[0]; + + assert.equal(map.container, "map"); + assert.equal(map.options.crs, harness.L.CRS.Simple); + assert.deepEqual( + { + minZoom: map.options.minZoom, + maxZoom: map.options.maxZoom, + zoomControl: map.options.zoomControl, + }, + { minZoom: -2, maxZoom: 4, zoomControl: true }, + ); + assert.deepEqual(map.fitBoundsCalls[0].bounds, [ + [0, 0], + [20, 30], + ]); +}); + +test("map draws a 10-foot grid across the floor dimensions", () => { + const harness = loadMapScript({ + routeCoordinates: [{ x: 5, y: 5 }], + floorBounds: { width: 30, height: 20 }, + }); + const grid = harness.layers.filter( + (layer) => layer.type === "polyline" && layer.options.color === "#cccccc", + ); + const map = harness.maps[0]; + + assert.deepEqual( + grid.map((layer) => layer.points), + [ + [ + [0, 0], + [20, 0], + ], + [ + [0, 10], + [20, 10], + ], + [ + [0, 20], + [20, 20], + ], + [ + [0, 30], + [20, 30], + ], + [ + [0, 0], + [0, 30], + ], + [ + [10, 0], + [10, 30], + ], + [ + [20, 0], + [20, 30], + ], + ], + ); + assert.equal(grid.length, 7); + assert.ok(grid.every((layer) => layer.map === map)); + assert.ok( + grid.every( + (layer) => layer.options.weight === 1 && layer.options.opacity === 0.4, + ), + ); +}); + +test("map converts backend x/y points and styles the route line", () => { + const harness = loadMapScript({ + routeCoordinates: [ + { x: 12, y: 4 }, + { x: 20, y: 15 }, + ], + floorBounds: { width: 30, height: 20 }, + }); + const routeLine = harness.layers.find( + (layer) => + layer.type === "polyline" && layer.options.className === "animated-route", + ); + const routeFit = harness.maps[0].fitBoundsCalls[1]; + + assert.equal(routeLine.map, harness.maps[0]); + assert.deepEqual(routeLine.points, [ + [4, 12], + [15, 20], + ]); + assert.deepEqual(routeLine.options, { + color: "blue", + weight: 4, + dashArray: "10, 10", + className: "animated-route", + }); + assert.deepEqual(routeFit.bounds.toArray(), [ + [4, 12], + [15, 20], + ]); + assert.deepEqual(routeFit.options, { padding: [20, 20] }); +}); + +test("map marks the route start green and end red", () => { + const harness = loadMapScript({ + routeCoordinates: [ + { x: 3, y: 7 }, + { x: 22, y: 11 }, + ], + floorBounds: { width: 30, height: 20 }, + }); + const markers = harness.layers.filter( + (layer) => layer.type === "circleMarker", + ); + + assert.equal(markers.length, 2); + assert.equal(markers[0].map, harness.maps[0]); + assert.deepEqual(markers[0].points, [7, 3]); + assert.deepEqual(markers[0].options, { + radius: 6, + color: "green", + fillColor: "green", + fillOpacity: 1, + }); + assert.deepEqual(markers[1].points, [11, 22]); + assert.deepEqual(markers[1].options, { + radius: 6, + color: "red", + fillColor: "red", + fillOpacity: 1, + }); +}); + +test("map invalidates its size on the resize event and deferred startup check", () => { + const harness = loadMapScript({ + routeCoordinates: [{ x: 5, y: 5 }], + floorBounds: { width: 30, height: 20 }, + }); + const map = harness.maps[0]; + + assert.equal(harness.window.listenerCount("resize"), 1); + assert.equal(map.invalidateSizeCalls, 1); + + harness.dispatchWindowEvent("resize"); + assert.equal(map.invalidateSizeCalls, 2); +}); From 334c4c9436190a3a9237b36ff723ddc8d631310f Mon Sep 17 00:00:00 2001 From: simonteague6 Date: Sat, 29 Aug 2026 23:04:40 -0500 Subject: [PATCH 4/5] Expand graph manager contract tests --- tests/test_graph_manager.py | 378 ++++++++++++++++++++++++++++++++++++ 1 file changed, 378 insertions(+) create mode 100644 tests/test_graph_manager.py diff --git a/tests/test_graph_manager.py b/tests/test_graph_manager.py new file mode 100644 index 0000000..5963c6b --- /dev/null +++ b/tests/test_graph_manager.py @@ -0,0 +1,378 @@ +import json + +import networkx as nx +import pytest + +from app import logic +from app.logic import graph_manager as manager + + +@pytest.fixture(autouse=True) +def reset_global_graph(): + manager.G = None + yield + manager.G = None + + +def configure_data_root(monkeypatch, tmp_path): + repo_root = tmp_path / "repo" + logic_path = repo_root / "app" / "logic" + data_path = repo_root / "data" + logic_path.mkdir(parents=True) + data_path.mkdir() + monkeypatch.setattr(manager, "__file__", str(logic_path / "graph_manager.py")) + return data_path + + +def write_json(path, value): + path.write_text(json.dumps(value), encoding="utf-8") + + +def make_graph(*nodes, edges=()): + graph = nx.DiGraph() + for node_id, attributes in nodes: + graph.add_node(node_id, **attributes) + for source, target, attributes in edges: + graph.add_edge(source, target, **attributes) + return graph + + +def node(floor=1, node_type="waypoint", x=0, y=0, name="Node"): + return {"floor": floor, "type": node_type, "coords": [x, y], "name": name} + + +def test_read_json_files_loads_nodes_and_edges(monkeypatch, tmp_path): + data_path = configure_data_root(monkeypatch, tmp_path) + nodes = [{"id": "a", "type": "room"}] + edges = [{"source": "a", "target": "a", "weight": 0}] + write_json(data_path / "nodes.json", nodes) + write_json(data_path / "edges.json", edges) + + assert manager.read_json_files() == (nodes, edges) + + +@pytest.mark.parametrize( + ("missing_file", "message"), + [ + ("nodes.json", "Failed to load nodes data"), + ("edges.json", "Failed to load edges data"), + ], +) +def test_read_json_files_reports_missing_data( + monkeypatch, tmp_path, missing_file, message +): + data_path = configure_data_root(monkeypatch, tmp_path) + write_json(data_path / "nodes.json", []) + write_json(data_path / "edges.json", []) + (data_path / missing_file).unlink() + + with pytest.raises(RuntimeError, match=message): + manager.read_json_files() + + +@pytest.mark.parametrize( + ("invalid_file", "message"), + [ + ("nodes.json", "Failed to load nodes data"), + ("edges.json", "Failed to load edges data"), + ], +) +def test_read_json_files_reports_invalid_json( + monkeypatch, tmp_path, invalid_file, message +): + data_path = configure_data_root(monkeypatch, tmp_path) + write_json(data_path / "nodes.json", []) + write_json(data_path / "edges.json", []) + (data_path / invalid_file).write_text("not json", encoding="utf-8") + + with pytest.raises(RuntimeError, match=message): + manager.read_json_files() + + +def test_read_json_files_uses_module_path_instead_of_working_directory( + monkeypatch, tmp_path +): + data_path = configure_data_root(monkeypatch, tmp_path) + nodes = [{"id": "from-module-path"}] + edges = [{"source": "from-module-path", "target": "from-module-path"}] + write_json(data_path / "nodes.json", nodes) + write_json(data_path / "edges.json", edges) + other_directory = tmp_path / "unrelated-working-directory" + other_directory.mkdir() + monkeypatch.chdir(other_directory) + + assert manager.read_json_files() == (nodes, edges) + + +def test_build_graph_preserves_node_and_edge_data(monkeypatch): + nodes = [ + {"id": "a", "name": "A", "floor": 1}, + {"id": "b", "name": "B", "floor": 2}, + ] + edges = [{"source": "a", "target": "b", "weight": 4, "instruction": "Go up."}] + monkeypatch.setattr(manager, "read_json_files", lambda: (nodes, edges)) + + graph = manager.build_graph() + + assert isinstance(graph, nx.DiGraph) + assert graph.nodes["a"] == nodes[0] + assert graph["a"]["b"] == edges[0] + assert not graph.has_edge("b", "a") + + +def test_reload_graph_replaces_cached_graph(monkeypatch): + graph = nx.DiGraph() + monkeypatch.setattr(manager, "build_graph", lambda: graph) + + manager.reload_graph() + + assert manager.G is graph + + +def test_shortest_route_uses_edge_weights_and_handles_missing_routes(): + graph = nx.DiGraph() + graph.add_weighted_edges_from( + [("start", "direct", 10), ("start", "detour", 2), ("detour", "direct", 3)] + ) + + assert manager.shortest_route(graph, "start", "direct") == [ + "start", + "detour", + "direct", + ] + assert manager.shortest_route(graph, "direct", "start") is None + assert manager.shortest_route(graph, "unknown", "start") is None + + +def test_shortest_route_returns_the_start_node_for_same_node_routes(): + graph = nx.DiGraph() + graph.add_node("room") + + assert manager.shortest_route(graph, "room", "room") == ["room"] + + +def test_get_floor_bounds_reads_dimensions(monkeypatch, tmp_path): + data_path = configure_data_root(monkeypatch, tmp_path) + write_json( + data_path / "floors.json", + {"BLDG": {"2": {"width_feet": 120, "height_feet": 80}}}, + ) + + assert manager.get_floor_bounds("BLDG", 2) == {"width": 120, "height": 80} + + +@pytest.mark.parametrize( + ("floors_data", "building", "floor", "message"), + [ + ({"BLDG": {}}, "BLDG", 2, "No floor data for BLDG floor 2"), + ({}, "BLDG", 2, "No floor data for BLDG floor 2"), + ], +) +def test_get_floor_bounds_rejects_unknown_floor( + monkeypatch, tmp_path, floors_data, building, floor, message +): + data_path = configure_data_root(monkeypatch, tmp_path) + write_json(data_path / "floors.json", floors_data) + + with pytest.raises(RuntimeError, match=message): + manager.get_floor_bounds(building, floor) + + +def test_get_floor_bounds_reports_invalid_json(monkeypatch, tmp_path): + data_path = configure_data_root(monkeypatch, tmp_path) + (data_path / "floors.json").write_text("not json", encoding="utf-8") + + with pytest.raises(RuntimeError, match="Failed to load floors data"): + manager.get_floor_bounds("BLDG", 1) + + +def test_get_floor_bounds_reports_missing_file(monkeypatch, tmp_path): + configure_data_root(monkeypatch, tmp_path) + + with pytest.raises(RuntimeError, match="Failed to load floors data"): + manager.get_floor_bounds("BLDG", 1) + + +def test_get_options_separates_connectors_and_rooms(monkeypatch): + nodes = [ + {"id": "e", "name": "Elevator", "type": "elevator"}, + {"id": "s", "name": "Stairs", "type": "staircase"}, + {"id": "r", "name": "Room", "type": "room"}, + {"id": "w", "name": "Waypoint", "type": "waypoint"}, + ] + monkeypatch.setattr(manager, "read_json_files", lambda: (nodes, [])) + + entrances, classrooms = manager.get_options() + + assert entrances == [ + {"id": "e", "name": "Elevator"}, + {"id": "s", "name": "Stairs"}, + ] + assert classrooms == [{"id": "r", "name": "Room"}] + + +def test_get_directions_returns_instructions_and_route_coordinates(): + manager.G = make_graph( + ("start", node(floor=1, x=1, y=2)), + ("middle", node(floor=1, x=3, y=4)), + ("end", node(floor=1, x=5, y=6)), + edges=[ + ("start", "middle", {"weight": 1, "instruction": "Walk ahead."}), + ("middle", "end", {"weight": 1, "instruction": "Turn right."}), + ], + ) + + result = manager.get_directions("start", "end") + + assert result == { + "directions": ["Walk ahead.", "Turn right."], + "coordinates": [ + {"x": 1, "y": 2, "floor": 1}, + {"x": 3, "y": 4, "floor": 1}, + {"x": 5, "y": 6, "floor": 1}, + ], + } + + +def test_get_directions_handles_same_start_and_destination(): + manager.G = make_graph(("room", node(floor=5, x=12, y=-3))) + + assert manager.get_directions("room", "room") == { + "directions": ["You are already at your destination."], + "coordinates": [{"x": 12, "y": -3, "floor": 5}], + } + + +@pytest.mark.parametrize( + ("current_type", "next_type", "expected"), + [ + ("staircase", "waypoint", "Take the stairs from floor 1 to floor 2."), + ("waypoint", "staircase", "Take the stairs from floor 1 to floor 2."), + ("elevator", "waypoint", "Move from floor 1 to floor 2."), + ("waypoint", "elevator", "Move from floor 1 to floor 2."), + ("waypoint", "waypoint", "Move from floor 1 to floor 2."), + ], +) +def test_get_directions_describes_floor_changes(current_type, next_type, expected): + manager.G = make_graph( + ("start", node(floor=1, node_type=current_type)), + ("end", node(floor=2, node_type=next_type)), + edges=[("start", "end", {"weight": 1, "instruction": "Ignore me."})], + ) + + result = manager.get_directions("start", "end") + + assert result["directions"] == [expected] + assert [coordinate["floor"] for coordinate in result["coordinates"]] == [1, 2] + + +def test_get_directions_returns_message_when_no_path_exists(): + manager.G = make_graph(("start", node()), ("end", node())) + + assert manager.get_directions("start", "end") == "No path found from start to end." + + +@pytest.mark.parametrize( + ("start", "end"), + [("missing", "end"), ("start", "missing")], +) +def test_get_directions_returns_message_for_missing_nodes(start, end): + manager.G = make_graph(("start", node()), ("end", node())) + + assert manager.get_directions(start, end) == ( + f"No path found from {start} to {end}." + ) + + +def test_get_directions_uses_weighted_route_instructions(): + manager.G = make_graph( + ("start", node(x=0, y=0)), + ("direct", node(x=10, y=0)), + ("detour", node(x=2, y=0)), + ("end", node(x=4, y=0)), + edges=[ + ("start", "direct", {"weight": 10, "instruction": "Take direct."}), + ("direct", "end", {"weight": 10, "instruction": "Finish direct."}), + ("start", "detour", {"weight": 2, "instruction": "Take detour."}), + ("detour", "end", {"weight": 3, "instruction": "Finish detour."}), + ], + ) + + result = manager.get_directions("start", "end") + + assert result["directions"] == ["Take detour.", "Finish detour."] + assert [coordinate["x"] for coordinate in result["coordinates"]] == [0, 2, 4] + + +def test_get_directions_reuses_cached_graph(monkeypatch): + manager.G = make_graph( + ("start", node()), + ("end", node()), + edges=[("start", "end", {"weight": 1, "instruction": "Arrive."})], + ) + reload_calls = [] + monkeypatch.setattr(manager, "reload_graph", lambda: reload_calls.append(True)) + + assert manager.get_directions("start", "end")["directions"] == ["Arrive."] + assert reload_calls == [] + + +def test_get_directions_lazily_loads_graph(monkeypatch): + graph = make_graph( + ("start", node()), + ("end", node()), + edges=[("start", "end", {"weight": 1, "instruction": "Arrive."})], + ) + + def load_graph(): + manager.G = graph + + monkeypatch.setattr(manager, "reload_graph", load_graph) + + assert manager.get_directions("start", "end")["directions"] == ["Arrive."] + + +def test_main_draws_positioned_subgraph_and_saves_preview(monkeypatch): + nodes = [ + {"id": "positioned", "coords": [10, 20]}, + {"id": "unpositioned", "coords": [None, 30]}, + ] + graph = make_graph( + ("positioned", node(x=10, y=20)), + ("unpositioned", node(x=None, y=30)), + edges=[ + ("positioned", "unpositioned", {"weight": 1, "instruction": "Go."}) + ], + ) + drawn = [] + saved = [] + direction_calls = [] + monkeypatch.setattr(manager, "read_json_files", lambda: (nodes, [])) + monkeypatch.setattr(manager, "build_graph", lambda: graph) + monkeypatch.setattr( + manager.nx, + "draw", + lambda graph, pos, **kwargs: drawn.append((graph, pos, kwargs)), + ) + monkeypatch.setattr(manager.plt, "savefig", lambda path: saved.append(path)) + monkeypatch.setattr( + manager, + "get_directions", + lambda start, end: direction_calls.append((start, end)), + ) + + manager.main() + + assert manager.G is graph + assert len(drawn) == 1 + assert set(drawn[0][0].nodes) == {"positioned"} + assert drawn[0][1] == {"positioned": (10, 20)} + assert saved == ["map_preview.png"] + assert direction_calls == [("NPB_5_102", "NPB_5_E1")] + + +def test_logic_exports_public_graph_manager_api(): + assert logic.__all__ == ["get_directions", "get_floor_bounds", "get_options"] + assert logic.get_directions is manager.get_directions + assert logic.get_floor_bounds is manager.get_floor_bounds + assert logic.get_options is manager.get_options From 9de026781def00f6f618c0b7c2877b0fa6a9fd1e Mon Sep 17 00:00:00 2001 From: simonteague6 Date: Sat, 29 Aug 2026 23:24:56 -0500 Subject: [PATCH 5/5] Integrate regression coverage and release checks --- README.md | 22 +- app/logic/data_entry.py | 5 +- app/logic/graph_manager.py | 5 + app/routes.py | 15 +- app/static/css/styles.css | 30 +- app/static/js/directions.js | 53 ++-- app/static/js/map.js | 54 ++-- app/templates/index.html | 12 +- justfile | 3 +- tests/conftest.py | 15 + tests/frontend/directions.test.mjs | 15 + tests/frontend/harness.mjs | 26 +- tests/frontend/map.test.mjs | 46 +++ tests/test_data_entry.py | 9 + tests/test_data_integrity.py | 152 ++++++++++ tests/test_generate_vertical_edges.py | 408 ++++++++++++++++++++++++++ tests/test_graph_manager.py | 25 +- tests/test_routes.py | 247 ++++++++++++++++ tests/test_smoke.py | 70 +++-- tests/test_validate_data.py | 273 +++++++++++++++++ tools/generate_vertical_edges.py | 51 ++-- tools/validate_data.py | 66 ++++- 22 files changed, 1481 insertions(+), 121 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_data_integrity.py create mode 100644 tests/test_generate_vertical_edges.py create mode 100644 tests/test_routes.py create mode 100644 tests/test_validate_data.py diff --git a/README.md b/README.md index 5e0338c..b732c2a 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Each instruction can be dismissed as you go, similar to how Google Maps lets you |**Flask**|Turns our Python code into a running website| |**NetworkX**|Models the building as a graph and calculates the shortest path between rooms| |**HTML & CSS**|Structure and styling of the webpage| -|**SVG / Leaflet.js**|Draws the animated blue route line over the floor plan image| +|**Leaflet.js**|Draws the building grid, route line, and route markers| |**JSON**|Text files that store our building data (rooms, coordinates, connections)| |**uv**|Package manager — installs and manages Python libraries| |**Git**|Version control — tracks changes so the team can collaborate without overwriting each other| @@ -35,8 +35,8 @@ The building is modeled as a **graph** — locations are **nodes** and the walki 2. **name** — the human-readable label (e.g., `"5.102"` or `"Main Staircase"`), displayed in directions 3. **type** — the category of location (e.g., `"room"`, `"staircase"`, `"elevator"`, `"waypoint"`, `"spine"`). A `"spine"` node represents a main hallway/backbone connector used to route movement through the building, and the field is used to filter and categorize nodes 4. **building** — the building code (e.g., `"NPB"`), allowing multi-building support -5. **coords** — the pixel coordinates `[x, y]` on the floor plan image, used to draw the blue route line -6. **floor** — the floor number the node is on, allowing multi-floor pathfinding and filtering +5. **coords** — the `[x, y]` coordinates used to draw the blue route line on the map grid +6. **floor** — the floor number the node is on, allowing multi-floor pathfinding and filtering. The browser map preview supports one floor at a time; multi-floor routes still show their step-by-step directions. Each edge stores 4 things: @@ -75,15 +75,13 @@ campus_navigator/ │ │ │ ├── static/ ← Files the browser downloads directly │ │ ├── css/ -│ │ │ └── style.css ← Page styling -│ │ ├── js/ -│ │ │ └── map.js ← Draws the blue route line (Leaflet.js logic) -│ │ └── img/ -│ │ └── floorplan.png ← The floor plan image displayed on screen +│ │ │ └── styles.css ← Page styling +│ │ └── js/ +│ │ ├── directions.js ← Step-by-step direction cards +│ │ └── map.js ← Draws the route on a Leaflet grid │ │ │ └── templates/ ← HTML pages Flask serves to the user -│ ├── base.html ← Shared page skeleton (nav bar, footer, etc.) -│ └── index.html ← The main search + map page +│ └── index.html ← The search, directions, and map page │ └── data/ ← Building data — treated like a database ├── nodes.json ← Every room/intersection with its name and coordinates @@ -116,7 +114,7 @@ graph_manager.py returns: text directions + pixel coordinates routes.py passes results to index.html (via Flask's render_template) ↓ index.html displays the step-by-step text directions -map.js reads the coordinates and draws the blue line over floorplan.png +map.js reads the coordinates and draws the blue line over a coordinate grid ↓ User sees directions on screen ✓ ``` @@ -143,7 +141,7 @@ Write the Python function that loads the JSON files, builds the NetworkX graph, **Workspace: `app/templates/`, `app/static/`** -Build the HTML/CSS pages and write the JavaScript (`map.js`) that draws the animated blue line over the floor plan. Keep it mobile-friendly — users will be on their phones while walking. +Build the HTML/CSS pages and write the JavaScript that draws the animated blue line over the coordinate grid. Keep it mobile-friendly. Users will be on their phones while walking. ### 🔌 Integration (1 person) diff --git a/app/logic/data_entry.py b/app/logic/data_entry.py index 913980b..48d14d5 100644 --- a/app/logic/data_entry.py +++ b/app/logic/data_entry.py @@ -2,9 +2,11 @@ from pathlib import Path BASE_DIR = Path(__file__).resolve().parent -DATA_DIR = BASE_DIR / "data" +REPO_ROOT = BASE_DIR.parents[1] +DATA_DIR = REPO_ROOT / "data" NODES_FILE = DATA_DIR / "nodes.json" EDGES_FILE = DATA_DIR / "edges.json" +DEFAULT_BUILDING = "NPB" # JSON helpers @@ -55,6 +57,7 @@ def add_node(): "id": node_id, "name": label, "type": node_type, + "building": DEFAULT_BUILDING, "coords": [x, y], "floor": floor, } diff --git a/app/logic/graph_manager.py b/app/logic/graph_manager.py index 88a8874..00f24b5 100644 --- a/app/logic/graph_manager.py +++ b/app/logic/graph_manager.py @@ -147,6 +147,11 @@ def get_directions(start, end): directions.append( f"Take the stairs from floor {current_node['floor']} to floor {next_node['floor']}." ) + elif ( + current_node.get("type") == "elevator" + and next_node.get("type") == "elevator" + ): + directions.append(edge_data["instruction"]) else: directions.append( f"Move from floor {current_node['floor']} to floor {next_node['floor']}." diff --git a/app/routes.py b/app/routes.py index 1de759a..cd887f5 100644 --- a/app/routes.py +++ b/app/routes.py @@ -18,6 +18,7 @@ def directions(): steps = None coordinates = None floor_bounds = None + map_error = None error = None entrance = request.form.get("entrance") classroom = request.form.get("classroom") @@ -32,9 +33,16 @@ def directions(): steps = result["directions"] coordinates = result["coordinates"] if coordinates: - building = entrance.split("_")[0] - floor = coordinates[0]["floor"] - floor_bounds = get_floor_bounds(building, floor) + route_floors = {coordinate["floor"] for coordinate in coordinates} + if len(route_floors) > 1: + map_error = ( + "Map preview is unavailable for routes spanning multiple floors. " + "Follow the directions panel for this route." + ) + else: + building = entrance.split("_")[0] + floor = coordinates[0]["floor"] + floor_bounds = get_floor_bounds(building, floor) except RuntimeError as e: error = str(e) entrances, classrooms = get_options() @@ -43,6 +51,7 @@ def directions(): steps=steps, coordinates=coordinates, floor_bounds=floor_bounds, + map_error=map_error, error=error, entranceOptions=entrances, classroomOptions=classrooms, diff --git a/app/static/css/styles.css b/app/static/css/styles.css index a464b4b..1d9257a 100644 --- a/app/static/css/styles.css +++ b/app/static/css/styles.css @@ -97,7 +97,7 @@ button:hover { margin: 10px 0; padding-left: 20px; } -.ateps-box li { +.steps-box li { margin-bottom: 8px; color: #555; } @@ -111,7 +111,7 @@ button:hover { box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); } -.direction-box h2 { +.directions-box h2 { margin-top: 0; color: #0c2340; font-size: 18px; @@ -146,7 +146,7 @@ button:hover { background: #d4ffd4; border-left: 5px solid #28a745; } -#next--step-btn { +#next-step-btn { margin-top: 10px; padding: 8px 12px; background: #007bff; @@ -155,7 +155,7 @@ button:hover { border-radius: 5px; cursor: pointer; } -#next--step-btn:hover { +#next-step-btn:hover { background: #0056b3; } @@ -181,3 +181,25 @@ html { width: 100%; height: 100vh; } + +@media (max-width: 768px) { + .container { + flex-direction: column; + height: auto; + min-height: 100vh; + } + + .left-panel, + .right-panel { + box-sizing: border-box; + width: 100%; + } + + .right-panel { + height: 50vh; + } + + #map { + height: 50vh; + } +} diff --git a/app/static/js/directions.js b/app/static/js/directions.js index db205da..7a2a0bb 100644 --- a/app/static/js/directions.js +++ b/app/static/js/directions.js @@ -2,37 +2,42 @@ document.addEventListener("DOMContentLoaded", () => { if (typeof directionSteps === "undefined" || !directionSteps.length) return; const container = document.getElementById("directions-card-container"); + if (!container) return; + let currentIndex = 0; function renderSteps(index) { - container.innerHTML = ""; - - if (index >= directionSteps.length) { - container.innerHTML = ` -
-

You have arrived!

-
- `; - return; - } - - const stepText = directionSteps[index]; + container.replaceChildren(); const card = document.createElement("div"); - card.className = "direction-card active"; - - card.innerHTML = ` -

Step ${index + 1}

-

${stepText}

- - `; + card.className = + index >= directionSteps.length + ? "direction-card arrived" + : "direction-card active"; + + const heading = document.createElement("h3"); + heading.textContent = + index >= directionSteps.length + ? "You have arrived!" + : `Step ${index + 1}`; + card.appendChild(heading); + + if (index < directionSteps.length) { + const paragraph = document.createElement("p"); + paragraph.textContent = directionSteps[index]; + card.appendChild(paragraph); + + const nextButton = document.createElement("button"); + nextButton.id = "next-step-btn"; + nextButton.textContent = "Next"; + nextButton.addEventListener("click", () => { + currentIndex++; + renderSteps(currentIndex); + }); + card.appendChild(nextButton); + } container.appendChild(card); - - document.getElementById("next-step-btn").addEventListener("click", () => { - currentIndex++; - renderSteps(currentIndex); - }); } renderSteps(currentIndex); diff --git a/app/static/js/map.js b/app/static/js/map.js index 34ae9d0..9475ac7 100644 --- a/app/static/js/map.js +++ b/app/static/js/map.js @@ -1,12 +1,31 @@ document.addEventListener("DOMContentLoaded", () => { - if (!window.routeCoordinates || !window.floorBounds) { + const { routeCoordinates, floorBounds } = window; + if (!routeCoordinates || !floorBounds) { console.error("routeCoordinates or floorBounds missing from template"); return; } + if (!window.L) { + console.error("Leaflet is missing from the page"); + return; + } + if (!Array.isArray(routeCoordinates)) { + console.error("routeCoordinates must be an array"); + return; + } + + const floors = new Set( + routeCoordinates + .map((coordinate) => coordinate.floor) + .filter((floor) => floor !== undefined), + ); + if (floors.size > 1) { + console.error("Map preview only supports routes on one floor"); + return; + } // Initialize map with simple CRS - const map = L.map("map", { - crs: L.CRS.Simple, + const map = window.L.map("map", { + crs: window.L.CRS.Simple, minZoom: -2, maxZoom: 4, zoomControl: true, @@ -26,7 +45,7 @@ document.addEventListener("DOMContentLoaded", () => { // Vertical lines for (let x = 0; x <= floorBounds.width; x += gridSize) { - L.polyline( + window.L.polyline( [ [0, x], [floorBounds.height, x], @@ -41,7 +60,7 @@ document.addEventListener("DOMContentLoaded", () => { // Horizontal lines for (let y = 0; y <= floorBounds.height; y += gridSize) { - L.polyline( + window.L.polyline( [ [y, 0], [y, floorBounds.width], @@ -60,21 +79,20 @@ document.addEventListener("DOMContentLoaded", () => { // Convert backend coordinates → Leaflet [lat, lng] const latlngs = routeCoordinates.map((c) => [c.y, c.x]); - // Draw animated route line - const routeLine = L.polyline(latlngs, { - color: "blue", - weight: 4, - dashArray: "10, 10", - className: "animated-route", - }).addTo(map); + if (latlngs.length > 0) { + // Draw animated route line + const routeLine = window.L.polyline(latlngs, { + color: "blue", + weight: 4, + dashArray: "10, 10", + className: "animated-route", + }).addTo(map); - // Fit map to route - map.fitBounds(routeLine.getBounds(), { padding: [20, 20] }); + // Fit map to route + map.fitBounds(routeLine.getBounds(), { padding: [20, 20] }); - // Start + end markers - if (latlngs.length > 0) { // Start marker (green) - L.circleMarker(latlngs[0], { + window.L.circleMarker(latlngs[0], { radius: 6, color: "green", fillColor: "green", @@ -82,7 +100,7 @@ document.addEventListener("DOMContentLoaded", () => { }).addTo(map); // End marker (red) - L.circleMarker(latlngs[latlngs.length - 1], { + window.L.circleMarker(latlngs[latlngs.length - 1], { radius: 6, color: "red", fillColor: "red", diff --git a/app/templates/index.html b/app/templates/index.html index 628f645..94edca7 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -29,7 +29,7 @@

Choose Your Options



- +
Route: @@ -61,7 +61,15 @@

Directions

{% endif %}
- {% if coordinates %} + {% if map_error %} +
+

{{ map_error }}

+
+ {% elif coordinates %}