File Coverage

blib/lib/Spreadsheet/Compare/Reporter/HTML.pm
Criterion Covered Total %
statement 35 116 30.1
branch 2 16 12.5
condition 0 10 0.0
subroutine 5 13 38.4
pod 7 8 87.5
total 49 163 30.0


line stmt bran cond sub pod time code
1             package Spreadsheet::Compare::Reporter::HTML;
2              
3             # TODO: (issue) better JAvascript event handler
4             # TODO: (issue) optional read templates/css from files
5              
6 2     2   3063 use Mojo::Base 'Spreadsheet::Compare::Reporter', -signatures;
  2         48  
  2         63  
7 2     2   393 use Spreadsheet::Compare::Common;
  2         5  
  2         10  
8 2     2   17 use Mojo::Template;
  2         4  
  2         28  
9              
10             has sheet_order => sub { [qw(Differences Missing Additional Duplicates All)] };
11              
12             my %format_defaults = (
13             fmt_head => 'font-weight: bold; text-align: left;',
14             fmt_headerr => 'background-color: yellow',
15             fmt_default => 'color: black;',
16             fmt_left_odd => 'color: blue;',
17             fmt_right_odd => 'color: red;',
18             fmt_diff_odd => 'color: green;',
19             fmt_left_even => 'color: blue; background-color: silver;',
20             fmt_right_even => 'color: red; background-color: silver;',
21             fmt_diff_even => 'color: green; background-color: silver;',
22             fmt_left_high => 'background-color: yellow;',
23             fmt_right_high => 'background-color: yellow;',
24             fmt_diff_high => 'background-color: yellow;',
25             fmt_left_low => 'background-color: lime;',
26             fmt_right_low => 'background-color: lime;',
27             fmt_diff_low => 'background-color: lime;',
28             );
29              
30             has $_, $format_defaults{$_} for keys %format_defaults;
31              
32             has report_filename => sub {
33             ( my $title = $_[0]->test_title ) =~ s/[^\w-]/_/g;
34             return "$title.html";
35             };
36              
37              
38 4     4 0 9 sub init ($self) {
  4         6  
  4         7  
39 4         12 my $css_txt = $self->css;
40 4         39 $css_txt .= ".$_ { " . $self->$_() . "}\n\n" for keys %format_defaults;
41 4         280 $self->css($css_txt);
42 4         44 return $self;
43             }
44              
45              
46 0     0 1 0 sub add_stream ( $self, $name ) {
  0         0  
  0         0  
  0         0  
47 0         0 $self->{ws}{$name} = {};
48 0         0 return $self;
49             }
50              
51              
52 0     0   0 sub _get_fmt ( $self, $name, $side ) {
  0         0  
  0         0  
  0         0  
  0         0  
53 0 0 0     0 $self->{$name}{odd} ^= 1
      0        
54             if $name eq 'Additional' and $side eq 'right'
55             or $side eq 'left';
56              
57 0 0       0 my $oe = $self->{$name}{odd} ? 'odd' : 'even';
58              
59 0         0 return ( "fmt_${side}_$oe", "fmt_${side}_high", "fmt_${side}_low" );
60             }
61              
62              
63 0     0 1 0 sub write_row ( $self, $name, $robj ) {
  0         0  
  0         0  
  0         0  
  0         0  
64              
65 0         0 my($fnorm) = $self->_get_fmt( $name, $robj->side );
66              
67 0   0     0 my $rref = $self->{ws}{$name}{rows} //= [];
68 0         0 push @$rref, {
69             data => $self->output_record($robj),
70             row_fmt => $fnorm,
71             };
72              
73 0         0 return $self;
74             }
75              
76              
77 0     0 1 0 sub write_fmt_row ( $self, $name, $robj ) {
  0         0  
  0         0  
  0         0  
  0         0  
78 0         0 my $data = $self->output_record($robj);
79 0         0 my $mask = $self->strip_ignore( $robj->limit_mask );
80 0         0 my $off = $self->head_offset;
81              
82 0         0 my( $fnorm, $fhigh, $flow ) = $self->_get_fmt( $name, $robj->side );
83              
84 0         0 my @fmts = map { $fnorm } 1 .. $off;
  0         0  
85 0 0       0 push @fmts, map { $_ ? ( $_ == 1 ? $fhigh : $flow ) : '' } @$mask;
  0 0       0  
86              
87 0   0     0 my $rref = $self->{ws}{$name}{rows} //= [];
88 0         0 push @$rref, {
89             data => $data,
90             row_fmt => $fnorm,
91             cell_fmt => \@fmts,
92             };
93              
94 0         0 return $self;
95             }
96              
97              
98 0     0 1 0 sub write_header ( $self, $name ) {
  0         0  
  0         0  
  0         0  
99             $self->{ws}{$name}{header} = {
100 0         0 data => $self->header,
101             row_fmt => "fmt_head",
102             };
103 0         0 return $self;
104             }
105              
106              
107 0     0 1 0 sub mark_header ( $self, $name, $mask ) {
  0         0  
  0         0  
  0         0  
  0         0  
108 0         0 my $smask = $self->strip_ignore($mask);
109 0         0 my $off = $self->head_offset;
110 0         0 my @fmts = map { '' } 1 .. $off;
  0         0  
111 0 0       0 push @fmts, map { $_ ? 'fmt_headerr' : '' } @$smask;
  0         0  
112 0         0 $self->{ws}{$name}{header}{cell_fmt} = \@fmts;
113 0         0 return $self;
114             }
115              
116              
117 2     2 1 5 sub write_summary ( $self, $summary, $filename ) {
  2         5  
  2         4  
  2         8  
  2         11  
118              
119 2 50       21 $filename .= '.html' unless $filename =~ /\.html$/i;
120 2         13 my $pout = $self->report_fullname($filename);
121 2         252 ( $self->{title} = $pout->basename ) =~ s/\.[^\.]*$//;
122 2         74 INFO "saving HTML summary to '$pout'";
123              
124 2         31 $self->{summary} = $summary;
125 2         17 $self->{header} = $self->stat_head;
126              
127 2         24 my $mt = Mojo::Template->new( vars => 1 );
128 2         24 my $tmpl = $self->summary_template;
129              
130 2         36 TRACE "Template:\n$tmpl";
131 2         38 my $res = $mt->render( $tmpl, $self );
132 2 50       22725 LOGDIE "Failed to render HTML template: $res" if ref($res) eq 'Mojo::Exception';
133              
134 2         26 $pout->parent->mkpath;
135 2         566 $pout->spew($res);
136              
137 2         1579 return $self;
138             }
139              
140              
141 0     0     sub _fill_html_template ($self) {
  0            
  0            
142 0           my @sheets = grep { $self->{ws}{$_} } @{ $self->sheet_order() };
  0            
  0            
143              
144 0           $self->{sheet_names} = \@sheets;
145 0           $self->{active} = 'Differences';
146              
147 0           my $mt = Mojo::Template->new( vars => 1 );
148 0           my $tmpl = $self->sheet_template;
149              
150 0           TRACE "Template:\n$tmpl";
151 0           my $res = $mt->render( $tmpl, $self );
152 0 0         LOGDIE "Failed to render HTML template: $res" if ref($res) eq 'Mojo::Exception';
153 0           return $res;
154             }
155              
156              
157 0     0 1   sub save_and_close ($self) {
  0            
  0            
158              
159 0           my $pout = $self->report_fullname;
160 0           INFO "saving HTML report to '$pout'";
161 0           ( $self->{title} = $pout->basename ) =~ s/\.[^\.]*$//;
162              
163 0           $pout->parent->mkpath;
164 0           $pout->spew( $self->_fill_html_template );
165              
166 0           return $self;
167             }
168              
169              
170             has sheet_template => <<'MOJO';
171             <!DOCTYPE html>
172             <html lang="en">
173             <head>
174             <meta charset="utf-8">
175             <meta http-equiv="X-UA-Compatible" content="IE=edge">
176             <meta name="viewport" content="width=device-width, initial-scale=1">
177              
178             <title><%= $title %></title>
179              
180             <style><%= $css %></style>
181              
182             <script>
183              
184             let in_scroll_timer;
185             let batch_size = 20;
186              
187             document.addEventListener('DOMContentLoaded', function () {
188             let active_top = document.getElementById('top-' + '<%= $active %>');
189             window.addEventListener('message', function (ev) {
190             if (ev.data === 'toggle') timed_scroll();
191             });
192             window.addEventListener('resize', timed_scroll);
193             window.addEventListener('scroll', timed_scroll);
194             toggle_tab(active_top);
195             });
196              
197             function timed_scroll () {
198             if ( in_scroll_timer ) return;
199             in_scroll_timer = setTimeout(function () {
200             in_scroll_timer = undefined;
201             load_rows();
202             }, 250);
203             }
204              
205             function toggle_tab (li) {
206             for (let el of document.querySelectorAll(".topbar-item") ) {
207             li === el ? el.classList.add('active') : el.classList.remove('active');
208             }
209             for (let el of document.querySelectorAll(".sheet") ) {
210             li.id === 'top-' + el.id ? el.classList.add('active') : el.classList.remove('active');
211             }
212             timed_scroll();
213             }
214              
215             function is_visible (el) {
216             let rect = el.getBoundingClientRect();
217             return ( rect.top >= 0 && rect.top <= window.innerHeight );
218             }
219              
220             function load_rows () {
221             let sheet = document.querySelector(".sheet.active");
222             let row = document.querySelector("#" + sheet.id + " .lastrow");
223             if (!row) return;
224             if (!is_visible(row)) return;
225             let rcount = 0;
226             let rsize = row.clientHeight ? window.innerHeight / row.clientHeight : batch_size;
227             do {
228             let next = row.nextElementSibling;
229             if (!next) break;
230             rcount++;
231             row.classList.remove("lastrow");
232             row = next;
233             row.classList.add("visible");
234             } while ( rcount <= rsize );
235             row.classList.add("lastrow");
236             }
237              
238             </script>
239              
240             </head>
241              
242             <body class="single-sheet" id="sheet-body">
243              
244             <div class="topbar" id="div-topbar">
245             <ul class="test">
246             <% for my $sheet ( @$sheet_names ) { =%>
247             <li class="topbar-item" id="top-<%= $sheet %>" onclick="toggle_tab(this)"><a href="#"><%= $sheet %></a></li>
248             <% } =%>
249             </ul>
250             </div> <!-- topbar -->
251              
252             <% for my $sheet ( @$sheet_names ) { =%>
253             <div class="sheet" id="<%= $sheet %>">
254             <table>
255             <thead>
256             <% my $hdr = $ws->{$sheet}{header}; =%>
257             <% my @htxt = $hdr->{data}->@*; =%>
258             <% my $trclass = $hdr->{row_fmt} // ''; =%>
259              
260             <tr class="header-row <%= $trclass %>">
261             <% for my $i ( 0 .. $#htxt ) { =%>
262             <% my $thclass = $hdr->{cell_fmt} ? $hdr->{cell_fmt}[$i] // '': ''; =%>
263             <th class="<%= $thclass %>"><%= $htxt[$i] // '' %></th>
264             <% } =%>
265             </tr>
266             </thead>
267             <tbody>
268             <% my $rows = $ws->{$sheet}{rows}; =%>
269             <% my $rowcount = 0; =%>
270             <% for my $r ( @$rows ) { =%>
271             <% my $trclass = $r->{row_fmt} // ''; =%>
272             <% $trclass .= ' visible lastrow' unless $rowcount++; =%>
273             <% my @rtxt = $r->{data}->@*; =%>
274             <tr class="<%= $trclass %>">
275             <% for my $i ( 0 .. $#rtxt ) { =%>
276             <% my $tdclass = $r->{cell_fmt} ? $r->{cell_fmt}[$i] // '': ''; =%>
277             <td class="<%= $tdclass %>"><%= $rtxt[$i] // ''%></td>
278             <% } =%>
279             </tr>
280             <% } =%>
281             </tbody>
282             </table>
283             </div> <!-- sheet -->
284             <% } =%>
285              
286             </body>
287             </html>
288             MOJO
289              
290              
291             has css => <<'CSS';
292             html {
293             margin: 0;
294             padding: 0;
295             height: 100%;
296             font-family: sans-serif;
297             font-size: medium;
298             }
299              
300             body {
301             margin: 0;
302             padding: 0;
303             /* overflow: hidden; */
304             display: flex;
305             height: 100%;
306             line-height: inherit; background-color: gray;
307             }
308              
309             body.single-sheet {
310             flex-direction: column;
311             }
312              
313             body.summary {
314             flex-direction: row;
315             }
316              
317             .topbar ul {
318             list-style: none;
319             width: 100%;
320             margin-block-start: 0.5rem;
321             margin-block-end: unset;
322             padding-inline-start: .5rem;
323             }
324              
325             .topbar li {
326             padding: .5rem .5rem .5rem;
327             display: inline-block;
328             width: 5rem;
329             text-align: center;
330             border-style: solid;
331             border-color: gray;
332             border-width: 1px 1px 0 1px;
333             border-radius: .5rem .5rem 0 0;
334             background-color: silver;
335             }
336              
337             .topbar li.active {
338             background-color: white;
339             }
340              
341             .topbar li:hover {
342             text-decoration: none;
343             background-color: white;
344             }
345              
346             .sidebar {
347             display: flex;
348             order: -1;
349             flex: 0 0 20rem;
350             height: 100%;
351             overflow: auto;
352             background-color: #262626;
353             }
354              
355             .sidebar ul {
356             list-style: none;
357             width: 100%;
358             margin-block-start: .3rem;
359             margin-block-end: unset;
360             padding-inline-start: .3rem;
361             }
362              
363             .sidebar li {
364             padding-left: .3rem;
365             padding-top: .5rem;
366             padding-bottom: .5rem;
367             display: block;
368             text-align: left;
369             }
370              
371             .sidebar li.active {
372             background-color: #404040;
373             }
374              
375             .sidebar li:hover {
376             text-decoration: none;
377             background-color: #404040;
378             }
379              
380             li.sidebar-test-item {
381             padding-left: 1rem;
382             }
383              
384             .suite-table, .test-iframe {
385             flex: 1;
386             overflow: auto;
387             }
388              
389             a {
390             color: black;
391             text-decoration: none;
392             }
393              
394             .sidebar li a {
395             color: #bfbfbf;
396             }
397              
398             .sheet {
399             display: block;
400             visibility: hidden;
401             opacity: 0;
402             transition: visibility 0s, opacity 0.1s linear;
403             position: absolute;
404             }
405              
406             .suite-table, .test-iframe {
407             display: none;
408             }
409              
410             .sheet.active {
411             opacity: 1;
412             visibility: visible;
413             position: static;
414             }
415              
416             .suite-table.active, .test-iframe.active {
417             display: flex;
418             }
419              
420             .suite-table th.left {
421             text-align: left;
422             }
423              
424             table {
425             background-color: white;
426             }
427              
428             table, th, td {
429             border-collapse:collapse;
430             }
431              
432              
433             tr {
434             display: none;
435             }
436              
437             tr.visible, .suite-row, .header-row, .suite-header {
438             display: table-row;
439             }
440              
441             th, td {
442             border:thin solid black;
443             padding:5px;
444             white-space: nowrap;
445             text-align: left;
446             }
447              
448             th {
449             position: sticky;
450             top: 0;
451             background-color: white;
452             }
453             CSS
454              
455              
456             has summary_template => <<'MOJO';
457             <!DOCTYPE html>
458             <html lang="en">
459             <head>
460             <meta charset="utf-8">
461             <meta http-equiv="X-UA-Compatible" content="IE=edge">
462             <meta name="viewport" content="width=device-width, initial-scale=1">
463              
464             <title><%= $title %></title>
465              
466             <style><%= $css %></style>
467              
468             <script>
469              
470             function toggle_test (ev, li) {
471             ev.stopPropagation();
472             for (let el of document.querySelectorAll(".sidebar-item") ) {
473             el.classList.remove('active');
474             }
475             for (let el of document.querySelectorAll(".suite-table, .test-iframe") ) {
476             if (li.id === 'side-' + el.id ) {
477             el.classList.add('active');
478             if ( el.classList.contains("test-iframe") ) {
479             el.contentWindow.postMessage('toggle', '*');
480             }
481             } else {
482             el.classList.remove('active');
483             }
484             }
485             li.classList.add('active');
486             }
487              
488             </script>
489              
490             </head>
491              
492             <body class="summary">
493              
494             <div class="sidebar" id="div-sidebar">
495             <ul class="suite">
496             <% my $i = 0; =%>
497             <% for my $suite ( sort keys %$summary ) { =%>
498             <% my $active = $i++ ? '' : 'active'; =%>
499             <li class="sidebar-item sidebar-suite-item <%= $active %>" id="side-<%= $suite %>" onclick="toggle_test(event, this)"><a href="#"><%= $suite %></a></li>
500             <% for my $test ( $summary->{$suite}->@* ) { =%>
501             <li class="sidebar-item sidebar-test-item" id="side-<%= $test->{full} %>" onclick="toggle_test(event, this)"><a href="#"><%= $test->{title} %></a></li>
502             <% } =%>
503             <% } =%>
504             </ul>
505             </div> <!-- sidebar -->
506              
507             <% $i = 0; =%>
508             <% for my $suite ( sort keys %$summary ) { =%>
509             <% my $active = $i++ ? '' : 'active'; =%>
510             <div class="suite-table <%= $active %>" id="<%= $suite %>">
511             <table>
512             <thead>
513             <tr class="suite-header">
514             <% for my $htxt ( @$header ) { =%>
515             <% next if $htxt eq 'link'; =%>
516             <th><%= $htxt =%></th>
517             <% } =%>
518             </tr>
519             </thead>
520             <tbody>
521             <% for my $test ( $summary->{$suite}->@* ) { =%>
522             <% my $result = $test->{result}; =%>
523             <% $result->{title} = $test->{title}; =%>
524             <% my @hdr = grep { $_ ne 'link' } @$header; %>
525             <% my $data = [ @$result{@hdr} ]; =%>
526             <tr class="suite-row">
527             <% for my $dtxt ( @$data ) { =%>
528             <% $dtxt //= ''; =%>
529             <% my $class = $dtxt =~ /^[a-z]/i ? 'left' : 'right'; =%>
530             <th class="<%= $class %>"><%= $dtxt %></th>
531             <% } =%>
532             </tr>
533             <% } =%>
534             </tbody>
535             </table>
536             </div>
537              
538             <% for my $test ( $summary->{$suite}->@* ) { =%>
539             <iframe class="test-iframe" id="<%= $test->{full} %>" src="<%= $test->{report} %>"></iframe>
540             <% } =%>
541             <% } =%>
542              
543             </body>
544             </html>
545             MOJO
546              
547              
548             1;
549              
550             =head1 NAME
551              
552             Spreadsheet::Compare::Reporter::HMTL - HTML Report Adapter for Spreadsheet::Compare
553              
554             =head1 DESCRIPTION
555              
556             Handles writing Spreadsheet::Compare reports in HTML format.
557              
558             =head1 ATTRIBUTES
559              
560             The format attributes have to be valid css styles
561              
562             The defaults for the attributes are:
563              
564             fmt_head => 'font-weight: bold; text-align: left;',
565             fmt_headerr => 'background-color: yellow',
566             fmt_default => 'color: black;',
567             fmt_left_odd => 'color: blue;',
568             fmt_right_odd => 'color: red;',
569             fmt_diff_odd => 'color: green;',
570             fmt_left_even => 'color: blue; background-color: silver;',
571             fmt_right_even => 'color: red; background-color: silver;',
572             fmt_diff_even => 'color: green; background-color: silver;',
573             fmt_left_high => 'background-color: yellow;',
574             fmt_right_high => 'background-color: yellow;',
575             fmt_diff_high => 'background-color: yellow;',
576             fmt_left_low => 'background-color: lime;',
577             fmt_right_low => 'background-color: lime;',
578             fmt_diff_low => 'background-color: lime;',
579              
580             =head2 css
581              
582             A scalar containing the style sheet used for all HTML output.
583              
584             =head2 sheet_order
585              
586             A reference to an array with stream names setting the order of the output worksheet tabs
587             default is: [qw(Differences Missing Additional Duplicates All)]
588              
589             =head2 sheet_template
590              
591             A scalar containing a Mojo::Template for constructing an output worksheet.
592              
593             =head2 summary_template
594              
595             A scalar containing a Mojo::Template for constructing the summary page.
596              
597             =head1 METHODS
598              
599             see L<Spreadsheet::Compare::Reporter>
600              
601             =cut