Lumi/scripts/verify-user-age-statistics.js
2026-07-29 23:41:34 +02:00

187 lines
9.1 KiB
JavaScript

const assert = require("assert");
const fs = require("fs");
const os = require("os");
const path = require("path");
const Database = require("better-sqlite3");
const isolatedDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-user-age-"));
process.env.LUMI_DATA_DIR = isolatedDataDir;
const { formatDuration, parseDurationFormat } = require("../src/services/duration-format");
const { UserAgeStatistics } = require("../src/services/user-age-statistics");
const placeholders = require("../src/services/placeholders");
const coreDatabase = require("../src/services/db");
coreDatabase.migrate();
const hour = 60 * 60 * 1000;
const day = 24 * hour;
let now = 100 * day;
const database = new Database(":memory:");
database.exec(`
CREATE TABLE user_profiles (
id TEXT PRIMARY KEY,
internal_username TEXT NOT NULL
);
CREATE TABLE user_identities (
user_id TEXT NOT NULL,
provider TEXT NOT NULL,
provider_user_id TEXT NOT NULL
);
`);
const service = new UserAgeStatistics(database, { now: () => now });
database.prepare("INSERT INTO user_profiles (id, internal_username) VALUES (?, ?)").run("profile-1", "ViewerOne");
database.prepare("INSERT INTO user_identities (user_id, provider, provider_user_id) VALUES (?, ?, ?)").run("profile-1", "twitch", "viewer-1");
const base = {
platform: "twitch",
userId: "viewer-1",
scopeId: "channel-1",
type: "follow",
source: "test-event",
authoritative: true
};
service.setState({ ...base, active: true, startAt: now - 10 * day, occurredAt: now, eventId: "follow-1" });
let rows = service.listDiagnostics({ platform: "twitch", userId: "viewer-1", type: "follow" });
assert.strictEqual(rows.length, 1, "new follow should create one interval");
assert.strictEqual(rows[0].end_at, null, "new follow interval should remain open");
now += 2 * day;
service.setState({ ...base, active: false, occurredAt: now, eventId: "unfollow-1" });
rows = service.listDiagnostics({ platform: "twitch", userId: "viewer-1", type: "follow" });
assert.strictEqual(rows[0].end_at, now, "unfollow should close the open interval");
const firstCompleted = { ...rows[0] };
now += day;
service.setState({ ...base, active: true, startAt: now, occurredAt: now, eventId: "follow-2" });
service.setState({ ...base, active: true, startAt: now, occurredAt: now, eventId: "follow-2" });
rows = service.listDiagnostics({ platform: "twitch", userId: "viewer-1", type: "follow" });
assert.strictEqual(rows.length, 2, "refollow should create exactly one second interval");
assert.deepStrictEqual(
Object.fromEntries(Object.entries(rows.find((row) => row.id === firstCompleted.id)).filter(([key]) => !["updated_at"].includes(key))),
Object.fromEntries(Object.entries(firstCompleted).filter(([key]) => !["updated_at"].includes(key))),
"completed intervals must not be modified"
);
now += 3 * day;
let durations = service.getDurations(base);
assert.strictEqual(durations.current, 3 * day, "current should include only the open interval");
assert.strictEqual(durations.total, 15 * day, "total should combine completed and active intervals");
const leaders = service.getLeaderboard("follow", { platform: "twitch", limit: 10 });
assert.strictEqual(leaders[0].username, "ViewerOne", "leaderboards should resolve linked Lumi profiles");
assert.ok(leaders.every((entry) => !entry.label.includes("channel-1")), "leaderboards must not expose channel identifiers beside usernames");
service.setState({ ...base, active: false, occurredAt: now, eventId: "unfollow-2" });
durations = service.getDurations(base);
assert.strictEqual(durations.current, null, "inactive state should have no current duration");
service.setState({ ...base, scopeId: "channel-2", active: true, occurredAt: now, eventId: "channel-2-follow" });
assert.strictEqual(service.getDurations({ ...base, scopeId: "channel-1" }).current, null, "channels must remain isolated");
assert.strictEqual(service.getDurations({ ...base, scopeId: "channel-2" }).current, 0, "second channel should have its own interval");
const profileStatistics = service.getProfileStatistics("profile-1");
assert.ok(profileStatistics.every((entry) => !entry.label.includes("channel-1") && !entry.label.includes("channel-2")),
"profile statistics must use friendly channel labels without exposing IDs");
assert.ok(profileStatistics.some((entry) => entry.label.includes("Channel 1")) &&
profileStatistics.some((entry) => entry.label.includes("Channel 2")),
"multiple isolated scopes must remain distinguishable without raw IDs");
const multiScopeLeaders = service.getLeaderboard("follow", { platform: "twitch", limit: 10 });
assert.strictEqual(multiScopeLeaders.filter((entry) => entry.username === "ViewerOne").length, 1,
"leaderboards must collapse hidden channel scopes into one user row");
assert.strictEqual(multiScopeLeaders[0].numericValue, 15 * day,
"leaderboards must rank a user's longest scoped tenure without double-counting concurrent channels");
service.setState({
platform: "discord",
userId: "viewer-1",
scopeId: "guild-1",
type: "member",
active: true,
occurredAt: now,
eventId: "guild-1-member",
source: "test"
});
assert.strictEqual(service.getDurations({
platform: "discord",
userId: "viewer-1",
scopeId: "guild-2",
type: "member"
}).total, null, "Discord guilds must remain isolated");
const combined = 2 * 30 * day + 3 * day + 4 * hour + 47 * 60 * 1000;
assert.strictEqual(formatDuration(combined, "MM-DD-mm"), "2 months, 3 days, 287 minutes", "omitted hours should flow into minutes");
assert.strictEqual(formatDuration(3 * day + 2 * hour, "HH-mm"), "74 hours", "larger omitted units should flow into the largest selected unit");
assert.strictEqual(formatDuration(287 * 60 * 1000, "mm"), "287 minutes", "minutes-only format should contain total minutes");
assert.strictEqual(formatDuration(day + hour + 60 * 1000, "DD-HH-mm"), "1 day, 1 hour, 1 minute", "singular labels should be correct");
assert.strictEqual(formatDuration(2 * day + 2 * hour + 2 * 60 * 1000, "D-H-m"), "2 days, 2 hours, 2 minutes", "plural labels should be correct");
assert.strictEqual(formatDuration(500, "s"), "", "all-zero selected units should return an empty string");
assert.throws(() => parseDurationFormat("DD-YY"), /largest to smallest/, "out-of-order units should fail deterministically");
const restarted = new UserAgeStatistics(database, { now: () => now });
const beforeRestart = restarted.listDiagnostics({ platform: "twitch", scopeId: "channel-2", type: "follow" });
restarted.reconcileSnapshot({
platform: "twitch",
scopeId: "channel-2",
type: "follow",
activeRecords: [{ userId: "viewer-1" }],
observedAt: now,
source: "restart-test",
complete: true
});
const afterRestart = restarted.listDiagnostics({ platform: "twitch", scopeId: "channel-2", type: "follow" });
assert.strictEqual(afterRestart.length, beforeRestart.length, "restart reconciliation must not duplicate valid intervals");
assert.strictEqual(afterRestart[0].end_at, null, "restart reconciliation must not close valid intervals");
restarted.setState({
platform: "youtube",
userId: "viewer-2",
scopeId: "youtube-channel",
type: "subscriber",
active: true,
occurredAt: now,
eventId: "youtube-observed",
source: "youtube-first-observed",
authoritative: false
});
const observed = restarted.listDiagnostics({ platform: "youtube", userId: "viewer-2" })[0];
assert.strictEqual(observed.start_at, now, "unavailable historical starts must begin at first observation");
assert.strictEqual(observed.start_authoritative, 0, "first-observed timestamps must not be marked authoritative");
placeholders.registerCorePlaceholders({ userAgeStatistics: restarted });
const invalidFormat = placeholders.validateTemplate({
fieldId: "core.custom_commands.static_response",
template: "{{twitch.user.follow_age.current}.{format.YYY-DD}}",
outputAudience: "user",
runtimeContext: { runtime: true }
});
assert.strictEqual(invalidFormat.ok, false, "unsupported duration tokens should fail shared placeholder validation");
const existing = placeholders.renderTemplate({
fieldId: "core.custom_commands.static_response",
template: "Hello {{user.public.display_name}}",
outputAudience: "user",
runtimeContext: { runtime: true, user: { displayName: "Lumi Friend" } }
});
const formatted = placeholders.renderTemplate({
fieldId: "core.custom_commands.static_response",
template: "{{twitch.user.follow_age.current}.{format.DD-HH-mm}}",
outputAudience: "user",
runtimeContext: {
runtime: true,
platform: "twitch",
user: { platformId: "viewer-1" },
ctx: { meta: { tags: { "room-id": "channel-2" } } }
}
});
Promise.all([existing, formatted]).then(([existingResult, formattedResult]) => {
assert.strictEqual(existingResult.rendered, "Hello Lumi Friend", "existing placeholders must remain unchanged");
assert.strictEqual(formattedResult.rendered, "", "a zero-duration current interval should render empty");
database.close();
coreDatabase.db.close();
fs.rmSync(isolatedDataDir, { recursive: true, force: true });
console.log("User-age statistics verification passed.");
}).catch((error) => {
database.close();
coreDatabase.db.close();
fs.rmSync(isolatedDataDir, { recursive: true, force: true });
console.error(error);
process.exit(1);
});