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()