blob: 816ffb11a1b3309b8b55e0d1fb630accea37c21c [file] [log] [blame]
// 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,
// 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; }
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;
}