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 #!/usr/bin/python
import gi
gi.require_version("Gtk", "4.0")
import bisect
import pathlib import pathlib
import reporter import reporter
import re
import sys import sys
import xdg.BaseDirectory
import xml.etree.ElementTree
from gi.repository import Gtk 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): class Window(Gtk.Window):
def __init__(self): def __init__(self):
Gtk.Window.__init__(self, title="Xfstests Results") Gtk.Window.__init__(self, title="Xfstests Results")
self.chooser = reporter.TestChooser() self.chooser = reporter.TestChooser()
self.testview = reporter.TestView() self.testview = reporter.TestViewer()
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.child = Gtk.Box.new(Gtk.Orientation.HORIZONTAL, 5) self.child = Gtk.Box.new(Gtk.Orientation.HORIZONTAL, 5)
self.child.append(self.left) self.child.append(self.chooser)
self.child.append(self.right) self.child.append(self.testview)
self.lskip.set_xalign(0) self.set_default_size(1300, 800)
self.lpass.set_xalign(0)
self.set_default_size(1100, 750)
self.set_child(self.child) self.set_child(self.child)
self.chooser.connect("test-selected", self.test_changed) self.chooser.connect("test-selected", self.test_changed)
self.passing.connect("notify::active", self.show_passing) if len(sys.argv) > 1:
self.skipped.connect("notify::active", self.show_skipped) self.testview.set_test_result(reporter.testchooser.Path(pathlib.Path(sys.argv[1])))
self.search.connect("search-changed", self.search_changed)
def test_changed(self, window, file): def test_changed(self, window, file):
self.testview.set_test_result(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): class Application(Gtk.Application):

View File

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

View File

@ -1,11 +1,19 @@
#!/usr/bin/python #!/usr/bin/python
from gi.repository import GObject from gi.repository import GObject
from gi.repository import Gtk
import html
import xml.etree.ElementTree import xml.etree.ElementTree
class PassingTest: class PassingTest:
def __init__(self, elm): def __init__(self, elm):
self.time = elm.attrib["time"] 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: class SkippedTest:
def __init__(self, elm): def __init__(self, elm):
@ -16,11 +24,13 @@ class SkippedTest:
class FailedTest: class FailedTest:
def __init__(self, elm): def __init__(self, elm):
self.time = elm.attrib["time"] self.time = elm.attrib["time"]
self.system_out = Gtk.TextBuffer()
self.system_err = Gtk.TextBuffer()
for e in elm: for e in elm:
match e.tag: match e.tag:
case "failure": self.message = e.attrib["message"] case "failure": self.message = e.attrib["message"]
case "system-out": self.system_out = e.text case "system-out": self.system_out.set_text(html.unescape(e.text))
case "system-err": self.system_err = e.text case "system-err": self.system_err.set_text(html.unescape(e.text))
class TestCase(GObject.GObject): class TestCase(GObject.GObject):
@ -30,7 +40,7 @@ class TestCase(GObject.GObject):
self.versions = dict() self.versions = dict()
def __getitem__(self, key): return self.versions.get(key, None) 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: 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()