File size: 6,621 Bytes
879c715 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 | import { describe, expect, it } from "vitest";
import {
canonicalJson,
computeContractHash,
computeInputHash,
computeInputHashFromTweets,
PROCESSOR_VERSION,
semanticTweetFields,
} from "../src/hash.js";
import type { PooledTweet } from "../src/tweet.js";
function pooled(overrides: Record<string, unknown> = {}): PooledTweet {
return {
id: "100",
url: "https://x.com/someone/status/100",
text: "hello world",
captured_at: "2026-05-21T03:04:35.954Z",
created_at: "2026-05-20T10:00:00.000Z",
author: { id: "author-a", username: "someone", display_name: "Some One" },
contributed_by: "osolmaz",
pooled_at: "2026-05-21T03:05:00.000Z",
...overrides,
};
}
describe("canonicalJson", () => {
it("sorts keys and drops undefined values", () => {
expect(canonicalJson({ b: 1, a: 2, c: undefined })).toBe('{"a":2,"b":1}');
expect(canonicalJson({ nested: { z: 1, a: 2 } })).toBe('{"nested":{"a":2,"z":1}}');
expect(canonicalJson([{ b: 1, a: 2 }])).toBe('[{"a":2,"b":1}]');
});
it("keeps array order", () => {
expect(canonicalJson(["b", "a"])).toBe('["b","a"]');
});
});
describe("computeInputHash", () => {
it("is stable across capture reorderings, metrics changes and contributor differences", () => {
const first = pooled();
const second = pooled({
captured_at: "2026-06-01T00:00:00.000Z",
pooled_at: "2026-06-01T00:00:00.000Z",
contributed_by: "alice",
like_count: 500,
});
expect(computeInputHashFromTweets("100:someone", [first])).toBe(
computeInputHashFromTweets("100:someone", [second]),
);
});
it("changes when the semantic input changes", () => {
const baseline = pooled();
const differentText = pooled({ text: "different words" });
const withReply = pooled({ in_reply_to_status_id: "99" });
const withQuote = pooled({ quoted_status_id: "42" });
const subscriberOnly = pooled({ is_subscriber_only: true });
const differentAuthor = pooled({ author: { id: "author-b", username: "someone" } });
const baseHash = computeInputHashFromTweets("100:someone", [baseline]);
expect(computeInputHashFromTweets("100:someone", [differentText])).not.toBe(baseHash);
expect(computeInputHashFromTweets("100:someone", [withReply])).not.toBe(baseHash);
expect(computeInputHashFromTweets("100:someone", [withQuote])).not.toBe(baseHash);
expect(computeInputHashFromTweets("100:someone", [subscriberOnly])).not.toBe(baseHash);
expect(computeInputHashFromTweets("100:someone", [differentAuthor])).not.toBe(baseHash);
});
it("changes when membership changes", () => {
const root = pooled();
const reply = pooled({ id: "101", text: "reply" });
const single = computeInputHashFromTweets("100:someone", [root]);
const multi = computeInputHashFromTweets("100:someone", [root, reply]);
expect(single).not.toBe(multi);
});
it("does not depend on the order tweets are supplied", () => {
const one = pooled();
const two = pooled({ id: "101", text: "reply" });
const a = computeInputHashFromTweets("100:someone", [one, two]);
const b = computeInputHashFromTweets("100:someone", [two, one]);
expect(a).toBe(b);
});
it("respects expanded URLs from raw arrays or entities.urls", () => {
const viaArray = computeInputHashFromTweets("100:someone", [
pooled({ expanded_urls: ["https://example.com/a"] }),
]);
const viaEntities = computeInputHashFromTweets("100:someone", [
pooled({ entities: { urls: [{ expanded_url: "https://example.com/a" }] } }),
]);
const noUrl = computeInputHashFromTweets("100:someone", [pooled()]);
expect(viaArray).toBe(viaEntities);
expect(viaArray).not.toBe(noUrl);
});
it("normalizes semantic fields for downstream consumers", () => {
const fields = semanticTweetFields(
pooled({ conversation_id: "42", entities: { urls: [{ expanded_url: "https://x/1" }] } }),
);
expect(fields.expanded_urls).toEqual(["https://x/1"]);
expect(fields.conversation_id).toBe("42");
expect(fields.author_username).toBe("someone");
expect(fields.is_retweet).toBe(false);
});
it("handles numeric references and malformed optional URL containers deterministically", () => {
const fields = semanticTweetFields(
pooled({
conversation_id: 42,
in_reply_to_tweet_id: 9,
quoted_tweet_id: 8,
entities: { urls: [null, { expanded_url: 7 }] },
expanded_urls: ["", 7],
}),
);
expect(fields).toMatchObject({ conversation_id: "42", reply_to: "9", quoted_status_id: "8" });
expect(fields.expanded_urls).toEqual([]);
expect(semanticTweetFields(pooled({ entities: null })).expanded_urls).toEqual([]);
expect(
semanticTweetFields(pooled({ entities: { urls: "not-an-array" } })).expanded_urls,
).toEqual([]);
});
});
describe("computeContractHash", () => {
const baseline = {
taxonomy_version: 1,
labels: [{ name: "ai", description: "d" }],
model: "zai-org/GLM-5.2",
processor_version: PROCESSOR_VERSION,
prompt_template_id: "batch-v1",
output_schema_id: "units-v1",
normalization_id: "free-labels-v1",
} as const;
it("changes when any contract field changes", () => {
const base = computeContractHash(baseline);
expect(computeContractHash({ ...baseline, taxonomy_version: 2 })).not.toBe(base);
expect(computeContractHash({ ...baseline, model: "other" })).not.toBe(base);
expect(
computeContractHash({
...baseline,
labels: [{ name: "ai", description: "different" }],
}),
).not.toBe(base);
expect(computeContractHash({ ...baseline, processor_version: 2 })).not.toBe(base);
expect(computeContractHash({ ...baseline, prompt_template_id: "batch-v2" })).not.toBe(base);
});
it("is stable under cosmetic label reorder", () => {
const original = computeContractHash({
...baseline,
labels: [
{ name: "ai", description: "a" },
{ name: "agents", description: "b" },
],
});
const reordered = computeContractHash({
...baseline,
labels: [
{ name: "agents", description: "b" },
{ name: "ai", description: "a" },
],
});
expect(original).toBe(reordered);
});
});
describe("computeInputHash from fields", () => {
it("matches the tweet-based helper", () => {
const tweet = pooled();
const fromFields = computeInputHash("100:someone", [semanticTweetFields(tweet)]);
const fromTweet = computeInputHashFromTweets("100:someone", [tweet]);
expect(fromFields).toBe(fromTweet);
});
});
|