Skip to content

Commit 0797cf3

Browse files
authored
Merge pull request #28 from joelhelbling/feat/policy-testing-harness
Handoff immutability policies, Phase 2: testing harness & mutation detector
2 parents 2bd4dec + 7acb476 commit 0797cf3

4 files changed

Lines changed: 241 additions & 0 deletions

File tree

lib/shifty/rspec.rb

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
require "shifty/testing"
2+
3+
# Opt-in RSpec sugar for testing Shifty workers under handoff policies.
4+
# `require "shifty/rspec"` from your spec_helper; assumes RSpec is loaded.
5+
6+
RSpec::Matchers.define :mutate_input do |input|
7+
match do |worker|
8+
Shifty::Testing.mutates_input?(worker, input)
9+
end
10+
11+
failure_message do |worker|
12+
"expected the worker's task to mutate its input #{input.inspect}, but it did not"
13+
end
14+
15+
failure_message_when_negated do |worker|
16+
"expected the worker's task not to mutate its input, but it mutated " \
17+
"#{input.inspect}. It is only correct under policy :isolated (mutation " \
18+
"stays local) or :shared (mutation is intentional); it will raise under :frozen."
19+
end
20+
end
21+
22+
# Runs the worker through the framework against deeply frozen input —
23+
# the strictest policy — proving the task is non-destructive and therefore
24+
# correct under every policy (§9.4, "test at the ceiling").
25+
#
26+
# Expects `worker` and `safe_input` to be defined with `let`.
27+
RSpec.shared_examples "a policy-safe worker" do
28+
it "processes a deeply frozen input without raising" do
29+
expect {
30+
Shifty::Testing.run(worker, inputs: [safe_input], policy: :frozen)
31+
}.not_to raise_error
32+
end
33+
34+
it "does not mutate its input" do
35+
expect(worker).not_to mutate_input(safe_input)
36+
end
37+
end

lib/shifty/testing.rb

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
require "shifty"
2+
3+
module Shifty
4+
# Test harness: runs a worker through the framework so unit tests
5+
# exercise the same handoff policy the production pipeline will.
6+
# Deliberately not loaded by `require "shifty"` — opt in with
7+
# `require "shifty/testing"`.
8+
module Testing
9+
class << self
10+
# Feeds the inputs to the worker via a source and collects its
11+
# outputs until the end-of-stream sentinel (nil). The worker's
12+
# declared/effective policy governs each handoff, exactly as in
13+
# production; pass policy: to override it for policy-matrix tests.
14+
def run(worker, inputs:, policy: nil, max_shifts: 10_000)
15+
harness(worker, policy) do |subject|
16+
subject.supply = source_for(inputs)
17+
outputs = []
18+
shifts = 0
19+
until (value = subject.shift).nil?
20+
outputs << value
21+
if (shifts += 1) > max_shifts
22+
raise Error, "Shifty::Testing.run exceeded #{max_shifts} shifts " \
23+
"without seeing the nil end-of-stream sentinel. The worker's " \
24+
"task probably converts nil into a non-nil value; let nil " \
25+
"pass through (e.g. `value && ...`), or raise max_shifts:."
26+
end
27+
end
28+
outputs
29+
end
30+
end
31+
32+
# The mutation detector (§6.4): hands the task a private mutable
33+
# deep copy and reports whether the task changed it — surfacing
34+
# mutation even when the current policy permits or hides it.
35+
def mutates_input?(worker, input)
36+
copy = begin
37+
Marshal.load(Marshal.dump(input))
38+
rescue TypeError => e
39+
raise Error, "Shifty::Testing.mutates_input? needs a deep-copyable " \
40+
"(Marshal-dumpable) input, but got an instance of #{input.class} " \
41+
"(#{e.message})."
42+
end
43+
baseline = Marshal.dump(copy)
44+
harness(worker, :shared) do |subject|
45+
subject.supply = source_for([copy])
46+
subject.shift
47+
end
48+
Marshal.dump(copy) != baseline
49+
end
50+
51+
private
52+
53+
# Temporarily rewires the caller's actual worker (a dup would defeat
54+
# task closures that reference their own worker, e.g. side_worker's
55+
# policy check) and restores its policy, supply, and Fiber afterward,
56+
# so the harness never leaves a mark on the worker under test.
57+
def harness(worker, policy)
58+
saved = {
59+
policy: worker.instance_variable_get(:@policy),
60+
supply: worker.instance_variable_get(:@supply),
61+
fiber: worker.instance_variable_get(:@my_little_machine)
62+
}
63+
worker.instance_variable_set(:@policy, Policy.validate!(policy)) if policy
64+
worker.instance_variable_set(:@my_little_machine, nil)
65+
worker.instance_variable_set(:@supply, nil)
66+
yield worker
67+
ensure
68+
worker.instance_variable_set(:@policy, saved[:policy])
69+
worker.instance_variable_set(:@supply, saved[:supply])
70+
worker.instance_variable_set(:@my_little_machine, saved[:fiber])
71+
end
72+
73+
def source_for(inputs)
74+
series = inputs.dup
75+
Worker.new(tags: [:source]) do
76+
series.each { |input| Fiber.yield input }
77+
loop { Fiber.yield nil }
78+
end
79+
end
80+
end
81+
end
82+
end

spec/shifty/rspec_sugar_spec.rb

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
require "spec_helper"
2+
require "shifty/rspec"
3+
4+
module Shifty
5+
RSpec.describe "shifty/rspec sugar" do
6+
include DSL
7+
8+
describe "the mutate_input matcher" do
9+
context "detects a mutating worker" do
10+
Given(:worker) { side_worker(policy: :shared) { |v| v << :boo } }
11+
Then { expect(worker).to mutate_input([:a]) }
12+
end
13+
14+
context "passes a non-destructive worker" do
15+
Given(:worker) { relay_worker { |v| v + [:x] } }
16+
Then { expect(worker).not_to mutate_input([:a]) }
17+
end
18+
end
19+
20+
describe %(the "a policy-safe worker" shared example) do
21+
context "with a non-destructive worker" do
22+
it_behaves_like "a policy-safe worker" do
23+
let(:worker) { Shifty::Worker.new { |v| v&.to_s } }
24+
let(:safe_input) { [:a] }
25+
end
26+
end
27+
end
28+
end
29+
end

spec/shifty/testing_spec.rb

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
require "spec_helper"
2+
require "shifty/testing"
3+
4+
module Shifty
5+
RSpec.describe Testing do
6+
include DSL
7+
8+
after { Shifty.reset_configuration! }
9+
10+
describe ".run" do
11+
context "feeds inputs through the framework and collects outputs" do
12+
Given(:worker) { relay_worker { |v| v.upcase } }
13+
When(:outputs) { Testing.run(worker, inputs: ["a", "b", "c"]) }
14+
Then { expect(outputs).to eq(["A", "B", "C"]) }
15+
end
16+
17+
context "exercises the worker's effective policy (parity with production)" do
18+
Given(:mutator) { Worker.new { |v| v && v << :x } }
19+
Then do
20+
expect { Testing.run(mutator, inputs: [[:a]]) }
21+
.to raise_error(PolicyViolation)
22+
end
23+
end
24+
25+
context "a worker declaring :isolated runs under :isolated" do
26+
Given(:mutator) { Worker.new(policy: :isolated) { |v| v && v << :x } }
27+
When(:outputs) { Testing.run(mutator, inputs: [[:a]]) }
28+
Then { expect(outputs).to eq([[:a, :x]]) }
29+
end
30+
31+
context "an explicit policy: override beats the worker's own declaration" do
32+
Given(:mutator) { Worker.new(policy: :shared) { |v| v && v << :x } }
33+
Then do
34+
expect { Testing.run(mutator, inputs: [[:a]], policy: :frozen) }
35+
.to raise_error(PolicyViolation)
36+
end
37+
And { expect(mutator.effective_policy).to eq(:shared) }
38+
end
39+
40+
context "false is a legitimate payload, not end-of-stream" do
41+
# relay_worker's `value &&` guard passes false through untouched,
42+
# so the negator applies only to the two true inputs.
43+
Given(:flipper) { relay_worker { |v| !v } }
44+
When(:outputs) { Testing.run(flipper, inputs: [true, false, true]) }
45+
Then { expect(outputs).to eq([false, false, false]) }
46+
And { expect(outputs.length).to eq(3) }
47+
end
48+
49+
context "raises a diagnostic when a worker never passes the nil sentinel through" do
50+
Given(:worker) { Worker.new { |v| v.to_s } }
51+
Then do
52+
expect { Testing.run(worker, inputs: [:a], max_shifts: 50) }
53+
.to raise_error(Shifty::Error, /end-of-stream sentinel/)
54+
end
55+
end
56+
57+
context "collects a non-1:1 output stream (filter) until end of stream" do
58+
Given(:evens) { filter_worker { |v| v.even? } }
59+
When(:outputs) { Testing.run(evens, inputs: [1, 2, 3, 4]) }
60+
Then { expect(outputs).to eq([2, 4]) }
61+
end
62+
end
63+
64+
describe ".mutates_input?" do
65+
context "an undumpable input raises a descriptive error" do
66+
Given(:worker) { relay_worker { |v| v } }
67+
Then do
68+
expect { Testing.mutates_input?(worker, proc {}) }
69+
.to raise_error(Shifty::Error, /deep-copyable/)
70+
end
71+
end
72+
73+
context "a mutating task is detected" do
74+
Given(:worker) { Worker.new(policy: :shared) { |v| v << :x } }
75+
Then { expect(Testing.mutates_input?(worker, [:a])).to be true }
76+
end
77+
78+
context "a non-destructive task is not" do
79+
Given(:worker) { relay_worker { |v| v + [:x] } }
80+
Then { expect(Testing.mutates_input?(worker, [:a])).to be false }
81+
end
82+
83+
context "detects mutation even when the worker's own policy would hide it" do
84+
Given(:worker) { side_worker(policy: :isolated) { |v| v << :boo } }
85+
Then { expect(Testing.mutates_input?(worker, [:a])).to be true }
86+
end
87+
end
88+
89+
describe "opt-in loading" do
90+
Then { expect(File.read(File.expand_path("../../lib/shifty.rb", __dir__))).not_to match(%r{shifty/(testing|rspec)}) }
91+
end
92+
end
93+
end

0 commit comments

Comments
 (0)