Files
jackwener__opencli/clis/twitter/utils.test.js
jakevin 4475d4efe3 feat(twitter): P1+P2+P3+P4+P5 — search filters, bookmark folders, engagement scoring, sibling dedupe + help docs (#1406)
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)
2026-05-08 02:36:39 +08:00

170 lines
5.9 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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']);
});
});