mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-26 04:57:47 -04:00
plugins: ranking module and directory metadata for the plugin browser (1/3) (#3751)
* plugins: forward directory metadata (version, icon, permissions, dates) to the shell * plugins: ranking module for recommendations, trending and picks * plugins: mark new and recently updated plugins on the browser cards, keep badges clear of the vote count * i18n: allow the lowercase plugin recency badge next to the New action
This commit is contained in:
1 parent
40b6eaa4fe
commit
846a0ff22f
10 files changed
+531
-11
No files matched your search
@@ -11,13 +11,19 @@ import (
|
||||
const feedbackURL = site.API + "/plugins"
|
||||
|
||||
type Feedback struct {
|
||||
Upvotes int
|
||||
Status []string
|
||||
IssueURL string
|
||||
Similar []string
|
||||
Upvotes int
|
||||
Status []string
|
||||
IssueURL string
|
||||
Similar []string
|
||||
Version string
|
||||
Icon string
|
||||
Permissions []string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
// FetchFeedback retrieves community upvotes and moderator status from the directory API.
|
||||
// FetchFeedback retrieves community upvotes, moderator status and directory metadata
|
||||
// (version, icon, permissions, dates) from the directory API.
|
||||
// Best-effort: any failure returns a nil map.
|
||||
func FetchFeedback() map[string]Feedback {
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
@@ -39,6 +45,12 @@ func FetchFeedback() map[string]Feedback {
|
||||
Status []string `json:"status"`
|
||||
IssueURL string `json:"issueUrl"`
|
||||
Similar []string `json:"similar"`
|
||||
|
||||
Version string `json:"version"`
|
||||
Icon string `json:"icon"`
|
||||
Permissions []string `json:"permissions"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
} `json:"plugins"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
@@ -47,7 +59,17 @@ func FetchFeedback() map[string]Feedback {
|
||||
|
||||
feedback := make(map[string]Feedback, len(payload.Plugins))
|
||||
for _, p := range payload.Plugins {
|
||||
feedback[p.ID] = Feedback{Upvotes: p.Upvotes, Status: p.Status, IssueURL: p.IssueURL, Similar: p.Similar}
|
||||
feedback[p.ID] = Feedback{
|
||||
Upvotes: p.Upvotes,
|
||||
Status: p.Status,
|
||||
IssueURL: p.IssueURL,
|
||||
Similar: p.Similar,
|
||||
Version: p.Version,
|
||||
Icon: p.Icon,
|
||||
Permissions: p.Permissions,
|
||||
CreatedAt: p.CreatedAt,
|
||||
UpdatedAt: p.UpdatedAt,
|
||||
}
|
||||
}
|
||||
return feedback
|
||||
}
|
||||
@@ -171,3 +171,43 @@ func TestPluginInfoFromPluginIncludesScreenshot(t *testing.T) {
|
||||
assert.Equal(t, "https://raw.githubusercontent.com/AvengeMedia/dms-plugins/master/DankNotepadModule/screenshot.png", info.Screenshot)
|
||||
assert.True(t, info.FirstParty)
|
||||
}
|
||||
|
||||
func TestApplyFeedbackCarriesDirectoryMetadata(t *testing.T) {
|
||||
info := pluginInfoFromPlugin(coreplugins.Plugin{ID: "demo", Name: "Demo"})
|
||||
|
||||
applyFeedback(&info, coreplugins.Feedback{
|
||||
Upvotes: 7,
|
||||
Status: []string{"reviewed"},
|
||||
IssueURL: "https://example.com/issues/1",
|
||||
Similar: []string{"other"},
|
||||
Version: "1.2.0",
|
||||
Icon: "extension",
|
||||
Permissions: []string{"settings_read"},
|
||||
CreatedAt: "2026-01-02T03:04:05Z",
|
||||
UpdatedAt: "2026-02-03T04:05:06Z",
|
||||
})
|
||||
|
||||
raw, err := json.Marshal(info)
|
||||
assert.NoError(t, err)
|
||||
|
||||
var decoded map[string]any
|
||||
assert.NoError(t, json.Unmarshal(raw, &decoded))
|
||||
assert.Equal(t, float64(7), decoded["upvotes"])
|
||||
assert.Equal(t, "1.2.0", decoded["version"])
|
||||
assert.Equal(t, "extension", decoded["icon"])
|
||||
assert.Equal(t, []any{"settings_read"}, decoded["permissions"])
|
||||
assert.Equal(t, "2026-01-02T03:04:05Z", decoded["created_at"])
|
||||
assert.Equal(t, "2026-02-03T04:05:06Z", decoded["updated_at"])
|
||||
}
|
||||
|
||||
func TestApplyFeedbackOmitsMissingMetadata(t *testing.T) {
|
||||
info := pluginInfoFromPlugin(coreplugins.Plugin{ID: "demo", Name: "Demo"})
|
||||
|
||||
applyFeedback(&info, coreplugins.Feedback{})
|
||||
|
||||
raw, err := json.Marshal(info)
|
||||
assert.NoError(t, err)
|
||||
assert.NotContains(t, string(raw), "created_at")
|
||||
assert.NotContains(t, string(raw), "updated_at")
|
||||
assert.NotContains(t, string(raw), "version")
|
||||
}
|
||||
@@ -34,13 +34,9 @@ func HandleList(conn *ipc.ConnWriter, req ipc.Request) {
|
||||
result := make([]PluginInfo, len(pluginList))
|
||||
for i, p := range pluginList {
|
||||
installed, _ := manager.IsInstalled(p)
|
||||
fb := feedback[p.ID]
|
||||
info := pluginInfoFromPlugin(p)
|
||||
info.Installed = installed
|
||||
info.Upvotes = fb.Upvotes
|
||||
info.Status = fb.Status
|
||||
info.IssueURL = fb.IssueURL
|
||||
info.Similar = fb.Similar
|
||||
applyFeedback(&info, feedback[p.ID])
|
||||
result[i] = info
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,11 @@ type PluginInfo struct {
|
||||
Status []string `json:"status,omitempty"`
|
||||
IssueURL string `json:"issueUrl,omitempty"`
|
||||
Similar []string `json:"similar,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Permissions []string `json:"permissions,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type SuccessResult struct {
|
||||
|
||||
@@ -27,6 +27,18 @@ func pluginInfoFromPlugin(plugin coreplugins.Plugin) PluginInfo {
|
||||
}
|
||||
}
|
||||
|
||||
func applyFeedback(info *PluginInfo, fb coreplugins.Feedback) {
|
||||
info.Upvotes = fb.Upvotes
|
||||
info.Status = fb.Status
|
||||
info.IssueURL = fb.IssueURL
|
||||
info.Similar = fb.Similar
|
||||
info.Version = fb.Version
|
||||
info.Icon = fb.Icon
|
||||
info.Permissions = fb.Permissions
|
||||
info.CreatedAt = fb.CreatedAt
|
||||
info.UpdatedAt = fb.UpdatedAt
|
||||
}
|
||||
|
||||
func isFirstPartyRepo(repo string) bool {
|
||||
return strings.HasPrefix(repo, "https://github.com/AvengeMedia")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
.pragma library
|
||||
|
||||
// Ranking for the plugin browser: recommendations, trending, picks of the
|
||||
// day and of the week. Pure functions over the registry list, no I/O.
|
||||
//
|
||||
// A plugin is what the plugin list returns: id, category, author, upvotes,
|
||||
// status, similar, compositors, firstParty, featured, installed, created_at,
|
||||
// updated_at. Dates are ISO strings; a missing date is treated as old.
|
||||
|
||||
const DAY_MS = 86400000;
|
||||
|
||||
function _time(value) {
|
||||
const t = Date.parse(value || "");
|
||||
return isNaN(t) ? 0 : t;
|
||||
}
|
||||
|
||||
function _days(from, now) {
|
||||
if (!from)
|
||||
return 3650;
|
||||
return Math.max(0, (now - from) / DAY_MS);
|
||||
}
|
||||
|
||||
// Registry lists reach QML as sequences that Array.isArray() rejects.
|
||||
function _list(value) {
|
||||
if (!value || typeof value !== "object" || typeof value.length !== "number")
|
||||
return [];
|
||||
const out = [];
|
||||
for (let i = 0; i < value.length; i++)
|
||||
out.push(value[i]);
|
||||
return out;
|
||||
}
|
||||
|
||||
function _has(list, value) {
|
||||
return _list(list).indexOf(value) >= 0;
|
||||
}
|
||||
|
||||
function isUsable(plugin, compositor) {
|
||||
if (!plugin || !plugin.id)
|
||||
return false;
|
||||
if (_has(plugin.status, "broken") || _has(plugin.status, "unmaintained"))
|
||||
return false;
|
||||
const comps = _list(plugin.compositors);
|
||||
if (!compositor || comps.length === 0 || _has(comps, "any"))
|
||||
return true;
|
||||
return _has(comps, compositor);
|
||||
}
|
||||
|
||||
// Compositors a plugin is limited to when the running one is not among them, else [].
|
||||
function unsupportedOn(plugin, compositor) {
|
||||
const comps = _list(plugin ? plugin.compositors : null);
|
||||
if (!compositor || comps.length === 0 || _has(comps, "any") || _has(comps, compositor))
|
||||
return [];
|
||||
return comps;
|
||||
}
|
||||
|
||||
// filters: { usable, fresh, official }, every set one has to match.
|
||||
function matchesFilters(plugin, filters, compositor, now) {
|
||||
if (!filters)
|
||||
return true;
|
||||
if (filters.usable && !isUsable(plugin, compositor))
|
||||
return false;
|
||||
if (filters.fresh && recency(plugin, now) === "")
|
||||
return false;
|
||||
if (filters.official && !plugin.firstParty)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// What the user already runs, read from the installed plugins.
|
||||
function buildTaste(plugins) {
|
||||
const taste = {
|
||||
installed: {},
|
||||
similar: {},
|
||||
categories: {},
|
||||
authors: {},
|
||||
count: 0
|
||||
};
|
||||
for (let i = 0; i < plugins.length; i++) {
|
||||
const p = plugins[i];
|
||||
if (!p.installed)
|
||||
continue;
|
||||
taste.installed[p.id] = true;
|
||||
taste.count++;
|
||||
const cat = (p.category || "").toLowerCase();
|
||||
if (cat)
|
||||
taste.categories[cat] = (taste.categories[cat] || 0) + 1;
|
||||
if (p.author)
|
||||
taste.authors[p.author] = (taste.authors[p.author] || 0) + 1;
|
||||
const sim = p.similar || [];
|
||||
for (let s = 0; s < sim.length; s++)
|
||||
taste.similar[sim[s]] = (taste.similar[sim[s]] || 0) + 1;
|
||||
}
|
||||
return taste;
|
||||
}
|
||||
|
||||
// Votes with diminishing returns: the 40th vote weighs less than the 4th.
|
||||
function popularity(plugin) {
|
||||
return Math.log(1 + Math.max(0, plugin.upvotes || 0)) / Math.LN2;
|
||||
}
|
||||
|
||||
// Votes per unit of age, so a young plugin that gathers votes fast outranks
|
||||
// an old one sitting on the same count. The two-week offset keeps a plugin
|
||||
// published an hour ago from winning on a single vote.
|
||||
function trending(plugin, now) {
|
||||
const age = _days(_time(plugin.created_at), now);
|
||||
return (plugin.upvotes || 0) / Math.pow(age + 14, 0.8);
|
||||
}
|
||||
|
||||
// 1 on the day of the last update, halves every 45 days.
|
||||
function freshness(plugin, now) {
|
||||
const age = _days(_time(plugin.updated_at), now);
|
||||
return Math.pow(0.5, age / 45);
|
||||
}
|
||||
|
||||
// "new" for two weeks after a plugin enters the registry, "updated" for a
|
||||
// week after a release, "" otherwise. Drives the badge on the browser cards.
|
||||
function recency(plugin, now) {
|
||||
const t = now || Date.now();
|
||||
if (_days(_time(plugin.created_at), t) <= 14)
|
||||
return "new";
|
||||
if (_days(_time(plugin.updated_at), t) <= 7)
|
||||
return "updated";
|
||||
return "";
|
||||
}
|
||||
|
||||
function affinity(plugin, taste) {
|
||||
if (!taste || taste.count === 0)
|
||||
return 0;
|
||||
let score = 0;
|
||||
if (taste.similar[plugin.id])
|
||||
score += 2 + Math.min(2, taste.similar[plugin.id] - 1);
|
||||
const sim = plugin.similar || [];
|
||||
for (let s = 0; s < sim.length; s++) {
|
||||
if (taste.installed[sim[s]]) {
|
||||
score += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const cat = (plugin.category || "").toLowerCase();
|
||||
if (cat && taste.categories[cat])
|
||||
score += Math.min(1, taste.categories[cat] / taste.count * 2);
|
||||
if (plugin.author && taste.authors[plugin.author])
|
||||
score += 0.5;
|
||||
return score;
|
||||
}
|
||||
|
||||
function trust(plugin) {
|
||||
let score = 0;
|
||||
if (_has(plugin.status, "reviewed"))
|
||||
score += 1;
|
||||
if (plugin.firstParty)
|
||||
score += 0.5;
|
||||
if (plugin.featured)
|
||||
score += 1;
|
||||
return score;
|
||||
}
|
||||
|
||||
function scoreParts(plugin, taste, now) {
|
||||
return {
|
||||
popularity: popularity(plugin),
|
||||
trending: trending(plugin, now) * 4,
|
||||
freshness: freshness(plugin, now) * 1.5,
|
||||
affinity: affinity(plugin, taste) * 1.5,
|
||||
trust: trust(plugin)
|
||||
};
|
||||
}
|
||||
|
||||
function score(plugin, taste, now) {
|
||||
const p = scoreParts(plugin, taste, now);
|
||||
return p.popularity + p.trending + p.freshness + p.affinity + p.trust;
|
||||
}
|
||||
|
||||
// The strongest reason a plugin is shown, as a stable key the UI translates.
|
||||
function reason(plugin, taste, now) {
|
||||
if (taste && taste.similar[plugin.id])
|
||||
return "similar";
|
||||
if (plugin.featured)
|
||||
return "featured";
|
||||
const parts = scoreParts(plugin, taste, now);
|
||||
if (_days(_time(plugin.created_at), now) <= 14)
|
||||
return "new";
|
||||
if (parts.trending >= parts.popularity && parts.trending >= parts.affinity)
|
||||
return "trending";
|
||||
if (parts.affinity > 0 && parts.affinity >= parts.popularity)
|
||||
return "category";
|
||||
return "popular";
|
||||
}
|
||||
|
||||
function _ranked(plugins, now, options, scoreFn) {
|
||||
const compositor = options.compositor;
|
||||
const exclude = options.exclude || [];
|
||||
const rows = [];
|
||||
for (let i = 0; i < plugins.length; i++) {
|
||||
const p = plugins[i];
|
||||
if (p.installed || !isUsable(p, compositor) || _has(exclude, p.id))
|
||||
continue;
|
||||
rows.push({
|
||||
plugin: p,
|
||||
score: scoreFn(p)
|
||||
});
|
||||
}
|
||||
rows.sort((a, b) => {
|
||||
if (b.score !== a.score)
|
||||
return b.score - a.score;
|
||||
return a.plugin.id < b.plugin.id ? -1 : a.plugin.id > b.plugin.id ? 1 : 0;
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Best first, with at most `perAuthor` entries by the same author so one
|
||||
// prolific author cannot fill the shelf. When that leaves the shelf short,
|
||||
// the skipped entries fill the remaining places in rank order.
|
||||
function _diverse(rows, limit, perAuthor) {
|
||||
const out = [];
|
||||
const skipped = [];
|
||||
const seen = {};
|
||||
for (let i = 0; i < rows.length && out.length < limit; i++) {
|
||||
const author = rows[i].plugin.author || "";
|
||||
seen[author] = (seen[author] || 0) + 1;
|
||||
if (author && seen[author] > perAuthor) {
|
||||
skipped.push(rows[i].plugin);
|
||||
continue;
|
||||
}
|
||||
out.push(rows[i].plugin);
|
||||
}
|
||||
for (let i = 0; i < skipped.length && out.length < limit; i++)
|
||||
out.push(skipped[i]);
|
||||
return out;
|
||||
}
|
||||
|
||||
function recommend(plugins, options) {
|
||||
const o = options || {};
|
||||
const now = o.now || Date.now();
|
||||
const taste = o.taste || buildTaste(plugins);
|
||||
const rows = _ranked(plugins, now, o, p => score(p, taste, now));
|
||||
return _diverse(rows, o.limit || 8, o.perAuthor || 2);
|
||||
}
|
||||
|
||||
function trendingNow(plugins, options) {
|
||||
const o = options || {};
|
||||
const now = o.now || Date.now();
|
||||
const rows = _ranked(plugins, now, o, p => trending(p, now)).filter(r => r.score > 0);
|
||||
return _diverse(rows, o.limit || 8, o.perAuthor || 2);
|
||||
}
|
||||
|
||||
function newArrivals(plugins, options) {
|
||||
const o = options || {};
|
||||
const now = o.now || Date.now();
|
||||
const maxAge = o.maxAgeDays || 14;
|
||||
const rows = _ranked(plugins, now, o, p => _time(p.created_at)).filter(r => r.score > 0 && _days(r.score, now) <= maxAge);
|
||||
return _diverse(rows, o.limit || 8, o.perAuthor || 2);
|
||||
}
|
||||
|
||||
function _dayNumber(now) {
|
||||
return Math.floor(now / DAY_MS);
|
||||
}
|
||||
|
||||
// Weeks start on Monday (day 0 of the epoch was a Thursday).
|
||||
function _weekNumber(now) {
|
||||
return Math.floor((_dayNumber(now) + 3) / 7);
|
||||
}
|
||||
|
||||
function _hash(text) {
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
h ^= text.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619);
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
// One pick per period, the same for everybody on the same day: drawn from the
|
||||
// best reviewed plugins that have a screenshot, rotated by the date so the
|
||||
// spot moves on instead of showing the top plugin forever. Installed plugins
|
||||
// stay eligible on purpose: the pick is a shared headline, not a suggestion.
|
||||
function _pick(plugins, now, key, poolSize, excludeId) {
|
||||
const rows = [];
|
||||
for (let i = 0; i < plugins.length; i++) {
|
||||
const p = plugins[i];
|
||||
if (!isUsable(p, "") || !_has(p.status, "reviewed") || !p.screenshot || p.id === excludeId)
|
||||
continue;
|
||||
rows.push({
|
||||
plugin: p,
|
||||
score: popularity(p) + trending(p, now) * 4 + freshness(p, now)
|
||||
});
|
||||
}
|
||||
if (rows.length === 0)
|
||||
return null;
|
||||
rows.sort((a, b) => b.score !== a.score ? b.score - a.score : (a.plugin.id < b.plugin.id ? -1 : 1));
|
||||
const pool = rows.slice(0, Math.min(poolSize, rows.length));
|
||||
return pool[_hash(key) % pool.length].plugin;
|
||||
}
|
||||
|
||||
function pickOfTheWeek(plugins, now) {
|
||||
const t = now || Date.now();
|
||||
return _pick(plugins, t, "week:" + _weekNumber(t), 12, "");
|
||||
}
|
||||
|
||||
function pickOfTheDay(plugins, now) {
|
||||
const t = now || Date.now();
|
||||
const week = pickOfTheWeek(plugins, t);
|
||||
return _pick(plugins, t, "day:" + _dayNumber(t), 30, week ? week.id : "");
|
||||
}
|
||||
@@ -97,8 +97,10 @@ DCard {
|
||||
Row {
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: upvoteBadge.visible ? upvoteBadge.left : parent.right
|
||||
anchors.margins: Theme.spacingXS
|
||||
spacing: Theme.spacingXXS
|
||||
clip: true
|
||||
|
||||
Repeater {
|
||||
model: root.badges
|
||||
@@ -113,6 +115,7 @@ DCard {
|
||||
}
|
||||
|
||||
PluginBadge {
|
||||
id: upvoteBadge
|
||||
anchors.top: parent.top
|
||||
anchors.right: parent.right
|
||||
anchors.margins: Theme.spacingXS
|
||||
|
||||
@@ -7,6 +7,7 @@ import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import "../Common/PluginRanking.js" as PluginRanking
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
@@ -1365,6 +1366,13 @@ Singleton {
|
||||
icon: "",
|
||||
tone: "warning"
|
||||
});
|
||||
var recency = PluginRanking.recency(plugin);
|
||||
if (recency)
|
||||
badges.push({
|
||||
label: recency === "new" ? I18n.tr("new", "adjective, lowercase plugin badge, recently added") : I18n.tr("updated", "adjective, lowercase plugin badge, recently updated"),
|
||||
icon: recency === "new" ? "fiber_new" : "update",
|
||||
tone: "secondary"
|
||||
});
|
||||
var status = plugin.status || [];
|
||||
for (var i = 0; i < status.length; i++)
|
||||
badges.push({
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import vm from "node:vm";
|
||||
import test from "node:test";
|
||||
|
||||
const rank = vm.createContext({});
|
||||
vm.runInContext(readFileSync(new URL("../Common/PluginRanking.js", import.meta.url), "utf8").replace(".pragma library", ""), rank);
|
||||
|
||||
const NOW = Date.UTC(2026, 9, 10);
|
||||
const DAY = 86400000;
|
||||
const ago = days => new Date(NOW - days * DAY).toISOString();
|
||||
const plugin = (id, extra) => Object.assign({ id, name: id, author: "a-" + id, category: "utilities", upvotes: 0, status: ["reviewed"], compositors: ["any"], screenshot: "x.png", created_at: ago(100), updated_at: ago(100) }, extra || {});
|
||||
const ids = list => Array.from(list, p => p.id);
|
||||
|
||||
test("installed, broken, unmaintained and wrong-compositor plugins are never recommended", () => {
|
||||
const list = [plugin("mine", { installed: true, upvotes: 50 }), plugin("broken", { status: ["broken"], upvotes: 50 }), plugin("old", { status: ["unmaintained"], upvotes: 50 }), plugin("hypr", { compositors: ["hyprland"], upvotes: 50 }), plugin("ok", { upvotes: 1 })];
|
||||
assert.deepEqual(ids(rank.recommend(list, { now: NOW, compositor: "niri" })), ["ok"]);
|
||||
assert.deepEqual(ids(rank.recommend(list, { now: NOW, compositor: "hyprland" })), ["hypr", "ok"]);
|
||||
});
|
||||
|
||||
test("a plugin similar to an installed one outranks a slightly more voted stranger", () => {
|
||||
const list = [plugin("shot", { installed: true, similar: ["capture"] }), plugin("capture", { upvotes: 3 }), plugin("stranger", { upvotes: 6, category: "media" })];
|
||||
assert.equal(rank.recommend(list, { now: NOW })[0].id, "capture");
|
||||
assert.equal(rank.reason(list[1], rank.buildTaste(list), NOW), "similar");
|
||||
});
|
||||
|
||||
test("votes still win when the gap is large", () => {
|
||||
const list = [plugin("shot", { installed: true, similar: ["capture"] }), plugin("capture", { upvotes: 1 }), plugin("star", { upvotes: 400, category: "media" })];
|
||||
assert.equal(rank.recommend(list, { now: NOW })[0].id, "star");
|
||||
});
|
||||
|
||||
test("trending favours votes gathered fast over the same votes gathered slowly", () => {
|
||||
const list = [plugin("veteran", { upvotes: 10, created_at: ago(400) }), plugin("rising", { upvotes: 10, created_at: ago(10) }), plugin("silent", { upvotes: 0, created_at: ago(1) })];
|
||||
assert.deepEqual(ids(rank.trendingNow(list, { now: NOW })), ["rising", "veteran"]);
|
||||
});
|
||||
|
||||
test("a single vote on a brand-new plugin does not beat an established one", () => {
|
||||
const list = [plugin("fresh", { upvotes: 1, created_at: ago(0) }), plugin("solid", { upvotes: 30, created_at: ago(60) })];
|
||||
assert.equal(rank.trendingNow(list, { now: NOW })[0].id, "solid");
|
||||
});
|
||||
|
||||
test("one author gets at most two places", () => {
|
||||
const list = ["a", "b", "c", "d"].map((id, i) => plugin(id, { author: "same", upvotes: 40 - i })).concat([plugin("other", { upvotes: 1 })]);
|
||||
assert.deepEqual(ids(rank.recommend(list, { now: NOW, limit: 3 })), ["a", "b", "other"]);
|
||||
});
|
||||
|
||||
test("a short shelf is filled with the entries the author limit skipped", () => {
|
||||
const list = [1, 2, 3, 4].map(n => plugin("p" + n, { author: "same", upvotes: 10 - n }));
|
||||
assert.deepEqual(ids(rank.recommend(list, { now: NOW, limit: 3 })), ["p1", "p2", "p3"]);
|
||||
list.push(plugin("other", { upvotes: 1 }));
|
||||
assert.deepEqual(ids(rank.recommend(list, { now: NOW, limit: 3 })), ["p1", "p2", "other"]);
|
||||
});
|
||||
|
||||
test("excluded ids are left out of every shelf", () => {
|
||||
const list = [plugin("a", { upvotes: 9, created_at: ago(2) }), plugin("b", { upvotes: 5, created_at: ago(3) })];
|
||||
const options = { now: NOW, exclude: ["a"] };
|
||||
assert.deepEqual(ids(rank.recommend(list, options)), ["b"]);
|
||||
assert.deepEqual(ids(rank.trendingNow(list, options)), ["b"]);
|
||||
assert.deepEqual(ids(rank.newArrivals(list, options)), ["b"]);
|
||||
});
|
||||
|
||||
test("new arrivals are the last two weeks, newest first", () => {
|
||||
const list = [plugin("month", { created_at: ago(30) }), plugin("week", { created_at: ago(6) }), plugin("today", { created_at: ago(0.2) }), plugin("undated", { created_at: "" })];
|
||||
assert.deepEqual(ids(rank.newArrivals(list, { now: NOW })), ["today", "week"]);
|
||||
});
|
||||
|
||||
test("picks are stable within their period, differ from each other and rotate", () => {
|
||||
const list = [];
|
||||
for (let i = 0; i < 60; i++)
|
||||
list.push(plugin("p" + i, { upvotes: 60 - i }));
|
||||
const day = rank.pickOfTheDay(list, NOW);
|
||||
const week = rank.pickOfTheWeek(list, NOW);
|
||||
assert.equal(rank.pickOfTheDay(list, NOW + 5 * 3600000).id, day.id);
|
||||
assert.notEqual(day.id, week.id);
|
||||
const monday = Date.UTC(2026, 9, 5);
|
||||
const weekIds = new Set([0, 1, 2, 3, 4, 5, 6].map(d => rank.pickOfTheWeek(list, monday + d * DAY).id));
|
||||
assert.equal(weekIds.size, 1);
|
||||
const days = new Set();
|
||||
for (let d = 0; d < 30; d++)
|
||||
days.add(rank.pickOfTheDay(list, NOW + d * DAY).id);
|
||||
assert.ok(days.size >= 12, "30 days gave only " + days.size + " different picks");
|
||||
const weeks = new Set();
|
||||
for (let w = 0; w < 12; w++)
|
||||
weeks.add(rank.pickOfTheWeek(list, NOW + w * 7 * DAY).id);
|
||||
assert.ok(weeks.size >= 5, "12 weeks gave only " + weeks.size + " different picks");
|
||||
});
|
||||
|
||||
test("picks need a review and a screenshot, and survive an empty list", () => {
|
||||
assert.equal(rank.pickOfTheDay([], NOW), null);
|
||||
assert.equal(rank.pickOfTheWeek([plugin("raw", { status: [] }), plugin("blind", { screenshot: "" })], NOW), null);
|
||||
});
|
||||
|
||||
test("no taste, no dates, no votes: still a deterministic order", () => {
|
||||
const list = [{ id: "b" }, { id: "a" }, { id: "c", upvotes: 1 }];
|
||||
assert.deepEqual(ids(rank.recommend(list, { now: NOW })), ["c", "a", "b"]);
|
||||
});
|
||||
|
||||
test("a card is marked new for two weeks, then updated for a week after a release", () => {
|
||||
assert.equal(rank.recency(plugin("a", { created_at: ago(3), updated_at: ago(1) }), NOW), "new");
|
||||
assert.equal(rank.recency(plugin("b", { created_at: ago(90), updated_at: ago(2) }), NOW), "updated");
|
||||
assert.equal(rank.recency(plugin("c", { created_at: ago(90), updated_at: ago(30) }), NOW), "");
|
||||
assert.equal(rank.recency(plugin("d", { created_at: undefined, updated_at: undefined }), NOW), "");
|
||||
});
|
||||
|
||||
test("a plugin limited to other compositors names them, a portable one names none", () => {
|
||||
assert.deepEqual(Array.from(rank.unsupportedOn(plugin("a", { compositors: ["hyprland", "sway"] }), "niri")), ["hyprland", "sway"]);
|
||||
assert.deepEqual(Array.from(rank.unsupportedOn(plugin("b", { compositors: ["niri"] }), "niri")), []);
|
||||
assert.deepEqual(Array.from(rank.unsupportedOn(plugin("c", { compositors: ["any"] }), "niri")), []);
|
||||
assert.deepEqual(Array.from(rank.unsupportedOn(plugin("d", { compositors: ["hyprland"] }), "")), []);
|
||||
assert.deepEqual(Array.from(rank.unsupportedOn(plugin("e", { compositors: undefined }), "niri")), []);
|
||||
});
|
||||
|
||||
test("quick filters keep only the plugins matching every set filter", () => {
|
||||
const here = plugin("a", { compositors: ["niri"], firstParty: true, created_at: ago(2), updated_at: ago(1) });
|
||||
const elsewhere = plugin("b", { compositors: ["hyprland"], firstParty: false, created_at: ago(200), updated_at: ago(100) });
|
||||
assert.equal(rank.matchesFilters(elsewhere, {}, "niri", NOW), true);
|
||||
assert.equal(rank.matchesFilters(elsewhere, null, "niri", NOW), true);
|
||||
assert.equal(rank.matchesFilters(here, { usable: true, fresh: true, official: true }, "niri", NOW), true);
|
||||
assert.equal(rank.matchesFilters(elsewhere, { usable: true }, "niri", NOW), false);
|
||||
assert.equal(rank.matchesFilters(elsewhere, { fresh: true }, "niri", NOW), false);
|
||||
assert.equal(rank.matchesFilters(elsewhere, { official: true }, "niri", NOW), false);
|
||||
assert.equal(rank.matchesFilters(here, { usable: true }, "hyprland", NOW), false);
|
||||
});
|
||||
|
||||
test("lists that are not real arrays (QML sequences) are still read", () => {
|
||||
const seq = items => Object.assign(Object.create(null), items, { length: items.length });
|
||||
assert.equal(rank.isUsable(plugin("a", { compositors: seq(["hyprland"]) }), "niri"), false);
|
||||
assert.equal(rank.isUsable(plugin("b", { status: seq(["broken"]) }), "niri"), false);
|
||||
assert.deepEqual(Array.from(rank.unsupportedOn(plugin("c", { compositors: seq(["hyprland"]) }), "niri")), ["hyprland"]);
|
||||
});
|
||||
@@ -26,6 +26,7 @@ ALLOWED = [
|
||||
{"Device", "device"}, # label vs inline generic-noun fallback
|
||||
{"Until %1", "until %1"}, # sentence-initial vs mid-sentence position
|
||||
{"Up", "up"}, # scroll direction vs uptime prefix
|
||||
{"New", "new"}, # create action vs lowercase plugin recency badge
|
||||
{"Custom Color", "Custom color"}, # dms-plugins checkout, not this repo
|
||||
{"Media Player", "Media player"}, # dms-plugins checkout, not this repo
|
||||
]
|
||||
|
||||
Reference in new issue
Block a user