Commit 578c1a32 authored by Jérome Perrin's avatar Jérome Perrin

Tests for graphs on support request frontpage

/reviewed-on nexedi/erp5!763
parents 81e17101 5c24cfdc
...@@ -112,7 +112,7 @@ ...@@ -112,7 +112,7 @@
<item> <item>
<key> <string>text_content</string> </key> <key> <string>text_content</string> </key>
<value> <string>CACHE MANIFEST\n <value> <string>CACHE MANIFEST\n
# v1.1.0\n # v1.1.1\n
CACHE:\n CACHE:\n
gadget_field_graph_echarts.html/echarts-all.js\n gadget_field_graph_echarts.html/echarts-all.js\n
gadget_field_graph_echarts.html/gadget_global.js\n gadget_field_graph_echarts.html/gadget_global.js\n
...@@ -279,8 +279,8 @@ NETWORK:\n ...@@ -279,8 +279,8 @@ NETWORK:\n
</tuple> </tuple>
<state> <state>
<tuple> <tuple>
<float>1506590312.39</float> <float>1538646068.83</float>
<string>UTC</string> <string>GMT+9</string>
</tuple> </tuple>
</state> </state>
</object> </object>
......
<!DOCTYPE html> <!DOCTYPE html>
<!--html style="height: 100%"-->
<html manifest="gadget_field_graph_echarts.appcache" style="height: 300px"> <html manifest="gadget_field_graph_echarts.appcache" style="height: 300px">
<head> <head>
<meta http-equiv="Content-type" content="text/html; charset=utf-8" /> <meta http-equiv="Content-type" content="text/html; charset=utf-8" />
...@@ -23,7 +21,7 @@ ...@@ -23,7 +21,7 @@
</head> </head>
<body style="height: 100%; margin: 0"> <body style="height: 100%; margin: 0">
<div class="graph-content" style="height: 95%; width: 95%"> <div class="graph-content" style="height: 95%; width: 95%" disabled>
</div> </div>
</body> </body>
</html> </html>
\ No newline at end of file
...@@ -228,7 +228,7 @@ ...@@ -228,7 +228,7 @@
</item> </item>
<item> <item>
<key> <string>actor</string> </key> <key> <string>actor</string> </key>
<value> <string>zope</string> </value> <value> <string>ERP5TypeTestCase</string> </value>
</item> </item>
<item> <item>
<key> <string>comment</string> </key> <key> <string>comment</string> </key>
...@@ -242,7 +242,7 @@ ...@@ -242,7 +242,7 @@
</item> </item>
<item> <item>
<key> <string>serial</string> </key> <key> <string>serial</string> </key>
<value> <string>962.25420.32482.53094</string> </value> <value> <string>970.25483.20924.1058</string> </value>
</item> </item>
<item> <item>
<key> <string>state</string> </key> <key> <string>state</string> </key>
...@@ -260,8 +260,8 @@ ...@@ -260,8 +260,8 @@
</tuple> </tuple>
<state> <state>
<tuple> <tuple>
<float>1506590298.97</float> <float>1538645729.6</float>
<string>UTC</string> <string>GMT+9</string>
</tuple> </tuple>
</state> </state>
</object> </object>
......
...@@ -36,7 +36,7 @@ ...@@ -36,7 +36,7 @@
if (title === undefined) { if (title === undefined) {
throw new Error("No title provided", data); throw new Error("No title provided", data);
} }
graph_data_and_parameter.title = {text: title, x: "center"}; graph_data_and_parameter.title = { text: title, x: "center" };
// tooltip // tooltip
// ECharts have to enable the tooltip manually. // ECharts have to enable the tooltip manually.
...@@ -56,10 +56,13 @@ ...@@ -56,10 +56,13 @@
for (i = 0; i < data.length; i = i + 1) { for (i = 0; i < data.length; i = i + 1) {
trace = data[i]; trace = data[i];
trace_type = trace.type || 'bar'; trace_type = trace.type || "bar";
type_list.push(trace_type); type_list.push(trace_type);
trace_value_dict = trace.value_dict || {}; trace_value_dict = trace.value_dict || {};
if (trace_value_dict[0] === undefined || trace_value_dict[1] === undefined) { if (
trace_value_dict[0] === undefined ||
trace_value_dict[1] === undefined
) {
throw new Error("Unexpected data for ECharts", data); throw new Error("Unexpected data for ECharts", data);
} }
...@@ -69,16 +72,16 @@ ...@@ -69,16 +72,16 @@
// If the graph type is pie, set the pie radius // If the graph type is pie, set the pie radius
// plotly doesn't have this option. // plotly doesn't have this option.
if (trace_type === 'pie') { if (trace_type === "pie") {
dataset.radius = '55%'; dataset.radius = "55%";
dataset.center = ['50%', '60%']; dataset.center = ["50%", "60%"];
} }
// For pie graph, the legend labels come from each item's title(aka trace.title) // For pie graph, the legend labels come from each item's title(aka trace.title)
// For graph which contains the axis, the legend labels come from the item's value_dict[0]. // For graph which contains the axis, the legend labels come from the item's value_dict[0].
// See the trace_value_dict in below. But the duplicated value_dict[0] seems for 2D graph // See the trace_value_dict in below. But the duplicated value_dict[0] seems for 2D graph
// seems is redandunt. // seems is redandunt.
if (trace.type !== 'pie') { if (trace.type !== "pie") {
graph_data_and_parameter.legend.data.push(dataset.name); graph_data_and_parameter.legend.data.push(dataset.name);
} }
...@@ -91,21 +94,21 @@ ...@@ -91,21 +94,21 @@
// Value // Value
for (j = 0; j < trace_value_dict[1].length; j = j + 1) { for (j = 0; j < trace_value_dict[1].length; j = j + 1) {
dataset.data.push( dataset.data.push({
{
value: trace_value_dict[1][j], value: trace_value_dict[1][j],
name: label_list[j], name: label_list[j],
itemStyle: null itemStyle: null
} });
);
// Handle the colors in different ways. Maybe enhanced latter // Handle the colors in different ways. Maybe enhanced latter
if (trace.colors) { if (trace.colors) {
// In the pie graph, set the color each individual "data" item. // In the pie graph, set the color each individual "data" item.
if (trace.type === 'pie') { if (trace.type === "pie") {
dataset.data[j].itemStyle = {normal: {color: trace.colors[j]}}; dataset.data[j].itemStyle = {
normal: { color: trace.colors[j] }
};
} else { } else {
// In other types of graph, set the color for each group. // In other types of graph, set the color for each group.
dataset.itemStyle = {normal: {color: trace.colors[0]}}; dataset.itemStyle = { normal: { color: trace.colors[0] } };
} }
} }
} }
...@@ -113,15 +116,21 @@ ...@@ -113,15 +116,21 @@
} }
// For the pie graph, the legend label is the value_dict[0] // For the pie graph, the legend label is the value_dict[0]
if (trace.type === 'pie') { if (trace.type === "pie") {
graph_data_and_parameter.legend.data = label_list; graph_data_and_parameter.legend.data = label_list;
} }
// Axis // Axis
if (trace.type !== 'pie') { if (trace.type !== "pie") {
// if not value type provided, set it as "value". // if not value type provided, set it as "value".
graph_data_and_parameter.yAxis.push({type: 'value', name: layout.axis_dict[1].title}); graph_data_and_parameter.yAxis.push({
graph_data_and_parameter.xAxis.push({data: label_list, name: layout.axis_dict[0].title}); type: "value",
name: layout.axis_dict[1].title
});
graph_data_and_parameter.xAxis.push({
data: label_list,
name: layout.axis_dict[0].title
});
} else { } else {
graph_data_and_parameter.xAxis = null; graph_data_and_parameter.xAxis = null;
graph_data_and_parameter.yAxis = null; graph_data_and_parameter.yAxis = null;
...@@ -140,9 +149,6 @@ ...@@ -140,9 +149,6 @@
///////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////
// ready // ready
///////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////
.ready(function (gadget) {
gadget.property_dict = {};
})
///////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////
// published methods // published methods
...@@ -155,42 +161,69 @@ ...@@ -155,42 +161,69 @@
///////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////
// declared methods // declared methods
///////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////
.declareMethod('render', function (option_dict) { .declareMethod("render", function (option_dict) {
var gadget = this; var gadget = this;
//delegate rendering to onStateChange to avoid redrawing the graph //delegate rendering to onStateChange to avoid redrawing the graph
//every time render is called (a form might call render every time //every time render is called (a form might call render every time
//some other fields needs update) //some other fields needs update)
gadget.changeState({value: option_dict.value}); gadget.changeState({ value: option_dict.value });
}) })
.onStateChange(function (modification_dict) { .onStateChange(function (modification_dict) {
var gadget = this, var gadget = this,
container,
graph_data_and_parameter, graph_data_and_parameter,
chart; chart;
// the gadget is ready when both the graph is rendered and the click handler is attached.
container = gadget.element.querySelector(".graph-content"); if (
chart = echarts.init(container); modification_dict.hasOwnProperty("clickHandlerReady") ||
graph_data_and_parameter = getGraphDataAndParameterFromConfiguration(modification_dict.value); modification_dict.hasOwnProperty("chartRendered")
) {
if (gadget.state.clickHandlerReady && gadget.state.chartRendered) {
gadget.element.querySelector(".graph-content").removeAttribute("disabled");
} else {
gadget.element.querySelector(".graph-content").setAttribute("disabled");
}
}
if (modification_dict.hasOwnProperty("value")) {
chart = echarts.getInstanceByDom(
gadget.element.querySelector(".graph-content")
);
graph_data_and_parameter = getGraphDataAndParameterFromConfiguration(
modification_dict.value
);
chart.on("finished", function onFinished() {
gadget.changeState({ chartRendered: true });
chart.off("finish", onFinished);
});
chart.setOption(graph_data_and_parameter); chart.setOption(graph_data_and_parameter);
gadget.changeState({ chartRendered: false });
this.listenToClickEventOnTheChart(chart); this.listenToClickEventOnTheChart(chart);
}
gadget.property_dict.chart = chart;
}) })
.declareService(function () { .declareService(function () {
var gadget = this; var gadget = this,
return loopEventListener(window, "resize", {passive: true}, function () { chart = echarts.init(gadget.element.querySelector(".graph-content"));
gadget.property_dict.chart.resize(); return loopEventListener(
}, false); window,
"resize",
{ passive: true },
function () {
chart.resize();
},
false
);
}) })
.declareJob('listenToClickEventOnTheChart', function (chart) {
.declareJob("listenToClickEventOnTheChart", function (chart) {
var gadget = this, var gadget = this,
defer = RSVP.defer(); defer = RSVP.defer();
// XXX https://lab.nexedi.com/nexedi/renderjs/blob/master/renderjs.js#L25 // XXX https://lab.nexedi.com/nexedi/renderjs/blob/master/renderjs.js#L25
chart.on('click', function (params) { chart.on("click", function (params) {
return gadget.chartItemClick([params.name, params.seriesName]) return gadget
.chartItemClick([params.name, params.seriesName])
.push(undefined, defer.reject); .push(undefined, defer.reject);
}); });
gadget.changeState({ clickHandlerReady: true });
return defer.promise; return defer.promise;
}); });
}(window, rJS, RSVP, echarts, loopEventListener)); }(window, rJS, RSVP, echarts, loopEventListener));
...@@ -228,7 +228,7 @@ ...@@ -228,7 +228,7 @@
</item> </item>
<item> <item>
<key> <string>actor</string> </key> <key> <string>actor</string> </key>
<value> <string>zope</string> </value> <value> <string>ERP5TypeTestCase</string> </value>
</item> </item>
<item> <item>
<key> <string>comment</string> </key> <key> <string>comment</string> </key>
...@@ -242,7 +242,7 @@ ...@@ -242,7 +242,7 @@
</item> </item>
<item> <item>
<key> <string>serial</string> </key> <key> <string>serial</string> </key>
<value> <string>961.52409.12026.48179</string> </value> <value> <string>970.25488.14039.8704</string> </value>
</item> </item>
<item> <item>
<key> <string>state</string> </key> <key> <string>state</string> </key>
...@@ -260,8 +260,8 @@ ...@@ -260,8 +260,8 @@
</tuple> </tuple>
<state> <state>
<tuple> <tuple>
<float>1504517195.47</float> <float>1538645846.68</float>
<string>UTC</string> <string>GMT+9</string>
</tuple> </tuple>
</state> </state>
</object> </object>
......
...@@ -38,7 +38,7 @@ ...@@ -38,7 +38,7 @@
</div> </div>
<div class="bottom"> <div class="bottom">
<input type="submit" class="ui-disabled" data-i18n="[value]Restore Filter" value="Restore Filter" disabled /> <input type="submit" class="ui-disabled" data-i18n="[value]Reset Filter" value="Reset Filter" disabled />
</div> </div>
<div data-gadget-url="gadget_erp5_page_form.html" data-gadget-scope="last"></div> <div data-gadget-url="gadget_erp5_page_form.html" data-gadget-scope="last"></div>
......
if 'sort_on' in kw: if 'sort_on' in kw:
del kw['sort_on'] del kw['sort_on']
return context.searchFolder(sort_on=[('modification_date', 'descending')], **kw) return context.searchFolder(sort_on=[
('modification_date', 'descending'),
# XXX to get stable test result, we also sort by start date (because modification date
# has a one second precision)
('delivery.start_date', 'descending'),
], **kw)
from datetime import timedelta
from json import dumps from json import dumps
portal = context.getPortalObject() portal = context.getPortalObject()
# Get the split date # Get the split date
now_date = DateTime() now_date = DateTime().asdatetime()
date_2 = now_date - 2 date_2_midnight = DateTime(now_date - timedelta(days=2)).earliestTime()
date_7 = now_date - 7 date_7_midnight = DateTime(now_date - timedelta(days=7)).earliestTime()
date_30 = now_date - 30 date_30_midnight = DateTime(now_date - timedelta(days=30)).earliestTime()
# we can not use str.join...
date_2_midnight = DateTime(str(date_2.year()) + "-" + str(date_2.month()) + "-" + str(date_2.day()))
date_7_midnight = DateTime(str(date_7.year()) + "-" + str(date_7.month()) + "-" + str(date_7.day()))
date_30_midnight = DateTime(str(date_30.year()) + "-" + str(date_30.month()) + "-" + str(date_30.day()))
support_request_list = portal.portal_catalog( support_request_list = portal.portal_catalog(
portal_type="Support Request", portal_type="Support Request",
select_list=['simulation_state', 'start_date'], select_list=['simulation_state', 'start_date'],
**{"delivery.start_date": {"query": now_date, "range": "ngt"}} **{"delivery.start_date": {"query": DateTime(now_date), "range": "ngt"}}
) )
count_by_state = {} count_by_state = {}
count_by_date = {"le2": {}, "2to7": {}, "7to30": {}, "gt30": {}} count_by_date = {"le2": {}, "2to7": {}, "7to30": {}, "gt30": {}}
for sr in support_request_list: for sr in support_request_list:
sr_date = sr.start_date sr = sr.getObject()
sr_state = sr.getProperty("simulation_state") sr_date = sr.getStartDate()
sr_state = sr.getSimulationState()
if sr_state not in count_by_state: if sr_state not in count_by_state:
count_by_state[sr_state] = 0 count_by_state[sr_state] = 0
......
<?xml version="1.0"?>
<ZopeData>
<record id="1" aka="AAAAAAAAAAE=">
<pickle>
<global name="Image" module="OFS.Image"/>
</pickle>
<pickle>
<dictionary>
<item>
<key> <string>__name__</string> </key>
<value> <string>testFrontPageDashboardLastMonthActivity-reference-snapshot-1.png</string> </value>
</item>
<item>
<key> <string>content_type</string> </key>
<value> <string>image/png</string> </value>
</item>
<item>
<key> <string>height</string> </key>
<value> <int>285</int> </value>
</item>
<item>
<key> <string>precondition</string> </key>
<value> <string></string> </value>
</item>
<item>
<key> <string>title</string> </key>
<value> <string></string> </value>
</item>
<item>
<key> <string>width</string> </key>
<value> <int>390</int> </value>
</item>
</dictionary>
</pickle>
</record>
</ZopeData>
<?xml version="1.0"?>
<ZopeData>
<record id="1" aka="AAAAAAAAAAE=">
<pickle>
<global name="ZopePageTemplate" module="Products.PageTemplates.ZopePageTemplate"/>
</pickle>
<pickle>
<dictionary>
<item>
<key> <string>_bind_names</string> </key>
<value>
<object>
<klass>
<global name="NameAssignments" module="Shared.DC.Scripts.Bindings"/>
</klass>
<tuple/>
<state>
<dictionary>
<item>
<key> <string>_asgns</string> </key>
<value>
<dictionary>
<item>
<key> <string>name_subpath</string> </key>
<value> <string>traverse_subpath</string> </value>
</item>
</dictionary>
</value>
</item>
</dictionary>
</state>
</object>
</value>
</item>
<item>
<key> <string>content_type</string> </key>
<value> <string>text/html</string> </value>
</item>
<item>
<key> <string>expand</string> </key>
<value> <int>0</int> </value>
</item>
<item>
<key> <string>id</string> </key>
<value> <string>testFrontPageDashboardLastMonthActivity</string> </value>
</item>
<item>
<key> <string>output_encoding</string> </key>
<value> <string>utf-8</string> </value>
</item>
<item>
<key> <string>title</string> </key>
<value> <unicode>Last Month Activity on Front Page Dashboard</unicode> </value>
</item>
</dictionary>
</pickle>
</record>
</ZopeData>
<html>
<head>
<title tal:content="template/title"></title>
<meta http-equiv="content-type" content="text/html;charset=utf-8">
</head>
<body>
<table cellpadding="1" cellspacing="1" border="1">
<thead>
<tr><td colspan="3" tal:content="template/title"></td></tr>
</thead><tbody>
<tal:block metal:use-macro="here/Zuite_CommonTemplate/macros/init" />
<tal:block metal:use-macro="here/Zuite_SupportRequestUITemplate/macros/cleanup_module" />
<tal:block metal:use-macro="here/Zuite_SupportRequestUITemplate/macros/create_data" />
<tr><td>open</td>
<td>${base_url}/web_site_module/erp5_officejs_support_request_ui/</td><td></td></tr>
<!-- reset filter is disabled -->
<tr><td>waitForElementPresent</td>
<td>//input[@data-i18n="[value]Reset Filter" and @disabled]</td><td></td></tr>
<tr><td>waitForElementPresent</td>
<td>css=#wrap2 iframe</td><td></td></tr>
<tr><td>selectFrame</td>
<td>css=#wrap2 iframe</td><td></td></tr>
<tr><td>waitForElementPresent</td>
<td>//canvas</td><td></td></tr>
<tr><td>waitForElementPresent</td>
<td>//div[@class="graph-content" and not(@disabled)]</td><td></td></tr>
<tr><td>storeEval</td>
<td>selenium.browserbot.getCurrentWindow().echarts.getInstanceByDom(
selenium.browserbot.findElement("css=.graph-content"))</td>
<td>graph_instance</td></tr>
<tr><td>assertEval</td>
<td>(function(){
var series = storedVars['graph_instance'].getOption()['series'],
title = storedVars['graph_instance'].getOption()['title'][0].text;
Assert.equals("Last Month Activity", title);
Assert.equals(1, series.length);
Assert.equals("pie", series[0]["type"]);
Assert.equals("Support Request", series[0]["name"]);
Assert.equals(
JSON.stringify([
{name: "Opened", value: 3},
{name: "Submitted", value: 2},
{name: "Suspended", value: 0},
{name: "Closed", value: 0}]),
JSON.stringify(series[0]["data"].map(e => {return {name: e.name, value: e.value}; })));
return "ok";
})()</td>
<td>ok</td></tr>
<tr><td colspan="3"><b>Verify the rendering of the graph matches our reference snapshot</b></td></tr>
<tr><td>verifyImageMatchSnapshot</td>
<td>//canvas</td>
<td>20</td></tr>
<tr><td colspan="3"><b>Clicking on a serie filter the listbox of recent updates</b></td></tr>
<!--
In the last month, we have 4 Opened and 3 Submitted. The pie shows submitted on the right
and opened on the left.
0 x 100%
0 --------------------------------------- ... >
| Last Month Activity
| [  ] Opened [ ] Submitted ....
|
| _____
| Submitted .-' | '-.
| \ .' | '.
y | \ / | \
| ;\ X | ;
| | \ / ---|---- Opened
| ; / ;
| \ / /
| './ .'
| '-._____.-'
.
.
V 100%
We click in the `Submitted` zone, where's there's a X in this ascii art.
debugging tips:
selenium.browserbot.findElement('//canvas').addEventListener(
'click',
function(e) {
canvasSize = {
x: selenium.browserbot.findElement('//canvas').clientWidth,
y: selenium.browserbot.findElement('//canvas').clientHeight }
console.log(
`${e.clientX} x ${e.clientY}\n${(e.clientX / canvasSize.x * 100).toFixed(2)}% ${(e.clientY / canvasSize.y * 100).toFixed(2)}%`
)
})
or use conditional breakpoint on "clientToLocal" from echarts-all.js :
(e.type == "click" || e.type == "mousedown") && console.log(e.type +" at", e.clientX, e.clientY, (e.clientX / document.querySelector('canvas').clientWidth * 100).toFixed(2), (e.clientY / document.querySelector('canvas').clientHeight * 100).toFixed(2))
and compare real click with simulated clicks.
-->
<tr><td>storeEval</td>
<!-- 49% of the horizontal axis -->
<td>Math.floor(selenium.browserbot.findElement('//canvas').clientWidth * 0.49)</td>
<td>x</td></tr>
<tr><td>storeEval</td>
<!-- 40% of of vertical axis -->
<td>Math.floor(selenium.browserbot.findElement('//canvas').clientHeight * 0.4)</td>
<td>y</td></tr>
<tr><td>assertEval</td>
<!-- echarts ignore the click without a mousedown on the element before -->
<td>var e = new MouseEvent("mousedown", {});
/* echarts will adjust the location with browser based heuristics
https://github.com/ecomfe/zrender/blob/4.0.5/src/core/event.js#L20
to make this easier, we precalculate the coordinates in a way that should
be cross browser compatible. */
e.zrX = storedVars.x;
e.zrY = storedVars.y;
selenium.browserbot.findElement("//canvas/..").dispatchEvent(e)</td>
<td>true</td></tr>
<tr><td>assertEval</td>
<td>var e = new MouseEvent("click", {});
e.zrX = storedVars.x;
e.zrY = storedVars.y;
selenium.browserbot.findElement("//canvas/..").dispatchEvent(e)</td>
<td>true</td></tr>
<!-- XXX shouldn't selenium "clickAt" do this ?
<tr><td>clickAt</td>
<td>//canvas/..</td>
<td>${x},${y}</td></tr> -->
<tr><td colspan="3"><b>Recent updates listbox is updated with the clicked series.</b></td>
<tr><td>selectFrame</td>
<td>relative=top</td><td></td></tr>
<tr><td>waitForText</td>
<td>//h1[@data-i18n="Recent Updates"]</td>
<td>Recent Updates (3)</td></tr> <!-- FIXME: this should be 2 here. 123dsfa is not "last month" -->
<tr><td>waitForText</td>
<td>//h1[@data-i18n="Recent Updates"]/../..//td[1]/a</td>
<td>Yesterday - RobotMaking - Submitted</td></tr>
<!-- reset filter is now enabled -->
<tr><td>waitForElementPresent</td>
<td>//input[@data-i18n="[value]Reset Filter" and not(@disabled)]</td><td></td></tr>
<tr><td>click</td>
<td>//input[@data-i18n="[value]Reset Filter"]</td><td></td></tr>
<tr><td>waitForText</td>
<td>//h1[@data-i18n="Recent Updates"]</td>
<td>glob:Recent Updates (1 - 5 / *)</td></tr>
</tbody></table>
</body>
</html>
\ No newline at end of file
<?xml version="1.0"?>
<ZopeData>
<record id="1" aka="AAAAAAAAAAE=">
<pickle>
<global name="Image" module="OFS.Image"/>
</pickle>
<pickle>
<dictionary>
<item>
<key> <string>__name__</string> </key>
<value> <string>testFrontPageDashboardSupportRequestPipe-reference-snapshot-1.png</string> </value>
</item>
<item>
<key> <string>content_type</string> </key>
<value> <string>image/png</string> </value>
</item>
<item>
<key> <string>height</string> </key>
<value> <int>285</int> </value>
</item>
<item>
<key> <string>precondition</string> </key>
<value> <string></string> </value>
</item>
<item>
<key> <string>title</string> </key>
<value> <string></string> </value>
</item>
<item>
<key> <string>width</string> </key>
<value> <int>404</int> </value>
</item>
</dictionary>
</pickle>
</record>
</ZopeData>
<?xml version="1.0"?>
<ZopeData>
<record id="1" aka="AAAAAAAAAAE=">
<pickle>
<global name="ZopePageTemplate" module="Products.PageTemplates.ZopePageTemplate"/>
</pickle>
<pickle>
<dictionary>
<item>
<key> <string>_bind_names</string> </key>
<value>
<object>
<klass>
<global name="NameAssignments" module="Shared.DC.Scripts.Bindings"/>
</klass>
<tuple/>
<state>
<dictionary>
<item>
<key> <string>_asgns</string> </key>
<value>
<dictionary>
<item>
<key> <string>name_subpath</string> </key>
<value> <string>traverse_subpath</string> </value>
</item>
</dictionary>
</value>
</item>
</dictionary>
</state>
</object>
</value>
</item>
<item>
<key> <string>content_type</string> </key>
<value> <string>text/html</string> </value>
</item>
<item>
<key> <string>expand</string> </key>
<value> <int>0</int> </value>
</item>
<item>
<key> <string>id</string> </key>
<value> <string>testFrontPageDashboardSupportRequestPipe</string> </value>
</item>
<item>
<key> <string>output_encoding</string> </key>
<value> <string>utf-8</string> </value>
</item>
<item>
<key> <string>title</string> </key>
<value> <unicode>Support Request Pipe on Front Page Dashboard</unicode> </value>
</item>
</dictionary>
</pickle>
</record>
</ZopeData>
<html>
<head>
<title tal:content="template/title"></title>
<meta http-equiv="content-type" content="text/html;charset=utf-8">
</head>
<body>
<table cellpadding="1" cellspacing="1" border="1">
<thead>
<tr><td colspan="3" tal:content="template/title"></td></tr>
</thead><tbody>
<tal:block metal:use-macro="here/Zuite_CommonTemplate/macros/init" />
<tal:block metal:use-macro="here/Zuite_SupportRequestUITemplate/macros/cleanup_module" />
<tal:block metal:use-macro="here/Zuite_SupportRequestUITemplate/macros/create_data" />
<tr><td>open</td>
<td>${base_url}/web_site_module/erp5_officejs_support_request_ui/</td><td></td></tr>
<!-- reset filter is disabled -->
<tr><td>waitForElementPresent</td>
<td>//input[@data-i18n="[value]Reset Filter" and @disabled]</td><td></td></tr>
<tr><td>waitForElementPresent</td>
<td>css=#wrap1 iframe</td><td></td></tr>
<tr><td>selectFrame</td>
<td>css=#wrap1 iframe</td><td></td></tr>
<tr><td>waitForElementPresent</td>
<td>//canvas</td><td></td></tr>
<tr><td>waitForElementPresent</td>
<td>//div[@class="graph-content" and not(@disabled)]</td><td></td></tr>
<tr><td>storeEval</td>
<td>selenium.browserbot.getCurrentWindow().echarts.getInstanceByDom(
selenium.browserbot.findElement("css=.graph-content"))</td>
<td>graph_instance</td></tr>
<tr><td>assertEval</td>
<td>(function(){
var series = storedVars['graph_instance'].getOption()['series'],
title = storedVars['graph_instance'].getOption()['title'][0].text;
Assert.equals("Support Request Pipe", title);
Assert.equals(3, series.length);
Assert.equals("bar", series[0]["type"]);
Assert.equals("Opened", series[0]["name"]);
Assert.equals(
JSON.stringify([
{name: "< 2", value: 1},
{name: "2-7", value: 2},
{name: "7-30", value: 0},
{name: "> 30", value: 1}]),
JSON.stringify(series[0]["data"].map(e => {return {name: e.name, value: e.value}; })));
Assert.equals("Submitted", series[1]["name"]);
Assert.equals(
JSON.stringify([
{name: "< 2", value: 1},
{name: "2-7", value: 0},
{name: "7-30", value: 1},
{name: "> 30", value: 1}]),
JSON.stringify(series[1]["data"].map(e => {return {name: e.name, value: e.value}; })));
Assert.equals("Suspended", series[2]["name"]);
Assert.equals(
JSON.stringify([
{name: "< 2", value: 0},
{name: "2-7", value: 0},
{name: "7-30", value: 0},
{name: "> 30", value: 1}]),
JSON.stringify(series[2]["data"].map(e => {return {name: e.name, value: e.value}; })));
return "ok";
})()</td>
<td>ok</td></tr>
<tr><td colspan="3"><b>Verify the rendering of the graph matches our reference snapshot</b></td></tr>
<tr><td>verifyImageMatchSnapshot</td>
<td>//canvas</td>
<td>20</td></tr>
<tr><td colspan="3"><b>Clicking on a serie filter the listbox of recent updates</b></td></tr>
<!--
Click on the first series (Open < 2), which should be the easiest to hit,
0 x 100%
0 ----------------------------------------------------- ... >
| Support Request Pipe
| [  ] Opened [ ] Submitted ....
|
|
| <-XXX ->
| __
| |//|
| we click |//|
y | on this X |//|
| V |//|
| __ __ |//|
| | | |//| |//|
| | X| |//| |//|
| | | |//| |//|
| | | |//| |//|
| | | |//| |//|
.----------------------|--------------- ....
. < 2 2-7
.
V 100%
The width of the XXX area depends on the total available width, so it's really hard to
estimate where to click.
On a 409x285 canvas (959x703 outer window), 20% is OK.
On a 639x285 canvas (1666x703 outer window), 30% is OK.
The approximated formula (by trial and error, so it migth be just wrong) is:
( 15% + (height/with ratio) * 10% ) of height
-->
<tr><td>storeEval</td>
<td>Math.floor(
( selenium.browserbot.findElement('//canvas').clientHeight/
selenium.browserbot.findElement('//canvas').clientWidth) * 0.1 +
selenium.browserbot.findElement('//canvas').clientWidth * 0.15)</td>
<td>x</td></tr>
<tr><td>storeEval</td>
<!-- 70% of of vertical axis -->
<td>Math.floor(selenium.browserbot.findElement('//canvas').clientHeight * 0.7)</td>
<td>y</td></tr>
<tr><td>assertEval</td>
<!-- echarts ignore the click without a mousedown on the element before -->
<td>var e = new MouseEvent("mousedown", {});
e.zrX = storedVars.x;
e.zrY = storedVars.y;
selenium.browserbot.findElement("//canvas/..").dispatchEvent(e)</td>
<td>true</td></tr>
<tr><td>assertEval</td>
<td>var e = new MouseEvent("click", {});
e.zrX = storedVars.x;
e.zrY = storedVars.y;
selenium.browserbot.findElement("//canvas/..").dispatchEvent(e)</td>
<td>true</td></tr>
<tr><td colspan="3"><b>Recent updates listbox is updated with the clicked series.</b></td>
<tr><td>selectFrame</td>
<td>relative=top</td><td></td></tr>
<tr><td>waitForText</td>
<td>//h1[@data-i18n="Recent Updates"]</td>
<td>Recent Updates (1)</td></tr>
<tr><td>waitForText</td>
<td>//h1[@data-i18n="Recent Updates"]/../..//td[1]/a</td>
<td>Yesterday - PlaneMaking - Open</td></tr>
<!-- reset filter is now enabled -->
<tr><td>waitForElementPresent</td>
<td>//input[@data-i18n="[value]Reset Filter" and not(@disabled)]</td><td></td></tr>
<tr><td>click</td>
<td>//input[@data-i18n="[value]Reset Filter"]</td><td></td></tr>
<tr><td>waitForText</td>
<td>//h1[@data-i18n="Recent Updates"]</td>
<td>glob:Recent Updates (1 - 5 / *)</td></tr>
</tbody></table>
</body>
</html>
\ No newline at end of file
"""Create some data for support request UI test. """Create some data for support request UI test.
Logged in user needs to be Assignee / Assignor on the support requests * Logged in user needs to be Assignee / Assignor on the support requests
included in business template. included in business template.
* Some "static" data is already in business template, but because the dashboard
display statistics about recent support requests (like "less than 2 days from now"),
we need to generate support requests at a date relative from now.
""" """
from DateTime import DateTime
from datetime import timedelta
portal = context.getPortalObject() portal = context.getPortalObject()
now = DateTime().asdatetime()
for support_request in portal.support_request_module.contentValues(): for support_request in portal.support_request_module.contentValues():
if support_request.getId().startswith('erp5_officejs_support_request_ui_test'): if support_request.getId().startswith('erp5_officejs_support_request_ui_test'):
...@@ -11,6 +18,38 @@ for support_request in portal.support_request_module.contentValues(): ...@@ -11,6 +18,38 @@ for support_request in portal.support_request_module.contentValues():
portal.portal_membership.getAuthenticatedMember().getId(), portal.portal_membership.getAuthenticatedMember().getId(),
['Assignee', 'Assignor']) ['Assignee', 'Assignor'])
support_request.reindexObject() support_request.reindexObject()
portal.portal_caches.clearAllCache() portal.portal_caches.clearAllCache()
portal.support_request_module.newContent(
portal_type='Support Request',
title="Two Weeks ago - PlaneMaking - Submitted",
start_date=DateTime(now - timedelta(days=15)),
source_project_value=portal.project_module.erp5_officejs_support_request_ui_test_project_001,
).submit()
portal.support_request_module.newContent(
portal_type='Support Request',
title="Last Week 2 - RobotMaking - Open",
start_date=DateTime(now - timedelta(days=5)),
source_project_value=portal.project_module.erp5_officejs_support_request_ui_test_project_001,
).validate()
portal.support_request_module.newContent(
portal_type='Support Request',
title="Last Week - RobotMaking - Open",
start_date=DateTime(now - timedelta(days=4)),
source_project_value=portal.project_module.erp5_officejs_support_request_ui_test_project_001,
).validate()
portal.support_request_module.newContent(
portal_type='Support Request',
title="Yesterday - RobotMaking - Submitted",
start_date=DateTime(now - timedelta(days=1)),
source_project_value=portal.project_module.erp5_officejs_support_request_ui_test_project_001,
).submit()
portal.support_request_module.newContent(
portal_type='Support Request',
title="Yesterday - PlaneMaking - Open",
start_date=DateTime(now - timedelta(days=1)),
source_project_value=portal.project_module.erp5_officejs_support_request_ui_test_project_001,
).validate()
return "Done." return "Done."
from StringIO import StringIO
portal = context.getPortalObject()
image_file = StringIO(image_data.replace('data:image/png;base64,', '').decode('base64'))
image_path = image_path.split('/')
existing = portal.restrictedTraverse(image_path, None)
if existing is None:
container = portal.restrictedTraverse(image_path[:-1])
container.manage_addProduct['OFSP'].manage_addImage(
image_path[-1],
image_file,
'')
else:
existing.manage_upload(image_file)
return "reference image at {} updated".format('/'.join(image_path))
<?xml version="1.0"?>
<ZopeData>
<record id="1" aka="AAAAAAAAAAE=">
<pickle>
<global name="PythonScript" module="Products.PythonScripts.PythonScript"/>
</pickle>
<pickle>
<dictionary>
<item>
<key> <string>Script_magic</string> </key>
<value> <int>3</int> </value>
</item>
<item>
<key> <string>_bind_names</string> </key>
<value>
<object>
<klass>
<global name="NameAssignments" module="Shared.DC.Scripts.Bindings"/>
</klass>
<tuple/>
<state>
<dictionary>
<item>
<key> <string>_asgns</string> </key>
<value>
<dictionary>
<item>
<key> <string>name_container</string> </key>
<value> <string>container</string> </value>
</item>
<item>
<key> <string>name_context</string> </key>
<value> <string>context</string> </value>
</item>
<item>
<key> <string>name_m_self</string> </key>
<value> <string>script</string> </value>
</item>
<item>
<key> <string>name_subpath</string> </key>
<value> <string>traverse_subpath</string> </value>
</item>
</dictionary>
</value>
</item>
</dictionary>
</state>
</object>
</value>
</item>
<item>
<key> <string>_params</string> </key>
<value> <string>image_data, image_path</string> </value>
</item>
<item>
<key> <string>id</string> </key>
<value> <string>Zuite_updateReferenceImage</string> </value>
</item>
</dictionary>
</pickle>
</record>
</ZopeData>
...@@ -82,3 +82,251 @@ Selenium.prototype.assertElementPositionRangeTop = function(locator, range){ ...@@ -82,3 +82,251 @@ Selenium.prototype.assertElementPositionRangeTop = function(locator, range){
Assert.fail(positionTop + " is not between " + minimumPositionTop + " and " + maximumPositionTop); Assert.fail(positionTop + " is not between " + minimumPositionTop + " and " + maximumPositionTop);
} }
}; };
// a memo test pathname => image counter
// TODO: reset this on testSuite.reset(), because we cannot re-run a test.
imageMatchReference = new Map();
function getReferenceImageCounter(testPathName) {
var counter = imageMatchReference.get(testPathName);
if (counter !== undefined) {
return counter;
}
counter = imageMatchReference.size + 1;
imageMatchReference.set(testPathName, counter);
return counter;
}
function getReferenceImageURL(testPathName) {
var imageCounter = getReferenceImageCounter(testPathName);
return testPathName + '-reference-snapshot-' + imageCounter + '.png';
}
/**
*
* Helper function to generate a DOM elements
*
* @param {string} tagName name of the element
* @param {Node?} childList list of child elements
* @param {Map<string,any>?} attributeDict attributes
* @param {string?} textContent
* @return {Node}
*/
function generateElement(tagName, childList, attributeDict, textContent) {
var element = document.createElement(tagName);
if (attributeDict) {
for (var attr in attributeDict) {
element.setAttribute(attr, attributeDict[attr]);
}
}
if (childList) {
childList.map(child => {
element.appendChild(child);
});
}
return element;
}
/**
* Generate an HTML form to update the reference snapshot
*
* @param {string} referenceImageURL relative URL of the reference image
* @param {string} newImageData the new image data, base64 encoded
* @param {Map<string,any>?} attributeDict attributes
* @return {Promise<string>} the base64 encoded html form
*/
function generateUpdateForm(referenceImageURL, newImageData) {
return new Promise((resolve, reject) => {
var fr = new FileReader();
fr.onerror = reject;
fr.onload = () => resolve(fr.result);
fr.readAsDataURL(
new Blob(
[
generateElement('html', [
generateElement('body', [
generateElement('p', [
document.createTextNode('Replacing this old snapshot:'),
generateElement('br'),
generateElement('img', [], {
src: location.origin + referenceImageURL,
alt: 'reference image'
}),
generateElement('br'),
document.createTextNode('with this new snapshot:'),
generateElement('br'),
generateElement('img', [], {
src: newImageData,
alt: 'new image'
})
]),
generateElement(
'form',
[
generateElement('input', [], {
type: 'hidden',
name: 'image_data',
value: newImageData
}),
generateElement('input', [], {
type: 'hidden',
name: 'image_path',
value: referenceImageURL
}),
generateElement('input', [], {
type: 'submit',
value: 'Update Reference Snapshot'
})
],
{
action:
location.origin +
'/' +
referenceImageURL.split('/')[1] + // ERP5 portal
'/Zuite_updateReferenceImage',
method: 'POST'
}
)
])
]).innerHTML
],
{ type: 'text/html' }
)
);
});
}
/**
* verify that the rendering of the element `locator` matches the previously saved reference.
*
* Arguments:
* locator - an element locator
* misMatchTolerance - the percentage of mismatch allowed. If this is 0, the
* images must be exactly same. If more than 0, image will also be resized.
*/
Selenium.prototype.doVerifyImageMatchSnapshot = (
locator,
misMatchTolerance
) => {
// XXX this is a do* method and not a assert* method because only do* methods are
// asynchronous.
// The asynchronicity of do* method is as follow Selenium.prototype.doXXX
// returns a function and this function will be called again and again until:
// * function returns true, which means step is successfull
// * function returns false, which means step is not finished and function will be called again
// * an execption is raised, in that case the step is failed
// * global timeout is reached.
// we implement the state management with similar approach as what's discussed
// https://stackoverflow.com/questions/30564053/how-can-i-synchronously-determine-a-javascript-promises-state
var promiseState, rejectionValue, canvasPromise;
return function assertCanvasImage() {
if (promiseState === 'pending') {
return false;
}
if (promiseState === 'resolved') {
return true;
}
if (promiseState === 'rejected') {
Assert.fail(rejectionValue);
}
misMatchTolerance = parseFloat(misMatchTolerance);
if (isNaN(misMatchTolerance)) {
misMatchTolerance = 0;
}
promiseState = 'pending';
element = selenium.browserbot.findElement(locator);
if (element.nodeName == 'CANVAS' /* instanceof HTMLCanvasElement XXX ? */) {
canvasPromise = Promise.resolve(element);
} else {
canvasPromise = html2canvas(element);
}
canvasPromise
.then(canvas => {
return canvas.toDataURL();
})
.then(actual => {
var referenceImageURL = getReferenceImageURL(
testFrame.getCurrentTestCase().pathname
);
return fetch(referenceImageURL)
.then(response => {
if (response.status === 200) {
return response.blob();
}
throw new Error('Feching reference failed ' + response.statusText);
})
.then(
blob => {
return new Promise((resolve, reject) => {
var fr = new FileReader();
fr.onload = d => resolve(fr.result);
fr.onerror = reject;
fr.readAsDataURL(blob);
});
},
e => {
// fetching reference was not found, return empty image instead, it will be different
return document.createElement('canvas').toDataURL();
}
)
.then(expected => {
return new Promise(resolve => {
var comparator = resemble(actual)
.outputSettings({
useCrossOrigin: false
})
.compareTo(expected);
if (misMatchTolerance > 0) {
comparator = comparator.scaleToSameSize();
}
comparator.onComplete(resolve);
});
})
.then(diff => {
if (diff.rawMisMatchPercentage <= misMatchTolerance) {
promiseState = 'resolved';
} else {
return generateUpdateForm(referenceImageURL, actual).then(
updateReferenceImageForm => {
htmlTestRunner.currentTest.currentRow.trElement
.querySelector('td')
.appendChild(
generateElement('div', [
document.createTextNode('Image differences:'),
generateElement('br'),
generateElement('img', [], {
src: diff.getImageDataUrl(),
alt: 'Image differences'
}),
generateElement('br'),
document.createTextNode('Click '),
generateElement(
'a',
[document.createTextNode('here')],
{
href: updateReferenceImageForm
}
),
document.createTextNode(
' to update reference snapshot.'
)
])
);
promiseState = 'rejected';
rejectionValue =
'Images are ' + diff.misMatchPercentage + '% different';
}
);
}
});
})
.catch(error => {
console.error(error);
promiseState = 'rejected';
rejectionValue = 'Error computing image differences ' + error;
});
};
};
This source diff could not be displayed because it is too large. You can view the blob instead.
...@@ -66,9 +66,7 @@ ...@@ -66,9 +66,7 @@
<item> <item>
<key> <string>categories</string> </key> <key> <string>categories</string> </key>
<value> <value>
<tuple> <tuple/>
<string>classification/collaborative/team</string>
</tuple>
</value> </value>
</item> </item>
<item> <item>
...@@ -111,7 +109,7 @@ ...@@ -111,7 +109,7 @@
</item> </item>
<item> <item>
<key> <string>version</string> </key> <key> <string>version</string> </key>
<value> <string>001</string> </value> <value> <string>4.1.0</string> </value>
</item> </item>
<item> <item>
<key> <string>workflow_history</string> </key> <key> <string>workflow_history</string> </key>
...@@ -224,7 +222,7 @@ ...@@ -224,7 +222,7 @@
</item> </item>
<item> <item>
<key> <string>actor</string> </key> <key> <string>actor</string> </key>
<value> <string>zope</string> </value> <value> <string>ERP5TypeTestCase</string> </value>
</item> </item>
<item> <item>
<key> <string>comment</string> </key> <key> <string>comment</string> </key>
...@@ -238,7 +236,7 @@ ...@@ -238,7 +236,7 @@
</item> </item>
<item> <item>
<key> <string>serial</string> </key> <key> <string>serial</string> </key>
<value> <string>961.1917.49098.19712</string> </value> <value> <string>970.25572.29237.55057</string> </value>
</item> </item>
<item> <item>
<key> <string>state</string> </key> <key> <string>state</string> </key>
...@@ -256,8 +254,8 @@ ...@@ -256,8 +254,8 @@
</tuple> </tuple>
<state> <state>
<tuple> <tuple>
<float>1504260717.22</float> <float>1537448123.64</float>
<string>UTC</string> <string>GMT+9</string>
</tuple> </tuple>
</state> </state>
</object> </object>
......
...@@ -48,6 +48,8 @@ to work-around a bug in IE on Win2K whereby the HTA application doesn't function ...@@ -48,6 +48,8 @@ to work-around a bug in IE on Win2K whereby the HTA application doesn't function
<script language="JavaScript" type="text/javascript" src="xpath/dom.js"></script> <script language="JavaScript" type="text/javascript" src="xpath/dom.js"></script>
<script language="JavaScript" type="text/javascript" src="xpath/xpath.js"></script> <script language="JavaScript" type="text/javascript" src="xpath/xpath.js"></script>
<script language="JavaScript" type="text/javascript" src="xpath/javascript-xpath-0.1.11.js"></script> <script language="JavaScript" type="text/javascript" src="xpath/javascript-xpath-0.1.11.js"></script>
<script language="JavaScript" type="text/javascript" src="lib/resemble.js"></script>
<script language="JavaScript" type="text/javascript" src="lib/html2canvas.js"></script>
<script language="JavaScript" type="text/javascript" src="scripts/user-extensions.js"></script> <script language="JavaScript" type="text/javascript" src="scripts/user-extensions.js"></script>
</head> </head>
......
...@@ -48,6 +48,8 @@ to work-around a bug in IE on Win2K whereby the HTA application doesn't function ...@@ -48,6 +48,8 @@ to work-around a bug in IE on Win2K whereby the HTA application doesn't function
<script language="JavaScript" type="text/javascript" src="xpath/dom.js"></script> <script language="JavaScript" type="text/javascript" src="xpath/dom.js"></script>
<script language="JavaScript" type="text/javascript" src="xpath/xpath.js"></script> <script language="JavaScript" type="text/javascript" src="xpath/xpath.js"></script>
<script language="JavaScript" type="text/javascript" src="xpath/javascript-xpath-0.1.11.js"></script> <script language="JavaScript" type="text/javascript" src="xpath/javascript-xpath-0.1.11.js"></script>
<script language="JavaScript" type="text/javascript" src="lib/resemble.js"></script>
<script language="JavaScript" type="text/javascript" src="lib/html2canvas.js"></script>
<script language="JavaScript" type="text/javascript" src="scripts/user-extensions.js"></script> <script language="JavaScript" type="text/javascript" src="scripts/user-extensions.js"></script>
</head> </head>
......
This source diff could not be displayed because it is too large. You can view the blob instead.
/*
James Cryer / Huddle
URL: https://github.com/Huddle/Resemble.js
*/
(function(root, factory) {
"use strict";
if (typeof define === "function" && define.amd) {
define([], factory);
} else if (typeof module === "object" && module.exports) {
module.exports = factory();
} else {
root.resemble = factory();
}
})(this, function() {
"use strict";
var Img;
var Canvas;
if (typeof Image !== "undefined") {
Img = Image;
} else {
Canvas = require("canvas-prebuilt"); // eslint-disable-line global-require
Img = Canvas.Image;
}
var document =
typeof window !== "undefined"
? window.document
: {
createElement: function() {
// This will work as long as only createElement is used on window.document
return new Canvas();
}
};
var oldGlobalSettings = {};
var globalOutputSettings = oldGlobalSettings;
function setGlobalOutputSettings(settings) {
var msg =
"warning resemble.outputSettings mutates global state, and " +
"will be removed in 3.0.0";
console.warn(msg);
globalOutputSettings = settings;
return this;
}
var resemble = function(fileData) {
var pixelTransparency = 1;
var errorPixelColor = {
// Color for Error Pixels. Between 0 and 255.
red: 255,
green: 0,
blue: 255,
alpha: 255
};
var targetPix = { r: 0, g: 0, b: 0, a: 0 }; // isAntialiased
function colorsDistance(c1, c2) {
return (
(Math.abs(c1.r - c2.r) +
Math.abs(c1.g - c2.g) +
Math.abs(c1.b - c2.b)) /
3
);
}
function withinBoundingBox(x, y, width, height, box) {
return (
x > (box.left || 0) &&
x < (box.right || width) &&
y > (box.top || 0) &&
y < (box.bottom || height)
);
}
function withinComparedArea(x, y, width, height) {
var isIncluded = true;
if (
boundingBox !== undefined &&
!withinBoundingBox(x, y, width, height, boundingBox)
) {
isIncluded = false;
}
if (
ignoredBox !== undefined &&
withinBoundingBox(x, y, width, height, ignoredBox)
) {
isIncluded = false;
}
return isIncluded;
}
var errorPixelTransform = {
flat: function(px, offset) {
px[offset] = errorPixelColor.red;
px[offset + 1] = errorPixelColor.green;
px[offset + 2] = errorPixelColor.blue;
px[offset + 3] = errorPixelColor.alpha;
},
movement: function(px, offset, d1, d2) {
px[offset] =
(d2.r * (errorPixelColor.red / 255) + errorPixelColor.red) /
2;
px[offset + 1] =
(d2.g * (errorPixelColor.green / 255) +
errorPixelColor.green) /
2;
px[offset + 2] =
(d2.b * (errorPixelColor.blue / 255) +
errorPixelColor.blue) /
2;
px[offset + 3] = d2.a;
},
flatDifferenceIntensity: function(px, offset, d1, d2) {
px[offset] = errorPixelColor.red;
px[offset + 1] = errorPixelColor.green;
px[offset + 2] = errorPixelColor.blue;
px[offset + 3] = colorsDistance(d1, d2);
},
movementDifferenceIntensity: function(px, offset, d1, d2) {
var ratio = colorsDistance(d1, d2) / 255 * 0.8;
px[offset] =
(1 - ratio) * (d2.r * (errorPixelColor.red / 255)) +
ratio * errorPixelColor.red;
px[offset + 1] =
(1 - ratio) * (d2.g * (errorPixelColor.green / 255)) +
ratio * errorPixelColor.green;
px[offset + 2] =
(1 - ratio) * (d2.b * (errorPixelColor.blue / 255)) +
ratio * errorPixelColor.blue;
px[offset + 3] = d2.a;
},
diffOnly: function(px, offset, d1, d2) {
px[offset] = d2.r;
px[offset + 1] = d2.g;
px[offset + 2] = d2.b;
px[offset + 3] = d2.a;
}
};
var errorPixel = errorPixelTransform.flat;
var errorType;
var boundingBox;
var ignoredBox;
var largeImageThreshold = 1200;
var useCrossOrigin = true;
var data = {};
var images = [];
var updateCallbackArray = [];
var tolerance = {
// between 0 and 255
red: 16,
green: 16,
blue: 16,
alpha: 16,
minBrightness: 16,
maxBrightness: 240
};
var ignoreAntialiasing = false;
var ignoreColors = false;
var scaleToSameSize = false;
function triggerDataUpdate() {
var len = updateCallbackArray.length;
var i;
for (i = 0; i < len; i++) {
if (typeof updateCallbackArray[i] === "function") {
updateCallbackArray[i](data);
}
}
}
function loop(w, h, callback) {
var x;
var y;
for (x = 0; x < w; x++) {
for (y = 0; y < h; y++) {
callback(x, y);
}
}
}
function parseImage(sourceImageData, width, height) {
var pixelCount = 0;
var redTotal = 0;
var greenTotal = 0;
var blueTotal = 0;
var alphaTotal = 0;
var brightnessTotal = 0;
var whiteTotal = 0;
var blackTotal = 0;
loop(width, height, function(horizontalPos, verticalPos) {
var offset = (verticalPos * width + horizontalPos) * 4;
var red = sourceImageData[offset];
var green = sourceImageData[offset + 1];
var blue = sourceImageData[offset + 2];
var alpha = sourceImageData[offset + 3];
var brightness = getBrightness(red, green, blue);
if (red === green && red === blue && alpha) {
if (red === 0) {
blackTotal++;
} else if (red === 255) {
whiteTotal++;
}
}
pixelCount++;
redTotal += red / 255 * 100;
greenTotal += green / 255 * 100;
blueTotal += blue / 255 * 100;
alphaTotal += (255 - alpha) / 255 * 100;
brightnessTotal += brightness / 255 * 100;
});
data.red = Math.floor(redTotal / pixelCount);
data.green = Math.floor(greenTotal / pixelCount);
data.blue = Math.floor(blueTotal / pixelCount);
data.alpha = Math.floor(alphaTotal / pixelCount);
data.brightness = Math.floor(brightnessTotal / pixelCount);
data.white = Math.floor(whiteTotal / pixelCount * 100);
data.black = Math.floor(blackTotal / pixelCount * 100);
triggerDataUpdate();
}
function loadImageData(fileDataForImage, callback) {
var fileReader;
var hiddenImage = new Img();
if (!hiddenImage.setAttribute) {
hiddenImage.setAttribute = function setAttribute() {};
}
if (useCrossOrigin) {
hiddenImage.setAttribute("crossorigin", "anonymous");
}
hiddenImage.onerror = function(err) {
hiddenImage.onload = null;
hiddenImage.onerror = null; // fixes pollution between calls
images.push({ error: err ? err + "" : "Image load error." });
callback();
};
hiddenImage.onload = function() {
hiddenImage.onload = null; // fixes pollution between calls
hiddenImage.onerror = null;
var hiddenCanvas = document.createElement("canvas");
var imageData;
// don't assign to hiddenImage, see https://github.com/Huddle/Resemble.js/pull/87/commits/300d43352a2845aad289b254bfbdc7cd6a37e2d7
var width = hiddenImage.width;
var height = hiddenImage.height;
if (scaleToSameSize && images.length === 1) {
width = images[0].width;
height = images[0].height;
}
hiddenCanvas.width = width;
hiddenCanvas.height = height;
hiddenCanvas
.getContext("2d")
.drawImage(hiddenImage, 0, 0, width, height);
imageData = hiddenCanvas
.getContext("2d")
.getImageData(0, 0, width, height);
images.push(imageData);
callback(imageData, width, height);
};
if (typeof fileDataForImage === "string") {
hiddenImage.src = fileDataForImage;
if (hiddenImage.complete && hiddenImage.naturalWidth > 0) {
hiddenImage.onload();
}
} else if (
typeof fileDataForImage.data !== "undefined" &&
typeof fileDataForImage.width === "number" &&
typeof fileDataForImage.height === "number"
) {
images.push(fileDataForImage);
callback(
fileDataForImage,
fileDataForImage.width,
fileDataForImage.height
);
} else if (
typeof Buffer !== "undefined" &&
fileDataForImage instanceof Buffer
) {
// If we have Buffer, assume we're on Node+Canvas and its supported
hiddenImage.src = fileDataForImage;
} else {
fileReader = new FileReader();
fileReader.onload = function(event) {
hiddenImage.src = event.target.result;
};
fileReader.readAsDataURL(fileDataForImage);
}
}
function isColorSimilar(a, b, color) {
var absDiff = Math.abs(a - b);
if (typeof a === "undefined") {
return false;
}
if (typeof b === "undefined") {
return false;
}
if (a === b) {
return true;
} else if (absDiff < tolerance[color]) {
return true;
}
return false;
}
function isPixelBrightnessSimilar(d1, d2) {
var alpha = isColorSimilar(d1.a, d2.a, "alpha");
var brightness = isColorSimilar(
d1.brightness,
d2.brightness,
"minBrightness"
);
return brightness && alpha;
}
function getBrightness(r, g, b) {
return 0.3 * r + 0.59 * g + 0.11 * b;
}
function isRGBSame(d1, d2) {
var red = d1.r === d2.r;
var green = d1.g === d2.g;
var blue = d1.b === d2.b;
return red && green && blue;
}
function isRGBSimilar(d1, d2) {
var red = isColorSimilar(d1.r, d2.r, "red");
var green = isColorSimilar(d1.g, d2.g, "green");
var blue = isColorSimilar(d1.b, d2.b, "blue");
var alpha = isColorSimilar(d1.a, d2.a, "alpha");
return red && green && blue && alpha;
}
function isContrasting(d1, d2) {
return (
Math.abs(d1.brightness - d2.brightness) >
tolerance.maxBrightness
);
}
function getHue(red, green, blue) {
var r = red / 255;
var g = green / 255;
var b = blue / 255;
var max = Math.max(r, g, b);
var min = Math.min(r, g, b);
var h;
var d;
if (max === min) {
h = 0; // achromatic
} else {
d = max - min;
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0);
break;
case g:
h = (b - r) / d + 2;
break;
case b:
h = (r - g) / d + 4;
break;
default:
h /= 6;
}
}
return h;
}
function isAntialiased(
sourcePix,
pix,
cacheSet,
verticalPos,
horizontalPos,
width
) {
var offset;
var distance = 1;
var i;
var j;
var hasHighContrastSibling = 0;
var hasSiblingWithDifferentHue = 0;
var hasEquivalentSibling = 0;
addHueInfo(sourcePix);
for (i = distance * -1; i <= distance; i++) {
for (j = distance * -1; j <= distance; j++) {
if (i === 0 && j === 0) {
// ignore source pixel
} else {
offset =
((verticalPos + j) * width + (horizontalPos + i)) *
4;
if (!getPixelInfo(targetPix, pix, offset, cacheSet)) {
continue;
}
addBrightnessInfo(targetPix);
addHueInfo(targetPix);
if (isContrasting(sourcePix, targetPix)) {
hasHighContrastSibling++;
}
if (isRGBSame(sourcePix, targetPix)) {
hasEquivalentSibling++;
}
if (Math.abs(targetPix.h - sourcePix.h) > 0.3) {
hasSiblingWithDifferentHue++;
}
if (
hasSiblingWithDifferentHue > 1 ||
hasHighContrastSibling > 1
) {
return true;
}
}
}
}
if (hasEquivalentSibling < 2) {
return true;
}
return false;
}
function copyPixel(px, offset, pix) {
if (errorType === "diffOnly") {
return;
}
px[offset] = pix.r; // r
px[offset + 1] = pix.g; // g
px[offset + 2] = pix.b; // b
px[offset + 3] = pix.a * pixelTransparency; // a
}
function copyGrayScalePixel(px, offset, pix) {
if (errorType === "diffOnly") {
return;
}
px[offset] = pix.brightness; // r
px[offset + 1] = pix.brightness; // g
px[offset + 2] = pix.brightness; // b
px[offset + 3] = pix.a * pixelTransparency; // a
}
function getPixelInfo(dst, pix, offset) {
if (pix.length > offset) {
dst.r = pix[offset];
dst.g = pix[offset + 1];
dst.b = pix[offset + 2];
dst.a = pix[offset + 3];
return true;
}
return false;
}
function addBrightnessInfo(pix) {
pix.brightness = getBrightness(pix.r, pix.g, pix.b); // 'corrected' lightness
}
function addHueInfo(pix) {
pix.h = getHue(pix.r, pix.g, pix.b);
}
function analyseImages(img1, img2, width, height) {
var hiddenCanvas = document.createElement("canvas");
var data1 = img1.data;
var data2 = img2.data;
hiddenCanvas.width = width;
hiddenCanvas.height = height;
var context = hiddenCanvas.getContext("2d");
var imgd = context.createImageData(width, height);
var pix = imgd.data;
var mismatchCount = 0;
var diffBounds = {
top: height,
left: width,
bottom: 0,
right: 0
};
var updateBounds = function(x, y) {
diffBounds.left = Math.min(x, diffBounds.left);
diffBounds.right = Math.max(x, diffBounds.right);
diffBounds.top = Math.min(y, diffBounds.top);
diffBounds.bottom = Math.max(y, diffBounds.bottom);
};
var time = Date.now();
var skip;
if (
!!largeImageThreshold &&
ignoreAntialiasing &&
(width > largeImageThreshold || height > largeImageThreshold)
) {
skip = 6;
}
var pixel1 = { r: 0, g: 0, b: 0, a: 0 };
var pixel2 = { r: 0, g: 0, b: 0, a: 0 };
loop(width, height, function(horizontalPos, verticalPos) {
if (skip) {
// only skip if the image isn't small
if (
verticalPos % skip === 0 ||
horizontalPos % skip === 0
) {
return;
}
}
var offset = (verticalPos * width + horizontalPos) * 4;
var isWithinComparedArea = withinComparedArea(
horizontalPos,
verticalPos,
width,
height
);
if (
!getPixelInfo(pixel1, data1, offset, 1) ||
!getPixelInfo(pixel2, data2, offset, 2)
) {
return;
}
if (ignoreColors) {
addBrightnessInfo(pixel1);
addBrightnessInfo(pixel2);
if (
isPixelBrightnessSimilar(pixel1, pixel2) ||
!isWithinComparedArea
) {
copyGrayScalePixel(pix, offset, pixel2);
} else {
errorPixel(pix, offset, pixel1, pixel2);
mismatchCount++;
updateBounds(horizontalPos, verticalPos);
}
return;
}
if (isRGBSimilar(pixel1, pixel2) || !isWithinComparedArea) {
copyPixel(pix, offset, pixel1);
} else if (
ignoreAntialiasing &&
(addBrightnessInfo(pixel1), // jit pixel info augmentation looks a little weird, sorry.
addBrightnessInfo(pixel2),
isAntialiased(
pixel1,
data1,
1,
verticalPos,
horizontalPos,
width
) ||
isAntialiased(
pixel2,
data2,
2,
verticalPos,
horizontalPos,
width
))
) {
if (
isPixelBrightnessSimilar(pixel1, pixel2) ||
!isWithinComparedArea
) {
copyGrayScalePixel(pix, offset, pixel2);
} else {
errorPixel(pix, offset, pixel1, pixel2);
mismatchCount++;
updateBounds(horizontalPos, verticalPos);
}
} else {
errorPixel(pix, offset, pixel1, pixel2);
mismatchCount++;
updateBounds(horizontalPos, verticalPos);
}
});
data.rawMisMatchPercentage = mismatchCount / (height * width) * 100;
data.misMatchPercentage = data.rawMisMatchPercentage.toFixed(2);
data.diffBounds = diffBounds;
data.analysisTime = Date.now() - time;
data.getImageDataUrl = function(text) {
var barHeight = 0;
if (text) {
barHeight = addLabel(text, context, hiddenCanvas);
}
context.putImageData(imgd, 0, barHeight);
return hiddenCanvas.toDataURL("image/png");
};
if (hiddenCanvas.toBuffer) {
data.getBuffer = function(includeOriginal) {
if (includeOriginal) {
var imageWidth = hiddenCanvas.width + 2;
hiddenCanvas.width = imageWidth * 3;
context.putImageData(img1, 0, 0);
context.putImageData(img2, imageWidth, 0);
context.putImageData(imgd, imageWidth * 2, 0);
} else {
context.putImageData(imgd, 0, 0);
}
return hiddenCanvas.toBuffer();
};
}
}
function addLabel(text, context, hiddenCanvas) {
var textPadding = 2;
context.font = "12px sans-serif";
var textWidth = context.measureText(text).width + textPadding * 2;
var barHeight = 22;
if (textWidth > hiddenCanvas.width) {
hiddenCanvas.width = textWidth;
}
hiddenCanvas.height += barHeight;
context.fillStyle = "#666";
context.fillRect(0, 0, hiddenCanvas.width, barHeight - 4);
context.fillStyle = "#fff";
context.fillRect(0, barHeight - 4, hiddenCanvas.width, 4);
context.fillStyle = "#fff";
context.textBaseline = "top";
context.font = "12px sans-serif";
context.fillText(text, textPadding, 1);
return barHeight;
}
function normalise(img, w, h) {
var c;
var context;
if (img.height < h || img.width < w) {
c = document.createElement("canvas");
c.width = w;
c.height = h;
context = c.getContext("2d");
context.putImageData(img, 0, 0);
return context.getImageData(0, 0, w, h);
}
return img;
}
function outputSettings(options) {
var key;
if (options.errorColor) {
for (key in options.errorColor) {
if (options.errorColor.hasOwnProperty(key)) {
errorPixelColor[key] =
options.errorColor[key] === void 0
? errorPixelColor[key]
: options.errorColor[key];
}
}
}
if (options.errorType && errorPixelTransform[options.errorType]) {
errorPixel = errorPixelTransform[options.errorType];
errorType = options.errorType;
}
if (
options.errorPixel &&
typeof options.errorPixel === "function"
) {
errorPixel = options.errorPixel;
}
pixelTransparency = isNaN(Number(options.transparency))
? pixelTransparency
: options.transparency;
if (options.largeImageThreshold !== undefined) {
largeImageThreshold = options.largeImageThreshold;
}
if (options.useCrossOrigin !== undefined) {
useCrossOrigin = options.useCrossOrigin;
}
if (options.boundingBox !== undefined) {
boundingBox = options.boundingBox;
}
if (options.ignoredBox !== undefined) {
ignoredBox = options.ignoredBox;
}
}
function compare(one, two) {
if (globalOutputSettings !== oldGlobalSettings) {
outputSettings(globalOutputSettings);
}
function onceWeHaveBoth() {
var width;
var height;
if (images.length === 2) {
if (images[0].error || images[1].error) {
data = {};
data.error = images[0].error
? images[0].error
: images[1].error;
triggerDataUpdate();
return;
}
width =
images[0].width > images[1].width
? images[0].width
: images[1].width;
height =
images[0].height > images[1].height
? images[0].height
: images[1].height;
if (
images[0].width === images[1].width &&
images[0].height === images[1].height
) {
data.isSameDimensions = true;
} else {
data.isSameDimensions = false;
}
data.dimensionDifference = {
width: images[0].width - images[1].width,
height: images[0].height - images[1].height
};
analyseImages(
normalise(images[0], width, height),
normalise(images[1], width, height),
width,
height
);
triggerDataUpdate();
}
}
images = [];
loadImageData(one, onceWeHaveBoth);
loadImageData(two, onceWeHaveBoth);
}
function getCompareApi(param) {
var secondFileData;
var hasMethod = typeof param === "function";
if (!hasMethod) {
// assume it's file data
secondFileData = param;
}
var self = {
scaleToSameSize: function() {
scaleToSameSize = true;
if (hasMethod) {
param();
}
return self;
},
useOriginalSize: function() {
scaleToSameSize = false;
if (hasMethod) {
param();
}
return self;
},
ignoreNothing: function() {
tolerance.red = 0;
tolerance.green = 0;
tolerance.blue = 0;
tolerance.alpha = 0;
tolerance.minBrightness = 0;
tolerance.maxBrightness = 255;
ignoreAntialiasing = false;
ignoreColors = false;
if (hasMethod) {
param();
}
return self;
},
ignoreLess: function() {
tolerance.red = 16;
tolerance.green = 16;
tolerance.blue = 16;
tolerance.alpha = 16;
tolerance.minBrightness = 16;
tolerance.maxBrightness = 240;
ignoreAntialiasing = false;
ignoreColors = false;
if (hasMethod) {
param();
}
return self;
},
ignoreAntialiasing: function() {
tolerance.red = 32;
tolerance.green = 32;
tolerance.blue = 32;
tolerance.alpha = 32;
tolerance.minBrightness = 64;
tolerance.maxBrightness = 96;
ignoreAntialiasing = true;
ignoreColors = false;
if (hasMethod) {
param();
}
return self;
},
ignoreColors: function() {
tolerance.alpha = 16;
tolerance.minBrightness = 16;
tolerance.maxBrightness = 240;
ignoreAntialiasing = false;
ignoreColors = true;
if (hasMethod) {
param();
}
return self;
},
ignoreAlpha: function() {
tolerance.red = 16;
tolerance.green = 16;
tolerance.blue = 16;
tolerance.alpha = 255;
tolerance.minBrightness = 16;
tolerance.maxBrightness = 240;
ignoreAntialiasing = false;
ignoreColors = false;
if (hasMethod) {
param();
}
return self;
},
repaint: function() {
if (hasMethod) {
param();
}
return self;
},
outputSettings: function(options) {
outputSettings(options);
return self;
},
onComplete: function(callback) {
updateCallbackArray.push(callback);
var wrapper = function() {
compare(fileData, secondFileData);
};
wrapper();
return getCompareApi(wrapper);
}
};
return self;
}
var rootSelf = {
onComplete: function(callback) {
updateCallbackArray.push(callback);
loadImageData(fileData, function(imageData, width, height) {
parseImage(imageData.data, width, height);
});
},
compareTo: function(secondFileData) {
return getCompareApi(secondFileData);
},
outputSettings: function(options) {
outputSettings(options);
return rootSelf;
}
};
return rootSelf;
};
function applyIgnore(api, ignore) {
switch (ignore) {
case "nothing":
api.ignoreNothing();
break;
case "less":
api.ignoreLess();
break;
case "antialiasing":
api.ignoreAntialiasing();
break;
case "colors":
api.ignoreColors();
break;
case "alpha":
api.ignoreAlpha();
break;
default:
throw new Error("Invalid ignore: " + ignore);
}
}
resemble.compare = function(image1, image2, options, cb) {
var callback;
var opt;
if (typeof options === "function") {
callback = options;
opt = {};
} else {
callback = cb;
opt = options || {};
}
var res = resemble(image1);
var compare;
if (opt.output) {
res.outputSettings(opt.output);
}
compare = res.compareTo(image2);
if (opt.scaleToSameSize) {
compare.scaleToSameSize();
}
if (typeof opt.ignore === "string") {
applyIgnore(compare, opt.ignore);
} else if (opt.ignore && opt.ignore.forEach) {
opt.ignore.forEach(function(v) {
applyIgnore(compare, v);
});
}
compare.onComplete(function(data) {
if (data.error) {
callback(data.error);
} else {
callback(null, data);
}
});
};
resemble.outputSettings = setGlobalOutputSettings;
return resemble;
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment