Skip to content

Commit 000c7f5

Browse files
clfhhcclaude
andcommitted
chore: parity scenarios for M3 Slice D-ops (zip / concat / race / takeUntil)
Widens the parity-tests Impl interface with the four subscription-managed combinators and adds 8 cross-impl scenarios covering the producer-shape pattern. Until @graphrefly/native publishes rustImpl, these run against legacyImpl only; rust arm flips on with the napi binding. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 00459a1 commit 000c7f5

3 files changed

Lines changed: 245 additions & 2 deletions

File tree

packages/parity-tests/impls/legacy.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import {
88
COMPLETE,
99
combine,
10+
concat,
1011
DATA,
1112
DIRTY,
1213
distinctUntilChanged,
@@ -23,15 +24,18 @@ import {
2324
node,
2425
PAUSE,
2526
pairwise,
27+
race,
2628
RESOLVED,
2729
RESUME,
2830
reduce,
2931
scan,
3032
skip,
3133
TEARDOWN,
3234
take,
35+
takeUntil,
3336
takeWhile,
3437
withLatestFrom,
38+
zip,
3539
} from "@graphrefly/legacy-pure-ts";
3640
import type { Impl } from "./types.js";
3741

@@ -64,4 +68,8 @@ export const legacyImpl: Impl = {
6468
first,
6569
find,
6670
elementAt,
71+
zip,
72+
concat,
73+
race,
74+
takeUntil,
6775
};

packages/parity-tests/impls/types.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,23 @@ export interface Impl {
5757
// M3 Slice C-3 — flow operators (Rust port: `graphrefly-operators`,
5858
// landed 2026-05-06 per `~/src/graphrefly-rs/docs/migration-status.md`).
5959
// TS legacy from `packages/legacy-pure-ts/src/extra/operators/take.ts`.
60-
// `takeUntil` is intentionally NOT included — Rust port defers it to a
61-
// later subscription-managed slice (D020 category B).
6260
readonly take: typeof legacy.take;
6361
readonly skip: typeof legacy.skip;
6462
readonly takeWhile: typeof legacy.takeWhile;
6563
readonly last: typeof legacy.last;
6664
readonly first: typeof legacy.first;
6765
readonly find: typeof legacy.find;
6866
readonly elementAt: typeof legacy.elementAt;
67+
68+
// M3 Slice D-ops — subscription-managed combinators (Rust port:
69+
// `graphrefly-operators::ops_impl`, landed 2026-05-06 per
70+
// `~/src/graphrefly-rs/docs/migration-status.md`). TS legacy from
71+
// `packages/legacy-pure-ts/src/extra/operators/combine.ts` (zip /
72+
// concat / race) and `take.ts` (takeUntil). All four are
73+
// producer-shape ops that subscribe to upstream sources from inside
74+
// their fn body.
75+
readonly zip: typeof legacy.zip;
76+
readonly concat: typeof legacy.concat;
77+
readonly race: typeof legacy.race;
78+
readonly takeUntil: typeof legacy.takeUntil;
6979
}
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
/**
2+
* Subscription-managed combinator parity scenarios (M3 Slice D-ops).
3+
*
4+
* Covers the four producer-shape ops in `combine.ts` + `take.ts`:
5+
* `zip` / `concat` / `race` / `takeUntil`. Each is a node with no
6+
* declared deps that subscribes to its upstream sources from inside
7+
* its fn body and re-enters Core to emit on itself.
8+
*
9+
* Rust port reference: `~/src/graphrefly-rs/crates/graphrefly-operators/src/ops_impl.rs`
10+
* (Slice D-ops, landed 2026-05-06 per `~/src/graphrefly-rs/docs/migration-status.md`).
11+
*
12+
* Until `@graphrefly/native` publishes `rustImpl` in `impls/rust.ts`,
13+
* these scenarios run against `legacyImpl` only. When `rustImpl` flips
14+
* non-null, divergences fail loud — the rust arm uses the same
15+
* `impl.<name>` surface.
16+
*/
17+
18+
import { describe, expect, test } from "vitest";
19+
import { impls } from "../../impls/registry.js";
20+
21+
// =====================================================================
22+
// zip — pair handles N-wise across N sources
23+
// =====================================================================
24+
25+
describe.each(impls)("R5.7 subscription — zip parity — $name", (impl) => {
26+
test("zip pairs DATA from two sources", () => {
27+
const s1 = impl.node<number>([], { name: "s1" });
28+
const s2 = impl.node<number>([], { name: "s2" });
29+
const z = impl.zip(s1, s2);
30+
31+
const seen: unknown[] = [];
32+
const unsub = z.subscribe((msgs) => {
33+
for (const msg of msgs) if (msg[0] === impl.DATA) seen.push(msg[1]);
34+
});
35+
36+
try {
37+
s1.down([[impl.DATA, 1]]);
38+
s2.down([[impl.DATA, 10]]);
39+
s1.down([[impl.DATA, 2]]);
40+
s2.down([[impl.DATA, 20]]);
41+
42+
expect(seen).toEqual([
43+
[1, 10],
44+
[2, 20],
45+
]);
46+
} finally {
47+
unsub();
48+
}
49+
});
50+
51+
test("zip buffers per-source until all sources have DATA", () => {
52+
const s1 = impl.node<number>([], { name: "s1" });
53+
const s2 = impl.node<number>([], { name: "s2" });
54+
const z = impl.zip(s1, s2);
55+
56+
const seen: unknown[] = [];
57+
const unsub = z.subscribe((msgs) => {
58+
for (const msg of msgs) if (msg[0] === impl.DATA) seen.push(msg[1]);
59+
});
60+
61+
try {
62+
s1.down([[impl.DATA, 1]]);
63+
s1.down([[impl.DATA, 2]]);
64+
s1.down([[impl.DATA, 3]]);
65+
expect(seen).toEqual([]);
66+
67+
s2.down([[impl.DATA, 100]]);
68+
expect(seen).toEqual([[1, 100]]);
69+
} finally {
70+
unsub();
71+
}
72+
});
73+
});
74+
75+
// =====================================================================
76+
// concat — sequentially forward `first` then `second`
77+
// =====================================================================
78+
79+
describe.each(impls)("R5.7 subscription — concat parity — $name", (impl) => {
80+
test("concat forwards first then second after first completes", () => {
81+
const s1 = impl.node<number>([], { name: "s1" });
82+
const s2 = impl.node<number>([], { name: "s2" });
83+
const c = impl.concat(s1, s2);
84+
85+
const seen: number[] = [];
86+
const unsub = c.subscribe((msgs) => {
87+
for (const msg of msgs) if (msg[0] === impl.DATA) seen.push(msg[1] as number);
88+
});
89+
90+
try {
91+
s1.down([[impl.DATA, 1]]);
92+
s1.down([[impl.DATA, 2]]);
93+
s1.down([[impl.COMPLETE]]);
94+
s2.down([[impl.DATA, 10]]);
95+
s2.down([[impl.DATA, 20]]);
96+
97+
expect(seen).toEqual([1, 2, 10, 20]);
98+
} finally {
99+
unsub();
100+
}
101+
});
102+
103+
test("concat buffers second-source DATA during phase zero", () => {
104+
const s1 = impl.node<number>([], { name: "s1" });
105+
const s2 = impl.node<number>([], { name: "s2" });
106+
const c = impl.concat(s1, s2);
107+
108+
const seen: number[] = [];
109+
const unsub = c.subscribe((msgs) => {
110+
for (const msg of msgs) if (msg[0] === impl.DATA) seen.push(msg[1] as number);
111+
});
112+
113+
try {
114+
s1.down([[impl.DATA, 1]]);
115+
// s2 emits BEFORE s1 completes — should buffer.
116+
s2.down([[impl.DATA, 99]]);
117+
expect(seen).toEqual([1]);
118+
119+
s1.down([[impl.COMPLETE]]);
120+
// On phase transition, buffered s2 DATA drains.
121+
expect(seen).toEqual([1, 99]);
122+
} finally {
123+
unsub();
124+
}
125+
});
126+
});
127+
128+
// =====================================================================
129+
// race — first source to emit DATA wins; losers are silently ignored
130+
// =====================================================================
131+
132+
describe.each(impls)("R5.7 subscription — race parity — $name", (impl) => {
133+
test("race winner forwards subsequent DATA; loser DATA is ignored", () => {
134+
const s1 = impl.node<number>([], { name: "s1" });
135+
const s2 = impl.node<number>([], { name: "s2" });
136+
const r = impl.race(s1, s2);
137+
138+
const seen: number[] = [];
139+
const unsub = r.subscribe((msgs) => {
140+
for (const msg of msgs) if (msg[0] === impl.DATA) seen.push(msg[1] as number);
141+
});
142+
143+
try {
144+
s1.down([[impl.DATA, 1]]); // s1 wins
145+
s2.down([[impl.DATA, 99]]); // ignored
146+
s1.down([[impl.DATA, 2]]); // forwarded
147+
expect(seen).toEqual([1, 2]);
148+
} finally {
149+
unsub();
150+
}
151+
});
152+
153+
test("race ignores losers across multiple sources", () => {
154+
const s1 = impl.node<number>([], { name: "s1" });
155+
const s2 = impl.node<number>([], { name: "s2" });
156+
const s3 = impl.node<number>([], { name: "s3" });
157+
const r = impl.race(s1, s2, s3);
158+
159+
const seen: number[] = [];
160+
const unsub = r.subscribe((msgs) => {
161+
for (const msg of msgs) if (msg[0] === impl.DATA) seen.push(msg[1] as number);
162+
});
163+
164+
try {
165+
s2.down([[impl.DATA, 50]]); // s2 wins
166+
s1.down([[impl.DATA, 1]]);
167+
s3.down([[impl.DATA, 100]]);
168+
s2.down([[impl.DATA, 60]]);
169+
expect(seen).toEqual([50, 60]);
170+
} finally {
171+
unsub();
172+
}
173+
});
174+
});
175+
176+
// =====================================================================
177+
// takeUntil — terminate on notifier DATA
178+
// =====================================================================
179+
180+
describe.each(impls)("R5.7 subscription — takeUntil parity — $name", (impl) => {
181+
test("takeUntil forwards source until notifier emits", () => {
182+
const src = impl.node<number>([], { name: "src" });
183+
const notif = impl.node<unknown>([], { name: "notif" });
184+
const t = impl.takeUntil(src, notif);
185+
186+
const seen: number[] = [];
187+
let completed = false;
188+
const unsub = t.subscribe((msgs) => {
189+
for (const msg of msgs) {
190+
if (msg[0] === impl.DATA) seen.push(msg[1] as number);
191+
else if (msg[0] === impl.COMPLETE) completed = true;
192+
}
193+
});
194+
195+
try {
196+
src.down([[impl.DATA, 1]]);
197+
src.down([[impl.DATA, 2]]);
198+
notif.down([[impl.DATA, "stop-signal"]]);
199+
src.down([[impl.DATA, 3]]); // ignored — already completed
200+
201+
expect(seen).toEqual([1, 2]);
202+
expect(completed).toBe(true);
203+
} finally {
204+
unsub();
205+
}
206+
});
207+
208+
test("takeUntil does not forward notifier value", () => {
209+
const src = impl.node<number>([], { name: "src" });
210+
const notif = impl.node<unknown>([], { name: "notif" });
211+
const t = impl.takeUntil(src, notif);
212+
213+
const seen: unknown[] = [];
214+
const unsub = t.subscribe((msgs) => {
215+
for (const msg of msgs) if (msg[0] === impl.DATA) seen.push(msg[1]);
216+
});
217+
218+
try {
219+
notif.down([[impl.DATA, "ignored-payload"]]);
220+
expect(seen).toEqual([]);
221+
} finally {
222+
unsub();
223+
}
224+
});
225+
});

0 commit comments

Comments
 (0)