test_ShortcutRanker.js (54880B)
1 "use strict"; 2 3 ChromeUtils.defineESModuleGetters(this, { 4 sinon: "resource://testing-common/Sinon.sys.mjs", 5 }); 6 7 add_task(async function test_weightedSampleTopSites_no_guid_last() { 8 // Ranker are utilities we are testing 9 const Ranker = ChromeUtils.importESModule( 10 "resource://newtab/lib/SmartShortcutsRanker/RankShortcuts.mjs" 11 ); 12 const provider = new Ranker.RankShortcutsProvider(); 13 // We are going to stub a database call 14 const { NewTabUtils } = ChromeUtils.importESModule( 15 "resource://gre/modules/NewTabUtils.sys.mjs" 16 ); 17 18 await NewTabUtils.init(); 19 20 const sandbox = sinon.createSandbox(); 21 22 // Stub DB call 23 sandbox 24 .stub(NewTabUtils.activityStreamProvider, "executePlacesQuery") 25 .resolves([ 26 ["a", 5, 10], 27 ["b", 2, 10], 28 ]); 29 30 // First item here intentially has no guid 31 const input = [ 32 { url: "no-guid.com" }, 33 { guid: "a", url: "a.com" }, 34 { guid: "b", url: "b.com" }, 35 ]; 36 37 const prefValues = { 38 trainhopConfig: { 39 smartShortcuts: { 40 // fset: "custom", // uncomment iff your build defines a "custom" fset you want to use 41 eta: 0, 42 click_bonus: 10, 43 positive_prior: 1, 44 negative_prior: 1, 45 fset: 1, 46 }, 47 }, 48 }; 49 50 const result = await provider.rankTopSites(input, prefValues, { 51 isStartup: false, 52 }); 53 54 Assert.ok(Array.isArray(result), "returns an array"); 55 Assert.equal( 56 result[result.length - 1].url, 57 "no-guid.com", 58 "top-site without GUID is last" 59 ); 60 61 sandbox.restore(); 62 }); 63 64 add_task(async function test_sumNorm() { 65 const Ranker = ChromeUtils.importESModule( 66 "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs" 67 ); 68 let vec = [1, 1]; 69 let result = Ranker.sumNorm(vec); 70 Assert.ok( 71 result.every((v, i) => Math.abs(v - [0.5, 0.5][i]) < 1e-6), 72 "sum norm works as expected for dense array" 73 ); 74 75 vec = [0, 0]; 76 result = Ranker.sumNorm(vec); 77 Assert.ok( 78 result.every((v, i) => Math.abs(v - [0.0, 0.0][i]) < 1e-6), 79 "if sum is 0.0, it should return the original vector, input is zeros" 80 ); 81 82 vec = [1, -1]; 83 result = Ranker.sumNorm(vec); 84 Assert.ok( 85 result.every((v, i) => Math.abs(v - [1.0, -1.0][i]) < 1e-6), 86 "if sum is 0.0, it should return the original vector, input contains negatives" 87 ); 88 }); 89 90 add_task(async function test_computeLinearScore() { 91 const Ranker = ChromeUtils.importESModule( 92 "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs" 93 ); 94 95 let entry = { a: 1, b: 0, bias: 1 }; 96 let weights = { a: 1, b: 0, bias: 0 }; 97 let result = Ranker.computeLinearScore(entry, weights); 98 Assert.equal(result, 1, "check linear score with one non-zero weight"); 99 100 entry = { a: 1, b: 1, bias: 1 }; 101 weights = { a: 1, b: 1, bias: 1 }; 102 result = Ranker.computeLinearScore(entry, weights); 103 Assert.equal(result, 3, "check linear score with 1 everywhere"); 104 105 entry = { bias: 1 }; 106 weights = { a: 1, b: 1, bias: 1 }; 107 result = Ranker.computeLinearScore(entry, weights); 108 Assert.equal(result, 1, "check linear score with empty entry, get bias"); 109 110 entry = { a: 1, b: 1, bias: 1 }; 111 weights = {}; 112 result = Ranker.computeLinearScore(entry, weights); 113 Assert.equal(result, 0, "check linear score with empty weights"); 114 115 entry = { a: 1, b: 1, bias: 1 }; 116 weights = { a: 3 }; 117 result = Ranker.computeLinearScore(entry, weights); 118 Assert.equal(result, 3, "check linear score with a missing weight"); 119 }); 120 121 add_task(async function test_interpolateWrappedHistogram() { 122 const Ranker = ChromeUtils.importESModule( 123 "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs" 124 ); 125 let hist = [1, 2]; 126 let t = 0.5; 127 let result = Ranker.interpolateWrappedHistogram(hist, t); 128 Assert.equal(result, 1.5, "test linear interpolation square in the middle"); 129 130 hist = [1, 2, 5]; 131 t = 2.5; 132 result = Ranker.interpolateWrappedHistogram(hist, t); 133 Assert.equal( 134 result, 135 (hist[0] + hist[2]) / 2, 136 "test linear interpolation correctly wraps around" 137 ); 138 139 hist = [1, 2, 5]; 140 t = 5; 141 result = Ranker.interpolateWrappedHistogram(hist, t); 142 Assert.equal( 143 result, 144 hist[2], 145 "linear interpolation will wrap around to the last index" 146 ); 147 }); 148 149 add_task(async function test_bayesHist() { 150 const Ranker = ChromeUtils.importESModule( 151 "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs" 152 ); 153 154 let vec = [1, 2]; 155 let pvec = [0.5, 0.5]; 156 let tau = 2; 157 let result = Ranker.bayesHist(vec, pvec, tau); 158 // checking the math 159 // (1+2*.5)/(3+2)........... 2/5 160 // (2+2*.5)/(3+2)........... 3/5 161 Assert.ok( 162 result.every((v, i) => Math.abs(v - [0.4, 0.6][i]) < 1e-6), 163 "bayes histogram is expected for typical input" 164 ); 165 vec = [1, 2]; 166 pvec = [0.5, 0.5]; 167 tau = 0.0; 168 result = Ranker.bayesHist(vec, pvec, tau); 169 Assert.ok( 170 result.every((v, i) => Math.abs(v - vec[i] / 3) < 1e-6), // 3 is sum of vec 171 "bayes histogram is a sum norming function if tau is 0" 172 ); 173 }); 174 175 add_task(async function test_normSites() { 176 const Ranker = ChromeUtils.importESModule( 177 "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs" 178 ); 179 let Y = { 180 a: [2, 2], 181 b: [3, 3], 182 }; 183 let result = Ranker.normHistDict(Y); 184 Assert.ok( 185 result.a.every( 186 (v, i) => Math.abs(v - [4 / (4 + 9), 4 / (4 + 9)][i]) < 1e-6 187 ), 188 "normSites, basic input, first array" 189 ); 190 Assert.ok( 191 result.b.every( 192 (v, i) => Math.abs(v - [9 / (4 + 9), 9 / (4 + 9)][i]) < 1e-6 193 ), 194 "normSites, basic input, second array" 195 ); 196 197 Y = []; 198 result = Ranker.normHistDict(Y); 199 Assert.deepEqual(result, [], "normSites handles empty array"); 200 }); 201 202 add_task(async function test_clampWeights() { 203 const Ranker = ChromeUtils.importESModule( 204 "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs" 205 ); 206 let weights = { a: 0, b: 1000, bias: 0 }; 207 let result = Ranker.clampWeights(weights, 100); 208 info("clampWeights clamps a big weight vector"); 209 Assert.equal(result.a, 0); 210 Assert.equal(result.b, 100); 211 Assert.equal(result.bias, 0); 212 213 weights = { a: 1, b: 1, bias: 1 }; 214 result = Ranker.clampWeights(weights, 100); 215 info("clampWeights ignores a small weight vector"); 216 Assert.equal(result.a, 1); 217 Assert.equal(result.b, 1); 218 Assert.equal(result.bias, 1); 219 }); 220 221 add_task(async function test_updateWeights_batch() { 222 // Import the module that exports updateWeights 223 const Ranker = ChromeUtils.importESModule( 224 "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs" 225 ); 226 227 const eta = 1; 228 const click_bonus = 1; 229 const features = ["a", "b", "bias"]; 230 231 function sigmoid(x) { 232 return 1 / (1 + Math.exp(-x)); 233 } 234 function approxEqual(actual, expected, eps = 1e-12) { 235 Assert.lessOrEqual( 236 Math.abs(actual - expected), 237 eps, 238 `expected ${actual} ≈ ${expected}` 239 ); 240 } 241 242 // single click on guid_A updates all weights 243 const initial1 = { a: 0, b: 1, bias: 0.1 }; 244 const scores1 = { 245 guid_A: { final: 1.1, a: 1, b: 1, bias: 1 }, 246 }; 247 248 let updated = await Ranker.updateWeights( 249 { 250 data: { guid_A: { clicks: 1, impressions: 0 } }, 251 scores: scores1, 252 features, 253 weights: { ...initial1 }, 254 eta, 255 click_bonus, 256 }, 257 false 258 ); 259 260 const delta = sigmoid(1.1) - 1; // gradient term for a positive click 261 approxEqual(updated.a, 0 - Number(delta) * 1); 262 approxEqual(updated.b, 1 - Number(delta) * 1); 263 approxEqual(updated.bias, 0.1 - Number(delta) * 1); 264 265 // missing guid -> no-op (weights unchanged) 266 const initial2 = { a: 0, b: 1000, bias: 0 }; 267 const scores2 = { 268 guid_A: { final: 1, a: 1, b: 1e-3, bias: 1 }, 269 }; 270 271 updated = await Ranker.updateWeights( 272 { 273 data: { guid_B: { clicks: 1, impressions: 0 } }, // guid_B not in scores 274 scores: scores2, 275 features, 276 weights: { ...initial2 }, 277 eta, 278 click_bonus, 279 }, 280 false 281 ); 282 283 Assert.equal(updated.a, 0); 284 Assert.equal(updated.b, 1000); 285 Assert.equal(updated.bias, 0); 286 }); 287 288 add_task(async function test_buildFrecencyFeatures_shape_and_empty() { 289 const Ranker = ChromeUtils.importESModule( 290 "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs" 291 ); 292 293 // empty inputs → empty feature maps 294 let out = await Ranker.buildFrecencyFeatures({}, {}); 295 Assert.ok( 296 out && "refre" in out && "rece" in out && "freq" in out, 297 "returns transposed object with feature keys" 298 ); 299 Assert.deepEqual(out.refre, {}, "refre empty"); 300 Assert.deepEqual(out.rece, {}, "rece empty"); 301 Assert.deepEqual(out.freq, {}, "freq empty"); 302 303 // single guid with no visits → zeros 304 out = await Ranker.buildFrecencyFeatures({ guidA: [] }, { guidA: 42 }); 305 Assert.equal(out.refre.guidA, 0, "refre zero with no visits"); 306 Assert.equal(out.freq.guidA, 0, "freq zero with no visits"); 307 Assert.equal(out.rece.guidA, 0, "rece zero with no visits"); 308 }); 309 310 add_task(async function test_buildFrecencyFeatures_recency_monotonic() { 311 const Ranker = ChromeUtils.importESModule( 312 "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs" 313 ); 314 const sandbox = sinon.createSandbox(); 315 316 // Fix time so the decay is deterministic 317 const nowMs = Date.UTC(2025, 0, 1); // Jan 1, 2025 318 const clock = sandbox.useFakeTimers({ now: nowMs }); 319 320 const dayMs = 864e5; 321 // visits use microseconds since epoch; function computes age with visit_date_us / 1000 322 const us = v => Math.round(v * 1000); 323 324 const visitsByGuid = { 325 // one very recent visit (age ~0d) 326 recent: [{ visit_date_us: us(nowMs), visit_type: 2 /* any value */ }], 327 // one older visit (age 10d) 328 old: [{ visit_date_us: us(nowMs - 10 * dayMs), visit_type: 2 }], 329 // both recent + old → should have larger rece (sum of decays) 330 both: [ 331 { visit_date_us: us(nowMs), visit_type: 2 }, 332 { visit_date_us: us(nowMs - 10 * dayMs), visit_type: 2 }, 333 ], 334 }; 335 const visitCounts = { recent: 10, old: 10, both: 10 }; 336 337 const out = await Ranker.buildFrecencyFeatures(visitsByGuid, visitCounts, { 338 halfLifeDays: 28, // default 339 }); 340 341 Assert.greater( 342 out.rece.recent, 343 out.rece.old, 344 "more recent visit → larger recency score" 345 ); 346 Assert.greater( 347 out.rece.both, 348 out.rece.recent, 349 "two visits (recent+old) → larger recency sum than just recent" 350 ); 351 352 clock.restore(); 353 sandbox.restore(); 354 }); 355 356 add_task( 357 async function test_buildFrecencyFeatures_log_scaling_and_interaction() { 358 const Ranker = ChromeUtils.importESModule( 359 "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs" 360 ); 361 const sandbox = sinon.createSandbox(); 362 const clock = sandbox.useFakeTimers({ now: Date.UTC(2025, 0, 1) }); 363 364 const nowMs = Date.now(); 365 const us = v => Math.round(v * 1000); 366 367 // Same single visit (type held constant) for both GUIDs → same rece and same type sum. 368 // Only visit_count differs → freq/refre should scale with log1p(visit_count). 369 const visitsByGuid = { 370 A: [{ visit_date_us: us(nowMs), visit_type: 2 /* 'typed' typically */ }], 371 B: [{ visit_date_us: us(nowMs), visit_type: 2 }], 372 }; 373 const visitCounts = { A: 9, B: 99 }; // different lifetime counts 374 375 const out = await Ranker.buildFrecencyFeatures(visitsByGuid, visitCounts); 376 377 // rece should be identical (same timestamps) 378 Assert.equal( 379 out.rece.A, 380 out.rece.B, 381 "recency is independent of visit_count" 382 ); 383 384 // freq and refre should scale with log1p(total) 385 const ratio = Math.log1p(visitCounts.B) / Math.log1p(visitCounts.A); 386 const approxEqual = (a, b, eps = 1e-9) => 387 Assert.lessOrEqual(Math.abs(a - b), eps); 388 389 approxEqual(out.freq.B / out.freq.A, ratio, 1e-9); 390 approxEqual(out.refre.B / out.refre.A, ratio, 1e-9); 391 392 clock.restore(); 393 sandbox.restore(); 394 } 395 ); 396 397 add_task(async function test_buildFrecencyFeatures_halfLife_effect() { 398 const Ranker = ChromeUtils.importESModule( 399 "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs" 400 ); 401 const sandbox = sinon.createSandbox(); 402 const nowMs = Date.UTC(2025, 0, 1); 403 const clock = sandbox.useFakeTimers({ now: nowMs }); 404 405 const dayMs = 864e5; 406 const us = v => Math.round(v * 1000); 407 408 // Two visits: one now, one 28 days ago. 409 const visits = [ 410 { 411 visit_date_us: us(nowMs), 412 visit_type: 1, 413 }, 414 { 415 visit_date_us: us(nowMs - 28 * dayMs), 416 visit_type: 1, 417 }, 418 ]; 419 const visitsByGuid = { X: visits }; 420 const visitCounts = { X: 10 }; 421 422 const outShort = await Ranker.buildFrecencyFeatures( 423 visitsByGuid, 424 visitCounts, 425 { 426 halfLifeDays: 7, // faster decay 427 } 428 ); 429 const outLong = await Ranker.buildFrecencyFeatures( 430 visitsByGuid, 431 visitCounts, 432 { 433 halfLifeDays: 56, // slower decay 434 } 435 ); 436 437 Assert.greater( 438 outLong.rece.X, 439 outShort.rece.X, 440 "larger half-life → slower decay → bigger recency sum" 441 ); 442 443 clock.restore(); 444 sandbox.restore(); 445 }); 446 447 add_task( 448 async function test_buildFrecencyFeatures_unknown_types_are_zero_bonus() { 449 const Ranker = ChromeUtils.importESModule( 450 "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs" 451 ); 452 const sandbox = sinon.createSandbox(); 453 const clock = sandbox.useFakeTimers({ now: Date.UTC(2025, 0, 1) }); 454 455 const nowMs = Date.now(); 456 const us = v => Math.round(v * 1000); 457 458 // Use an unknown visit_type so TYPE_SCORE[visit_type] falls back to 0. 459 const visitsByGuid = { 460 U: [{ visit_date_us: us(nowMs), visit_type: 99999 }], 461 }; 462 const visitCounts = { U: 100 }; 463 464 const out = await Ranker.buildFrecencyFeatures(visitsByGuid, visitCounts); 465 466 Assert.equal(out.freq.U, 0, "unknown visit_type → zero frequency bonus"); 467 Assert.equal(out.refre.U, 0, "unknown visit_type → zero interaction"); 468 Assert.greater(out.rece.U, 0, "recency is still > 0 for a recent visit"); 469 470 clock.restore(); 471 sandbox.restore(); 472 } 473 ); 474 475 add_task( 476 async function test_weightedSampleTopSites_new_features_scores_and_norms() { 477 const Ranker = ChromeUtils.importESModule( 478 "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs" 479 ); 480 481 // 2 GUIDs with simple, distinct raw values so ordering is obvious 482 const guids = ["g1", "g2"]; 483 const input = { 484 guid: guids, 485 features: ["bmark", "rece", "freq", "refre", "bias"], 486 487 // raw feature maps keyed by guid 488 bmark_scores: { g1: 1, g2: 0 }, // bookmark flag/intensity 489 rece_scores: { g1: 2.0, g2: 1.0 }, // recency-only raw 490 freq_scores: { g1: 0.5, g2: 0.25 }, // frequency-only raw 491 refre_scores: { g1: 10, g2: 5 }, // interaction raw 492 493 // per-feature normalization state (whatever your normUpdate expects) 494 norms: { bmark: null, rece: null, freq: null, refre: null }, 495 496 // weights: only new features (and bias) contribute to final 497 weights: { bmark: 1, rece: 1, freq: 1, refre: 1, bias: 0 }, 498 499 // unused here (no "thom", "hour", "daily") 500 clicks: [], 501 impressions: [], 502 alpha: 1, 503 beta: 1, 504 tau: 0, 505 hourly_seasonality: {}, 506 daily_seasonality: {}, 507 }; 508 509 // Pre-compute what normUpdate will return for each raw vector, 510 // so we can assert exact equality with weightedSampleTopSites output. 511 const vecBmark = guids.map(g => input.bmark_scores[g]); // [1, 0] 512 const vecRece = guids.map(g => input.rece_scores[g]); // [2, 1] 513 const vecFreq = guids.map(g => input.freq_scores[g]); // [0.5, 0.25] 514 const vecRefre = guids.map(g => input.refre_scores[g]); // [10, 5] 515 516 const [expBmark, expBmarkNorm] = Ranker.normUpdate( 517 vecBmark, 518 input.norms.bmark 519 ); 520 const [expRece, expReceNorm] = Ranker.normUpdate(vecRece, input.norms.rece); 521 const [expFreq, expFreqNorm] = Ranker.normUpdate(vecFreq, input.norms.freq); 522 const [expRefre, expRefreNorm] = Ranker.normUpdate( 523 vecRefre, 524 input.norms.refre 525 ); 526 527 const result = await Ranker.weightedSampleTopSites(input); 528 529 // shape 530 Assert.ok( 531 result && result.score_map && result.norms, 532 "returns {score_map, norms}" 533 ); 534 535 // per-feature, per-guid scores match normUpdate outputs 536 Assert.equal( 537 result.score_map.g1.bmark, 538 expBmark[0], 539 "bmark g1 normalized value" 540 ); 541 Assert.equal( 542 result.score_map.g2.bmark, 543 expBmark[1], 544 "bmark g2 normalized value" 545 ); 546 547 Assert.equal( 548 result.score_map.g1.rece, 549 expRece[0], 550 "rece g1 normalized value" 551 ); 552 Assert.equal( 553 result.score_map.g2.rece, 554 expRece[1], 555 "rece g2 normalized value" 556 ); 557 558 Assert.equal( 559 result.score_map.g1.freq, 560 expFreq[0], 561 "freq g1 normalized value" 562 ); 563 Assert.equal( 564 result.score_map.g2.freq, 565 expFreq[1], 566 "freq g2 normalized value" 567 ); 568 569 Assert.equal( 570 result.score_map.g1.refre, 571 expRefre[0], 572 "refre g1 normalized value" 573 ); 574 Assert.equal( 575 result.score_map.g2.refre, 576 expRefre[1], 577 "refre g2 normalized value" 578 ); 579 580 // updated norms propagated back out 581 Assert.deepEqual(result.norms.bmark, expBmarkNorm, "bmark norms updated"); 582 Assert.deepEqual(result.norms.rece, expReceNorm, "rece norms updated"); 583 Assert.deepEqual(result.norms.freq, expFreqNorm, "freq norms updated"); 584 585 // THIS WILL FAIL until you fix the bug in the refre block: 586 // updated_norms.rece = refre_norm; 587 // should be: 588 // updated_norms.refre = refre_norm; 589 Assert.deepEqual(result.norms.refre, expRefreNorm, "refre norms updated"); 590 591 // final score equals computeLinearScore with provided weights 592 const expectedFinalG1 = Ranker.computeLinearScore( 593 result.score_map.g1, 594 input.weights 595 ); 596 const expectedFinalG2 = Ranker.computeLinearScore( 597 result.score_map.g2, 598 input.weights 599 ); 600 Assert.equal( 601 result.score_map.g1.final, 602 expectedFinalG1, 603 "final matches linear score (g1)" 604 ); 605 Assert.equal( 606 result.score_map.g2.final, 607 expectedFinalG2, 608 "final matches linear score (g2)" 609 ); 610 611 // sanity: g1 should outrank g2 with strictly larger raw vectors across all features 612 Assert.greaterOrEqual( 613 result.score_map.g1.final, 614 result.score_map.g2.final, 615 "g1 final ≥ g2 final as all contributing features favor g1" 616 ); 617 } 618 ); 619 620 add_task(async function test_rankshortcuts_queries_returning_nothing() { 621 const Ranker = ChromeUtils.importESModule( 622 "resource://newtab/lib/SmartShortcutsRanker/RankShortcuts.mjs" 623 ); 624 const { NewTabUtils } = ChromeUtils.importESModule( 625 "resource://gre/modules/NewTabUtils.sys.mjs" 626 ); 627 await NewTabUtils.init(); 628 629 const sandbox = sinon.createSandbox(); 630 const stub = sandbox 631 .stub(NewTabUtils.activityStreamProvider, "executePlacesQuery") 632 .resolves([]); // <— mock: query returns nothing 633 634 const topsites = [{ guid: "g1" }, { guid: "g2" }]; 635 const places = "moz_places"; 636 const hist = "moz_historyvisits"; 637 638 // fetchVisitCountsByGuid: returns {} if no rows come back 639 { 640 const out = await Ranker.fetchVisitCountsByGuid(topsites, places); 641 Assert.deepEqual(out, {}, "visit counts: empty rows → empty map"); 642 } 643 644 // fetchLast10VisitsByGuid: pre-seeds all guids → each should be [] 645 { 646 const out = await Ranker.fetchLast10VisitsByGuid(topsites, hist, places); 647 Assert.deepEqual( 648 out, 649 { g1: [], g2: [] }, 650 "last 10 visits: empty rows → each guid has []" 651 ); 652 } 653 654 // fetchBookmarkedFlags: ensures every guid present with false 655 { 656 const out = await Ranker.fetchBookmarkedFlags( 657 topsites, 658 "moz_bookmarks", 659 places 660 ); 661 Assert.deepEqual( 662 out, 663 { g1: false, g2: false }, 664 "bookmarks: empty rows → every guid is false" 665 ); 666 } 667 668 // fetchDailyVisitsSpecific: ensures every guid has a 7-bin zero hist 669 { 670 const out = await Ranker.fetchDailyVisitsSpecific(topsites, hist, places); 671 Assert.ok( 672 Array.isArray(out.g1) && out.g1.length === 7, 673 "daily specific: 7 bins" 674 ); 675 Assert.ok( 676 Array.isArray(out.g2) && out.g2.length === 7, 677 "daily specific: 7 bins" 678 ); 679 Assert.ok( 680 out.g1.every(v => v === 0) && out.g2.every(v => v === 0), 681 "daily specific: all zeros" 682 ); 683 } 684 685 // fetchDailyVisitsAll: returns a 7-bin zero hist 686 { 687 const out = await Ranker.fetchDailyVisitsAll(hist); 688 Assert.ok(Array.isArray(out) && out.length === 7, "daily all: 7 bins"); 689 Assert.ok( 690 out.every(v => v === 0), 691 "daily all: all zeros" 692 ); 693 } 694 695 // fetchHourlyVisitsSpecific: ensures every guid has a 24-bin zero hist 696 { 697 const out = await Ranker.fetchHourlyVisitsSpecific(topsites, hist, places); 698 Assert.ok( 699 Array.isArray(out.g1) && out.g1.length === 24, 700 "hourly specific: 24 bins" 701 ); 702 Assert.ok( 703 Array.isArray(out.g2) && out.g2.length === 24, 704 "hourly specific: 24 bins" 705 ); 706 Assert.ok( 707 out.g1.every(v => v === 0) && out.g2.every(v => v === 0), 708 "hourly specific: all zeros" 709 ); 710 } 711 712 // fetchHourlyVisitsAll: returns a 24-bin zero hist 713 { 714 const out = await Ranker.fetchHourlyVisitsAll(hist); 715 Assert.ok(Array.isArray(out) && out.length === 24, "hourly all: 24 bins"); 716 Assert.ok( 717 out.every(v => v === 0), 718 "hourly all: all zeros" 719 ); 720 } 721 722 // sanity: we actually exercised the stub at least once 723 Assert.greater(stub.callCount, 0, "executePlacesQuery was called"); 724 725 sandbox.restore(); 726 }); 727 728 // cover the explicit "no topsites" early-return branches 729 add_task(async function test_rankshortcuts_no_topsites_inputs() { 730 const Ranker = ChromeUtils.importESModule( 731 "resource://newtab/lib/SmartShortcutsRanker/RankShortcuts.mjs" 732 ); 733 const { NewTabUtils } = ChromeUtils.importESModule( 734 "resource://gre/modules/NewTabUtils.sys.mjs" 735 ); 736 await NewTabUtils.init(); 737 738 const sandbox = sinon.createSandbox(); 739 const stub = sandbox 740 .stub(NewTabUtils.activityStreamProvider, "executePlacesQuery") 741 .resolves([]); // should not matter (early return) 742 743 // empty topsites → {} 744 Assert.deepEqual( 745 await Ranker.fetchVisitCountsByGuid([], "moz_places"), 746 {}, 747 "visit counts early return {}" 748 ); 749 Assert.deepEqual( 750 await Ranker.fetchLast10VisitsByGuid([], "moz_historyvisits", "moz_places"), 751 {}, 752 "last10 early return {}" 753 ); 754 Assert.deepEqual( 755 await Ranker.fetchBookmarkedFlags([], "moz_bookmarks", "moz_places"), 756 {}, 757 "bookmarks early return {}" 758 ); 759 Assert.deepEqual( 760 await Ranker.fetchDailyVisitsSpecific( 761 [], 762 "moz_historyvisits", 763 "moz_places" 764 ), 765 {}, 766 "daily specific early return {}" 767 ); 768 Assert.deepEqual( 769 await Ranker.fetchHourlyVisitsSpecific( 770 [], 771 "moz_historyvisits", 772 "moz_places" 773 ), 774 {}, 775 "hourly specific early return {}" 776 ); 777 778 // note: the *All* variants don't take topsites and thus aren't part of this early-return set. 779 780 // make sure we didn't query DB for early-return cases 781 Assert.equal(stub.callCount, 0, "no DB calls for early-return functions"); 782 783 sandbox.restore(); 784 }); 785 786 add_task(async function test_rankTopSites_sql_pipeline_happy_path() { 787 const Ranker = ChromeUtils.importESModule( 788 "resource://newtab/lib/SmartShortcutsRanker/RankShortcuts.mjs" 789 ); 790 const { NewTabUtils } = ChromeUtils.importESModule( 791 "resource://gre/modules/NewTabUtils.sys.mjs" 792 ); 793 794 await NewTabUtils.init(); 795 const sandbox = sinon.createSandbox(); 796 797 // freeze time so recency/seasonality are stable 798 const nowFixed = Date.UTC(2025, 0, 1, 12, 0, 0); // Jan 1, 2025 @ noon UTC 799 const clock = sandbox.useFakeTimers({ now: nowFixed }); 800 801 // inputs: one strong site (g1), one weaker (g2), and one without guid 802 const topsites = [ 803 { guid: "g1", url: "https://g1.com", frecency: 1000 }, 804 { guid: "g2", url: "https://g2.com", frecency: 10 }, 805 { url: "https://no-guid.com" }, // should end up last 806 ]; 807 808 // provider under test 809 const provider = new Ranker.RankShortcutsProvider(); 810 811 // keep cache interactions in-memory + observable 812 const initialWeights = { 813 bmark: 1, 814 rece: 1, 815 freq: 1, 816 refre: 1, 817 hour: 1, 818 daily: 1, 819 bias: 0, 820 frec: 0, 821 }; 822 const fakeCache = { 823 weights: { ...initialWeights }, 824 init_weights: { ...initialWeights }, 825 norms: null, 826 // minimal score_map so updateWeights can run even if eta > 0 (we set eta=0 below) 827 score_map: { g1: { final: 0 }, g2: { final: 0 } }, 828 }; 829 sandbox.stub(provider.sc_obj, "get").resolves(fakeCache); 830 const setSpy = sandbox.stub(provider.sc_obj, "set").resolves(); 831 832 // stub DB: return realistic rows per SQL shape 833 const execStub = sandbox 834 .stub(NewTabUtils.activityStreamProvider, "executePlacesQuery") 835 .callsFake(async sql => { 836 const s = String(sql); 837 838 // --- Bookmarks (fetchBookmarkedFlags) 839 if (s.includes("FROM moz_bookmarks")) { 840 // rows: [key, bookmark_count] 841 return [ 842 ["g1", 2], // bookmarked 843 ["g2", 0], // not bookmarked 844 ]; 845 } 846 847 // --- Visit counts (fetchVisitCountsByGuid) 848 if (s.includes("COALESCE(p.visit_count")) { 849 // rows: [guid, visit_count] 850 return [ 851 ["g1", 42], 852 ["g2", 3], 853 ]; 854 } 855 856 // --- Last 10 visits (fetchLast10VisitsByGuid) 857 if (s.includes("LIMIT 10") && s.includes("ORDER BY vv.visit_date DESC")) { 858 // rows: [guid, visit_date_us, visit_type], ordered by guid/date DESC 859 const nowUs = nowFixed * 1000; 860 const dayUs = 864e8; 861 // g1: two recent visits (typed + link); g2: one older link 862 return [ 863 ["g1", nowUs, 2], // TYPED 864 ["g1", nowUs - 1 * Number(dayUs), 1], // LINK (yesterday) 865 ["g2", nowUs - 10 * Number(dayUs), 1], // LINK (older) 866 ]; 867 } 868 869 // --- Daily specific (fetchDailyVisitsSpecific): rows [key, dow, count] 870 if ( 871 s.includes("strftime('%w'") && 872 s.includes("GROUP BY place_ids.guid") 873 ) { 874 return [ 875 ["g1", 1, 3], // Mon 876 ["g1", 2, 2], // Tue 877 ["g2", 1, 1], // Mon 878 ]; 879 } 880 881 // --- Daily all (fetchDailyVisitsAll): rows [dow, count] 882 if (s.includes("strftime('%w'") && !s.includes("place_ids.guid")) { 883 return [ 884 [0, 10], 885 [1, 20], 886 [2, 15], 887 [3, 12], 888 [4, 8], 889 [5, 5], 890 [6, 4], 891 ]; 892 } 893 894 // --- Hourly specific (fetchHourlyVisitsSpecific): rows [key, hour, count] 895 if ( 896 s.includes("strftime('%H'") && 897 s.includes("GROUP BY place_ids.guid") 898 ) { 899 return [ 900 ["g1", 9, 5], 901 ["g1", 10, 3], 902 ["g2", 9, 1], 903 ]; 904 } 905 906 // --- Hourly all (fetchHourlyVisitsAll): rows [hour, count] 907 if (s.includes("strftime('%H'") && !s.includes("place_ids.guid")) { 908 return Array.from({ length: 24 }, (_, h) => [ 909 h, 910 h >= 8 && h <= 18 ? 10 : 2, 911 ]); 912 } 913 914 // --- Shortcut interactions (fetchShortcutInteractions) 915 // The query varies by implementation; if your helper hits SQL, fall back to "empty" 916 if ( 917 s.toLowerCase().includes("shortcut") || 918 s.toLowerCase().includes("smartshortcuts") 919 ) { 920 return []; 921 } 922 923 // default: empty for any other SQL the provider might issue 924 return []; 925 }); 926 927 // prefs: set eta=0 so updateWeights is a no-op (deterministic) 928 // NOTE: Feature set comes from the module's SHORTCUT_FSETS. 929 // If your build's default fset already includes ["bmark","rece","freq","refre","hour","daily","bias","frec"], 930 // this test will exercise all the SQL above. If not, it still passes the core assertions. 931 const prefValues = { 932 trainhopConfig: { 933 smartShortcuts: { 934 // fset: "custom", // uncomment iff your build defines a "custom" fset you want to use 935 eta: 0, 936 click_bonus: 10, 937 positive_prior: 1, 938 negative_prior: 1, 939 fset: 8, 940 telem: true, 941 }, 942 }, 943 }; 944 945 // run 946 const out = await provider.rankTopSites(topsites, prefValues, { 947 isStartup: false, 948 }); 949 950 // assertions 951 Assert.ok(Array.isArray(out), "returns an array"); 952 Assert.ok( 953 out.every( 954 site => 955 Object.prototype.hasOwnProperty.call(site, "scores") && 956 Object.prototype.hasOwnProperty.call(site, "weights") 957 ), 958 "Every shortcut should expose scores and weights (even when null) because telem=true" 959 ); 960 Assert.equal( 961 out[out.length - 1].url, 962 "https://no-guid.com", 963 "no-guid item is last" 964 ); 965 966 // If the active fset includes the “new” features, g1 should outrank g2 based on our SQL. 967 const rankedGuids = out.filter(x => x.guid).map(x => x.guid); 968 if (rankedGuids.length === 2) { 969 // This is a soft assertion—kept as ok() instead of equal() so the test still passes 970 // on builds where the active fset does not include those features. 971 Assert.strictEqual( 972 rankedGuids[0], 973 "g1", 974 `expected g1 to outrank g2 when feature set includes (bmark/rece/freq/refre/hour/daily); got ${rankedGuids}` 975 ); 976 } 977 978 // cache writes happened (norms + score_map) 979 Assert.ok(setSpy.calledWith("norms"), "norms written to cache"); 980 Assert.ok(setSpy.calledWith("score_map"), "score_map written to cache"); 981 982 // sanity: DB stub exercised 983 Assert.greater(execStub.callCount, 0, "executePlacesQuery was called"); 984 985 // cleanup 986 clock.restore(); 987 sandbox.restore(); 988 }); 989 990 // 991 // 2) weightedSampleTopSites: "open" feature sets per-guid scores and norms 992 // 993 add_task(async function test_weightedSampleTopSites_open_feature_basic() { 994 const Worker = ChromeUtils.importESModule( 995 "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs" 996 ); 997 998 // Three guids; g1 and g3 are open, g2 is closed. 999 const guid = ["g1", "g2", "g3"]; 1000 const input = { 1001 features: ["open", "bias"], 1002 guid, 1003 // keep only "open" contributing to final; set bias to 0 for clarity 1004 weights: { open: 1, bias: 0 }, 1005 // no prior norms → normUpdate will initialize from first value 1006 norms: { open: null }, 1007 // provide the open_scores dict in the format rankTopSites will pass 1008 open_scores: { g1: 1, g2: 0, g3: 1 }, 1009 }; 1010 1011 const out = await Worker.weightedSampleTopSites(input); 1012 1013 // shape 1014 Assert.ok(out && out.score_map && out.norms, "returns {score_map, norms}"); 1015 Assert.ok(out.norms.open, "norms include 'open'"); 1016 1017 // The normalized values are opaque, but ordering should reflect inputs: 1018 const s1 = out.score_map.g1.open; 1019 const s2 = out.score_map.g2.open; 1020 const s3 = out.score_map.g3.open; 1021 1022 Assert.greater(s1, s2, "an open guid should score higher than a closed one"); 1023 Assert.greater(s3, s2, "another open guid should score higher than closed"); 1024 Assert.equal( 1025 Math.abs(s1 - s3) < 1e-12, // normalization treats identical inputs identically 1026 true, 1027 "two open guids with identical inputs get identical normalized scores" 1028 ); 1029 1030 // final should reflect the 'open' score since bias has weight 0 1031 Assert.equal(out.score_map.g1.final, s1, "final = open (g1)"); 1032 Assert.equal(out.score_map.g2.final, s2, "final = open (g2)"); 1033 Assert.equal(out.score_map.g3.final, s3, "final = open (g3)"); 1034 }); 1035 1036 // 1037 // 3) weightedSampleTopSites: "open" feature honors existing running-norm state 1038 // 1039 add_task(async function test_weightedSampleTopSites_open_with_existing_norms() { 1040 const Worker = ChromeUtils.importESModule( 1041 "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs" 1042 ); 1043 1044 const guid = ["A", "B", "C", "D"]; 1045 const open_scores = { A: 0, B: 1, C: 0, D: 1 }; 1046 1047 // Seed a prior norm object to ensure running mean/var is updated & used. 1048 const prior = { beta: 1e-3, mean: 0.5, var: 1.0 }; 1049 const input = { 1050 features: ["open", "bias"], 1051 guid, 1052 weights: { open: 1, bias: 0 }, 1053 norms: { open: prior }, 1054 open_scores, 1055 }; 1056 1057 const out = await Worker.weightedSampleTopSites(input); 1058 1059 // Norm object should be updated (mean or var shifts minutely due to beta) 1060 Assert.ok(out.norms.open, "open norm returned"); 1061 Assert.notEqual(out.norms.open.mean, prior.mean, "running mean updated"); 1062 Assert.greater(out.norms.open.var, 0, "variance stays positive"); 1063 1064 // Basic ordering still holds 1065 const finals = guid.map(g => out.score_map[g].final); 1066 // open (1) items should outrank closed (0) items after normalization 1067 const maxClosed = Math.max(finals[0], finals[2]); // A,C 1068 const minOpen = Math.min(finals[1], finals[3]); // B,D 1069 Assert.greater(minOpen, maxClosed, "open > closed after normalization"); 1070 }); 1071 1072 add_task(async function test_buildFrecencyFeatures_unid_counts_unique_days() { 1073 const Ranker = ChromeUtils.importESModule( 1074 "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs" 1075 ); 1076 const sandbox = sinon.createSandbox(); 1077 1078 // Fix the clock so "now" is deterministic 1079 const nowMs = Date.UTC(2025, 0, 10); // Jan 10, 2025 1080 const clock = sandbox.useFakeTimers({ now: nowMs }); 1081 const us = ms => Math.round(ms * 1000); 1082 const dayMs = 864e5; 1083 1084 const visitsByGuid = { 1085 g1: [ 1086 { visit_date_us: us(nowMs), visit_type: 1 }, // today 1087 { visit_date_us: us(nowMs - 2 * dayMs), visit_type: 1 }, // 2 days ago 1088 { visit_date_us: us(nowMs - 2 * dayMs), visit_type: 1 }, // same day as above 1089 ], 1090 g2: [ 1091 { visit_date_us: us(nowMs - 7 * dayMs), visit_type: 1 }, // 7 days ago 1092 ], 1093 }; 1094 const visitCounts = { g1: 3, g2: 1 }; 1095 1096 const out = await Ranker.buildFrecencyFeatures(visitsByGuid, visitCounts); 1097 1098 Assert.equal(out.unid.g1, 2, "g1 has visits on 2 unique days"); 1099 Assert.equal(out.unid.g2, 1, "g2 has visits on 1 unique day"); 1100 1101 clock.restore(); 1102 sandbox.restore(); 1103 }); 1104 1105 add_task(async function test_weightedSampleTopSites_unid_feature() { 1106 const Worker = ChromeUtils.importESModule( 1107 "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs" 1108 ); 1109 1110 const guids = ["a", "b"]; 1111 const input = { 1112 features: ["unid", "bias"], 1113 guid: guids, 1114 // explicit scores: a=5 unique days, b=1 1115 unid_scores: { a: 5, b: 1 }, 1116 norms: { unid: null }, 1117 weights: { unid: 1, bias: 0 }, 1118 }; 1119 1120 const result = await Worker.weightedSampleTopSites(input); 1121 1122 Assert.ok(result.norms.unid, "norms include 'unid'"); 1123 const ua = result.score_map.a.unid; 1124 const ub = result.score_map.b.unid; 1125 Assert.greater(ua, ub, "site with more unique days visited scores higher"); 1126 Assert.equal(result.score_map.a.final, ua, "final equals unid score (a)"); 1127 Assert.equal(result.score_map.b.final, ub, "final equals unid score (b)"); 1128 }); 1129 1130 // 1131 // 1) CTR feature: ordering + final uses normalized CTR when weighted 1132 // 1133 add_task(async function test_weightedSampleTopSites_ctr_basic() { 1134 const Worker = ChromeUtils.importESModule( 1135 "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs" 1136 ); 1137 1138 // g1: 9/10 → .909; g2: 1/10 → .1; g3: 0/0 → smoothed to (0+1)/(0+1)=1 1139 // After normalization, g3 should be highest, then g1, then g2. 1140 const input = { 1141 features: ["ctr", "bias"], 1142 guid: ["g1", "g2", "g3"], 1143 clicks: [9, 1, 0], 1144 impressions: [10, 10, 0], 1145 norms: { ctr: null }, 1146 weights: { ctr: 1, bias: 0 }, 1147 }; 1148 1149 const out = await Worker.weightedSampleTopSites(input); 1150 1151 // shape 1152 Assert.ok(out && out.score_map && out.norms, "returns {score_map, norms}"); 1153 Assert.ok(out.norms.ctr, "returns ctr norms"); 1154 1155 // ordering by normalized ctr (highest to lowest): g3 > g1 > g2 1156 const s1 = out.score_map.g1.ctr; 1157 const s2 = out.score_map.g2.ctr; 1158 const s3 = out.score_map.g3.ctr; 1159 1160 Assert.greater(s3, s1, "g3 (1.0 smoothed) > g1 (~0.909)"); 1161 Assert.greater(s1, s2, "g1 (~0.909) > g2 (0.1)"); 1162 1163 // finals reflect ctr weight only 1164 Assert.equal(out.score_map.g1.final, s1, "final equals ctr (g1)"); 1165 Assert.equal(out.score_map.g2.final, s2, "final equals ctr (g2)"); 1166 Assert.equal(out.score_map.g3.final, s3, "final equals ctr (g3)"); 1167 }); 1168 1169 // 1170 // 2) CTR smoothing edge cases: zero impressions produce finite values 1171 // 1172 add_task(async function test_weightedSampleTopSites_ctr_smoothing_zero_imps() { 1173 const Worker = ChromeUtils.importESModule( 1174 "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs" 1175 ); 1176 1177 // gA: clicks=0, imps=0 → (0+1)/(0+1)=1.0 1178 // gB: clicks=5, imps=0 → (5+1)/(0+1)=6.0 (still finite, larger than 1.0) 1179 const input = { 1180 features: ["ctr"], 1181 guid: ["gA", "gB"], 1182 clicks: [0, 5], 1183 impressions: [0, 0], 1184 norms: { ctr: null }, 1185 weights: { ctr: 1 }, 1186 }; 1187 1188 const out = await Worker.weightedSampleTopSites(input); 1189 1190 const a = out.score_map.gA.ctr; 1191 const b = out.score_map.gB.ctr; 1192 1193 // normalized values are opaque, but ordering must follow raw_ctr (B > A) 1194 Assert.greater(b, a, "higher smoothed CTR should score higher"); 1195 1196 // finiteness check 1197 Assert.ok(Number.isFinite(a) && Number.isFinite(b), "scores are finite"); 1198 }); 1199 1200 // 1201 // 3) CTR running norm: prior norm influences (mean,var) and persists 1202 // 1203 add_task(async function test_weightedSampleTopSites_ctr_with_prior_norm() { 1204 const Worker = ChromeUtils.importESModule( 1205 "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs" 1206 ); 1207 1208 const prior = { beta: 1e-3, mean: 0.3, var: 1.0 }; // seeded running stats 1209 const input = { 1210 features: ["ctr"], 1211 guid: ["x", "y"], 1212 clicks: [3, 0], 1213 impressions: [10, 10], // raw ctr: x=.364, y=.091 → with +1 smoothing: (.3~) but ordering same 1214 norms: { ctr: prior }, 1215 weights: { ctr: 1 }, 1216 }; 1217 1218 const out = await Worker.weightedSampleTopSites(input); 1219 1220 Assert.ok(out.norms.ctr, "ctr norm returned"); 1221 Assert.notEqual(out.norms.ctr.mean, prior.mean, "running mean updated"); 1222 Assert.greater(out.norms.ctr.var, 0, "variance positive"); 1223 1224 const x = out.score_map.x.ctr; 1225 const y = out.score_map.y.ctr; 1226 Assert.greater(x, y, "higher ctr ranks higher under prior normalization"); 1227 }); 1228 1229 // sticky clicks testing 1230 1231 add_task(async function test_fetchShortcutLastClickPositions_empty() { 1232 const Ranker = ChromeUtils.importESModule( 1233 "resource://newtab/lib/SmartShortcutsRanker/RankShortcuts.mjs" 1234 ); 1235 const out = await Ranker.fetchShortcutLastClickPositions( 1236 [], 1237 "smart_shortcuts", 1238 "moz_places" 1239 ); 1240 Assert.deepEqual(out, [], "empty guid list → empty result"); 1241 }); 1242 1243 add_task( 1244 async function test_fetchShortcutLastClickPositions_happy_path_and_numImps() { 1245 const Ranker = ChromeUtils.importESModule( 1246 "resource://newtab/lib/SmartShortcutsRanker/RankShortcuts.mjs" 1247 ); 1248 const { NewTabUtils } = ChromeUtils.importESModule( 1249 "resource://gre/modules/NewTabUtils.sys.mjs" 1250 ); 1251 await NewTabUtils.init(); 1252 1253 const sandbox = sinon.createSandbox(); 1254 const guids = ["g1", "g2", "g3"]; 1255 const numImps = 7; 1256 1257 // Stub DB: we just need to return rows in the final SELECT shape: 1258 // [guid, position|null] 1259 const execStub = sandbox 1260 .stub(NewTabUtils.activityStreamProvider, "executePlacesQuery") 1261 .callsFake(async _sql => { 1262 // Return positions for two guids; one missing (null) 1263 return [ 1264 ["g1", 3], 1265 ["g2", null], 1266 // g3 absent → should default to null in the aligned output 1267 ]; 1268 }); 1269 1270 const out = await Ranker.fetchShortcutLastClickPositions( 1271 guids, 1272 "smart_shortcuts", 1273 "moz_places", 1274 numImps 1275 ); 1276 1277 Assert.deepEqual( 1278 out, 1279 [3, null, null], 1280 "aligned array: g1=3, g2=null, g3=null (missing → null)" 1281 ); 1282 Assert.greater(execStub.callCount, 0, "DB was queried"); 1283 sandbox.restore(); 1284 } 1285 ); 1286 1287 add_task(async function test_placeGuidsByPositions_basic_and_collisions() { 1288 const Ranker = ChromeUtils.importESModule( 1289 "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs" 1290 ); 1291 1292 const guids = ["a", "b", "c", "d"]; 1293 // a→1, b→1 (collision), c→3, d→null 1294 const pos = new Map(Object.entries({ a: 1, b: 1, c: 3, d: null })); 1295 1296 const out = Ranker.placeGuidsByPositions(guids, pos); 1297 // Pass #1 (hard place): out[1] = "a", out[3] = "c" 1298 // Pass #2 (fill holes left→right) with [b, d] in original order: 1299 // holes are idx 0 then 2 → place "b" at 0, "d" at 2 1300 Assert.deepEqual( 1301 out, 1302 ["b", "a", "d", "c"], 1303 "collision resolved by first-come; others fill holes stably" 1304 ); 1305 }); 1306 1307 add_task(async function test_placeGuidsByPositions_inferred_size() { 1308 const Ranker = ChromeUtils.importESModule( 1309 "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs" 1310 ); 1311 1312 const guids = ["p", "q"]; 1313 const pos = new Map(Object.entries({ p: 0, q: null })); 1314 const out = Ranker.placeGuidsByPositions(guids, pos); 1315 Assert.equal( 1316 out.length, 1317 guids.length, 1318 "default size inferred to guids.length" 1319 ); 1320 Assert.deepEqual(out, ["p", "q"]); 1321 }); 1322 1323 add_task(async function test_fetchShortcutLastClickPositions_empty() { 1324 const Ranker = ChromeUtils.importESModule( 1325 "resource://newtab/lib/SmartShortcutsRanker/RankShortcuts.mjs" 1326 ); 1327 // Empty GUID list → empty aligned output; should not query DB. 1328 const { NewTabUtils } = ChromeUtils.importESModule( 1329 "resource://gre/modules/NewTabUtils.sys.mjs" 1330 ); 1331 await NewTabUtils.init(); 1332 1333 const sandbox = sinon.createSandbox(); 1334 const stub = sandbox 1335 .stub(NewTabUtils.activityStreamProvider, "executePlacesQuery") 1336 .resolves([]); 1337 1338 const out = await Ranker.fetchShortcutLastClickPositions( 1339 [], 1340 "smart_shortcuts", 1341 "moz_places", 1342 10 1343 ); 1344 Assert.deepEqual(out, [], "empty guid list → empty result"); 1345 Assert.equal(stub.callCount, 0, "no DB query for empty input"); 1346 sandbox.restore(); 1347 }); 1348 1349 add_task(async function test_placeGuidsByPositions_empty_inputs() { 1350 const Ranker = ChromeUtils.importESModule( 1351 "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs" 1352 ); 1353 1354 // Empty guids, empty positions 1355 { 1356 const out = Ranker.placeGuidsByPositions([], new Map()); 1357 Assert.deepEqual(out, [], "empty → empty"); 1358 } 1359 1360 // Empty guids, non-empty positions map (should still produce empty) 1361 { 1362 const pos = new Map(Object.entries({ x: 5, y: 0 })); 1363 const out = Ranker.placeGuidsByPositions([], pos); 1364 Assert.deepEqual(out, [], "no guids to place → empty result"); 1365 } 1366 }); 1367 1368 add_task(async function test_placeGuidsByPositions_all_null_positions() { 1369 const Ranker = ChromeUtils.importESModule( 1370 "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs" 1371 ); 1372 1373 const guids = ["a", "b", "c"]; 1374 const pos = new Map(Object.entries({ a: null, b: null, c: null })); 1375 const out = Ranker.placeGuidsByPositions(guids, pos); 1376 Assert.deepEqual(out, guids, "all null → stable original order"); 1377 }); 1378 1379 add_task(async function test_placeGuidsByPositions_plain_object_positions() { 1380 const Ranker = ChromeUtils.importESModule( 1381 "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs" 1382 ); 1383 1384 const guids = ["a", "b", "c"]; 1385 // Use a plain object (exercises normalization) 1386 const pos = new Map(Object.entries({ a: 2, b: 0, c: 1 })); 1387 const out = Ranker.placeGuidsByPositions(guids, pos); 1388 Assert.deepEqual(out, ["b", "c", "a"], "plain object map works"); 1389 }); 1390 1391 add_task(async function test_placeGuidsByPositions_negative_and_noninteger() { 1392 const Ranker = ChromeUtils.importESModule( 1393 "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs" 1394 ); 1395 1396 const guids = ["a", "b", "c", "d"]; 1397 // a→-1 (ignored), b→1.7 (non-integer → ignored), c→0 (valid), d→null 1398 const pos = new Map(Object.entries({ a: -1, b: 1.7, c: 0, d: null })); 1399 const out = Ranker.placeGuidsByPositions(guids, pos); 1400 // Pass #1 puts c at 0. Others (a,b,d) fill holes in order → [c, a, b, d] 1401 Assert.deepEqual( 1402 out, 1403 ["c", "a", "b", "d"], 1404 "negative & non-integer treated as unpositioned" 1405 ); 1406 }); 1407 1408 add_task( 1409 async function test_placeGuidsByPositions_large_position_goes_lastish() { 1410 const Ranker = ChromeUtils.importESModule( 1411 "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs" 1412 ); 1413 1414 const guids = ["a", "b", "c"]; 1415 // If your implementation infers size as max(guids.length, maxPos+1), 1416 // this will create a 6-slot array. The fill pass will leave trailing nulls. 1417 // We at least assert that ordering of real items is stable and includes "a" at its slot. 1418 const pos = new Map(Object.entries({ a: 5, b: null, c: null })); 1419 const out = Ranker.placeGuidsByPositions(guids, pos); 1420 1421 // out length may be > guids.length depending on your impl; assert item order: 1422 const nonNull = out.filter(x => x !== null); 1423 // "a" should still appear once, and b/c follow in original order somewhere after 1424 Assert.ok(nonNull.includes("a"), "contains 'a'"); 1425 // The fill order is stable for the unpositioned ones 1426 const idxB = nonNull.indexOf("b"); 1427 const idxC = nonNull.indexOf("c"); 1428 Assert.greater( 1429 idxC, 1430 idxB, 1431 "unpositioned items stay in input order (b before c)" 1432 ); 1433 } 1434 ); 1435 1436 add_task( 1437 async function test_applyStickyClicks_preserves_null_and_clamps_negative() { 1438 const Worker = ChromeUtils.importESModule( 1439 "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs" 1440 ); 1441 1442 // positions aligned with guids; pos[1] is null; pos[2] becomes negative after shift 1443 const guids = ["g1", "g2", "g3"]; 1444 const positions = [3, null, 1]; // absolute 1445 const numSponsored = 2; // shift by 2 → [1, null, -1] → clamp -1 → 0 1446 1447 const out = Worker.applyStickyClicks(positions, guids, numSponsored); 1448 // After building guid→pos: g1→1, g2→null, g3→0 1449 // Hard place: out[1]=g1, out[0]=g3; fill hole(s) with g2 → [g3, g1, g2] 1450 Assert.deepEqual( 1451 out, 1452 ["g3", "g1", "g2"], 1453 "null preserved, negatives clamped to 0" 1454 ); 1455 } 1456 ); 1457 1458 add_task( 1459 async function test_applyStickyClicks_undefined_numSponsored_defaults_zero() { 1460 const Worker = ChromeUtils.importESModule( 1461 "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs" 1462 ); 1463 1464 const guids = ["g1", "g2"]; 1465 const positions = [4, null]; // shift by undefined → treated as 0 in robust version 1466 1467 const out = Worker.applyStickyClicks(positions, guids, undefined); 1468 // guid→pos: g1→4, g2→null → places g1 at 4 (if your placer grows size) or ignores (if not) 1469 // We assert at least the unpositioned stays in order and g1 appears once. 1470 Assert.ok(out.includes("g1") && out.includes("g2"), "both guids present"); 1471 } 1472 ); 1473 1474 function prefsFor(features, extra = {}) { 1475 // Map every feature in FEATURE_META to a weight; default 0, chosen ones 100. 1476 const baseWeights = { 1477 thom_weight: 0, 1478 frec_weight: 0, 1479 hour_weight: 0, 1480 daily_weight: 0, 1481 bmark_weight: 0, 1482 rece_weight: 0, 1483 freq_weight: 0, 1484 refre_weight: 0, 1485 open_weight: 0, 1486 unid_weight: 0, 1487 ctr_weight: 0, 1488 bias_weight: 100, 1489 }; 1490 for (const f of features) { 1491 const key = { 1492 thom: "thom_weight", 1493 frec: "frec_weight", 1494 hour: "hour_weight", 1495 daily: "daily_weight", 1496 bmark: "bmark_weight", 1497 rece: "rece_weight", 1498 freq: "freq_weight", 1499 refre: "refre_weight", 1500 open: "open_weight", 1501 unid: "unid_weight", 1502 ctr: "ctr_weight", 1503 bias: "bias_weight", 1504 }[f]; 1505 if (key) { 1506 baseWeights[key] = 100; 1507 } 1508 } 1509 1510 return { 1511 trainhopConfig: { 1512 smartShortcuts: { 1513 features, 1514 // make training a no-op for determinism 1515 eta: 0, 1516 click_bonus: 10, 1517 positive_prior: 1, 1518 negative_prior: 1, 1519 sticky_numimps: 0, // off for matrix tests 1520 ...baseWeights, 1521 ...extra, 1522 }, 1523 }, 1524 }; 1525 } 1526 1527 function attachLocalWorker(provider, WorkerMod) { 1528 provider._rankShortcutsWorker = { 1529 async post(name, args) { 1530 // name is a string like "weightedSampleTopSites" 1531 // Worker functions are exported with same names. 1532 // Some are standalone, some are exported in the class wrapper too. 1533 const fn = WorkerMod[name] || new WorkerMod.RankShortcutsWorker()[name]; 1534 if (typeof fn === "function") { 1535 // if it's a plain function, spread args; if method, pass as method 1536 return Array.isArray(args) ? fn(...args) : fn(args); 1537 } 1538 throw new Error(`No worker function for ${name}`); 1539 }, 1540 }; 1541 } 1542 1543 function stubDB(sinon, NewTabUtils, nowFixed) { 1544 return sinon 1545 .stub(NewTabUtils.activityStreamProvider, "executePlacesQuery") 1546 .callsFake(async sql => { 1547 const s = String(sql); 1548 1549 // Bookmarks 1550 if (s.includes("FROM moz_bookmarks")) { 1551 return [ 1552 ["g1", 1], 1553 ["g2", 0], 1554 ["g3", 0], 1555 ]; 1556 } 1557 1558 // Visit counts 1559 if (s.includes("COALESCE(p.visit_count")) { 1560 return [ 1561 ["g1", 20], 1562 ["g2", 10], 1563 ["g3", 5], 1564 ]; 1565 } 1566 1567 // Last 10 visits (guid, visit_date_us, visit_type) 1568 if (s.includes("LIMIT 10") && s.includes("ORDER BY vv.visit_date DESC")) { 1569 const us = ms => Math.round(ms * 1000); 1570 const day = 864e5; 1571 return [ 1572 ["g1", us(nowFixed), 2], // typed now 1573 ["g1", us(nowFixed - day), 1], // link 1574 ["g2", us(nowFixed - 2 * day), 1], 1575 ["g3", us(nowFixed - 10 * day), 1], 1576 ]; 1577 } 1578 1579 // Daily specific (guid, dow, count) 1580 if ( 1581 s.includes("strftime('%w'") && 1582 s.includes("GROUP BY place_ids.guid") 1583 ) { 1584 return [ 1585 ["g1", 1, 3], 1586 ["g2", 1, 1], 1587 ]; 1588 } 1589 1590 // Daily all (dow, count) 1591 if (s.includes("strftime('%w'") && !s.includes("place_ids.guid")) { 1592 return [ 1593 [0, 10], 1594 [1, 20], 1595 [2, 15], 1596 [3, 12], 1597 [4, 8], 1598 [5, 5], 1599 [6, 4], 1600 ]; 1601 } 1602 1603 // Hourly specific (guid, hour, count) 1604 if ( 1605 s.includes("strftime('%H'") && 1606 s.includes("GROUP BY place_ids.guid") 1607 ) { 1608 return [ 1609 ["g1", 9, 5], 1610 ["g2", 10, 1], 1611 ]; 1612 } 1613 1614 // Hourly all (hour, count) 1615 if (s.includes("strftime('%H'") && !s.includes("place_ids.guid")) { 1616 return Array.from({ length: 24 }, (_, h) => [ 1617 h, 1618 h >= 8 && h <= 18 ? 10 : 2, 1619 ]); 1620 } 1621 1622 // Shortcut interactions (clicks/imps aggregation over 2 months) 1623 if ( 1624 s.includes("moz_newtab_shortcuts_interaction") && 1625 s.includes("SUM(") 1626 ) { 1627 return [ 1628 ["g1", 5, 10], // clicks, imps 1629 ["g2", 1, 10], 1630 ["g3", 0, 0], 1631 ]; 1632 } 1633 1634 // Sticky-clicks helper (only in its dedicated test) 1635 if (s.includes("ROW_NUMBER()") && s.includes("tile_position")) { 1636 // Shape: [guid, position|null] 1637 return [ 1638 ["g1", 3], 1639 ["g2", null], 1640 ["g3", null], 1641 ]; 1642 } 1643 1644 return []; 1645 }); 1646 } 1647 1648 add_task(async function test_rankTopSites_feature_matrix() { 1649 const Ranker = ChromeUtils.importESModule( 1650 "resource://newtab/lib/SmartShortcutsRanker/RankShortcuts.mjs" 1651 ); 1652 const Worker = ChromeUtils.importESModule( 1653 "resource://newtab/lib/SmartShortcutsRanker/RankShortcutsWorkerClass.mjs" 1654 ); 1655 const { NewTabUtils } = ChromeUtils.importESModule( 1656 "resource://gre/modules/NewTabUtils.sys.mjs" 1657 ); 1658 const { sinon } = ChromeUtils.importESModule( 1659 "resource://testing-common/Sinon.sys.mjs" 1660 ); 1661 await NewTabUtils.init(); 1662 1663 const sandbox = sinon.createSandbox(); 1664 const nowFixed = Date.UTC(2025, 0, 1, 12, 0, 0); 1665 const clock = sandbox.useFakeTimers({ now: nowFixed }); 1666 1667 // provider under test 1668 const provider = new Ranker.RankShortcutsProvider(); 1669 attachLocalWorker(provider, Worker); 1670 1671 // cache stubs: keep simple but real-ish 1672 const fakeCache = { 1673 weights: {}, 1674 init_weights: {}, 1675 norms: null, 1676 score_map: { g1: { final: 0 }, g2: { final: 0 }, g3: { final: 0 } }, 1677 time_last_update: 0, 1678 }; 1679 sandbox.stub(provider.sc_obj, "get").resolves(fakeCache); 1680 sandbox.stub(provider.sc_obj, "set").resolves(); 1681 1682 const dbStub = stubDB(sandbox, NewTabUtils, nowFixed); 1683 1684 // input topsites 1685 const topsites = [ 1686 { guid: "g1", url: "https://g1", frecency: 100 }, 1687 { guid: "g2", url: "https://g2", frecency: 10 }, 1688 { guid: "g3", url: "https://g3", frecency: 1 }, 1689 { url: "https://no-guid" }, // should remain last 1690 ]; 1691 1692 // build feature lists 1693 const ALL = [ 1694 "thom", 1695 "frec", 1696 "hour", 1697 "daily", 1698 "bmark", 1699 "rece", 1700 "freq", 1701 "refre", 1702 "unid", 1703 "ctr", 1704 "bias", 1705 ]; 1706 const singles = ALL.map(f => [f]); 1707 const pairs = [ 1708 ["frec", "bias"], 1709 ["thom", "bias"], 1710 ["ctr", "bias"], 1711 ["bmark", "bias"], 1712 ["rece", "freq"], 1713 ]; 1714 const triples = [ 1715 ["frec", "thom", "bias"], 1716 ["rece", "freq", "refre"], 1717 ]; 1718 1719 const cases = [...singles, ...pairs, ...triples]; 1720 1721 for (const features of cases) { 1722 const prefs = prefsFor(features, { sticky_numimps: 0 }); 1723 const out = await provider.rankTopSites( 1724 topsites, 1725 prefs, 1726 { isStartup: true }, 1727 /*numSponsored*/ 0 1728 ); 1729 1730 // basic shape assertions 1731 Assert.ok(Array.isArray(out), `(${features}) returns array`); 1732 Assert.equal(out.length, topsites.length, `(${features}) length stable`); 1733 Assert.equal( 1734 out[out.length - 1].url, 1735 "https://no-guid", 1736 `(${features}) no-guid item is last` 1737 ); 1738 1739 // permutation check for guided items 1740 const inGuids = topsites 1741 .filter(x => x.guid) 1742 .map(x => x.guid) 1743 .sort(); 1744 const outGuids = out 1745 .filter(x => x.guid) 1746 .map(x => x.guid) 1747 .sort(); 1748 Assert.deepEqual(outGuids, inGuids, `(${features}) guids preserved`); 1749 } 1750 1751 Assert.greater(dbStub.callCount, 0, "DB stub used at least once"); 1752 clock.restore(); 1753 sandbox.restore(); 1754 }); 1755 1756 add_task(function test_roundNum_precision_and_edge_cases() { 1757 const { roundNum } = ChromeUtils.importESModule( 1758 "resource://newtab/lib/SmartShortcutsRanker/RankShortcuts.mjs" 1759 ); 1760 1761 Assert.equal( 1762 roundNum(1.23456789), 1763 1.235, 1764 "roundNum rounds to four significant digits by default" 1765 ); 1766 Assert.equal( 1767 roundNum(987.65, 2), 1768 990, 1769 "roundNum respects the requested precision" 1770 ); 1771 Assert.equal( 1772 roundNum(5e-7), 1773 0, 1774 "roundNum clamps magnitudes smaller than eps to zero" 1775 ); 1776 Assert.equal( 1777 roundNum(-5e-7), 1778 0, 1779 "roundNum clamps small negative magnitudes to zero" 1780 ); 1781 Assert.equal( 1782 roundNum(5e-7, 4, 1e-9), 1783 5e-7, 1784 "roundNum uses the provided eps override when keeping small values" 1785 ); 1786 Assert.strictEqual( 1787 roundNum(Number.POSITIVE_INFINITY), 1788 Number.POSITIVE_INFINITY, 1789 "roundNum leaves non-finite numbers unchanged" 1790 ); 1791 Assert.ok(Number.isNaN(roundNum(NaN)), "roundNum returns NaN for NaN"); 1792 const sentinel = { foo: "bar" }; 1793 Assert.strictEqual( 1794 roundNum(sentinel), 1795 sentinel, 1796 "roundNum returns non-number inputs unchanged" 1797 ); 1798 });