mirror of
https://github.com/jackwener/OpenCLI.git
synced 2026-09-14 18:25:42 +08:00
4475d4efe3
Round 21 follow-up to #1400 (P0 write-action symmetry, merged `644d4517`). 5 features + help docs unified into one PR per WAWQAQ "全部合成一个 PR" directive. ## Scope - **P1** (`cf10c098`): `twitter search` `--from / --has / --exclude / --product` filters, mapping to X `from:` / `filter:` / `-filter:` / `f=` operators; legacy `--filter top|live` preserved (--product win on conflict) - **P2** (`a484a69a`): new `twitter bookmark-folders` + `bookmark-folder <id>`; X Premium GraphQL `bookmarkFoldersSlice` + `BookmarkFolderTimeline`; queryId 三层 fallback (placeholder.json → client-web bundle → pinned constants) - **P3** (`f209f914`): `--top-by-engagement N` to 7 tweet-shaped read commands (search/timeline/likes/bookmarks/list-tweets/tweets/thread); single helper in `utils.js`; formula `likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5`; **N=0 reference equality no-op** → existing 157 twitter tests 0 churn - **P4** (`89283fa0`): `TWITTER_BEARER_TOKEN` + composer image helpers extracted to `utils.js` (12 GraphQL adapter dedup); reply hardening; quote adds `--image` - **P5** (`a3d10a48`): sibling article-scope helper extracted to `shared.js` (9 write commands reuse, dedup with #1400 P0 invariant) - **docs** (`2a358d80`): help-doc precision (positional-omitted defaults + download/bookmarks/notifications/timeline/lists description thicken; concurrent #1401/#1403 wording preserved) 47 files / +2594/-470. Tests **96 → 216 (+120)**, manifest 798 → 801 (+3), typed-error-lint 190 → 189 (resolved 1 grandfathered sentinel). ## Iteration history (3 review fix commits on top of 6 author commits) - `7f93779b` — codex-mini1 lead fix1: 3 blocker bundle (P5 host invariant + P2 safe-id + sentinel removal + P1 fallback fail-fast) - `2a29ecc6` — codex-mini1 lead fix2: P3 help formula consistency (doc/help text matches actual `log10(views+1)×0.5`) - `df4dcd76` — codex-mini1 lead fix3 (F-P-1 aux catch): P2 `bookmark-folder --limit` upfront validation (`Number(kwargs.limit ?? 20)` + reject non-positive/non-integer + regression `0/negative/fractional/NaN` + `page.goto` zero-call assert) ## 4 progressive blockers caught (codex-mini1 lead 3 rounds + F-P-1 aux 1 round) 1. **P5 host invariant gap** (lead): article-scope helper preserved exact `/status/<id>` path but ignored link host → off-domain `https://evil.com/alice/status/<target>` would satisfy `__twHasLinkToTarget`. Fixed: `https` + X/Twitter host or subdomain + exact `/status/<id>` or `/i/status/<id>` path; query/hash allowed; off-domain/host-suffix/non-https/path-suffix/substring-id rejected; JSDOM positive + 5 negative anchors. 2. **P2 listing→detail round-trip + sentinel** (lead): `bookmark-folders` accepted opaque IDs but `bookmark-folder <id>` only accepted numeric → round-trip broken; new `author: 'unknown'` sentinel created fabricated author URL. Fixed: `[A-Za-z0-9_-]+` opaque safe-id (rejects `/`, `?`, `%`, spaces) + `resolveTwitterQueryId()` sanitization for queryId resolution; sentinel removed → empty author + canonical `/i/status/<id>` URL. 3. **P1 fallback silent tab miss** (lead): pushState fail → fallback typing into search box, `clickProductTabIfNeeded()` silent return on tab not found → user `--product photos` silently degraded to Top results. Fixed: throw `CommandExecutionError` when requested `--product` tab cannot be selected + invalid `--from` / `--limit` upfront pre-nav reject + double-direction tests. 4. **P2 limit silent normalize** (aux): `const limit = kwargs.limit || 20` → `--limit 0` silent → 20; negative/non-integer pre-IO unchecked. Fixed: `Number(kwargs.limit ?? 20)` + require positive integer before `page.goto` + regression covers `0/negative/fractional/NaN` + `page.goto` zero-call. ## Cultural sediment (Round 21 audit checklist 7 rules / 6 dimensions) This PR **immediately validated 4 of 7 rules** in review pipeline: - (b) silent-clamp class — P1 fallback silent tab miss (silent semantic-downgrade) + P2 `|| 20` silent normalize - (e) ID exact-not-substring — P5 host invariant (was only path-exact, not host-exact) - (f) grandfathered-not-exempt — P5 helper-refactor boundary lost host invariant + P2 new adapter inherited grandfathered `'unknown'` sentinel - (g) fallback-must-have-success-criterion — P1 fallback path missing post-condition assertion 7 rules / 6 dimensions: - (a) cross-grep sibling URL pattern — structural - (b) silent-clamp class — failure mode (input) - (c) broad querySelector → article-scoping — scope - (d) missing-validation early reject — boundary - (e) ID exact-not-substring — identity - (f) grandfathered-not-exempt (corollary: applies to new file + new helper-refactor boundary; not original-file line-edit) — time-axis - (g) fallback-must-have-success-criterion (sub-rule g': fallback unit test must include post-condition assertion, not just "doesn't throw") — failure mode (output) **Cross-PR validation 4-chain on meta-anchor "Structural exactness for identity matching"**: - #1391 URL layer (`isFacebookAuthRedirectPath`: top-level anchor + `\.php` + `(/|$)` segment edge) - #1392 URL parser layer (`parseGrokSessionId`: bare UUID exact / URL host-exact-or-subdomain + path-exact) - #1400 DOM layer (article-scoping: status-id `/\/status\/${id}(?:\/|$)/` regex / segment-array exact) - #1406 P5 helper-refactor boundary (full URL invariant in shared helper: host+path re-anchored after extraction) - Common invariant: boundary-lock structural shape; **fuzzy match is silent-failure 温床**; lesson lifecycle = surface-shift not add-and-forget. **Audit framework self-discipline**: each rule must have grep-able detection signal, otherwise rule degenerates to mantra. Framework is "7 rules + sub-instance pattern in new surface", not frozen 7 rules. **Round 17 race-mitigation 第 9 连续 race-free execution**: standard alternation cadence (#1400 A 组 → #1406 B 组), lead final + aux final + `@pr-monitor squash?` trigger, pr-monitor proactive ack + serial squash, lead silent on closeout. ## Validation gates (final head `df4dcd76`) Local: Twitter adapter tests `25 files / 216 tests`, focused P1/P2/P3/P5 tests `99/99`, `node --check` touched runtime, `npx tsc --noEmit`, `npm run build`, manifest 801 entries, typed-error-lint `189/189`, silent-column-drop `103/103`, doc-coverage `140/140`, docs:build clean, listing-id advisory `13` unchanged (wikipedia/trending residual non-Twitter), `git diff --check` clean. GitHub: build×3 (ubuntu/macos/windows) SUCCESS, unit-test shards SUCCESS, bun-test SUCCESS, adapter-test SUCCESS, audit SUCCESS, doc-coverage SUCCESS, docs-build SUCCESS, smoke-test skipped, PR `CLEAN/MERGEABLE`. Reviewers: - Lead: @codex-mini1 (3 fix rounds, all caught proactively + amend P3 help consistency) - Aux: @First-principles-1 (better-solution triangulation on P2 queryId 三层 fallback + P5 invariant + P3 N=0 reference no-op + caught P2 limit silent normalize) - Author: @opencli-user (5-feature scope + 7-rule sediment co-author + corollary contributor)
170 lines
5.9 KiB
JavaScript
170 lines
5.9 KiB
JavaScript
import { describe, expect, it } from 'vitest';
|
||
import { __test__ } from './utils.js';
|
||
|
||
const { computeEngagementScore, applyTopByEngagement, ENGAGEMENT_WEIGHTS } = __test__;
|
||
|
||
describe('computeEngagementScore', () => {
|
||
it('returns 0 for empty / nullish rows', () => {
|
||
expect(computeEngagementScore(null)).toBe(0);
|
||
expect(computeEngagementScore(undefined)).toBe(0);
|
||
expect(computeEngagementScore({})).toBe(0);
|
||
});
|
||
|
||
it('weights likes ×1', () => {
|
||
expect(computeEngagementScore({ likes: 10 })).toBe(10);
|
||
});
|
||
|
||
it('weights retweets ×3', () => {
|
||
expect(computeEngagementScore({ retweets: 5 })).toBe(15);
|
||
});
|
||
|
||
it('weights replies ×2', () => {
|
||
expect(computeEngagementScore({ replies: 4 })).toBe(8);
|
||
});
|
||
|
||
it('weights bookmarks ×5', () => {
|
||
expect(computeEngagementScore({ bookmarks: 6 })).toBe(30);
|
||
});
|
||
|
||
it('log-dampens views (log10(v+1) × 0.5)', () => {
|
||
// log10(99+1) * 0.5 = 1.0
|
||
expect(computeEngagementScore({ views: 99 })).toBeCloseTo(1.0, 2);
|
||
// log10(0+1) * 0.5 = 0
|
||
expect(computeEngagementScore({ views: 0 })).toBe(0);
|
||
});
|
||
|
||
it('coerces string-typed views (search/timeline returns views as a string)', () => {
|
||
// log10(9999+1) * 0.5 = 2.0
|
||
expect(computeEngagementScore({ views: '9999' })).toBeCloseTo(2.0, 2);
|
||
});
|
||
|
||
it('treats non-numeric strings as 0 instead of NaN-poisoning the score', () => {
|
||
expect(computeEngagementScore({ likes: 'abc', retweets: 5 })).toBe(15);
|
||
});
|
||
|
||
it('clamps negative values at 0 (defensive against bogus payloads)', () => {
|
||
expect(computeEngagementScore({ likes: -100, retweets: 2 })).toBe(6);
|
||
});
|
||
|
||
it('combines all signals additively', () => {
|
||
// likes×1 + retweets×3 + replies×2 + bookmarks×5 + log10(views+1)×0.5
|
||
// 10 + 30 + 8 + 25 + log10(1000)*0.5 = 73 + 1.5 = 74.5
|
||
const row = { likes: 10, retweets: 10, replies: 4, bookmarks: 5, views: 999 };
|
||
expect(computeEngagementScore(row)).toBeCloseTo(74.5, 2);
|
||
});
|
||
|
||
it('rounds to 2 decimal places for stable test fixtures', () => {
|
||
// log10(1+1) * 0.5 = 0.5 * log10(2) ≈ 0.150515
|
||
const score = computeEngagementScore({ views: 1 });
|
||
expect(score).toBe(0.15);
|
||
});
|
||
|
||
it('handles real search-row shape (no replies/bookmarks columns)', () => {
|
||
const searchRow = {
|
||
id: '123',
|
||
author: 'alice',
|
||
text: 'hi',
|
||
likes: 100,
|
||
views: '9999',
|
||
};
|
||
// 100 + log10(10000)*0.5 = 100 + 2.0 = 102.0
|
||
expect(computeEngagementScore(searchRow)).toBeCloseTo(102.0, 2);
|
||
});
|
||
|
||
it('handles real bookmarks-row shape (no views/replies columns)', () => {
|
||
const bookmarkRow = {
|
||
id: '123',
|
||
author: 'alice',
|
||
text: 'hi',
|
||
likes: 50,
|
||
retweets: 10,
|
||
bookmarks: 3,
|
||
};
|
||
// 50 + 30 + 15 = 95
|
||
expect(computeEngagementScore(bookmarkRow)).toBe(95);
|
||
});
|
||
|
||
it('exposes the documented weight table', () => {
|
||
expect(ENGAGEMENT_WEIGHTS).toEqual({
|
||
likes: 1,
|
||
retweets: 3,
|
||
replies: 2,
|
||
bookmarks: 5,
|
||
viewsLog: 0.5,
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('applyTopByEngagement', () => {
|
||
const rows = [
|
||
{ id: 'a', likes: 10 },
|
||
{ id: 'b', likes: 50 },
|
||
{ id: 'c', likes: 30 },
|
||
{ id: 'd', likes: 100 },
|
||
{ id: 'e', likes: 5 },
|
||
];
|
||
|
||
it('returns rows unchanged when topN is 0 (default)', () => {
|
||
expect(applyTopByEngagement(rows, 0)).toBe(rows);
|
||
});
|
||
|
||
it('returns rows unchanged when topN is negative', () => {
|
||
expect(applyTopByEngagement(rows, -3)).toBe(rows);
|
||
});
|
||
|
||
it('returns rows unchanged when topN is non-numeric', () => {
|
||
expect(applyTopByEngagement(rows, 'foo')).toBe(rows);
|
||
expect(applyTopByEngagement(rows, null)).toBe(rows);
|
||
expect(applyTopByEngagement(rows, undefined)).toBe(rows);
|
||
});
|
||
|
||
it('sorts descending by score and trims to top N when topN > 0', () => {
|
||
const result = applyTopByEngagement(rows, 3);
|
||
expect(result.map(r => r.id)).toEqual(['d', 'b', 'c']);
|
||
});
|
||
|
||
it('returns all rows when topN exceeds row count', () => {
|
||
const result = applyTopByEngagement(rows, 99);
|
||
expect(result.map(r => r.id)).toEqual(['d', 'b', 'c', 'a', 'e']);
|
||
});
|
||
|
||
it('floors fractional topN', () => {
|
||
const result = applyTopByEngagement(rows, 2.9);
|
||
expect(result.map(r => r.id)).toEqual(['d', 'b']);
|
||
});
|
||
|
||
it('is stable for ties (preserves original order)', () => {
|
||
const tieRows = [
|
||
{ id: 'first', likes: 10 },
|
||
{ id: 'second', likes: 10 },
|
||
{ id: 'third', likes: 10 },
|
||
{ id: 'fourth', likes: 100 },
|
||
];
|
||
const result = applyTopByEngagement(tieRows, 4);
|
||
// 'fourth' first, then ties retain original order
|
||
expect(result.map(r => r.id)).toEqual(['fourth', 'first', 'second', 'third']);
|
||
});
|
||
|
||
it('handles empty / non-array input gracefully', () => {
|
||
expect(applyTopByEngagement([], 5)).toEqual([]);
|
||
expect(applyTopByEngagement(null, 5)).toBeNull();
|
||
expect(applyTopByEngagement(undefined, 5)).toBeUndefined();
|
||
});
|
||
|
||
it('does not mutate the input array', () => {
|
||
const before = [...rows];
|
||
applyTopByEngagement(rows, 2);
|
||
expect(rows).toEqual(before);
|
||
});
|
||
|
||
it('mixes signals correctly when ranking', () => {
|
||
// bookmark-heavy row should beat like-heavy row even if likes are higher
|
||
const mixed = [
|
||
{ id: 'likes-only', likes: 100 }, // score = 100
|
||
{ id: 'bookmark-heavy', likes: 30, bookmarks: 20 }, // score = 30 + 100 = 130
|
||
];
|
||
const result = applyTopByEngagement(mixed, 2);
|
||
expect(result.map(r => r.id)).toEqual(['bookmark-heavy', 'likes-only']);
|
||
});
|
||
});
|