report-xfstests.py: Move the test viewer into a new file

Also:
- Add some custom CSS to the widgets
- Add tooltips to each test result cell
- Add a popover with failing test output
- Improve filtering in place of the view switches

Signed-off-by: Anna Schumaker <Anna.Schumaker@Netapp.com>
This commit is contained in:
Anna Schumaker 2022-02-03 15:35:32 -05:00
parent 6f3c37e568
commit 55f3239edc
4 changed files with 241 additions and 243 deletions

View File

@ -1,258 +1,27 @@
#!/usr/bin/python
import gi
gi.require_version("Gtk", "4.0")
import bisect
import pathlib
import reporter
import re
import sys
import xdg.BaseDirectory
import xml.etree.ElementTree
from gi.repository import Gtk
from gi.repository import GObject
from gi.repository import Gio
XFSTESTS = pathlib.Path(xdg.BaseDirectory.xdg_data_home) / "xfstests" / "date"
class TestCase(GObject.GObject):
def __init__(self, name):
GObject.GObject.__init__(self)
self.name = name
self.tests = dict()
def __getitem__(self, key):
if key == "Test Name":
return self.name
match len(self.tests[key]) if key in self.tests else None:
case None: return "Missing"
case 0: return "Passed"
case 1: return "Skipped"
case _: return "Failed"
def __setitem__(self, key, val):
self.tests[key] = val
def __str__(self):
return self.name
def __lt__(self, rhs):
return self.name < rhs.name
def passed(self):
return False not in [ len(elm) == 0 for elm in self.tests.values() ]
def failed(self):
return 3 in [ len(elm) for elm in self.tests.values() ]
def skipped(self):
return False not in [ len(elm) == 1 for elm in self.tests.values() ]
class TestResultsModel(GObject.GObject, Gio.ListModel):
def __init__(self):
GObject.GObject.__init__(self)
self.tests = [ ]
self.columns = [ "Test Name" ]
def do_get_item_type(self): return GObject.TYPE_PYOBJECT
def do_get_n_items(self): return len(self.tests)
def do_get_item(self, n): return self.tests[n]
def get_n_columns(self): return len(self.columns)
def get_column_name(self, n): return self.columns[n]
def find_testcase(self, name):
i = bisect.bisect(self.tests, name, key=str)
if i > 0 and self.tests[i-1].name == name:
return self.tests[i-1]
self.tests.insert(i, TestCase(name))
return self.tests[i]
def set_tests(self, path):
rm = len(self.tests)
self.columns = [ "Test Name" ]
self.tests.clear()
for file in sorted(path.iterdir() if path else []):
self.columns.append(file.stem)
root = xml.etree.ElementTree.parse(file).getroot()
for elm in root:
if elm.tag == "testcase":
testcase = self.find_testcase(elm.attrib["name"])
testcase[file.stem] = elm
self.emit("items-changed", 0, rm, len(self.tests))
class TestResultsFilter(Gtk.Filter):
def __init__(self):
Gtk.Filter.__init__(self)
self.show_passing = True
self.show_skipped = True
self.regex = re.compile("")
def set_regex(self, text):
change = Gtk.FilterChange.DIFFERENT
if text in self.regex.pattern:
change = Gtk.FilterChange.LESS_STRICT
elif self.regex.pattern in text:
change = Gtk.FilterChange.MORE_STRICT
self.regex = re.compile(text, re.I)
self.changed(change)
def set_show_passing(self, val):
self.show_passing = val
self.set_show_changed(self.show_passing)
def set_show_skipped(self, val):
self.show_skipped = val
self.set_show_changed(self.show_skipped)
def set_show_changed(self, newval):
match newval:
case True: self.changed(Gtk.FilterChange.LESS_STRICT)
case False: self.changed(Gtk.FilterChange.MORE_STRICT)
def do_match(self, item):
if not self.show_passing and item.passed():
return False
if not self.show_skipped and item.skipped():
return False
if not self.show_passing and not self.show_skipped and not item.failed():
return False
return self.regex.search(item.name) != None
class FilterPopover(Gtk.Popover):
def __init__(self, parent):
Gtk.Popover.__init__(self)
self.set_child(Gtk.Label())
self.set_autohide(False)
self.set_parent(parent)
def popup(self, text):
self.get_child().set_text(text)
super().popup()
class TestResultsFactory(Gtk.SignalListItemFactory):
def __init__(self, column):
Gtk.SignalListItemFactory.__init__(self)
self.column = column
self.connect("setup", self.setup)
self.connect("bind", self.bind)
self.connect("unbind", self.unbind)
self.connect("teardown", self.teardown)
def setup(self, factory, listitem):
listitem.set_child(Gtk.Label(xalign=0))
def bind(self, factory, listitem):
label = listitem.get_child()
text = listitem.get_item()[self.column]
match text:
case "Passed": label.set_markup("<span background='green'> Passed </span>")
case "Skipped": label.set_markup("<span background='orange'> Skipped </span>")
case "Failed": label.set_markup("<span background='red'> Failed </span>")
case _: label.set_text(" " + text)
def unbind(self, factory, listitem):
listitem.get_child().set_text("")
def teardown(self, factory, listitem):
listitem.set_child(None)
class TestWindow(Gtk.ScrolledWindow):
def __init__(self):
Gtk.ScrolledWindow.__init__(self, vexpand=True, hexpand=True,
hscrollbar_policy=Gtk.PolicyType.NEVER)
self.results = TestResultsModel()
self.filter = Gtk.FilterListModel.new(self.results, TestResultsFilter())
self.selection = Gtk.NoSelection.new(self.filter)
self.view = Gtk.ColumnView.new(self.selection)
self.set_child(self.view)
if len(sys.argv) > 1:
self.set_tests(pathlib.Path(sys.argv[1]))
def set_show_passing(self, show):
self.filter.get_filter().set_show_passing(show)
def set_show_skipped(self, show):
self.filter.get_filter().set_show_skipped(show)
def set_filter_regex(self, text):
self.filter.get_filter().set_regex(text)
def set_tests(self, path):
for column in [ c for c in self.view.get_columns() ]:
self.view.remove_column(column)
self.results.set_tests(path)
for n in range(self.results.get_n_columns()):
name = self.results.get_column_name(n)
col = Gtk.ColumnViewColumn.new(name, TestResultsFactory(name))
col.set_expand(n > 0)
self.view.append_column(col)
class Window(Gtk.Window):
def __init__(self):
Gtk.Window.__init__(self, title="Xfstests Results")
self.chooser = reporter.TestChooser()
self.testview = reporter.TestView()
self.passing = Gtk.Switch(halign=Gtk.Align.CENTER, active=True)
self.lpass = Gtk.Label.new("Show Passing Tests")
self.skipped = Gtk.Switch(halign=Gtk.Align.CENTER, active=True)
self.lskip = Gtk.Label.new("Show Skipped Tests")
self.search = Gtk.SearchEntry(placeholder_text="Type To Filter Tests")
self.popover = FilterPopover(parent=self.search)
self.xfstests = TestWindow()
self.left = Gtk.Grid(row_spacing=5)
self.left.attach(self.chooser, 0, 0, 2, 1)
self.left.attach(self.passing, 0, 1, 1, 1)
self.left.attach(self.lpass, 1, 1, 1, 1)
self.left.attach(self.skipped, 0, 2, 1, 1)
self.left.attach(self.lskip, 1, 2, 1, 1)
self.right = Gtk.Box.new(Gtk.Orientation.VERTICAL, 5)
self.right.append(self.testview)
self.right.append(self.search)
self.right.append(self.xfstests)
self.testview = reporter.TestViewer()
self.child = Gtk.Box.new(Gtk.Orientation.HORIZONTAL, 5)
self.child.append(self.left)
self.child.append(self.right)
self.child.append(self.chooser)
self.child.append(self.testview)
self.lskip.set_xalign(0)
self.lpass.set_xalign(0)
self.set_default_size(1100, 750)
self.set_default_size(1300, 800)
self.set_child(self.child)
self.chooser.connect("test-selected", self.test_changed)
self.passing.connect("notify::active", self.show_passing)
self.skipped.connect("notify::active", self.show_skipped)
self.search.connect("search-changed", self.search_changed)
if len(sys.argv) > 1:
self.testview.set_test_result(reporter.testchooser.Path(pathlib.Path(sys.argv[1])))
def test_changed(self, window, file):
self.testview.set_test_result(file)
self.xfstests.set_tests(file.path if file else None)
def show_passing(self, passing, param):
self.xfstests.set_show_passing(passing.get_active())
def show_skipped(self, skipped, param):
self.xfstests.set_show_skipped(skipped.get_active())
def search_changed(self, search):
try:
self.xfstests.set_filter_regex(search.get_text())
self.search.remove_css_class("warning")
self.popover.popdown()
except re.error as e:
self.search.add_css_class("warning")
self.popover.popup(str(e))
class Application(Gtk.Application):

View File

@ -3,14 +3,19 @@ from . import common
from . import testchooser
from . import testproperties
from . import testresults
from . import testviewer
from gi.repository import GObject
from gi.repository import Gtk
SizeGroup = Gtk.SizeGroup.new(Gtk.SizeGroupMode.VERTICAL)
class TestChooser(Gtk.Box):
def __init__(self):
Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL)
self.stack = testchooser.Stack()
self.append(Gtk.StackSwitcher(stack=self.stack))
self.switcher = Gtk.StackSwitcher(stack=self.stack)
SizeGroup.add_widget(self.switcher)
self.append(self.switcher)
self.append(self.stack)
self.stack.connect("test-selected", self.on_test_selected)
@ -21,15 +26,22 @@ class TestChooser(Gtk.Box):
def test_selected(self, path): pass
class TestView(Gtk.Box):
class TestViewer(Gtk.Box):
def __init__(self):
Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL)
self.stack = testproperties.Stack()
self.append(Gtk.StackSwitcher(stack=self.stack, halign=Gtk.Align.CENTER))
self.viewer = testviewer.Viewer()
self.switcher = Gtk.StackSwitcher(stack=self.stack, halign=Gtk.Align.CENTER)
self.append(self.switcher)
self.append(self.stack)
self.append(self.viewer)
self.append(testviewer.SearchEntry(self.viewer.get_filter()))
SizeGroup.add_widget(self.switcher)
def set_test_result(self, file):
self.stack.clear()
self.viewer.clear()
if file and file.get_is_test_result():
results = testresults.TestResults(file.path)
self.stack.show_properties(results)
self.viewer.show_results(results)

View File

@ -1,11 +1,19 @@
#!/usr/bin/python
from gi.repository import GObject
from gi.repository import Gtk
import html
import xml.etree.ElementTree
class PassingTest:
def __init__(self, elm):
self.time = elm.attrib["time"]
def timestr(self):
(m, s) = divmod(int(self.time), 60)
res = [ f"{s} second{'' if s == 1 else 's'}" ]
if m > 0: res.insert(0, f"{m} minute{'' if m == 1 else 's'}")
return ' and '.join(res)
class SkippedTest:
def __init__(self, elm):
@ -16,11 +24,13 @@ class SkippedTest:
class FailedTest:
def __init__(self, elm):
self.time = elm.attrib["time"]
self.system_out = Gtk.TextBuffer()
self.system_err = Gtk.TextBuffer()
for e in elm:
match e.tag:
case "failure": self.message = e.attrib["message"]
case "system-out": self.system_out = e.text
case "system-err": self.system_err = e.text
case "system-out": self.system_out.set_text(html.unescape(e.text))
case "system-err": self.system_err.set_text(html.unescape(e.text))
class TestCase(GObject.GObject):
@ -30,7 +40,7 @@ class TestCase(GObject.GObject):
self.versions = dict()
def __getitem__(self, key): return self.versions.get(key, None)
def __setitem__(self, key, value): self.versions[key] = None
def __setitem__(self, key, value): self.versions[key] = value
class TestResults:

207
reporter/testviewer.py Normal file
View File

@ -0,0 +1,207 @@
#!/usr/bin/python
import itertools
import re
from . import testresults
from gi.repository import GObject
from gi.repository import Gio
from gi.repository import Gtk
CSS = """
@define-color darkorange #cc7000;
@define-color darkred #8b0000;
label.passing-test {
background-color: green;
padding: 4px;
}
label.skipped-test {
background-color: darkorange;
padding: 4px;
}
menubutton.failed-test>button {
background: darkred;
border-color: darkred;
border-radius: 0px;
padding: 0px;
}
"""
def markup_tooltip(status, color, message):
return f"<span background='{color}'> Test {status} </span> {message}"
class Label(Gtk.Label):
def __init__(self, text):
Gtk.Label.__init__(self, xalign=0)
self.set_text(text)
class PassingTest(Gtk.Label):
def __init__(self, test):
Gtk.Label.__init__(self)
self.add_css_class("passing-test")
self.set_text(f"{test.time} seconds")
self.set_tooltip_markup(markup_tooltip("Passed", "green",
f"in {test.timestr()}"))
class SkippedTest(Gtk.Label):
def __init__(self, test):
Gtk.Label.__init__(self)
self.add_css_class("skipped-test")
self.set_text("Skipped")
self.set_tooltip_markup(markup_tooltip("Skipped", "#cc7000", test.message))
class TestOutput(Gtk.Box):
def __init__(self, stdout, stderr):
Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL)
self.add_css_class("popover")
self.stack = Gtk.Stack(vexpand=True, transition_type=Gtk.StackTransitionType.OVER_LEFT_RIGHT)
self.prepend(self.stack)
self.prepend(Gtk.StackSwitcher(stack=self.stack))
self.add_page("stdout", stdout)
self.add_page("stderr", stderr)
self.set_size_request(400, 500)
def add_page(self, name, buffer):
view = Gtk.TextView(monospace=True)
view.set_buffer(buffer)
self.stack.add_titled(Gtk.ScrolledWindow(child=view), name, name)
class FailedTest(Gtk.MenuButton):
def __init__(self, test):
Gtk.MenuButton.__init__(self, label="Failed")
self.set_tooltip_markup(markup_tooltip("Failed", "#8b0000", test.message))
self.set_popover(Gtk.Popover(child=TestOutput(test.system_out, test.system_err)))
self.add_css_class("failed-test")
class MissingTest(Gtk.Label):
def __init__(self, test):
Gtk.Label.__init__(self, sensitive=False)
self.set_name("missing-test")
self.set_text("Missing")
self.set_tooltip_text("Test Data Is Missing")
class Model(GObject.GObject, Gio.ListModel):
def __init__(self, results):
GObject.GObject.__init__(self)
self.results = results
self.columns = [ "Testcase" ] + self.results.versions
self.tests = sorted(results.tests.keys())
def get_columns(self): return self.columns
def do_get_item_type(self): return GObject.TYPE_PYOBJECT
def do_get_n_items(self): return len(self.tests)
def do_get_item(self, i): return self.results.tests[self.tests[i]]
class Filter(Gtk.Filter):
def __init__(self):
Gtk.Filter.__init__(self)
self.regex = re.compile("")
def set_regex(self, text):
change = Gtk.FilterChange.DIFFERENT
if text in self.regex.pattern:
change = Gtk.FilterChange.LESS_STRICT
elif self.regex.pattern in text:
change = Gtk.FilterChange.MORE_STRICT
self.regex = re.compile(text, re.I)
self.changed(change)
def do_match(self, testcase):
search = [ f"name={testcase.name}" ]
for key in sorted(testcase.versions.keys()):
keys = [ key ] + key.split("-")
match type(testcase[key]):
case testresults.PassingTest: res = [ "passing", "passed" ]
case testresults.SkippedTest: res = [ "skipped" ]
case testresults.FailedTest: res = [ "failed", "failing" ]
case _: res = [ "missing" ]
for k in keys:
for r in res:
search.append(f"{k}={r}")
return self.regex.search(" ".join(search)) != None
class Factory(Gtk.SignalListItemFactory):
def __init__(self, column):
Gtk.SignalListItemFactory.__init__(self)
self.column = column
self.connect("bind", self.on_bind)
self.connect("unbind", self.on_unbind)
def on_bind(self, factory, listitem):
testcase = listitem.get_item()
if self.column == "Testcase":
child = Label(testcase.name)
else:
result = testcase[self.column]
match type(result):
case testresults.PassingTest: child = PassingTest(result)
case testresults.SkippedTest: child = SkippedTest(result)
case testresults.FailedTest: child = FailedTest(result)
case _: child = child = MissingTest(result)
listitem.set_child(child)
def on_unbind(self, factory, listitem):
listitem.set_child(None)
class Viewer(Gtk.ScrolledWindow):
def __init__(self):
Gtk.ScrolledWindow.__init__(self, hscrollbar_policy=Gtk.PolicyType.NEVER,
hexpand=True, vexpand=True, margin_top=5)
self.provider = Gtk.CssProvider()
self.filter = Gtk.FilterListModel.new(filter=Filter())
self.selection = Gtk.NoSelection.new(self.filter)
self.columnview = Gtk.ColumnView.new(model=self.selection)
self.columnview.add_css_class("data-table")
self.provider.load_from_data(CSS.encode())
self.get_style_context().add_provider_for_display(self.get_display(), self.provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
self.set_child(self.columnview)
def get_filter(self):
return self.filter.get_filter()
def clear(self):
columns = self.columnview.get_columns()
columns = [ columns.get_item(i) for i in range(columns.get_n_items()) ]
for col in columns:
self.columnview.remove_column(col)
self.filter.set_model(None)
def show_results(self, results):
if results:
self.filter.set_model(Model(results))
for i, name in enumerate(self.filter.get_model().get_columns()):
column = Gtk.ColumnViewColumn.new(name, Factory(name))
column.set_expand(i > 0)
self.columnview.append_column(column)
class SearchEntry(Gtk.SearchEntry):
def __init__(self, filter):
Gtk.SearchEntry.__init__(self, placeholder_text="Type To Filter",
hexpand=True, margin_start=100, margin_end=100,
margin_top=5, margin_bottom=5)
self.popover = Gtk.Popover(child=Gtk.Label(), autohide=False)
self.popover.set_parent(self)
self.connect("search-changed", self.on_search_changed, filter)
def on_search_changed(self, search, filter):
try:
filter.set_regex(search.get_text())
search.remove_css_class("warning")
self.popover.popdown()
except re.error as e:
search.add_css_class("warning")
self.popover.get_child().set_text(str(e))
self.popover.popup()