PersonalityProvider.test.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511
  1. import {GlobalOverrider} from "test/unit/utils";
  2. import injector from "inject!lib/PersonalityProvider.jsm";
  3. const TIME_SEGMENTS = [
  4. {"id": "hour", "startTime": 3600, "endTime": 0, "weightPosition": 1},
  5. {"id": "day", "startTime": 86400, "endTime": 3600, "weightPosition": 0.75},
  6. {"id": "week", "startTime": 604800, "endTime": 86400, "weightPosition": 0.5},
  7. {"id": "weekPlus", "startTime": null, "endTime": 604800, "weightPosition": 0.25},
  8. ];
  9. const PARAMETER_SETS = {
  10. "paramSet1": {
  11. "recencyFactor": 0.5,
  12. "frequencyFactor": 0.5,
  13. "combinedDomainFactor": 0.5,
  14. "perfectFrequencyVisits": 10,
  15. "perfectCombinedDomainScore": 2,
  16. "multiDomainBoost": 0.1,
  17. "itemScoreFactor": 0,
  18. },
  19. "paramSet2": {
  20. "recencyFactor": 1,
  21. "frequencyFactor": 0.7,
  22. "combinedDomainFactor": 0.8,
  23. "perfectFrequencyVisits": 10,
  24. "perfectCombinedDomainScore": 2,
  25. "multiDomainBoost": 0.1,
  26. "itemScoreFactor": 0,
  27. },
  28. };
  29. describe("Personality Provider", () => {
  30. let instance;
  31. let PersonalityProvider;
  32. let globals;
  33. let NaiveBayesTextTaggerStub;
  34. let NmfTextTaggerStub;
  35. let RecipeExecutorStub;
  36. let baseURLStub;
  37. beforeEach(() => {
  38. globals = new GlobalOverrider();
  39. const testUrl = "www.somedomain.com";
  40. globals.sandbox.stub(global.Services.io, "newURI").returns({host: testUrl});
  41. globals.sandbox.stub(global.PlacesUtils.history, "executeQuery").returns({root: {childCount: 1, getChild: index => ({uri: testUrl, accessCount: 1})}});
  42. globals.sandbox.stub(global.PlacesUtils.history, "getNewQuery").returns({"TIME_RELATIVE_NOW": 1});
  43. globals.sandbox.stub(global.PlacesUtils.history, "getNewQueryOptions").returns({});
  44. NaiveBayesTextTaggerStub = globals.sandbox.stub();
  45. NmfTextTaggerStub = globals.sandbox.stub();
  46. RecipeExecutorStub = globals.sandbox.stub();
  47. baseURLStub = "";
  48. global.fetch = async server => ({
  49. ok: true,
  50. json: async () => {
  51. if (server === "services.settings.server/") {
  52. return {capabilities: {attachments: {base_url: baseURLStub}}};
  53. }
  54. return {};
  55. },
  56. });
  57. globals.sandbox.stub(global.Services.prefs, "getCharPref").callsFake(pref => pref);
  58. ({PersonalityProvider} = injector({
  59. "lib/NaiveBayesTextTagger.jsm": {NaiveBayesTextTagger: NaiveBayesTextTaggerStub},
  60. "lib/NmfTextTagger.jsm": {NmfTextTagger: NmfTextTaggerStub},
  61. "lib/RecipeExecutor.jsm": {RecipeExecutor: RecipeExecutorStub},
  62. }));
  63. instance = new PersonalityProvider(TIME_SEGMENTS, PARAMETER_SETS);
  64. instance.interestConfig = {
  65. history_item_builder: "history_item_builder",
  66. history_required_fields: ["a", "b", "c"],
  67. interest_finalizer: "interest_finalizer",
  68. item_to_rank_builder: "item_to_rank_builder",
  69. item_ranker: "item_ranker",
  70. interest_combiner: "interest_combiner",
  71. };
  72. // mock the RecipeExecutor
  73. instance.recipeExecutor = {
  74. executeRecipe: (item, recipe) => {
  75. if (recipe === "history_item_builder") {
  76. if (item.title === "fail") {
  77. return null;
  78. }
  79. return {title: item.title, score: item.frecency, type: "history_item"};
  80. } else if (recipe === "interest_finalizer") {
  81. return {title: item.title, score: item.score * 100, type: "interest_vector"};
  82. } else if (recipe === "item_to_rank_builder") {
  83. if (item.title === "fail") {
  84. return null;
  85. }
  86. return {item_title: item.title, item_score: item.score, type: "item_to_rank"};
  87. } else if (recipe === "item_ranker") {
  88. if ((item.title === "fail") || (item.item_title === "fail")) {
  89. return null;
  90. }
  91. return {title: item.title, score: item.item_score * item.score, type: "ranked_item"};
  92. }
  93. return null;
  94. },
  95. executeCombinerRecipe: (item1, item2, recipe) => {
  96. if (recipe === "interest_combiner") {
  97. if ((item1.title === "combiner_fail") || (item2.title === "combiner_fail")) {
  98. return null;
  99. }
  100. if (item1.type === undefined) {
  101. item1.type = "combined_iv";
  102. }
  103. if (item1.score === undefined) {
  104. item1.score = 0;
  105. }
  106. return {type: item1.type, score: item1.score + item2.score};
  107. }
  108. return null;
  109. },
  110. };
  111. });
  112. afterEach(() => {
  113. globals.restore();
  114. });
  115. describe("#init", () => {
  116. it("should return correct data for getAffinities", () => {
  117. const affinities = instance.getAffinities();
  118. assert.isDefined(affinities.timeSegments);
  119. assert.isDefined(affinities.parameterSets);
  120. });
  121. it("should return early and not initialize if getRecipe fails", async () => {
  122. sinon.stub(instance, "getRecipe").returns(Promise.resolve());
  123. await instance.init();
  124. assert.isUndefined(instance.initialized);
  125. });
  126. it("should return early if get recipe fails", async () => {
  127. sinon.stub(instance, "getRecipe").returns(Promise.resolve());
  128. sinon.stub(instance, "generateRecipeExecutor").returns(Promise.resolve());
  129. instance.interestConfig = undefined;
  130. await instance.init();
  131. assert.calledOnce(instance.getRecipe);
  132. assert.notCalled(instance.generateRecipeExecutor);
  133. assert.isUndefined(instance.initialized);
  134. assert.isUndefined(instance.interestConfig);
  135. });
  136. it("should call callback on successful init", async () => {
  137. sinon.stub(instance, "getRecipe").returns(Promise.resolve(true));
  138. instance.interestConfig = undefined;
  139. const callback = globals.sandbox.stub();
  140. instance.createInterestVector = async () => ({});
  141. sinon.stub(instance, "generateRecipeExecutor").returns(Promise.resolve(true));
  142. await instance.init(callback);
  143. assert.calledOnce(instance.getRecipe);
  144. assert.calledOnce(instance.generateRecipeExecutor);
  145. assert.calledOnce(callback);
  146. assert.isDefined(instance.interestVector);
  147. assert.isTrue(instance.initialized);
  148. });
  149. it("should return early and not initialize if generateRecipeExecutor fails", async () => {
  150. sinon.stub(instance, "getRecipe").returns(Promise.resolve(true));
  151. sinon.stub(instance, "generateRecipeExecutor").returns(Promise.resolve());
  152. instance.interestConfig = undefined;
  153. await instance.init();
  154. assert.calledOnce(instance.getRecipe);
  155. assert.isUndefined(instance.initialized);
  156. });
  157. it("should return early and not initialize if createInterestVector fails", async () => {
  158. sinon.stub(instance, "getRecipe").returns(Promise.resolve(true));
  159. instance.interestConfig = undefined;
  160. sinon.stub(instance, "generateRecipeExecutor").returns(Promise.resolve(true));
  161. instance.createInterestVector = async () => (null);
  162. await instance.init();
  163. assert.calledOnce(instance.getRecipe);
  164. assert.calledOnce(instance.generateRecipeExecutor);
  165. assert.isUndefined(instance.initialized);
  166. });
  167. it("should do generic init stuff when calling init with no cache", async () => {
  168. sinon.stub(instance, "getRecipe").returns(Promise.resolve(true));
  169. instance.interestConfig = undefined;
  170. instance.createInterestVector = async () => ({});
  171. sinon.stub(instance, "generateRecipeExecutor").returns(Promise.resolve(true));
  172. await instance.init();
  173. assert.calledOnce(instance.getRecipe);
  174. assert.calledOnce(instance.generateRecipeExecutor);
  175. assert.isDefined(instance.interestVector);
  176. assert.isTrue(instance.initialized);
  177. });
  178. });
  179. describe("#remote-settings", () => {
  180. it("should return a remote setting for getFromRemoteSettings", async () => {
  181. const settings = await instance.getFromRemoteSettings("attachment");
  182. assert.equal(typeof settings, "object");
  183. assert.equal(settings.length, 1);
  184. });
  185. });
  186. describe("#executor", () => {
  187. it("should return null if generateRecipeExecutor has no models", async () => {
  188. assert.isNull(await instance.generateRecipeExecutor());
  189. });
  190. it("should not generate taggers if already available", async () => {
  191. instance.taggers = {
  192. nbTaggers: ["first"],
  193. nmfTaggers: {first: "first"},
  194. };
  195. await instance.generateRecipeExecutor();
  196. assert.calledOnce(RecipeExecutorStub);
  197. const {args} = RecipeExecutorStub.firstCall;
  198. assert.equal(args[0].length, 1);
  199. assert.equal(args[0], "first");
  200. assert.equal(args[1].first, "first");
  201. });
  202. it("should pass recipe models to getRecipeExecutor on generateRecipeExecutor", async () => {
  203. instance.modelKeys = ["nb_model_sports", "nmf_model_sports"];
  204. instance.getFromRemoteSettings = async name => [
  205. {recordKey: "nb_model_sports", model_type: "nb"},
  206. {recordKey: "nmf_model_sports", model_type: "nmf", parent_tag: "nmf_sports_parent_tag"},
  207. ];
  208. await instance.generateRecipeExecutor();
  209. assert.calledOnce(RecipeExecutorStub);
  210. assert.calledOnce(NaiveBayesTextTaggerStub);
  211. assert.calledOnce(NmfTextTaggerStub);
  212. const {args} = RecipeExecutorStub.firstCall;
  213. assert.equal(args[0].length, 1);
  214. assert.isDefined(args[1].nmf_sports_parent_tag);
  215. });
  216. it("should skip any models not in modelKeys", async () => {
  217. instance.modelKeys = ["nb_model_sports"];
  218. instance.getFromRemoteSettings = async name => [
  219. {recordKey: "nb_model_sports", model_type: "nb"},
  220. {recordKey: "nmf_model_sports", model_type: "nmf", parent_tag: "nmf_sports_parent_tag"},
  221. ];
  222. await instance.generateRecipeExecutor();
  223. assert.calledOnce(RecipeExecutorStub);
  224. assert.calledOnce(NaiveBayesTextTaggerStub);
  225. assert.notCalled(NmfTextTaggerStub);
  226. const {args} = RecipeExecutorStub.firstCall;
  227. assert.equal(args[0].length, 1);
  228. assert.equal(Object.keys(args[1]).length, 0);
  229. });
  230. it("should skip any models not defined", async () => {
  231. instance.modelKeys = ["nb_model_sports", "nmf_model_sports"];
  232. instance.getFromRemoteSettings = async name => [
  233. {recordKey: "nb_model_sports", model_type: "nb"},
  234. ];
  235. await instance.generateRecipeExecutor();
  236. assert.calledOnce(RecipeExecutorStub);
  237. assert.calledOnce(NaiveBayesTextTaggerStub);
  238. assert.notCalled(NmfTextTaggerStub);
  239. const {args} = RecipeExecutorStub.firstCall;
  240. assert.equal(args[0].length, 1);
  241. assert.equal(Object.keys(args[1]).length, 0);
  242. });
  243. });
  244. describe("#recipe", () => {
  245. it("should get and fetch a new recipe on first getRecipe", async () => {
  246. sinon.stub(instance, "getFromRemoteSettings").returns(Promise.resolve([]));
  247. await instance.getRecipe();
  248. assert.calledOnce(instance.getFromRemoteSettings);
  249. assert.calledWith(instance.getFromRemoteSettings, "personality-provider-recipe");
  250. });
  251. it("should not fetch a recipe on getRecipe if cached", async () => {
  252. sinon.stub(instance, "getFromRemoteSettings").returns(Promise.resolve([]));
  253. instance.recipes = ["blah"];
  254. await instance.getRecipe();
  255. assert.notCalled(instance.getFromRemoteSettings);
  256. });
  257. });
  258. describe("#createInterestVector", () => {
  259. let mockHistory = [];
  260. beforeEach(() => {
  261. mockHistory = [
  262. {
  263. title: "automotive",
  264. description: "something about automotive",
  265. url: "http://example.com/automotive",
  266. frecency: 10,
  267. },
  268. {
  269. title: "fashion",
  270. description: "something about fashion",
  271. url: "http://example.com/fashion",
  272. frecency: 5,
  273. },
  274. {
  275. title: "tech",
  276. description: "something about tech",
  277. url: "http://example.com/tech",
  278. frecency: 1,
  279. },
  280. ];
  281. instance.fetchHistory = async () => mockHistory;
  282. });
  283. afterEach(() => {
  284. globals.restore();
  285. });
  286. it("should gracefully handle history entries that fail", async () => {
  287. mockHistory.push({title: "fail"});
  288. assert.isNotNull(await instance.createInterestVector());
  289. });
  290. it("should fail if the combiner fails", async () => {
  291. mockHistory.push({title: "combiner_fail", frecency: 111});
  292. let actual = await instance.createInterestVector();
  293. assert.isNull(actual);
  294. });
  295. it("should process history, combine, and finalize", async () => {
  296. let actual = await instance.createInterestVector();
  297. assert.equal(actual.score, 1600);
  298. });
  299. });
  300. describe("#calculateItemRelevanceScore", () => {
  301. it("it should return score for uninitialized provider", () => {
  302. instance.initialized = false;
  303. assert.equal(instance.calculateItemRelevanceScore({item_score: 2}), 2);
  304. });
  305. it("it should return 1 for uninitialized provider and no score", () => {
  306. instance.initialized = false;
  307. assert.equal(instance.calculateItemRelevanceScore({}), 1);
  308. });
  309. it("it should return -1 for busted item", () => {
  310. instance.initialized = true;
  311. assert.equal(instance.calculateItemRelevanceScore({title: "fail"}), -1);
  312. });
  313. it("it should return -1 for a busted ranking", () => {
  314. instance.initialized = true;
  315. instance.interestVector = {title: "fail", score: 10};
  316. assert.equal(instance.calculateItemRelevanceScore({title: "some item", score: 6}), -1);
  317. });
  318. it("it should return a score, and not change with interestVector", () => {
  319. instance.interestVector = {score: 10};
  320. instance.initialized = true;
  321. assert.equal(instance.calculateItemRelevanceScore({score: 2}), 20);
  322. assert.deepEqual(instance.interestVector, {score: 10});
  323. });
  324. });
  325. describe("#fetchHistory", () => {
  326. it("should return a history object for fetchHistory", async () => {
  327. const history = await instance.fetchHistory(["requiredColumn"], 1, 1);
  328. assert.equal(history.sql, `SELECT url, title, visit_count, frecency, last_visit_date, description\n FROM moz_places\n WHERE last_visit_date >= 1000000\n AND last_visit_date < 1000000 AND IFNULL(requiredColumn, "") <> "" LIMIT 30000`);
  329. assert.equal(history.options.columns.length, 1);
  330. assert.equal(Object.keys(history.options.params).length, 0);
  331. });
  332. });
  333. describe("#attachments", () => {
  334. it("should sync remote settings collection from onSync", async () => {
  335. sinon.stub(instance, "deleteAttachment").returns(Promise.resolve({}));
  336. sinon.stub(instance, "maybeDownloadAttachment").returns(Promise.resolve({}));
  337. await instance.onSync({
  338. data: {
  339. created: ["create-1", "create-2"],
  340. updated: [
  341. {old: "update-old-1", new: "update-new-1"},
  342. {old: "update-old-2", new: "update-new-2"},
  343. ],
  344. deleted: ["delete-2", "delete-1"],
  345. },
  346. });
  347. assert(instance.maybeDownloadAttachment.withArgs("create-1").calledOnce);
  348. assert(instance.maybeDownloadAttachment.withArgs("create-2").calledOnce);
  349. assert(instance.maybeDownloadAttachment.withArgs("update-new-1").calledOnce);
  350. assert(instance.maybeDownloadAttachment.withArgs("update-new-2").calledOnce);
  351. assert(instance.deleteAttachment.withArgs("delete-1").calledOnce);
  352. assert(instance.deleteAttachment.withArgs("delete-2").calledOnce);
  353. assert(instance.deleteAttachment.withArgs("update-old-1").calledOnce);
  354. assert(instance.deleteAttachment.withArgs("update-old-2").calledOnce);
  355. });
  356. it("should write a file from _downloadAttachment", async () => {
  357. const fetchStub = globals.sandbox.stub(global, "fetch").resolves({
  358. ok: true,
  359. arrayBuffer: async () => {},
  360. });
  361. baseURLStub = "/";
  362. const writeAtomicStub = globals.sandbox.stub(global.OS.File, "writeAtomic").resolves(Promise.resolve());
  363. globals.sandbox.stub(global.OS.Path, "join").callsFake((first, second) => first + second);
  364. globals.set("Uint8Array", class Uint8Array {});
  365. await instance._downloadAttachment({attachment: {location: "location", filename: "filename"}});
  366. const fetchArgs = fetchStub.firstCall.args;
  367. assert.equal(fetchArgs[0], "/location");
  368. const writeArgs = writeAtomicStub.firstCall.args;
  369. assert.equal(writeArgs[0], "/filename");
  370. assert.equal(writeArgs[2].tmpPath, "/filename.tmp");
  371. });
  372. it("should call reportError from _downloadAttachment if not valid response", async () => {
  373. globals.sandbox.stub(global, "fetch").resolves({ok: false});
  374. globals.sandbox.spy(global.Cu, "reportError");
  375. baseURLStub = "/";
  376. await instance._downloadAttachment({attachment: {location: "location", filename: "filename"}});
  377. assert.calledWith(Cu.reportError, "Failed to fetch /location: undefined");
  378. });
  379. it("should attempt _downloadAttachment three times for maybeDownloadAttachment", async () => {
  380. let existsStub;
  381. let statStub;
  382. let attachmentStub;
  383. sinon.stub(instance, "_downloadAttachment").returns(Promise.resolve());
  384. sinon.stub(instance, "_getFileStr").returns(Promise.resolve("1"));
  385. const makeDirStub = globals.sandbox.stub(global.OS.File, "makeDir").returns(Promise.resolve());
  386. globals.sandbox.stub(global.OS.Path, "join").callsFake((first, second) => first + second);
  387. existsStub = globals.sandbox.stub(global.OS.File, "exists").returns(Promise.resolve(true));
  388. statStub = globals.sandbox.stub(global.OS.File, "stat").returns(Promise.resolve({size: "1"}));
  389. attachmentStub = {
  390. attachment: {
  391. filename: "file",
  392. hash: "30",
  393. size: "1",
  394. },
  395. };
  396. await instance.maybeDownloadAttachment(attachmentStub);
  397. assert.calledWith(makeDirStub, "/");
  398. assert.calledOnce(existsStub);
  399. assert.calledOnce(statStub);
  400. assert.calledOnce(instance._getFileStr);
  401. assert.notCalled(instance._downloadAttachment);
  402. instance._getFileStr.resetHistory();
  403. existsStub.resetHistory();
  404. statStub.resetHistory();
  405. attachmentStub = {
  406. attachment: {
  407. filename: "file",
  408. hash: "31",
  409. size: "1",
  410. },
  411. };
  412. await instance.maybeDownloadAttachment(attachmentStub);
  413. assert.calledThrice(existsStub);
  414. assert.calledThrice(statStub);
  415. assert.calledThrice(instance._getFileStr);
  416. assert.calledThrice(instance._downloadAttachment);
  417. });
  418. it("should remove attachments when calling deleteAttachment", async () => {
  419. const makeDirStub = globals.sandbox.stub(global.OS.File, "makeDir").returns(Promise.resolve());
  420. const removeStub = globals.sandbox.stub(global.OS.File, "remove").returns(Promise.resolve());
  421. const removeEmptyDirStub = globals.sandbox.stub(global.OS.File, "removeEmptyDir").returns(Promise.resolve());
  422. globals.sandbox.stub(global.OS.Path, "join").callsFake((first, second) => first + second);
  423. await instance.deleteAttachment({attachment: {filename: "filename"}});
  424. assert.calledOnce(makeDirStub);
  425. assert.calledOnce(removeStub);
  426. assert.calledOnce(removeEmptyDirStub);
  427. assert.calledWith(removeStub, "/filename", {ignoreAbsent: true});
  428. });
  429. it("should return JSON when calling getAttachment", async () => {
  430. sinon.stub(instance, "maybeDownloadAttachment").returns(Promise.resolve());
  431. sinon.stub(instance, "_getFileStr").returns(Promise.resolve("{}"));
  432. const reportErrorStub = globals.sandbox.stub(global.Cu, "reportError");
  433. globals.sandbox.stub(global.OS.Path, "join").callsFake((first, second) => first + second);
  434. const record = {attachment: {filename: "filename"}};
  435. let returnValue = await instance.getAttachment(record);
  436. assert.notCalled(reportErrorStub);
  437. assert.calledOnce(instance._getFileStr);
  438. assert.calledWith(instance._getFileStr, "/filename");
  439. assert.calledOnce(instance.maybeDownloadAttachment);
  440. assert.calledWith(instance.maybeDownloadAttachment, record);
  441. assert.deepEqual(returnValue, {});
  442. instance._getFileStr.restore();
  443. sinon.stub(instance, "_getFileStr").returns(Promise.resolve({}));
  444. returnValue = await instance.getAttachment(record);
  445. assert.calledOnce(reportErrorStub);
  446. assert.calledWith(reportErrorStub, "Failed to load /filename: JSON.parse: unexpected character at line 1 column 2 of the JSON data");
  447. assert.deepEqual(returnValue, {});
  448. });
  449. it("should read and decode a file with _getFileStr", async () => {
  450. global.OS.File.read = async path => {
  451. if (path === "/filename") {
  452. return "binaryData";
  453. }
  454. return "";
  455. };
  456. globals.set("gTextDecoder", {
  457. decode: async binaryData => {
  458. if (binaryData === "binaryData") {
  459. return "binaryData";
  460. }
  461. return "";
  462. },
  463. });
  464. const returnValue = await instance._getFileStr("/filename");
  465. assert.equal(returnValue, "binaryData");
  466. });
  467. });
  468. });