diff --git a/report-xfstests.py b/report-xfstests.py
index bc93d69..754db5e 100755
--- a/report-xfstests.py
+++ b/report-xfstests.py
@@ -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(" Passed ")
- case "Skipped": label.set_markup(" Skipped ")
- case "Failed": label.set_markup(" Failed ")
- 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):
diff --git a/reporter/__init__.py b/reporter/__init__.py
index 4b183e9..748d633 100644
--- a/reporter/__init__.py
+++ b/reporter/__init__.py
@@ -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)
diff --git a/reporter/testresults.py b/reporter/testresults.py
index c0a11f7..c23e5ec 100644
--- a/reporter/testresults.py
+++ b/reporter/testresults.py
@@ -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:
diff --git a/reporter/testviewer.py b/reporter/testviewer.py
new file mode 100644
index 0000000..f35a00e
--- /dev/null
+++ b/reporter/testviewer.py
@@ -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" Test {status} {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()