test: add component test (#204)

This enables to run component tests with fixtures
This commit is contained in:
Christoffer Åström
2019-12-02 21:41:08 +01:00
committed by GitHub
parent b8e33eaa40
commit f0462f6670
22 changed files with 548 additions and 96 deletions

View File

@@ -28,7 +28,7 @@
}
},
{
"files": ["**/*.{int,spec}.{js,jsx}"],
"files": ["**/*.{int,comp,spec}.{js,jsx}"],
"env": {
"browser": false,
"node": true,
@@ -47,7 +47,7 @@
}
},
{
"files": ["**/*.int.js"],
"files": ["**/*.{int,comp}.js"],
"env": {
"browser": true
}
@@ -56,7 +56,8 @@
"files": ["**/templates/**/*.js"],
"rules": {
"import/no-unresolved": 0,
"import/extensions": 0
"import/extensions": 0,
"import/no-extraneous-dependencies": 0
}
},
{

View File

@@ -35,12 +35,12 @@ export default function Error({ title = 'Error', message = '', data = [] }) {
<WarningTriangle style={{ fontSize: '38px' }} />
</Grid>
<Grid item>
<Typography variant="h6" align="center">
<Typography variant="h6" align="center" data-tid="error-title">
{title}
</Typography>
</Grid>
<Grid item>
<Typography variant="subtitle1" align="center">
<Typography variant="subtitle1" align="center" data-tid="error-message">
{message}
</Typography>
</Grid>

View File

@@ -1,19 +1,13 @@
module.exports = {
env: {
test: {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }],
],
presets: [['@babel/preset-env', { targets: { node: 'current' } }]],
plugins: [
['@babel/plugin-transform-react-jsx'],
[
'istanbul',
{
exclude: [
'**/test/**',
'**/__test__/**',
'**/dist/**',
],
exclude: ['**/test/**', '**/__test__/**', '**/dist/**'],
},
],
],

View File

@@ -1,33 +0,0 @@
class Inject {
constructor(options = {}) {
this.options = options;
this.stylesheets = options.stylesheets || [];
this.scripts = options.scripts || [];
}
apply(compiler) {
if (!this.scripts.length || !this.stylesheets.length) {
return;
}
compiler.hooks.compilation.tap('Inject', compilation => {
compilation.hooks.htmlWebpackPluginAlterAssetTags.tap('Inject', htmlPluginData => {
this.scripts.forEach(s => {
htmlPluginData.head.unshift({
tagName: 'script',
closeTag: true,
attributes: { type: 'text/javascript', src: s },
});
});
this.stylesheets.forEach(s => {
htmlPluginData.head.unshift({
tagName: 'link',
closeTag: true,
attributes: { rel: 'stylesheet', type: 'text/css', href: s },
});
});
});
});
}
}
module.exports = Inject;

View File

@@ -7,8 +7,6 @@ const babelPresetReactPath = require.resolve('@babel/preset-react');
const sourceMapLoaderPath = require.resolve('source-map-loader');
const MonacoWebpackPlugin = require('monaco-editor-webpack-plugin');
const Inject = require('./head-injector');
const favicon = path.resolve(__dirname, '../../../docs/assets/njs.png');
const cfg = ({ srcDir, distDir, dev = false, serveConfig = {} }) => {
@@ -37,6 +35,7 @@ const cfg = ({ srcDir, distDir, dev = false, serveConfig = {} }) => {
'@nebula.js/locale': path.resolve(process.cwd(), 'apis/locale/src'),
}
: {}),
fixtures: path.resolve(process.cwd(), 'test/component'),
},
extensions: ['.js', '.jsx'],
},
@@ -83,6 +82,8 @@ const cfg = ({ srcDir, distDir, dev = false, serveConfig = {} }) => {
filename: 'eRender.html',
chunks: ['eRender'],
favicon,
scripts: serveConfig.scripts,
stylesheets: serveConfig.stylesheets,
}),
new HtmlWebpackPlugin({
template: path.resolve(srcDir, 'eDev.html'),
@@ -96,7 +97,6 @@ const cfg = ({ srcDir, distDir, dev = false, serveConfig = {} }) => {
chunks: ['eHub'],
favicon,
}),
new Inject(serveConfig),
],
};

View File

@@ -1,8 +1,6 @@
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const Inject = require('./head-injector');
const isSrc = /^([.]{2}\/)/;
const namespace = /^webpack:\/\/([^/]+)\//;
const NM = /node_modules/;
@@ -43,18 +41,24 @@ const cfg = ({ srcDir = path.resolve(__dirname, '../dist'), serveConfig = {} })
return `webpack://${info.namespace}/${info.resourcePath}`;
},
},
resolve: {
alias: {
fixtures: path.resolve(process.cwd(), 'test/component'),
},
},
plugins: [
new HtmlWebpackPlugin({
template: path.resolve(srcDir, 'eRender.html'),
filename: 'eRender.html',
inject: 'head',
scripts: serveConfig.scripts,
stylesheets: serveConfig.stylesheets,
}),
new HtmlWebpackPlugin({
template: path.resolve(srcDir, 'eDev.html'),
filename: 'eDev.html',
inject: 'head',
}),
new Inject(serveConfig),
],
};

View File

@@ -1,45 +1,50 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<base href="/">
<title>Nebula render</title>
<head>
<meta charset="UTF-8" />
<base href="/" />
<title>Nebula render</title>
<link href="https://fonts.googleapis.com/css?family=Source+Sans+Pro" rel="stylesheet">
<link href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:light" rel="stylesheet">
<link href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:semibold" rel="stylesheet">
<link href="https://fonts.googleapis.com/css?family=Source+Sans+Pro" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:light" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:semibold" rel="stylesheet" />
<% for (var stylesheet in htmlWebpackPlugin.options.stylesheets) { %>
<script type="text/javascript" src="<%= htmlWebpackPlugin.options.stylesheets[stylesheet] %>"></script>
<% } %> <% for (var script in htmlWebpackPlugin.options.scripts) { %>
<script type="text/javascript" src="<%= htmlWebpackPlugin.options.scripts[script] %>"></script>
<% } %>
<style>
html,
body {
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
<style>
html, body {
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
body {
background: linear-gradient(110deg, #92498f 0%, #45b3b2 100%);
font: normal 14px/16px 'Source Sans Pro', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
}
body {
background: linear-gradient(110deg, #92498F 0%, #45B3B2 100%);
font: normal 14px/16px "Source Sans Pro", Arial, sans-serif;
-webkit-font-smoothing: antialiased;
}
#chart-container {
position: absolute;
left: 8px;
bottom: 8px;
right: 8px;
top: 64px;
}
#chart-container {
position: absolute;
left: 8px;
bottom: 8px;
right: 8px;
top: 64px;
}
#chart-container.full {
top: 0;
right: 0;
bottom: 0;
left: 0;
}
</style>
</head>
<body>
<div id="chart-container"></div>
</body>
#chart-container.full {
top: 0;
right: 0;
bottom: 0;
left: 0;
}
</style>
</head>
<body>
<div id="chart-container"></div>
</body>
</html>

View File

@@ -1,6 +1,6 @@
import nucleus from '@nebula.js/nucleus';
import { openApp, params, info as serverInfo } from './connect';
import runFixture from './run-fixture';
const nuke = async ({ app, supernova: { name }, themes, theme }) => {
const nuked = nucleus.configured({
@@ -11,15 +11,15 @@ const nuke = async ({ app, supernova: { name }, themes, theme }) => {
}))
: undefined,
theme,
});
const nebbie = nuked(app, {
load: (type, config) => config.Promise.resolve(window[type.name]),
types: [
{
name,
},
],
});
const nebbie = nuked(app, {
load: (type, config) => config.Promise.resolve(window[type.name]),
});
return nebbie;
};
@@ -114,7 +114,70 @@ async function renderSnapshot() {
window.onHotChange(info.supernova.name, () => render());
}
if (params.snapshot) {
const renderFixture = async () => {
const element = document.querySelector('#chart-container');
const { theme } = params;
const { themes } = await serverInfo;
const fixture = runFixture(params.fixture);
const { instanceConfig, type, sn, object, snConfig } = fixture();
const config = {
themes: themes
? themes.map(t => ({
key: t,
load: async () => (await fetch(`/theme/${t}`)).json(),
}))
: undefined,
theme,
};
let mockedProps = {};
let mockedLayout = {};
const mockedObject = {
...mockedProps,
...object,
// beginSelections: async () => {},
// selectHyperCubeValues: async (path, dimNo, values, toggleMode) => {
// console.log(path, dimNo, values, toggleMode);
// },
// resetMadeSelections: async () => {},
getLayout:
object && object.getLayout
? async () => {
const layout = await object.getLayout();
mockedLayout = {
...mockedProps,
...layout,
};
return mockedLayout;
}
: async () => ({
...mockedProps,
}),
on() {},
once() {},
};
const mockedApp = {
// eslint-disable-next-line no-return-assign
createSessionObject: async p => (mockedProps = p),
getObject: async () => mockedObject,
};
const nebbie = nucleus(mockedApp, {
...config,
...instanceConfig,
types: [
{
name: type,
load: async () => sn,
},
],
});
nebbie.create({ type }, { ...snConfig, element });
};
if (params.fixture) {
renderFixture();
} else if (params.snapshot) {
renderSnapshot();
} else {
renderWithEngine();

View File

@@ -0,0 +1,13 @@
const runFixture = key => {
// const info = await serverInfo;
// see: https://webpack.js.org/guides/dependency-management/#requirecontext
try {
const context = require.context('fixtures', true, /\.fix\.js$/);
// context.keys().forEach(console.log);
return context(key).default;
} catch (_) {
return () => console.log('No fixtures found in test/component');
}
};
export default runFixture;

View File

@@ -55,7 +55,10 @@
"husky": "3.1.0",
"lerna": "3.18.5",
"lint-staged": "9.4.3",
"picasso.js": "0.29.0",
"picasso-plugin-q": "0.29.0",
"prettier": "1.19.1",
"qix-faker": "^0.3.0",
"rollup": "1.27.2",
"rollup-plugin-babel": "4.3.3",
"rollup-plugin-commonjs": "10.1.0",
@@ -76,6 +79,7 @@
"generated/*",
"packages/*",
"commands/*",
"apis/*"
"apis/*",
"test/component/*"
]
}

View File

@@ -0,0 +1,25 @@
import { hypercube } from 'qix-faker';
import sn from './src';
export default function fixture() {
return {
type: 'barchart',
sn,
snConfig: {
context: {
permissions: ['passive', 'interact'],
},
},
object: {
getLayout: async () => ({
qHyperCubeDef: null,
qHyperCube: hypercube({
seed: 13,
numRows: 20,
dimensions: [{ value: f => f.address.country(), maxCardinalRatio: 0.2 }],
measures: [f => f.commerce.price(10, 5000, 0, '$')],
}),
}),
},
};
}

View File

@@ -0,0 +1,15 @@
export default {
targets: [
{
path: 'qHyperCubeDef',
dimensions: {
min: 1,
max: 1,
},
measures: {
min: 1,
max: 1,
},
},
],
};

View File

@@ -0,0 +1,49 @@
import picassojs from 'picasso.js';
import picassoQ from 'picasso-plugin-q';
import properties from './object-properties';
import data from './data';
import picSelections from './pic-selections';
import definition from './pic-definition';
export default function supernova(/* env */) {
const picasso = picassojs();
picasso.use(picassoQ);
return {
qae: {
properties,
data,
},
component: {
created() {},
mounted(element) {
this.pic = picasso.chart({
element,
data: [],
settings: {},
});
this.picsel = picSelections({
selections: this.selections,
brush: this.pic.brush('selection'),
picassoQ,
});
},
render({ layout, context }) {
this.pic.update({
data: [
{
type: 'q',
key: 'qHyperCube',
data: layout.qHyperCube,
},
],
settings: definition({ layout, context }),
});
},
resize() {},
willUnmount() {},
destroy() {},
},
};
}

View File

@@ -0,0 +1,15 @@
const properties = {
qHyperCubeDef: {
qDimensions: [],
qMeasures: [],
qInitialDataFetch: [{ qWidth: 2, qHeight: 5000 }],
qSuppressZero: false,
qSuppressMissing: true,
},
showTitles: true,
title: '',
subtitle: '',
footnote: '',
};
export default properties;

View File

@@ -0,0 +1,68 @@
export default function picassoDefinition({ layout, context }) {
if (!layout.qHyperCube) {
throw new Error('Layout is missing a hypercube');
}
return {
scales: {
x: { data: { extract: { field: 'qDimensionInfo/0' } } },
y: {
data: { field: 'qMeasureInfo/0' },
expand: 0.2,
include: [0],
invert: true,
},
},
components: [
{
type: 'axis',
dock: 'left',
scale: 'y',
},
{
type: 'axis',
dock: 'bottom',
scale: 'x',
},
{
type: 'box',
data: {
extract: {
field: 'qDimensionInfo/0',
props: {
start: 0,
end: { field: 'qMeasureInfo/0' },
},
},
},
settings: {
major: { scale: 'x' },
minor: { scale: 'y' },
box: {
width: 0.7,
},
},
brush:
context.permissions.indexOf('interact') !== -1 && context.permissions.indexOf('select') !== -1
? {
trigger: [
{
contexts: ['selection'],
},
],
consume: [
{
context: 'selection',
data: ['', 'end'],
style: {
inactive: {
opacity: 0.3,
},
},
},
],
}
: {},
},
],
};
}

View File

@@ -0,0 +1,131 @@
/* eslint no-param-reassign: 0 */
// --- enable keyboard accessibility ---
// pressing enter (escape) key should confirm (cancel) selections
const KEYS = {
ENTER: 'Enter',
ESCAPE: 'Escape',
IE11_ESC: 'Esc',
SHIFT: 'Shift',
};
const instances = [];
let expando = 0;
const confirmOrCancelSelection = e => {
const active = instances.filter(a => a.selections && a.selections.isActive());
if (!active.length) {
return;
}
if (e.key === KEYS.ENTER) {
active.forEach(a => a.selections.confirm());
} else if (e.key === KEYS.ESCAPE || e.key === KEYS.IE11_ESC) {
active.forEach(a => a.selections.cancel());
}
};
const setup = () => {
document.addEventListener('keyup', confirmOrCancelSelection);
};
const teardown = () => {
document.removeEventListener('keyup', confirmOrCancelSelection);
};
// ------------------------------------------------------
const addListeners = (emitter, listeners) => {
Object.keys(listeners).forEach(type => {
emitter.on(type, listeners[type]);
});
};
const removeListeners = (emitter, listeners) => {
Object.keys(listeners).forEach(type => {
emitter.removeListener(type, listeners[type]);
});
};
export default function({ selections, brush, picassoQ } = {}, { path = '/qHyperCubeDef' } = {}) {
if (!selections) {
return {
release: () => {},
};
}
const key = ++expando;
let layout = null;
// interceptors primary job is to ensure selections only occur on either values OR ranges
const valueInterceptor = added => {
const brushes = brush.brushes();
brushes.forEach(b => {
if (b.type === 'range') {
// has range selections
brush.clear([]);
} else if (added[0] && added[0].key !== b.id) {
// has selections in another dimension
brush.clear([]);
}
});
return added.filter(t => t.value !== -2); // do not allow selection on null value
};
const rangeInterceptor = a => {
const v = brush.brushes().filter(b => b.type === 'value');
if (v.length) {
// has dimension values selected
brush.clear([]);
return a;
}
return a;
};
brush.intercept('set-ranges', rangeInterceptor);
brush.intercept('toggle-ranges', rangeInterceptor);
brush.intercept('toggle-values', valueInterceptor);
brush.intercept('set-values', valueInterceptor);
brush.intercept('add-values', valueInterceptor);
brush.on('start', () => selections.begin(path));
const selectionListeners = {
activate: () => {
// TODO - check if we can select in the current chart,
},
deactivated: () => brush.end(),
cleared: () => brush.clear(),
canceled: () => brush.end(),
};
addListeners(selections, selectionListeners);
brush.on('update', () => {
const generated = picassoQ.selections(brush, {}, layout);
generated.forEach(s => selections.select(s));
});
if (instances.length === 0) {
setup();
}
instances.push({
key,
selections,
});
return {
layout: lt => {
layout = lt;
},
release: () => {
layout = null;
const idx = instances.indexOf(instances.filter(i => i.key === key)[0]);
if (idx !== -1) {
instances.splice(idx, 1);
}
if (!instances.length) {
teardown();
}
removeListeners(selections, selectionListeners);
},
};
}

View File

@@ -0,0 +1,10 @@
describe('sn', () => {
const selector = '.nebulajs-cell [data-tid="error-title"';
it('should show incomplete visualization', async () => {
await page.goto('http://localhost:8000/render/?fixture=./object/incomplete-sn.fix.js&theme=dark');
await page.waitForSelector(selector, { visible: true });
const text = await page.$eval(selector, el => el.textContent);
expect(text).to.equal('Incomplete visualization');
});
});

View File

@@ -0,0 +1,13 @@
import incompleteSn from './incomplete-sn';
export default function fixture() {
return {
type: 'incomplete-sn',
sn: incompleteSn,
snConfig: {
context: {
permissions: ['passive', 'interact'],
},
},
};
}

View File

@@ -0,0 +1,20 @@
export default {
component: {
mounted(element) {
element.textContent = 'Hello engine!'; // eslint-disable-line no-param-reassign
},
},
qae: {
data: {
targets: [
{
path: '/qHyperCubeDef',
dimensions: {
min: 1,
max: 1,
},
},
],
},
},
};

View File

@@ -0,0 +1,13 @@
import sn from './sn';
export default function fixture() {
return {
type: 'sn',
sn,
snConfig: {
context: {
permissions: ['passive', 'interact'],
},
},
};
}

View File

@@ -0,0 +1,20 @@
export default {
component: {
mounted(element) {
element.textContent = 'Hello engine!'; // eslint-disable-line no-param-reassign
},
},
qae: {
data: {
targets: [
{
path: '/qHyperCubeDef',
dimensions: {
min: 0,
max: 0,
},
},
],
},
},
};

View File

@@ -7829,6 +7829,11 @@ extsprintf@^1.2.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f"
faker@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/faker/-/faker-4.1.0.tgz#1e45bbbecc6774b3c195fad2835109c6d748cc3f"
integrity sha1-HkW7vsxndLPBlfrSg1EJxtdIzD8=
fast-deep-equal@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49"
@@ -12302,6 +12307,16 @@ phin@^2.9.1:
version "2.9.3"
resolved "https://registry.yarnpkg.com/phin/-/phin-2.9.3.tgz#f9b6ac10a035636fb65dfc576aaaa17b8743125c"
picasso-plugin-q@0.29.0:
version "0.29.0"
resolved "https://registry.yarnpkg.com/picasso-plugin-q/-/picasso-plugin-q-0.29.0.tgz#5ac428605e7efde8f732079b6d5e5c6614b0c52a"
integrity sha512-zNWvGab7V7uw/mpAEuCbG7A6PUvM9bTHmvr9WoYGyT92evvvpcGGVafoTuct9qw6yK2HjK2rDwUjdq6+iZ20fg==
picasso.js@0.29.0:
version "0.29.0"
resolved "https://registry.yarnpkg.com/picasso.js/-/picasso.js-0.29.0.tgz#9c0b08cdb913ab6d82bf71b44f956a4f6eb00a49"
integrity sha512-AincOxuu6rE4Yx3+jY7jDd1wJlfAIYSNPjpfMOlqtn+JWM1Z+QBALpNIa/cWhvhdLxD7rFFVnto1Ciiikjahpg==
picomatch@^2.0.4, picomatch@^2.0.5:
version "2.0.7"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.0.7.tgz#514169d8c7cd0bdbeecc8a2609e34a7163de69f6"
@@ -13090,6 +13105,13 @@ q@^1.1.2, q@^1.5.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7"
qix-faker@^0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/qix-faker/-/qix-faker-0.3.0.tgz#63911eff5f2153bf93b6176faea8ffd833922435"
integrity sha512-S1oK/8Bl+MhIJHrn0KrPqmP6fqhNPNThPxaOLzKwDYHzF/st0wUfT+UBOhCyxpZJhhKmrp8SwJs0t3rpqRPo4A==
dependencies:
faker "^4.1.0"
qs@6.7.0:
version "6.7.0"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc"