| // Copyright 2025 The Dawn & Tint Authors | |
| // | |
| // Redistribution and use in source and binary forms, with or without | |
| // modification, are permitted provided that the following conditions are met: | |
| // | |
| // 1. Redistributions of source code must retain the above copyright notice, this | |
| // list of conditions and the following disclaimer. | |
| // | |
| // 2. Redistributions in binary form must reproduce the above copyright notice, | |
| // this list of conditions and the following disclaimer in the documentation | |
| // and/or other materials provided with the distribution. | |
| // | |
| // 3. Neither the name of the copyright holder nor the names of its | |
| // contributors may be used to endorse or promote products derived from | |
| // this software without specific prior written permission. | |
| // | |
| // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" | |
| // AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE | |
| // IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE | |
| // DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE | |
| // FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL | |
| // DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR | |
| // SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER | |
| // CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, | |
| // OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| // Controller for the Dawn failure expectations browser. | |
| const width = 700; | |
| const height = 700; | |
| // The application state. | |
| // The UI sets configuration parameters and callbacks. | |
| // The application logic populates values from the file. | |
| var Model = { | |
| file: undefined, | |
| file_lines: undefined, | |
| // Derived from file data | |
| num_leaves: 1, // updated on reread | |
| counted_data: undefined, // treemap data for failure counts by path | |
| counted_tags: undefined, // treemap data for tag counts | |
| live_tags: new Set(), // The set of leaf tags that are currently valid | |
| treemap_id_to_node: undefined, | |
| treemap_svg_node: undefined, | |
| // Dependent on file data. | |
| focus_tags: [], // only show items with these tags. Acts a stack | |
| path_prefix: '', // path prefix for all shown items | |
| // Derived from UI controls | |
| path_substring: '', | |
| max_levels: 4, // Level of detail | |
| show_skip: true, | |
| show_fail: true, | |
| show_retry: true, | |
| show_slow: true, | |
| show_with_bug: true, | |
| show_without_bug: true, | |
| show_cat_accuracy: true, | |
| show_cat_critical: true, | |
| show_cat_spurious: true, | |
| show_cat_none: true, | |
| // Callbacks that control the UI. | |
| // The function that will display numbered text lines. | |
| // e.g. numbered_lines_displayer([{lineNumber:1,line:"foo"}, {lineNumber:16,line:"bar"}]) | |
| numbered_lines_displayer: (list_of_number_and_text) => {}, | |
| detail_incrementer: () => {}, | |
| node_name_displayer: (str,index) => {}, | |
| tag_name_displayer: (str,index) => {}, | |
| }; | |
| function resetFileDerivedData() { | |
| Model.num_leaves = 1; | |
| Model.counted_data = undefined; | |
| Model.counted_tags = undefined; | |
| Model.live_tags = new Set(); | |
| Model.treemap_id_to_node = undefined; | |
| Model.treemap_svg_node = undefined; | |
| Model.focus_tags = []; | |
| Model.path_prefix = ''; | |
| // Clear outputs and controls | |
| Model.node_name_displayer('',0); // clear the path control | |
| Model.node_name_displayer('',1); | |
| Model.node_name_displayer('',2); | |
| Model.tag_name_displayer('',0); | |
| Model.tag_name_displayer('',1); | |
| Model.tag_name_displayer('',2); | |
| Model.numbered_lines_displayer([]); | |
| } | |
| resetFileDerivedData(); | |
| // Unique IDs, for elements and clip paths. | |
| var count = 0; | |
| function uid(name) { | |
| return new Id("O-" + (name == null ? "" : name + "-") + ++count); | |
| } | |
| function Id(id) { | |
| this.id = id; | |
| this.href = new URL(`#${id}`, location) + ""; | |
| } | |
| Id.prototype.toString = function() { | |
| return "url(" + this.href + ")"; | |
| }; | |
| // Treemap generation, for visualizing hierarchical test counts. | |
| function makeTreemap(counted_hierarchy) { | |
| const sorted = counted_hierarchy.sort((a, b) => b.height - a.height || b.value - a.value); | |
| const data = sorted; | |
| Model.num_leaves = data.value; | |
| // Specify the color scale. | |
| // Protect against the case where there is no data at all. | |
| const color = d3.scaleOrdinal((data.children ?? []).map(d => d.data.id), d3.schemeTableau10); | |
| // Compute the layout. | |
| const root = d3.treemap() | |
| .tile(d3.treemapSquarify) // e.g., d3.treemapSquarify | |
| .size([width, height]) | |
| .padding(0) | |
| .round(true) | |
| (data); | |
| // Create the SVG container. | |
| const svg = d3.create("svg") | |
| .attr("viewBox", [0, 0, width, height]) | |
| .attr("width", width) | |
| .attr("height", height) | |
| .attr("style", `min-width: 30%; | |
| max-width: 100%; | |
| height: auto; | |
| font: 15px sans-serif; | |
| border: 1px solid black`); | |
| // Add a cell for each node of the hierarchy. | |
| const cell = svg.selectAll("g") | |
| .data(root.descendants()) | |
| .join("g"); | |
| const format5 = d3.format("5,d"); | |
| const format = d3.format(",d"); | |
| const percent_format = d3.format(".2%"); | |
| function showNodeText(d) { | |
| if (Model.node_name_displayer) { | |
| Model.node_name_displayer(d.data.id??'',1); | |
| Model.node_name_displayer(`${format5(d.value)} / ${Model.num_leaves} = ${percent_format(d.value / Model.num_leaves)}`,2); | |
| } | |
| } | |
| cell.on("click", (e,target) => { | |
| if (e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) { | |
| handleSetPathPrefixOut(); | |
| } else { | |
| handleSetPathPrefix(target.data.id); | |
| } | |
| }); | |
| cell.on("mouseover", (e,target) => showNodeText(target)); | |
| // Append a tooltip. | |
| cell.append("title") | |
| //.text(d => `${d.ancestors().reverse().map(d => d.data.id).join(".")}\n${format(d.value)}`); | |
| .text(d => {Model.node_name_displayer(d.id,1); `${d.data.name}\n${format(d.value)}`;}); | |
| cell.append("path") | |
| .attr("id", d => (d.cellUid = uid("cell"))) | |
| .attr("fill", d => color(d.data.id)) | |
| .attr("fill-opacity", 0.9) | |
| .attr("stroke", 'white') | |
| .attr("d", d => (`M ${d.x0} ${d.y0} L ${d.x1} ${d.y0} L ${d.x1} ${d.y1} L ${d.x0} ${d.y1} Z`)); | |
| // Append a clipPath to ensure text does not overflow. | |
| cell.append("clipPath") | |
| .attr("id", d => (d.clipUid = uid("clip"))) | |
| .append("use") | |
| .attr("xlink:href", d => d.cellUid.href); | |
| Model.treemap_svg_node = svg.node(); | |
| setTreemapDetailLevel(); | |
| container.replaceChildren(svg.node()); | |
| } | |
| // Circular packmap generation, for visualizing tags. | |
| function makePackmap(data) { | |
| const pack = d3.pack() | |
| .size([width, height]) | |
| .padding(1); | |
| const root = pack(data); | |
| const svg = d3.create("svg") | |
| .attr("viewBox", [0, 0, width, height]) | |
| .attr("width", width) | |
| .attr("height", height) | |
| .attr("style", `min-width: 25%; | |
| max-width: 100%; | |
| height: auto; | |
| font: 15px sans-serif; | |
| border: 1px solid black`) | |
| .attr("text-anchor", "middle"); | |
| const node = svg.append("g") | |
| .selectAll() | |
| .data(root.descendants()) | |
| .join("g") | |
| .attr("transform", d => `translate(${d.x},${d.y})`); | |
| node.append("circle") | |
| .attr("r", d => d.r ?? 1) | |
| .attr("fill", d => d.children ? "white" : "#ccc") | |
| .attr("stroke", d => d.children ? "black" : null); | |
| const text = node | |
| .filter(d => !d.children && d.r > 20) | |
| .append("text") | |
| .attr("clip-path", d => `circle(${d.r})`); | |
| text.selectAll() | |
| .data(d => [d.data.id]) | |
| .join("tspan") | |
| .text(d=>d); | |
| function showTagText(d) { | |
| Model.tag_name_displayer(d.data.id??'',1); | |
| Model.tag_name_displayer(`${d.value}`,2); | |
| } | |
| node.on("click", (e,target) => { | |
| if (e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) { | |
| handleSetFocusTagOut(); | |
| } else { | |
| handleSetFocusTag(target.data.id); | |
| } | |
| }); | |
| node.on("mouseover", (e,target) => showTagText(target)); | |
| tag_container.replaceChildren(svg.node()); | |
| } | |
| function shouldBeVisible(d) { | |
| if (d.depth == Model.max_levels) { return true; } | |
| return d.depth < Model.max_levels && (!('children' in d)); | |
| } | |
| function setTreemapDetailLevel() { | |
| if (Model.treemap_svg_node) { | |
| d3.select(Model.treemap_svg_node) | |
| .selectAll("g") | |
| .attr("visibility", (d) => (shouldBeVisible(d) ? "visible" : "hidden")); | |
| } | |
| } | |
| // File reading | |
| function readFileToArrayBrowser(file) { | |
| return new Promise((resolve, reject) => { | |
| const reader = new FileReader(); | |
| reader.onload = function(event) { | |
| const text = event.target.result; | |
| const lines = text.split('\n'); | |
| resolve(lines); | |
| }; | |
| reader.onerror = function(error) { | |
| reject(error); | |
| }; | |
| reader.readAsText(file); | |
| }); | |
| } | |
| async function readfile(file) { | |
| try { | |
| const lines = await readFileToArrayBrowser(file); | |
| return lines; | |
| } catch (error) { | |
| console.error('Error reading file:', error); | |
| } | |
| return []; | |
| } | |
| async function updateFromFile() { | |
| if (Model.file !== undefined) { | |
| Model.file_lines = await readfile(Model.file); | |
| resetFileDerivedData(); | |
| updateFromLines(); | |
| } | |
| } | |
| async function updateFromLines() { | |
| if (Model.file_lines === undefined) { | |
| return; | |
| } | |
| const rowPredicate = (row) => { | |
| if (row.pathString.indexOf(Model.path_substring) < 0) { return false; } | |
| if (!row.pathString.startsWith(Model.path_prefix)) { return false; } | |
| if (!Model.show_skip && row.verdict === 'Skip') { return false; } | |
| if (!Model.show_fail && row.verdict === 'Failure') { return false; } | |
| if (!Model.show_retry && row.verdict === 'RetryOnFailure') { return false; } | |
| if (!Model.show_slow && row.verdict === 'Slow') { return false; } | |
| const has_bug = ! row.bug.match(/\/0+$/); | |
| if (!Model.show_with_bug && has_bug) { return false; } | |
| if (!Model.show_without_bug && !has_bug) { return false; } | |
| // Reject if there is a focus tag that is not in the row. | |
| if (Model.focus_tags.filter(t => row.tags.indexOf(t) < 0).length > 0) { return false; } | |
| // Triage categories | |
| if (!Model.show_cat_none && row.categories.size == 0) { return false; } | |
| if (!Model.show_cat_accuracy && row.categories.has('Accuracy')) { return false; } | |
| if (!Model.show_cat_critical && row.categories.has('Critical')) { return false; } | |
| if (!Model.show_cat_spurious && row.categories.has('Spurious')) { return false; } | |
| return true; | |
| }; | |
| const rows = prestratifyExpectationLines(Model.file_lines, rowPredicate); | |
| const stratified = d3.stratify() | |
| .id((d) => d.id) | |
| .parentId((d) => d.parentId) | |
| (rows); | |
| Model.counted_data = d3.hierarchy(stratified).count(); | |
| Model.treemap_id_to_node = {}; | |
| Model.counted_data.descendants().forEach((d) => {Model.treemap_id_to_node[d.data.id] = d;}); | |
| // Count occurrences of tags in filtered data. | |
| const tag_count = {}; | |
| for (d of Model.counted_data.leaves()) { | |
| for (tag of (d.data.data.tags ?? [])) { | |
| tag_count[tag] = 1 + (tag_count[tag]??0); | |
| } | |
| } | |
| // Create parent-child rows for tag-group relations. | |
| const group_rows = []; | |
| // Link each tag to its tag group. | |
| const tag_group_dict = parseTagGroups(Model.file_lines); | |
| const live_groups = new Set(); | |
| Model.live_tags = new Set(); | |
| for (const [group, tags] of Object.entries(tag_group_dict)) { | |
| for (const tag of tags) { | |
| const entry = {id: tag, parentId: group, count: (tag_count[tag] ?? 0)}; | |
| if (entry.count > 0) { | |
| group_rows.push(entry); | |
| live_groups.add(group); | |
| Model.live_tags.add(tag); | |
| } | |
| } | |
| } | |
| // Ensure there is a synthetic root. | |
| const kSyntheticTagRoot = 'All tags'; | |
| group_rows.push({ id: kSyntheticTagRoot, count: 0 }); | |
| // Link each tag groups to the synthetic root. | |
| for (const group of live_groups.values()) { | |
| group_rows.push({ id: group, parentId: kSyntheticTagRoot, count: 0 }); | |
| } | |
| const stratified_tags = d3.stratify() | |
| .id(d => d.id) | |
| .parentId(d => d.parentId) | |
| (group_rows); | |
| const root = d3.hierarchy(stratified_tags); | |
| Model.counted_tags = root.sum(d => d.data.count).sort((a,b) => b.value - a.value); | |
| updateGraphs(); | |
| // Display the rows, but only if they came from original text. | |
| // That is, don't display the synthetically created interior nodes. | |
| Model.numbered_lines_displayer(rows.filter((row) => row.hasOwnProperty('lineNumber'))); | |
| } | |
| async function updateGraphs() { | |
| if (Model.counted_data !== undefined) { | |
| makeTreemap(Model.counted_data); | |
| } | |
| if (Model.counted_tags !== undefined) { | |
| makePackmap(Model.counted_tags); | |
| } | |
| } | |
| // Controller | |
| async function handleInputFile(f) { | |
| Model.file = f; | |
| updateFromFile(); | |
| } | |
| async function handleSetLevel(value) { | |
| Model.max_levels = value; | |
| setTreemapDetailLevel(); | |
| } | |
| async function handleSetPathPrefix(path) { | |
| if (path === Model.path_prefix) { | |
| // The user clicked on the treemap node, but it's already | |
| // the whole treemap. Increment level of detail if the | |
| // node has children. | |
| const node = (Model.treemap_id_to_node ?? {})[path] ?? {}; | |
| if ((node.children ?? []).length > 0) { | |
| Model.detail_incrementer(); | |
| } | |
| } else { | |
| Model.path_prefix = path; | |
| Model.node_name_displayer(Model.path_prefix, 0); | |
| updateFromLines(); | |
| } | |
| } | |
| async function handleSetPathPrefixOut() { | |
| Model.path_prefix = parentOf(Model.path_prefix); // parentOf is in the other module. | |
| Model.node_name_displayer(Model.path_prefix, 0); | |
| updateFromLines(); | |
| } | |
| async function handleSetPathSubstring(path) { | |
| if (path !== Model.path_substring) { | |
| console.log(`setting path substring ${path}`); | |
| Model.path_substring = path; | |
| updateFromLines(); | |
| } | |
| } | |
| async function handleSetFocusTag(tag) { | |
| if (Model.live_tags.has(tag) && Model.focus_tags.indexOf(tag) < 0) { | |
| Model.focus_tags.push(tag); | |
| Model.tag_name_displayer(Model.focus_tags.join(' '), 0); | |
| updateFromLines(); | |
| } | |
| } | |
| async function handleSetFocusTagOut() { | |
| if (Model.focus_tags.length > 0) { | |
| Model.focus_tags.pop(); | |
| Model.tag_name_displayer(Model.focus_tags.join(' '), 0); | |
| updateFromLines(); | |
| } | |
| } | |
| async function handleSetShow(name, checked) { | |
| Model[`show_${name}`] = checked; | |
| updateFromLines(); | |
| } | |
| function SetNodeNameDisplayer(fn) { | |
| Model.node_name_displayer = fn; | |
| } | |
| function SetTagNameDisplayer(fn) { | |
| Model.tag_name_displayer = fn; | |
| } | |
| function SetNumberedLinesDisplayer(fn) { | |
| Model.numbered_lines_displayer = fn; | |
| } | |
| function SetDetailIncrementer(fn) { | |
| Model.detail_incrementer = fn; | |
| } |