Merge pull request #1138 from xodio/feat-1121-specialization-nodes-in-suggester

Improve suggester results for abstract and specialization nodes
This commit is contained in:
Kirill Shumilov
2018-03-22 15:40:43 +03:00
committed by GitHub
5 changed files with 396 additions and 76 deletions

View File

@@ -146,9 +146,28 @@ class Suggester extends React.Component {
const cls = classNames('Suggester-item', {
'is-highlighted': isHighlighted,
});
const value = regExpEscape(this.state.value);
const { item } = suggestion;
// TODO: Move extracting words for highlighter
// into `xod-patch-search` as `matched` property
// for each result
const searchWords = R.compose(
R.map(regExpEscape),
R.ifElse(
R.contains('('),
R.compose(
R.reject(R.isEmpty),
R.unless(
R.compose(R.isEmpty, R.nth(1)),
R.compose(R.unnest, R.over(R.lensIndex(1), R.split(',')))
),
R.slice(1, 3),
R.match(/^(.+)\(([a-z0-9-,]*)\){0,1}$/)
),
R.of
)
)(this.state.value);
return (
<div // eslint-disable-line jsx-a11y/no-static-element-interactions
role="button"
@@ -158,12 +177,12 @@ class Suggester extends React.Component {
>
<Highlighter
className="path"
searchWords={[value]}
searchWords={searchWords}
textToHighlight={item.path}
/>
<Highlighter
className="description"
searchWords={[value]}
searchWords={searchWords}
textToHighlight={item.description}
/>
</div>

View File

@@ -21,6 +21,7 @@
"dependencies": {
"fuse.js": "^3.0.5",
"ramda": "^0.24.1",
"ramda-fantasy": "^0.8.0",
"xod-func-tools": "^0.19.2",
"xod-project": "^0.19.2"
},

View File

@@ -1,6 +1,8 @@
import * as R from 'ramda';
import Fuse from 'fuse.js';
import { Maybe } from 'ramda-fantasy';
import { getBaseName } from 'xod-project';
import { foldMaybe } from 'xod-func-tools';
import createIndexData from './mapper';
@@ -59,7 +61,7 @@ const calculateEntryRate = (str, token) => {
// If str ends with token
if (str.length === index + token.length) return 0.5;
// If str doesn't contain token
if (index !== -1) return 3;
if (index === -1) return 3;
// If str contains token
return 2.5;
};
@@ -90,6 +92,22 @@ const refineScore = R.curry((query, result) =>
)(result)
);
const refineScoreForSpecializationNode = R.curry((query, result) =>
R.compose(
sortByScoreOrAlphabetically,
R.map(item => {
const tokens = query.trim().split(',');
const tokensInItem = R.filter(R.contains(R.__, item.item.path), tokens);
const tokensInItemCount = tokensInItem.length;
const newScore =
tokensInItemCount > 0 ? item.score / tokensInItemCount : Infinity;
return R.assoc('score', newScore, item);
})
)(result)
);
const getAverageScore = resultList =>
R.compose(
R.divide(R.__, resultList.length),
@@ -97,7 +115,10 @@ const getAverageScore = resultList =>
R.pluck('score')
)(resultList);
const reduceResults = results => {
const reduceResults = rawResults => {
// Cut results with "Infinity" score from results
const results = R.reject(R.propEq('score', Infinity), rawResults);
const averageScore = getAverageScore(results);
const scoreToFilter = Math.min(averageScore * 2, 0.3);
@@ -109,13 +130,41 @@ const reduceResults = results => {
return results;
};
const itemPathContainsOpeningBracket = R.pathSatisfies(R.contains('('), [
'item',
'path',
]);
const filterSpecializationNodes = R.curry((query, idx) =>
R.ifElse(
R.contains('('),
R.compose(
R.reject(R.complement(itemPathContainsOpeningBracket)),
foldMaybe(idx, refineScoreForSpecializationNode(R.__, idx)),
Maybe,
R.nth(1),
R.match(/\((.+)\){0,1}$/)
),
() => R.reject(itemPathContainsOpeningBracket)(idx)
)(query)
);
// :: String -> String
const takeQueryWithoutBrackets = R.compose(R.nth(0), R.split('('));
// :: [IndexData] -> SearchIndex
export const createIndex = data => {
const idx = new Fuse(data, options);
return {
search: query =>
R.compose(reduceResults, refineScore(query), idx.search.bind(idx))(query),
R.compose(
reduceResults,
filterSpecializationNodes(query),
refineScore(query),
idx.search.bind(idx),
takeQueryWithoutBrackets
)(query),
};
};
// :: [Patch] -> SearchIndex

View File

@@ -0,0 +1,159 @@
{
"name": "",
"patches": {
"@/main": {
"nodes": {
"SyY2S20KG": {
"id": "SyY2S20KG",
"position": {
"x": 102,
"y": -204
},
"type": "@/when-either-changes(number,string)"
},
"r18hH2RtM": {
"id": "r18hH2RtM",
"position": {
"x": -34,
"y": -204
},
"type": "@/when-either-changes(number,boolean)"
},
"rkNnr2RKf": {
"id": "rkNnr2RKf",
"position": {
"x": -170,
"y": -204
},
"type": "@/when-either-changes"
}
},
"path": "@/main"
},
"@/when-either-changes": {
"nodes": {
"B1x_SnRtG": {
"id": "B1x_SnRtG",
"position": {
"x": 170,
"y": 0
},
"type": "xod/patch-nodes/input-t2"
},
"ByE4B20Yf": {
"id": "ByE4B20Yf",
"position": {
"x": 136,
"y": 0
},
"type": "xod/patch-nodes/input-t1"
},
"SytmH2RFG": {
"id": "SytmH2RFG",
"position": {
"x": 136,
"y": 102
},
"type": "xod/patch-nodes/abstract"
},
"ryuwSh0Fz": {
"id": "ryuwSh0Fz",
"position": {
"x": 136,
"y": 204
},
"type": "xod/patch-nodes/output-pulse"
}
},
"path": "@/when-either-changes"
},
"@/when-either-changes(number,boolean)": {
"attachments": [
{
"content": "\nstruct State {\n};\n\n{{ GENERATED_CODE }}\n\nvoid evaluate(Context ctx) {\n //auto inValue = getValue<input_IN>(ctx);\n //emitValue<output_OUT>(ctx, inValue);\n}\n",
"encoding": "utf-8",
"filename": "patch.cpp"
}
],
"nodes": {
"ByY5H2RFf": {
"id": "ByY5H2RFf",
"position": {
"x": 68,
"y": 0
},
"type": "xod/patch-nodes/input-number"
},
"HJ7or3Atz": {
"id": "HJ7or3Atz",
"position": {
"x": 68,
"y": 204
},
"type": "xod/patch-nodes/output-pulse"
},
"SkgsBh0Yf": {
"id": "SkgsBh0Yf",
"position": {
"x": 68,
"y": 102
},
"type": "xod/patch-nodes/not-implemented-in-xod"
},
"rJ2qSn0Fz": {
"id": "rJ2qSn0Fz",
"position": {
"x": 102,
"y": 0
},
"type": "xod/patch-nodes/input-boolean"
}
},
"path": "@/when-either-changes(number,boolean)"
},
"@/when-either-changes(number,string)": {
"attachments": [
{
"content": "\nstruct State {\n};\n\n{{ GENERATED_CODE }}\n\nvoid evaluate(Context ctx) {\n //auto inValue = getValue<input_IN>(ctx);\n //emitValue<output_OUT>(ctx, inValue);\n}\n",
"encoding": "utf-8",
"filename": "patch.cpp"
}
],
"nodes": {
"BJFYS20Yz": {
"id": "BJFYS20Yz",
"position": {
"x": 68,
"y": 204
},
"type": "xod/patch-nodes/output-pulse"
},
"HJecrnAKG": {
"id": "HJecrnAKG",
"position": {
"x": 68,
"y": 102
},
"type": "xod/patch-nodes/not-implemented-in-xod"
},
"S1WYr30tM": {
"id": "S1WYr30tM",
"position": {
"x": 68,
"y": 0
},
"type": "xod/patch-nodes/input-number"
},
"SJrFBnCtf": {
"id": "SJrFBnCtf",
"position": {
"x": 102,
"y": 0
},
"type": "xod/patch-nodes/input-string"
}
},
"path": "@/when-either-changes(number,string)"
}
}
}

View File

@@ -6,85 +6,177 @@ import { loadProject } from 'xod-fs';
import { createIndex, createIndexData } from '../src/index';
const workspace = path.resolve(__dirname, '../../../workspace');
const getProjectPath = projectName => path.resolve(workspace, projectName);
const fixture = p => path.resolve(__dirname, './fixtures/', p);
describe('xod-patch-search/index', () => {
let indexData = [];
let idx = {};
const workspace = path.resolve(__dirname, '../../../workspace');
const getProjectPath = projectName => path.resolve(workspace, projectName);
describe('general search', () => {
let indexData = [];
let idx = {};
before(() =>
loadProject([workspace], getProjectPath('welcome-to-xod'))
.then(listPatches)
.then(createIndexData)
.then(iData => {
indexData = iData;
idx = createIndex(indexData);
})
);
before(() =>
loadProject([workspace], getProjectPath('welcome-to-xod'))
.then(listPatches)
.then(createIndexData)
.then(iData => {
indexData = iData;
idx = createIndex(indexData);
})
);
it('searches by path correctly', () => {
const result = idx.search('path:number');
assert.equal(result[0].item.path, 'xod/core/nth-number');
});
it('searches by path correctly', () => {
const result = idx.search('path:number');
assert.equal(result[0].item.path, 'xod/core/nth-number');
it('searches by lib correctly', () => {
const result = idx.search('lib:xod/core');
const expectedLength = R.compose(
R.length,
// We have to reject patches with path contains "(",
// cause we're not showing specialization nodes if
// query does not contain "(" symbol
R.reject(R.propSatisfies(R.contains('('), 'path')),
R.filter(R.propEq('lib', 'xod/core'))
)(indexData);
assert.lengthOf(result, expectedLength);
});
it('searches: "number"', () =>
assert.equal(
idx.search('number')[0].item.path,
'xod/core/nth-number' // Cause it has a `number` in the path and it alphabetically sorted (that's why not *-to-string)
));
it('searches: "lib:xod/patch-nodes number"', () => {
assert.equal(
idx.search('lib:xod/patch-nodes number')[0].item.path,
'xod/patch-nodes/input-number' // The same as above + filtered by lib
);
});
it('searches: "meter"', () => {
const results = idx.search('meter');
const foundPatchPaths = R.map(R.path(['item', 'path']), results);
assert.equal(
results[0].item.path,
'xod/common-hardware/gp2y0a02-range-meter' // Cause this node has a full match `meter` in the `path`
);
assert.includeMembers(foundPatchPaths, ['xod/units/m-to-ft']); // Don't care about position, but be sure that it's found
});
it('searches: "temp"', () =>
assert.equal(
idx.search('temp')[0].item.path,
'xod/units/c-to-f' // Cause this node has a `temperature` in the description
));
it('searches: "therm"', () => {
const results = idx.search('therm');
assert.equal(
results[0].item.path,
'xod/common-hardware/thermometer-tmp36' // Cause this node has a `thermometer` in the path and it alphabetically sorted
);
assert.equal(
results[1].item.path,
'xod/common-hardware/dht11-thermometer' // Cause this node has a `thermometer` in the path and it alphabetically sorted
);
});
it('searches: "ult"', () => {
const results = idx.search('ult');
assert.equal(
results[0].item.path,
'xod/common-hardware/hc-sr04-ultrasonic-range'
);
assert.equal(
results[1].item.path,
'xod/common-hardware/hc-sr04-ultrasonic-time'
);
assert.equal(results[2].item.path, 'xod/core/multiply');
});
});
it('searches by lib correctly', () => {
const result = idx.search('lib:xod/core');
const expectedLength = indexData.filter(item => item.lib === 'xod/core')
.length;
assert.lengthOf(result, expectedLength);
});
it('searches: "number"', () =>
assert.equal(
idx.search('number')[0].item.path,
'xod/core/nth-number' // Cause it has a `number` in the path and it alphabetically sorted (that's why not *-to-string)
));
it('searches: "lib:xod/patch-nodes number"', () => {
assert.equal(
idx.search('lib:xod/patch-nodes number')[0].item.path,
'xod/patch-nodes/input-number' // The same as above + filtered by lib
describe('abstract nodes and specializations', () => {
let indexData = [];
let idx = {};
before(() =>
loadProject([workspace], fixture('abstract-and-specializations.xodball'))
.then(listPatches)
.then(createIndexData)
.then(iData => {
indexData = iData;
idx = createIndex(indexData);
})
);
});
it('searches abstract node "when-either-ch" and doesn\'t show specialization nodes', () => {
const results = idx.search('when-either-ch');
const patchPaths = R.map(R.path(['item', 'path']), results);
it('searches: "meter"', () => {
const results = idx.search('meter');
const foundPatchPaths = R.map(R.path(['item', 'path']), results);
assert.equal(
results[0].item.path,
'xod/common-hardware/gp2y0a02-range-meter' // Cause this node has a full match `meter` in the `path`
);
assert.includeMembers(foundPatchPaths, ['xod/units/m-to-ft']); // Don't care about position, but be sure that it's found
});
assert.equal(results[0].item.path, '@/when-either-changes');
assert.isFalse(
R.contains('@/when-either-changes(number,string)', patchPaths)
);
assert.isFalse(
R.contains('@/when-either-changes(number,boolean)', patchPaths)
);
});
it('searches all specialization nodes for "when-either-ch("', () => {
const results = idx.search('when-either-ch(');
const patchPaths = R.map(R.path(['item', 'path']), results);
it('searches: "temp"', () =>
assert.equal(
idx.search('temp')[0].item.path,
'xod/units/c-to-f' // Cause this node has a `temperature` in the description
));
assert.isFalse(R.contains('@/when-either-changes', patchPaths));
assert.isTrue(
R.contains('@/when-either-changes(number,string)', patchPaths)
);
assert.isTrue(
R.contains('@/when-either-changes(number,boolean)', patchPaths)
);
});
it('searches both specialization nodes for "when-either-ch(mber"', () => {
const results = idx.search('when-either-ch(mber');
const patchPaths = R.map(R.path(['item', 'path']), results);
const specializations = R.take(2, patchPaths);
it('searches: "therm"', () => {
const results = idx.search('therm');
assert.equal(
results[0].item.path,
'xod/common-hardware/thermometer-tmp36' // Cause this node has a `thermometer` in the path and it alphabetically sorted
);
assert.equal(
results[1].item.path,
'xod/common-hardware/dht11-thermometer' // Cause this node has a `thermometer` in the path and it alphabetically sorted
);
});
assert.isFalse(R.contains('@/when-either-changes', patchPaths));
assert.sameMembers(
[
'@/when-either-changes(number,string)',
'@/when-either-changes(number,boolean)',
],
specializations
);
});
it('searches one specialization node for "when-(tr"', () => {
const results = idx.search('when-(tr');
it('searches: "ult"', () => {
const results = idx.search('ult');
assert.equal(
results[0].item.path,
'xod/common-hardware/hc-sr04-ultrasonic-range'
);
assert.equal(
results[1].item.path,
'xod/common-hardware/hc-sr04-ultrasonic-time'
);
assert.equal(results[2].item.path, 'xod/core/multiply');
assert.equal(
results[0].item.path,
'@/when-either-changes(number,string)'
);
});
it('searches one specialization node for "when-either-ch(ring"', () => {
const results = idx.search('when-either-ch(ring');
const patchPaths = R.map(R.path(['item', 'path']), results);
assert.equal(
results[0].item.path,
'@/when-either-changes(number,string)'
);
assert.isFalse(R.contains('@/when-either-changes', patchPaths));
});
it('searches one specialization node for "when-either-ch(number,boolean)"', () => {
const results = idx.search('when-either-ch(number,boolean)');
assert.equal(
results[0].item.path,
'@/when-either-changes(number,boolean)'
);
});
it('searches no specialization node for "when-either-ch(pulse)"', () => {
const results = idx.search('when-either-ch(pulse)');
assert.lengthOf(results, 0);
});
});
});