diff --git a/src/web/BUILD b/src/web/BUILD index cda586fe27..35cbea50aa 100644 --- a/src/web/BUILD +++ b/src/web/BUILD @@ -2,6 +2,7 @@ # Copyright (c) 2026, The OpenROAD Authors load("@rules_cc//cc:cc_library.bzl", "cc_library") +load("@rules_python//python:defs.bzl", "py_binary") load("//bazel:tcl_encode_or.bzl", "tcl_encode") load("//bazel:tcl_wrap_cc.bzl", "tcl_wrap_cc") @@ -10,6 +11,60 @@ package( features = ["layering_check"], ) +py_binary( + name = "embed_report_assets", + srcs = ["src/embed_report_assets.py"], + main = "src/embed_report_assets.py", +) + +genrule( + name = "report_assets", + srcs = [ + "src/style.css", + "src/theme.js", + "src/coordinates.js", + "src/ui-utils.js", + "src/checkbox-tree-model.js", + "src/vis-tree.js", + "src/websocket-manager.js", + "src/websocket-tile-layer.js", + "src/display-controls.js", + "src/inspector.js", + "src/ruler.js", + "src/tcl-completer.js", + "src/hierarchy-browser.js", + "src/menu-bar.js", + "src/clock-tree-widget.js", + "src/schematic-widget.js", + "src/charts-widget.js", + "src/timing-widget.js", + "src/main.js", + ], + outs = ["src/report_assets.cpp"], + cmd = "$(execpath :embed_report_assets)" + + " --output $@" + + " --css $(location src/style.css)" + + " --js $(location src/theme.js)" + + " $(location src/coordinates.js)" + + " $(location src/ui-utils.js)" + + " $(location src/checkbox-tree-model.js)" + + " $(location src/vis-tree.js)" + + " $(location src/websocket-manager.js)" + + " $(location src/websocket-tile-layer.js)" + + " $(location src/display-controls.js)" + + " $(location src/inspector.js)" + + " $(location src/ruler.js)" + + " $(location src/tcl-completer.js)" + + " $(location src/hierarchy-browser.js)" + + " $(location src/menu-bar.js)" + + " $(location src/clock-tree-widget.js)" + + " $(location src/schematic-widget.js)" + + " $(location src/charts-widget.js)" + + " $(location src/timing-widget.js)" + + " $(location src/main.js)", + tools = [":embed_report_assets"], +) + cc_library( name = "web", srcs = [ @@ -29,6 +84,7 @@ cc_library( "src/timing_report.cpp", "src/timing_report.h", "src/web.cpp", + ":report_assets", ], hdrs = [ "include/web/web.h", diff --git a/src/web/CMakeLists.txt b/src/web/CMakeLists.txt index be5b1009f0..31cd6d601b 100644 --- a/src/web/CMakeLists.txt +++ b/src/web/CMakeLists.txt @@ -12,6 +12,54 @@ swig_lib(NAME web ${ODB_HOME}/include ) +set(REPORT_ASSETS_CPP ${CMAKE_CURRENT_BINARY_DIR}/report_assets.cpp) +add_custom_command( + OUTPUT ${REPORT_ASSETS_CPP} + COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/src/embed_report_assets.py + --output ${REPORT_ASSETS_CPP} + --css ${CMAKE_CURRENT_SOURCE_DIR}/src/style.css + --js ${CMAKE_CURRENT_SOURCE_DIR}/src/theme.js + ${CMAKE_CURRENT_SOURCE_DIR}/src/coordinates.js + ${CMAKE_CURRENT_SOURCE_DIR}/src/ui-utils.js + ${CMAKE_CURRENT_SOURCE_DIR}/src/checkbox-tree-model.js + ${CMAKE_CURRENT_SOURCE_DIR}/src/vis-tree.js + ${CMAKE_CURRENT_SOURCE_DIR}/src/websocket-manager.js + ${CMAKE_CURRENT_SOURCE_DIR}/src/websocket-tile-layer.js + ${CMAKE_CURRENT_SOURCE_DIR}/src/display-controls.js + ${CMAKE_CURRENT_SOURCE_DIR}/src/inspector.js + ${CMAKE_CURRENT_SOURCE_DIR}/src/ruler.js + ${CMAKE_CURRENT_SOURCE_DIR}/src/tcl-completer.js + ${CMAKE_CURRENT_SOURCE_DIR}/src/hierarchy-browser.js + ${CMAKE_CURRENT_SOURCE_DIR}/src/menu-bar.js + ${CMAKE_CURRENT_SOURCE_DIR}/src/clock-tree-widget.js + ${CMAKE_CURRENT_SOURCE_DIR}/src/schematic-widget.js + ${CMAKE_CURRENT_SOURCE_DIR}/src/charts-widget.js + ${CMAKE_CURRENT_SOURCE_DIR}/src/timing-widget.js + ${CMAKE_CURRENT_SOURCE_DIR}/src/main.js + DEPENDS + src/embed_report_assets.py + src/style.css + src/theme.js + src/coordinates.js + src/ui-utils.js + src/checkbox-tree-model.js + src/vis-tree.js + src/websocket-manager.js + src/websocket-tile-layer.js + src/display-controls.js + src/inspector.js + src/ruler.js + src/tcl-completer.js + src/hierarchy-browser.js + src/menu-bar.js + src/clock-tree-widget.js + src/schematic-widget.js + src/charts-widget.js + src/timing-widget.js + src/main.js + COMMENT "Generating report_assets.cpp" +) + target_sources(web PRIVATE src/clock_tree_report.cpp @@ -23,6 +71,7 @@ target_sources(web src/timing_report.cpp src/web.cpp src/MakeWeb.cpp + ${REPORT_ASSETS_CPP} ) target_link_libraries(web diff --git a/src/web/README.md b/src/web/README.md index 1fb299c994..add9585b9e 100644 --- a/src/web/README.md +++ b/src/web/README.md @@ -106,6 +106,51 @@ save_image -web -display_option {routing false} \ layout.png ``` +### Save Report + +Generate a self-contained HTML timing report. The report uses the same +JavaScript frontend as the live web viewer but serves all data from a cache +embedded in the HTML file. No running server is required to view the report. + +```tcl +web_save_report + [-setup_paths count] + [-hold_paths count] + path +``` + +#### Options + +| Switch Name | Description | +| ----- | ----- | +| `-setup_paths` | Maximum number of setup timing paths to include. Default: `100`. | +| `-hold_paths` | Maximum number of hold timing paths to include. Default: `100`. | +| `path` | Output HTML file path. | + +The report includes: + +- **Layout view** with pre-rendered tiles at a fixed zoom level. Layer + visibility can be toggled using the same display controls as the live viewer. + Zoom is disabled; pan is allowed. +- **Timing table** with setup and hold paths. Clicking a path highlights it + on the layout via a pre-rendered overlay image. +- **Slack histogram** with setup/hold tabs. +- **Display controls**, hierarchy browser, clock tree, and other panels from + the live viewer (features that require server interaction show empty states). + +The report requires an internet connection to load Leaflet and GoldenLayout +CSS/JS from CDN. + +#### Examples + +```tcl +# Generate a report with default settings +web_save_report timing.html + +# Include more paths +web_save_report -setup_paths 200 -hold_paths 200 timing.html +``` + ## Features - **Tile-based rendering** — The server renders 256x256 PNG tiles on demand, diff --git a/src/web/include/web/web.h b/src/web/include/web/web.h index e01897a0ba..7088789018 100644 --- a/src/web/include/web/web.h +++ b/src/web/include/web/web.h @@ -33,6 +33,10 @@ class WebServer void serve(int port, const std::string& doc_root); + void saveReport(const std::string& filename, + int max_setup_paths, + int max_hold_paths); + void saveImage(const std::string& filename, int x0, int y0, diff --git a/src/web/src/charts-widget.js b/src/web/src/charts-widget.js index 7773cfea96..ee66d34468 100644 --- a/src/web/src/charts-widget.js +++ b/src/web/src/charts-widget.js @@ -96,7 +96,7 @@ function computeYAxis(maxCount) { } export class ChartsWidget { - constructor(container, app, redrawAllLayers) { + constructor(app, redrawAllLayers) { this._app = app; this._redrawAllLayers = redrawAllLayers; this._currentTab = 'setup'; @@ -107,12 +107,12 @@ export class ChartsWidget { this._chartArea = null; this._hoveredBar = null; - this._build(container); + this._build(); } // ---- DOM construction ---- - _build(container) { + _build() { const el = document.createElement('div'); el.className = 'charts-widget'; @@ -184,8 +184,7 @@ export class ChartsWidget { this._tooltip.style.display = 'none'; el.appendChild(this._tooltip); - container.element.appendChild(el); - this._el = el; + this.element = el; this._ctx = this._canvas.getContext('2d'); this._bindEvents(); @@ -482,7 +481,7 @@ export class ChartsWidget { `Slack: [${bar.lower.toFixed(precision)}, ${bar.upper.toFixed(precision)}) ${unit}`; this._tooltip.style.display = 'block'; - const rect = this._el.getBoundingClientRect(); + const rect = this.element.getBoundingClientRect(); const tx = e.clientX - rect.left + 12; const ty = e.clientY - rect.top - 10; this._tooltip.style.left = tx + 'px'; diff --git a/src/web/src/embed_report_assets.py b/src/web/src/embed_report_assets.py new file mode 100755 index 0000000000..709f53bea9 --- /dev/null +++ b/src/web/src/embed_report_assets.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: BSD-3-Clause +# Copyright (c) 2026, The OpenROAD Authors +# +# Embed JS/CSS files as C++ raw string literals for the standalone +# timing report HTML. Produces a .cpp file with const char* constants. +# +# Each JS file is wrapped in an IIFE to isolate file-private const/let +# declarations. Exported symbols are forwarded to outer-scope vars. + +import argparse +import re + + +def process_js_file(content): + """Process a single JS file for concatenation into a shared scope.""" + # Remove import lines. + content = re.sub(r"^import\s+.*;\s*$", "", content, flags=re.MULTILINE) + + # Find exported names and strip the export keyword. + # Two patterns: + # 1. export function/class/const Name ... + # 2. export { InternalA as ExportedA, InternalB, ... } + exported_names = [] + + # Pattern 1: export function/class/const Name + def capture_export_decl(m): + keyword = m.group(1) # function, class, or const + name = m.group(2) + exported_names.append(name) + return keyword + " " + name + + content = re.sub( + r"^export\s+(function|class|const)\s+(\w+)", + capture_export_decl, + content, + flags=re.MULTILINE, + ) + + # Pattern 2: export { InternalA as ExportedA, InternalB, ... } + # Collects (internal_name, exported_name) pairs; removes the line; + # will add alias assignments after the IIFE. + renamed_exports = [] # (internal, exported) + + def capture_export_block(m): + for item in m.group(1).split(","): + item = item.strip() + if not item: + continue + if " as " in item: + internal, exported = item.split(" as ", 1) + renamed_exports.append((internal.strip(), exported.strip())) + else: + renamed_exports.append((item, item)) + return "" # remove the export block + + content = re.sub(r"export\s*\{([^}]+)\}", capture_export_block, content) + + content = content.strip() + if not content: + return "" + + # Merge both export lists into a unified set of outer-scope names. + all_exported = list(exported_names) + for _, exported in renamed_exports: + if exported not in all_exported: + all_exported.append(exported) + + if not all_exported: + # No exports — wrap in a bare block for const/let isolation. + return "{\n" + content + "\n}" + + # Wrap in an IIFE. Exported names get outer-scope `var` declarations. + lines = [] + lines.append("var " + ", ".join(all_exported) + ";") + # IIFE returns an object with the exported names. + # For pattern-1 exports, the name is the same inside and out. + # For pattern-2 exports, we map internal -> exported. + return_pairs = [] + for name in exported_names: + return_pairs.append(name + ": " + name) + for internal, exported in renamed_exports: + return_pairs.append(exported + ": " + internal) + exports_obj = "{ " + ", ".join(return_pairs) + " }" + lines.append("var __e = (function() {") + lines.append(content) + lines.append("return " + exports_obj + ";") + lines.append("})();") + # Assign from returned object to outer vars. + for name in all_exported: + lines.append(name + " = __e." + name + ";") + return "\n".join(lines) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--output", "-o", required=True) + parser.add_argument("--css", required=True, help="style.css path") + parser.add_argument( + "--js", nargs="+", required=True, help="JS files in dependency order" + ) + args = parser.parse_args() + + # Read and process JS files. + js_parts = [] + for path in args.js: + with open(path, encoding="utf-8") as f: + content = f.read() + js_parts.append(f'// ── {path.split("/")[-1]} ──') + js_parts.append(process_js_file(content)) + combined_js = "\n".join(js_parts) + + # Read CSS. + with open(args.css, encoding="utf-8") as f: + css_content = f.read() + + with open(args.output, "w", encoding="utf-8") as out: + out.write("// Auto-generated — do not edit.\n") + out.write("#include \n") + out.write("namespace web {\n") + out.write('extern const std::string_view kReportCSS = R"__CSS__(\n') + out.write(css_content) + out.write(')__CSS__";\n\n') + out.write('extern const std::string_view kReportJS = R"__JS__(\n') + out.write(combined_js) + out.write(')__JS__";\n') + out.write("} // namespace web\n") + + +if __name__ == "__main__": + main() diff --git a/src/web/src/json_builder.h b/src/web/src/json_builder.h index 565f033ed5..4a3b85353d 100644 --- a/src/web/src/json_builder.h +++ b/src/web/src/json_builder.h @@ -155,6 +155,10 @@ class JsonBuilder buf_ += val ? "true" : "false"; } + void field(const std::string& key, const char* val) + { + field(key.c_str(), val); + } void field(const std::string& key, int val) { field(key.c_str(), val); } void field(const std::string& key, float val) { field(key.c_str(), val); } void field(const std::string& key, double val) { field(key.c_str(), val); } diff --git a/src/web/src/main.js b/src/web/src/main.js index f35ab4fbbd..691a9a8b8f 100644 --- a/src/web/src/main.js +++ b/src/web/src/main.js @@ -376,7 +376,8 @@ function createBrowser(container) { } function createTimingWidget(container) { - app.timingWidget = new TimingWidget(container, app, redrawAllLayers); + app.timingWidget = new TimingWidget(app, redrawAllLayers); + container.element.appendChild(app.timingWidget.element); } function createDRCWidget(container) { @@ -389,7 +390,8 @@ function createClockWidget(container) { } function createChartsWidget(container) { - app.chartsWidget = new ChartsWidget(container, app, redrawAllLayers); + app.chartsWidget = new ChartsWidget(app, redrawAllLayers); + container.element.appendChild(app.chartsWidget.element); } function createHelpWidget(container) { @@ -537,8 +539,13 @@ const LAYOUT_VERSION = 3; // Must be created before loadLayout so that components (e.g. SchematicWidget) // constructed during layout initialisation can access app.websocketManager. -const websocketUrl = `ws://${window.location.host || 'localhost:8080'}/ws`; -app.websocketManager = new WebSocketManager(websocketUrl, updateStatus); +const staticCache = window.__STATIC_CACHE__ || null; +if (staticCache) { + app.websocketManager = WebSocketManager.fromCache(staticCache, updateStatus); +} else { + const websocketUrl = `ws://${window.location.host || 'localhost:8080'}/ws`; + app.websocketManager = new WebSocketManager(websocketUrl, updateStatus); +} // Restore saved layout or use default const savedLayout = localStorage.getItem('gl-layout'); @@ -663,10 +670,39 @@ app.websocketManager.readyPromise.then(async () => { [(designHeight - maxDXDY) * scale, designWidth * scale] ]; app.map.fitBounds(app.fitBounds); + + if (staticCache) { + // Lock to the pre-rendered tile zoom level and fit. + const cacheZoom = staticCache.zoom; + app.map.setMinZoom(cacheZoom); + app.map.setMaxZoom(cacheZoom); + app.map.fitBounds(app.fitBounds); + app.map.scrollWheelZoom.disable(); + app.map.touchZoom.disable(); + app.map.boxZoom.disable(); + app.map.doubleClickZoom.disable(); + + // Path highlight overlay image. + app.pathOverlay = L.imageOverlay('', app.fitBounds, { + opacity: 1, interactive: false, zIndex: 1000, + }); + staticCache.setPathOverlay = (src) => { + if (src) { + app.pathOverlay.setUrl(src); + app.pathOverlay.addTo(app.map); + } else { + app.map.removeLayer(app.pathOverlay); + } + }; + } } // Click-to-select: convert click position to DBU and query server - app.map.on('click', (e) => { + if (staticCache) { + // Hide loading overlay — shapes are always ready in static mode. + document.getElementById('loading-overlay').style.display = 'none'; + } + if (!staticCache) app.map.on('click', (e) => { if (!app.designScale) return; if (app.rulerManager && app.rulerManager.isActive()) return; const { dbuX: dbu_x, dbuY: dbu_y } = latLngToDbu( @@ -711,7 +747,7 @@ app.websocketManager.readyPromise.then(async () => { }); // ─── Right-click rubber-band zoom ────────────────────────────── - { + if (!staticCache) { const container = app.map.getContainer(); let rbStart = null; // {x, y} in client coords let rbDiv = null; // overlay element diff --git a/src/web/src/request_handler.cpp b/src/web/src/request_handler.cpp index 3adb8f1be3..ded0f41962 100644 --- a/src/web/src/request_handler.cpp +++ b/src/web/src/request_handler.cpp @@ -355,21 +355,6 @@ static void writeInspectPayload(JsonBuilder& builder, } } -// Serialize a TimingNode to JSON. -static void serializeTimingNode(JsonBuilder& builder, const TimingNode& n) -{ - builder.beginObject(); - builder.field("pin", n.pin_name); - builder.field("fanout", n.fanout); - builder.field("rise", n.is_rising); - builder.field("clk", n.is_clock); - builder.field("time", n.time); - builder.field("delay", n.delay); - builder.field("slew", n.slew); - builder.field("load", n.load); - builder.endObject(); -} - static double extract_double_value(const std::string& json) { return extract_float_or(json, "value", 0.0F); @@ -532,22 +517,8 @@ WebSocketResponse dispatch_request( switch (req.type) { case WebSocketRequest::BOUNDS: { resp.type = 0; - const odb::Rect bounds = gen.getBounds(); JsonBuilder builder; - builder.beginObject(); - builder.beginArray("bounds"); - builder.beginArray(); - builder.value(bounds.yMin()); - builder.value(bounds.xMin()); - builder.endArray(); - builder.beginArray(); - builder.value(bounds.yMax()); - builder.value(bounds.xMax()); - builder.endArray(); - builder.endArray(); - builder.field("shapes_ready", gen.shapesReady()); - builder.field("pin_max_size", gen.getPinMaxSize()); - builder.endObject(); + serializeBoundsResponse(builder, gen, gen.shapesReady()); const std::string& json = builder.str(); resp.payload.assign(json.begin(), json.end()); break; @@ -555,22 +526,7 @@ WebSocketResponse dispatch_request( case WebSocketRequest::TECH: { resp.type = 0; JsonBuilder builder; - builder.beginObject(); - builder.beginArray("layers"); - for (const auto& name : gen.getLayers()) { - builder.value(name); - } - builder.endArray(); - builder.beginArray("sites"); - for (const auto& name : gen.getSites()) { - builder.value(name); - } - builder.endArray(); - builder.field("has_liberty", gen.hasSta()); - if (gen.getBlock()) { - builder.field("dbu_per_micron", gen.getBlock()->getDbUnitsPerMicron()); - } - builder.endObject(); + serializeTechResponse(builder, gen); const std::string& json = builder.str(); resp.payload.assign(json.begin(), json.end()); break; @@ -1629,35 +1585,7 @@ WebSocketResponse TimingHandler::handleTimingReport(const WebSocketRequest& req) req.timing_slack_min, req.timing_slack_max); JsonBuilder builder; - builder.beginObject(); - builder.beginArray("paths"); - for (const auto& p : paths) { - builder.beginObject(); - builder.field("start_clk", p.start_clk); - builder.field("end_clk", p.end_clk); - builder.field("required", p.required); - builder.field("arrival", p.arrival); - builder.field("slack", p.slack); - builder.field("skew", p.skew); - builder.field("path_delay", p.path_delay); - builder.field("logic_depth", p.logic_depth); - builder.field("fanout", p.fanout); - builder.field("start_pin", p.start_pin); - builder.field("end_pin", p.end_pin); - builder.beginArray("data_nodes"); - for (const auto& n : p.data_nodes) { - serializeTimingNode(builder, n); - } - builder.endArray(); - builder.beginArray("capture_nodes"); - for (const auto& n : p.capture_nodes) { - serializeTimingNode(builder, n); - } - builder.endArray(); - builder.endObject(); - } - builder.endArray(); - builder.endObject(); + serializeTimingPaths(builder, paths); const std::string& json = builder.str(); resp.payload.assign(json.begin(), json.end()); } catch (const std::exception& e) { @@ -1741,21 +1669,7 @@ WebSocketResponse TimingHandler::handleSlackHistogram( auto histogram = timing_report_->getSlackHistogram( req.histogram_is_setup, req.histogram_path_group, req.histogram_clock); JsonBuilder builder; - builder.beginObject(); - builder.beginArray("bins"); - for (const auto& bin : histogram.bins) { - builder.beginObject(); - builder.field("lower", bin.lower); - builder.field("upper", bin.upper); - builder.field("count", bin.count); - builder.field("negative", bin.is_negative); - builder.endObject(); - } - builder.endArray(); - builder.field("unconstrained_count", histogram.unconstrained_count); - builder.field("total_endpoints", histogram.total_endpoints); - builder.field("time_unit", histogram.time_unit); - builder.endObject(); + serializeSlackHistogram(builder, histogram); const std::string& json = builder.str(); resp.payload.assign(json.begin(), json.end()); } catch (const std::exception& e) { @@ -1775,18 +1689,7 @@ WebSocketResponse TimingHandler::handleChartFilters(const WebSocketRequest& req) std::lock_guard lock(tcl_eval_->mutex); auto filters = timing_report_->getChartFilters(); JsonBuilder builder; - builder.beginObject(); - builder.beginArray("path_groups"); - for (const auto& name : filters.path_groups) { - builder.value(name); - } - builder.endArray(); - builder.beginArray("clocks"); - for (const auto& name : filters.clocks) { - builder.value(name); - } - builder.endArray(); - builder.endObject(); + serializeChartFilters(builder, filters); const std::string& json = builder.str(); resp.payload.assign(json.begin(), json.end()); } catch (const std::exception& e) { diff --git a/src/web/src/schematic-widget.js b/src/web/src/schematic-widget.js index 8d310bb091..a86b925773 100644 --- a/src/web/src/schematic-widget.js +++ b/src/web/src/schematic-widget.js @@ -275,7 +275,7 @@ export class SchematicWidget { async initNetlistSVG() { try { if (!window.netlistsvg) { - throw new Error('netlistsvg not loaded — check the + + + + + + +
+
+ + + + + +)"; + + out.close(); + logger_->info(utl::WEB, 32, "Saved timing report to {}", filename); +} + void WebServer::saveImage(const std::string& filename, const int x0, const int y0, diff --git a/src/web/src/web.i b/src/web/src/web.i index 16f83115d2..9d6a718fbc 100644 --- a/src/web/src/web.i +++ b/src/web/src/web.i @@ -30,6 +30,14 @@ save_image_cmd(const char* filename, vis_json ? vis_json : ""); } +void +save_report_cmd(const char* filename, + int max_setup, int max_hold) +{ + web::WebServer *server = ord::OpenRoad::openRoad()->getWebServer(); + server->saveReport(filename, max_setup, max_hold); +} + } // namespace web %} // inline diff --git a/src/web/src/web.tcl b/src/web/src/web.tcl index 10954f84b8..8dac9bc53d 100644 --- a/src/web/src/web.tcl +++ b/src/web/src/web.tcl @@ -126,3 +126,30 @@ proc save_image { args } { rename $options "" } } + +sta::define_cmd_args "web_save_report" {[-setup_paths count] \ + [-hold_paths count] \ + path +} + +proc web_save_report { args } { + sta::parse_key_args "web_save_report" args \ + keys {-setup_paths -hold_paths} flags {} + + set max_setup 100 + if { [info exists keys(-setup_paths)] } { + sta::check_positive_int "-setup_paths" $keys(-setup_paths) + set max_setup $keys(-setup_paths) + } + + set max_hold 100 + if { [info exists keys(-hold_paths)] } { + sta::check_positive_int "-hold_paths" $keys(-hold_paths) + set max_hold $keys(-hold_paths) + } + + sta::check_argc_eq1 "web_save_report" $args + set path [lindex $args 0] + + web::save_report_cmd $path $max_setup $max_hold +} diff --git a/src/web/src/websocket-manager.js b/src/web/src/websocket-manager.js index 64ab0f3e82..a1dccd5d0e 100644 --- a/src/web/src/websocket-manager.js +++ b/src/web/src/websocket-manager.js @@ -2,6 +2,8 @@ // Copyright (c) 2026, The OpenROAD Authors // WebSocket manager with request/response tracking and auto-reconnect. +// Supports a cache mode for static reports where pre-computed responses +// are served from window.__STATIC_CACHE__ without a WebSocket connection. export class WebSocketManager { constructor(url, onStatusChange) { @@ -14,9 +16,30 @@ export class WebSocketManager { this.readyResolve = null; this.onStatusChange = onStatusChange || (() => {}); this.onPush = null; // callback for server-push notifications + this._cache = null; this.connect(); } + // Create a cache-backed instance (no WebSocket connection). + static fromCache(cache, onStatusChange) { + const mgr = Object.create(WebSocketManager.prototype); + mgr.url = null; + mgr.socket = null; + mgr.nextId = 1; + mgr.pending = new Map(); + mgr.reconnectDelay = 0; + mgr.onStatusChange = onStatusChange || (() => {}); + mgr.onPush = null; + mgr._cache = cache; + mgr.readyPromise = Promise.resolve(); + mgr.readyResolve = null; + return mgr; + } + + get isStaticMode() { + return !!this._cache; + } + connect() { this.readyPromise = new Promise(resolve => { this.readyResolve = resolve; @@ -89,6 +112,9 @@ export class WebSocketManager { } request(msg) { + if (this._cache) { + return this._cacheRequest(msg); + } const id = this.nextId++; msg.id = id; const promise = new Promise((resolve, reject) => { @@ -108,4 +134,49 @@ export class WebSocketManager { this.pending.delete(id); this.onStatusChange(); } + + // Serve a request from the embedded cache. + _cacheRequest(msg) { + const type = msg.type; + + // Tile requests — return a data URI string. + if (type === 'tile') { + const key = msg.layer + '/' + msg.z + '/' + msg.x + '/' + msg.y; + const b64 = this._cache.tiles && this._cache.tiles[key]; + if (b64) { + return Promise.resolve('data:image/png;base64,' + b64); + } + return Promise.reject(new Error('Tile not cached: ' + key)); + } + + // Timing highlight — drive the overlay image. + if (type === 'timing_highlight') { + const idx = msg.path_index; + if (idx >= 0 && this._cache.overlays) { + const side = msg.is_setup ? 'setup' : 'hold'; + const b64 = this._cache.overlays[side]?.[idx]; + if (this._cache.setPathOverlay) { + this._cache.setPathOverlay( + b64 ? 'data:image/png;base64,' + b64 : null); + } + } else if (this._cache.setPathOverlay) { + this._cache.setPathOverlay(null); + } + return Promise.resolve({}); + } + + // Parameterized JSON lookups. + let key = type; + if (type === 'timing_report') { + key = 'timing_report:' + (msg.is_setup ? 'setup' : 'hold'); + } else if (type === 'slack_histogram') { + key = 'slack_histogram:' + (msg.is_setup ? 'setup' : 'hold'); + } + const json = this._cache.json && this._cache.json[key]; + if (json !== undefined) { + return Promise.resolve(json); + } + + return Promise.reject(new Error('Not available in static mode: ' + type)); + } } diff --git a/src/web/src/websocket-tile-layer.js b/src/web/src/websocket-tile-layer.js index daee657a62..9d9163ba86 100644 --- a/src/web/src/websocket-tile-layer.js +++ b/src/web/src/websocket-tile-layer.js @@ -46,8 +46,12 @@ export function createWebSocketTileLayer(visibility) { x: coords.x, y: coords.y, ...vf, - }).then(blob => { - tile.src = URL.createObjectURL(blob); + }).then(data => { + if (typeof data === 'string') { + tile.src = data; // data URI from cache + } else { + tile.src = URL.createObjectURL(data); + } }).catch(() => { // Request was cancelled (e.g. by refreshTiles); ignore }); @@ -87,11 +91,15 @@ export function createWebSocketTileLayer(visibility) { x: coords.x, y: coords.y, ...vf, - }).then(blob => { + }).then(data => { if (tile.src && tile.src.startsWith('blob:')) { URL.revokeObjectURL(tile.src); } - tile.src = URL.createObjectURL(blob); + if (typeof data === 'string') { + tile.src = data; + } else { + tile.src = URL.createObjectURL(data); + } }).catch(() => { // Tile refresh failed; keep existing image }); diff --git a/src/web/test/BUILD b/src/web/test/BUILD index ff0c92de54..a0e57e0f4e 100644 --- a/src/web/test/BUILD +++ b/src/web/test/BUILD @@ -216,3 +216,24 @@ cc_test( "@googletest//:gtest_main", ], ) + +cc_test( + name = "save_report_test", + srcs = ["cpp/TestSaveReport.cpp"], + copts = ["-Isrc/web/src"], + data = [ + "//test:nangate45_data", + ], + features = ["-layering_check"], + deps = [ + "//src/gui", + "//src/odb", + "//src/tst", + "//src/tst:nangate45_fixture", + "//src/utl", + "//src/web", + "//third-party/lodepng", + "@googletest//:gtest", + "@googletest//:gtest_main", + ], +) diff --git a/src/web/test/cpp/CMakeLists.txt b/src/web/test/cpp/CMakeLists.txt index 87f5b0bee1..e10554e8fe 100644 --- a/src/web/test/cpp/CMakeLists.txt +++ b/src/web/test/cpp/CMakeLists.txt @@ -36,7 +36,21 @@ gtest_discover_tests(TestRequestHandler WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/.. ) +add_executable(TestSaveReport TestSaveReport.cpp) + +target_link_libraries(TestSaveReport ${TEST_LIBS}) + +target_include_directories(TestSaveReport + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/../../src +) + +gtest_discover_tests(TestSaveReport + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/.. +) + add_dependencies(build_and_test TestTileGenerator TestRequestHandler + TestSaveReport ) diff --git a/src/web/test/cpp/TestRequestHandler.cpp b/src/web/test/cpp/TestRequestHandler.cpp index 83b04e3f42..02489759aa 100644 --- a/src/web/test/cpp/TestRequestHandler.cpp +++ b/src/web/test/cpp/TestRequestHandler.cpp @@ -126,6 +126,24 @@ TEST(JsonEscapeTest, ControlChars) EXPECT_EQ(json_escape(input), "\\u0001"); } +//------------------------------------------------------------------------------ +// JsonBuilder overload tests +//------------------------------------------------------------------------------ + +// Verify that field(std::string, const char*) writes a JSON string, not a bool. +// Without an explicit overload, C++ prefers the standard pointer-to-bool +// conversion over the user-defined const char*-to-std::string conversion, +// silently writing "true" instead of the intended string value. +TEST(JsonBuilderTest, FieldStringKeyCharPtrValueWritesString) +{ + JsonBuilder b; + b.beginObject(); + b.field(std::string("dir"), "input"); + b.endObject(); + EXPECT_NE(b.str().find("\"input\""), std::string::npos); + EXPECT_EQ(b.str().find("true"), std::string::npos); +} + //------------------------------------------------------------------------------ // dispatch_request tests (BOUNDS, LAYERS, INFO) //------------------------------------------------------------------------------ @@ -382,9 +400,11 @@ class SelectHandlerTest : public tst::Nangate45Fixture block_->setDieArea(odb::Rect(0, 0, 100000, 100000)); block_->setCoreArea(odb::Rect(0, 0, 100000, 100000)); placeInst("BUF_X16", "buf1", 0, 0); - fake_current_ = {"current", "FakeCurrent", odb::Rect(0, 0, 100, 100)}; - fake_previous_ - = {"previous", "FakePrevious", odb::Rect(100, 100, 200, 200)}; + fake_current_ + = {.name = "current", .type = "FakeCurrent", .bbox = {0, 0, 100, 100}}; + fake_previous_ = {.name = "previous", + .type = "FakePrevious", + .bbox = {100, 100, 200, 200}}; gen_ = std::make_shared( getDb(), /*sta=*/nullptr, getLogger()); tcl_eval_ = std::make_shared(/*interp=*/nullptr, getLogger()); diff --git a/src/web/test/cpp/TestSaveImage.cpp b/src/web/test/cpp/TestSaveImage.cpp index 0fdcbb824f..8d1110a794 100644 --- a/src/web/test/cpp/TestSaveImage.cpp +++ b/src/web/test/cpp/TestSaveImage.cpp @@ -1,6 +1,8 @@ // SPDX-License-Identifier: BSD-3-Clause // Copyright (c) 2026, The OpenROAD Authors +#include + #include #include #include @@ -79,8 +81,9 @@ class SaveImageTest : public tst::Nangate45Fixture // Save to a temp file and register for cleanup. std::string tempPng(const std::string& label) { - std::string path = std::filesystem::temp_directory_path() - / ("web_test_" + label + ".png"); + std::string path + = std::filesystem::temp_directory_path() + / ("web_test_" + label + "_" + std::to_string(::getpid()) + ".png"); output_files_.push_back(path); return path; } diff --git a/src/web/test/cpp/TestSaveReport.cpp b/src/web/test/cpp/TestSaveReport.cpp new file mode 100644 index 0000000000..ffbe5534a7 --- /dev/null +++ b/src/web/test/cpp/TestSaveReport.cpp @@ -0,0 +1,411 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, The OpenROAD Authors + +#include + +#include +#include +#include +#include +#include +#include + +#include "gtest/gtest.h" +#include "json_builder.h" +#include "odb/db.h" +#include "odb/dbTypes.h" +#include "tile_generator.h" +#include "timing_report.h" +#include "tst/nangate45_fixture.h" +#include "web/web.h" + +namespace web { +namespace { + +// ─── Fixture ──────────────────────────────────────────────────────────────── + +class SaveReportTest : public tst::Nangate45Fixture +{ + protected: + void SetUp() override + { + block_->setDieArea(odb::Rect(0, 0, 20000, 20000)); + // Place several instances to ensure non-empty tiles. + for (int i = 0; i < 10; ++i) { + placeInst("BUF_X16", + ("buf" + std::to_string(i)).c_str(), + 2000 + i * 1500, + 2000 + i * 1500); + } + } + + void TearDown() override + { + for (const auto& path : output_files_) { + std::filesystem::remove(path); + } + } + + odb::dbInst* placeInst(const char* master_name, + const char* inst_name, + int x, + int y) + { + odb::dbMaster* master = lib_->findMaster(master_name); + EXPECT_NE(master, nullptr) << "Master not found: " << master_name; + odb::dbInst* inst = odb::dbInst::create(block_, master, inst_name); + inst->setLocation(x, y); + inst->setPlacementStatus(odb::dbPlacementStatus::PLACED); + return inst; + } + + std::string tempHtml(const std::string& label) + { + std::string path + = std::filesystem::temp_directory_path() + / ("web_test_" + label + "_" + std::to_string(::getpid()) + ".html"); + output_files_.push_back(path); + return path; + } + + static std::string readFile(const std::string& path) + { + std::ifstream f(path); + std::ostringstream ss; + ss << f.rdbuf(); + return ss.str(); + } + + // Generate a report (no STA — timing sections will be empty but valid). + void generateReport(const std::string& path, + int max_setup = 0, + int max_hold = 0) + { + WebServer server(getDb(), /*sta=*/nullptr, getLogger(), /*interp=*/nullptr); + server.saveReport(path, max_setup, max_hold); + } + + // Count occurrences of a substring in a string. + static int countOccurrences(const std::string& haystack, + const std::string& needle) + { + int count = 0; + size_t pos = 0; + while ((pos = haystack.find(needle, pos)) != std::string::npos) { + ++count; + pos += needle.size(); + } + return count; + } + + // Check if a string contains a substring. + static bool contains(const std::string& haystack, const std::string& needle) + { + return haystack.find(needle) != std::string::npos; + } + + std::vector output_files_; +}; + +// ─── HTML Structure ───────────────────────────────────────────────────────── + +TEST_F(SaveReportTest, GeneratesFile) +{ + const std::string path = tempHtml("generates"); + generateReport(path); + ASSERT_TRUE(std::filesystem::exists(path)); + EXPECT_GT(std::filesystem::file_size(path), 0u); +} + +TEST_F(SaveReportTest, ContainsRequiredHTMLElements) +{ + const std::string path = tempHtml("html_elements"); + generateReport(path); + const std::string html = readFile(path); + + EXPECT_TRUE(contains(html, "")); + EXPECT_TRUE(contains(html, "")); + EXPECT_TRUE(contains(html, "")); + EXPECT_TRUE(contains(html, "")); + EXPECT_TRUE(contains(html, "id=\"gl-container\"")); + EXPECT_TRUE(contains(html, "id=\"menu-bar\"")); + EXPECT_TRUE(contains(html, "id=\"loading-overlay\"")); + EXPECT_TRUE(contains(html, "leaflet.css")); + EXPECT_TRUE(contains(html, "goldenlayout-base.css")); +} + +TEST_F(SaveReportTest, ContainsStaticCache) +{ + const std::string path = tempHtml("static_cache"); + generateReport(path); + const std::string html = readFile(path); + + EXPECT_TRUE(contains(html, "window.__STATIC_CACHE__")); +} + +TEST_F(SaveReportTest, ContainsInlinedJS) +{ + const std::string path = tempHtml("inlined_js"); + generateReport(path); + const std::string html = readFile(path); + + EXPECT_TRUE(contains(html, "class WebSocketManager")); + EXPECT_TRUE(contains(html, "fromCache")); + EXPECT_TRUE(contains(html, "TimingWidget")); + EXPECT_TRUE(contains(html, "ChartsWidget")); +} + +TEST_F(SaveReportTest, GoldenLayoutFromCDN) +{ + const std::string path = tempHtml("gl_cdn"); + generateReport(path); + const std::string html = readFile(path); + + // GoldenLayout loaded via ES module import from CDN. + EXPECT_TRUE(contains(html, "type=\"module\"")); + EXPECT_TRUE(contains(html, "esm.sh/golden-layout")); + // No vendored golden-layout bundle in the HTML. + EXPECT_FALSE(contains(html, "goldenlayout.umd")); +} + +// ─── Cache JSON Responses ─────────────────────────────────────────────────── + +TEST_F(SaveReportTest, CachesValidTechResponse) +{ + const std::string path = tempHtml("tech"); + generateReport(path); + const std::string html = readFile(path); + + EXPECT_TRUE(contains(html, "\"tech\":")); + EXPECT_TRUE(contains(html, "\"layers\":")); + EXPECT_TRUE(contains(html, "\"has_liberty\":")); + EXPECT_TRUE(contains(html, "\"dbu_per_micron\":")); +} + +TEST_F(SaveReportTest, CachesValidBoundsResponse) +{ + const std::string path = tempHtml("bounds"); + generateReport(path); + const std::string html = readFile(path); + + EXPECT_TRUE(contains(html, "\"bounds\":")); + EXPECT_TRUE(contains(html, "\"shapes_ready\": true")); + EXPECT_TRUE(contains(html, "\"pin_max_size\":")); +} + +TEST_F(SaveReportTest, CachesTimingReportJSON) +{ + const std::string path = tempHtml("timing"); + generateReport(path); + const std::string html = readFile(path); + + EXPECT_TRUE(contains(html, "\"timing_report:setup\":")); + EXPECT_TRUE(contains(html, "\"timing_report:hold\":")); + // Both should have paths arrays (even if empty without STA). + EXPECT_GE(countOccurrences(html, "\"paths\":"), 2); +} + +TEST_F(SaveReportTest, CachesSlackHistogramJSON) +{ + const std::string path = tempHtml("histogram"); + generateReport(path); + const std::string html = readFile(path); + + EXPECT_TRUE(contains(html, "\"slack_histogram:setup\":")); + EXPECT_TRUE(contains(html, "\"slack_histogram:hold\":")); +} + +TEST_F(SaveReportTest, CachesChartFiltersJSON) +{ + const std::string path = tempHtml("filters"); + generateReport(path); + const std::string html = readFile(path); + + EXPECT_TRUE(contains(html, "\"chart_filters\":")); + EXPECT_TRUE(contains(html, "\"path_groups\":")); + EXPECT_TRUE(contains(html, "\"clocks\":")); +} + +TEST_F(SaveReportTest, CachesHeatmapsEmpty) +{ + const std::string path = tempHtml("heatmaps"); + generateReport(path); + const std::string html = readFile(path); + + EXPECT_TRUE(contains(html, "\"heatmaps\":")); + // Should be an empty stub. + EXPECT_TRUE(contains(html, "\"heatmaps\":[]")); +} + +// ─── Tile Cache ───────────────────────────────────────────────────────────── + +TEST_F(SaveReportTest, CachesTilesAtCorrectZoom) +{ + const std::string path = tempHtml("tile_zoom"); + generateReport(path); + const std::string html = readFile(path); + + // Tiles should be at zoom 1 (the hardcoded default). + EXPECT_TRUE(contains(html, "zoom: 1")); + // At least some tile with /1/ zoom level should exist. + EXPECT_TRUE(contains(html, "/1/")); +} + +TEST_F(SaveReportTest, CachesTiles) +{ + const std::string path = tempHtml("tiles"); + generateReport(path); + const std::string html = readFile(path); + + // At least some tiles should be cached. + EXPECT_TRUE(contains(html, "tiles: {")); + // At least one tile key with zoom 1 should exist. + EXPECT_TRUE(contains(html, "/1/")) << "No tiles at zoom 1 found"; +} + +TEST_F(SaveReportTest, TilesAreValidBase64) +{ + const std::string path = tempHtml("tile_base64"); + generateReport(path); + const std::string html = readFile(path); + + // Find any tile value: "layer/1/x/y":"base64..." + const std::string marker = "/1/"; + auto pos = html.find(marker); + ASSERT_NE(pos, std::string::npos) << "No tile entry found"; + // Skip to the value after the key. + pos = html.find("\":\"", pos); + ASSERT_NE(pos, std::string::npos); + pos += 3; // skip ":" + // Check the base64 prefix: iVBOR is the b64 encoding of \x89PNG\r\n. + EXPECT_EQ(html.substr(pos, 4), "iVBO") << "Tile doesn't look like base64 PNG"; +} + +// ─── Overlays ─────────────────────────────────────────────────────────────── + +TEST_F(SaveReportTest, OverlayArraysPresent) +{ + const std::string path = tempHtml("overlays"); + generateReport(path); + const std::string html = readFile(path); + + EXPECT_TRUE(contains(html, "setup: [")); + EXPECT_TRUE(contains(html, "hold: [")); +} + +// ─── Shared Serialization ─────────────────────────────────────────────────── + +TEST_F(SaveReportTest, SerializeTimingPathsRoundTrip) +{ + TimingPathSummary p; + p.start_clk = "clk1"; + p.end_clk = "clk2"; + p.slack = -0.5f; + p.arrival = 1.0f; + p.required = 1.5f; + p.skew = 0.1f; + p.path_delay = 0.9f; + p.logic_depth = 3; + p.fanout = 5; + p.start_pin = "a/Z"; + p.end_pin = "b/D"; + + TimingNode node; + node.pin_name = "a/Z"; + node.fanout = 2; + node.is_rising = true; + node.is_clock = false; + node.time = 0.5f; + node.delay = 0.1f; + node.slew = 0.02f; + node.load = 0.01f; + p.data_nodes.push_back(node); + + std::vector paths = {p}; + JsonBuilder b; + serializeTimingPaths(b, paths); + const std::string json = b.str(); + + EXPECT_TRUE(contains(json, "\"clk1\"")); + EXPECT_TRUE(contains(json, "\"clk2\"")); + EXPECT_TRUE(contains(json, "\"a/Z\"")); + EXPECT_TRUE(contains(json, "\"paths\":")); + EXPECT_TRUE(contains(json, "\"data_nodes\":")); + EXPECT_TRUE(contains(json, "\"capture_nodes\":")); +} + +TEST_F(SaveReportTest, SerializeSlackHistogramRoundTrip) +{ + SlackHistogramResult h; + h.bins.push_back({-1.0f, 0.0f, 10, true}); + h.bins.push_back({0.0f, 1.0f, 20, false}); + h.unconstrained_count = 5; + h.total_endpoints = 35; + h.time_unit = "ns"; + + JsonBuilder b; + serializeSlackHistogram(b, h); + const std::string json = b.str(); + + EXPECT_TRUE(contains(json, "\"bins\":")); + EXPECT_TRUE(contains(json, "\"unconstrained_count\": 5")); + EXPECT_TRUE(contains(json, "\"total_endpoints\": 35")); + EXPECT_TRUE(contains(json, "\"time_unit\": \"ns\"")); +} + +TEST_F(SaveReportTest, SerializeChartFiltersRoundTrip) +{ + ChartFilters f; + f.path_groups.emplace_back("default"); + f.clocks.emplace_back("clk"); + + JsonBuilder b; + serializeChartFilters(b, f); + const std::string json = b.str(); + + EXPECT_TRUE(contains(json, "\"path_groups\":")); + EXPECT_TRUE(contains(json, "\"default\"")); + EXPECT_TRUE(contains(json, "\"clocks\":")); + EXPECT_TRUE(contains(json, "\"clk\"")); +} + +TEST_F(SaveReportTest, SerializeTechResponse) +{ + TileGenerator gen(getDb(), /*sta=*/nullptr, getLogger()); + JsonBuilder b; + serializeTechResponse(b, gen); + const std::string json = b.str(); + + EXPECT_TRUE(contains(json, "\"layers\":")); + EXPECT_TRUE(contains(json, "\"sites\":")); + EXPECT_TRUE(contains(json, "\"has_liberty\":")); + EXPECT_TRUE(contains(json, "\"dbu_per_micron\":")); +} + +TEST_F(SaveReportTest, SerializeBoundsShapesReady) +{ + TileGenerator gen(getDb(), /*sta=*/nullptr, getLogger()); + + JsonBuilder b_true; + serializeBoundsResponse(b_true, gen, true); + EXPECT_TRUE(contains(b_true.str(), "\"shapes_ready\": true")); + + JsonBuilder b_false; + serializeBoundsResponse(b_false, gen, false); + EXPECT_TRUE(contains(b_false.str(), "\"shapes_ready\": false")); +} + +// ─── Edge Cases ───────────────────────────────────────────────────────────── + +TEST_F(SaveReportTest, ZeroPathsReport) +{ + const std::string path = tempHtml("zero_paths"); + generateReport(path, 0, 0); + const std::string html = readFile(path); + + ASSERT_TRUE(std::filesystem::exists(path)); + EXPECT_TRUE(contains(html, "\"paths\": []")); +} + +} // namespace +} // namespace web diff --git a/src/web/test/js/test-websocket-manager.js b/src/web/test/js/test-websocket-manager.js index 3f4d303e73..edb056538a 100644 --- a/src/web/test/js/test-websocket-manager.js +++ b/src/web/test/js/test-websocket-manager.js @@ -110,3 +110,122 @@ describe('WebSocketManager', () => { }); }); }); + +describe('WebSocketManager.fromCache', () => { + function makeCache(overrides = {}) { + return { + zoom: 1, + json: { + tech: { layers: ['M1'], sites: [], has_liberty: false, dbu_per_micron: 1000 }, + bounds: { bounds: [[0, 0], [100, 100]], shapes_ready: true }, + heatmaps: { active: '', heatmaps: [] }, + 'timing_report:setup': { paths: [{ slack: -0.1 }] }, + 'timing_report:hold': { paths: [{ slack: 0.2 }] }, + 'slack_histogram:setup': { bins: [], total_endpoints: 0 }, + chart_filters: { path_groups: [], clocks: [] }, + }, + tiles: { + 'M1/1/0/0': 'iVBORw0KGgo=', + }, + overlays: { + setup: ['base64data'], + hold: [null], + }, + setPathOverlay: null, + ...overrides, + }; + } + + it('creates instance without WebSocket', () => { + const mgr = WebSocketManager.fromCache(makeCache()); + assert.equal(mgr.isStaticMode, true); + assert.equal(mgr.socket, null); + }); + + it('readyPromise resolves immediately', async () => { + const mgr = WebSocketManager.fromCache(makeCache()); + await mgr.readyPromise; // Should not hang. + }); + + it('returns cached JSON for tech', async () => { + const cache = makeCache(); + const mgr = WebSocketManager.fromCache(cache); + const result = await mgr.request({ type: 'tech' }); + assert.deepEqual(result.layers, ['M1']); + }); + + it('returns cached JSON for bounds', async () => { + const cache = makeCache(); + const mgr = WebSocketManager.fromCache(cache); + const result = await mgr.request({ type: 'bounds' }); + assert.equal(result.shapes_ready, true); + }); + + it('returns setup timing report when is_setup=1', async () => { + const mgr = WebSocketManager.fromCache(makeCache()); + const result = await mgr.request({ type: 'timing_report', is_setup: 1 }); + assert.equal(result.paths[0].slack, -0.1); + }); + + it('returns hold timing report when is_setup=0', async () => { + const mgr = WebSocketManager.fromCache(makeCache()); + const result = await mgr.request({ type: 'timing_report', is_setup: 0 }); + assert.equal(result.paths[0].slack, 0.2); + }); + + it('returns setup histogram', async () => { + const mgr = WebSocketManager.fromCache(makeCache()); + const result = await mgr.request({ type: 'slack_histogram', is_setup: 1 }); + assert.equal(result.total_endpoints, 0); + }); + + it('returns chart_filters', async () => { + const mgr = WebSocketManager.fromCache(makeCache()); + const result = await mgr.request({ type: 'chart_filters' }); + assert.ok(Array.isArray(result.path_groups)); + }); + + it('rejects uncached request types', async () => { + const mgr = WebSocketManager.fromCache(makeCache()); + await assert.rejects( + mgr.request({ type: 'select' }), + { message: /Not available in static mode/ } + ); + }); + + it('returns data URI string for cached tile', async () => { + const mgr = WebSocketManager.fromCache(makeCache()); + const result = await mgr.request({ + type: 'tile', layer: 'M1', z: 1, x: 0, y: 0 + }); + assert.equal(typeof result, 'string'); + assert.ok(result.startsWith('data:image/png;base64,')); + }); + + it('rejects uncached tile', async () => { + const mgr = WebSocketManager.fromCache(makeCache()); + await assert.rejects( + mgr.request({ type: 'tile', layer: 'M1', z: 1, x: 9, y: 9 }), + { message: /Tile not cached/ } + ); + }); + + it('timing_highlight calls setPathOverlay with data URI', async () => { + let called = null; + const cache = makeCache(); + cache.setPathOverlay = (v) => { called = v; }; + const mgr = WebSocketManager.fromCache(cache); + await mgr.request({ type: 'timing_highlight', path_index: 0, is_setup: 1 }); + assert.ok(called !== null); + assert.ok(called.startsWith('data:image/png;base64,')); + }); + + it('timing_highlight with index -1 clears overlay', async () => { + let called = 'not_called'; + const cache = makeCache(); + cache.setPathOverlay = (v) => { called = v; }; + const mgr = WebSocketManager.fromCache(cache); + await mgr.request({ type: 'timing_highlight', path_index: -1 }); + assert.equal(called, null); + }); +});