Skip to content

Commit a1df02f

Browse files
Staacksclaude
andcommitted
Replace a NaN in an input element's buffer with its default
Maintainer's decision of 2026-09-02, recorded as input-default-does-not- replace-nan: a control cannot show NaN and the user could never enter it, so the buffer must not hold a value the control does not. The default of an edit, toggle, dropdown or slider replaces a NaN at the end of its buffer the same way it fills an empty one - written as it stands, not clamped to the element's range, and without counting as user input, because it came from an analysis write and an analysis with onUserInput must not re-run on its own output. Android has had it since 2026-09-02 (ExpView.expViewElement. applyDefault); this is the iOS half, and the entry leaves phyphox-docs with it. Experiment.seedInputDefaults treats a trailing NaN like an empty buffer, at every moment it already runs: when the experiment is built, on a clear, and before each analysis pass, for every page whether or not it is on screen. replaceValues reports no user input, so the rule's third part holds by construction. The slider joins the collected defaults - the reason it was kept out, that Android never seeded one, is gone, Android seeds it on every write pass like the rest - with min and max for the two buffers of a range slider, the values the handles show. The slider module's own catch-up between two cycles takes the NaN case too: Float(nan) is nothing to hand a UISlider. Tests: InputDefaultSeedingTests over the shared fixtures nan-vs-default and init-vs-default (the rule at its source, before any view exists, plus that a NaN written later is replaced again and never reads as user input), the former testTheSliderIsLeftAsItIs turned into testTheSliderIsSeededLikeTheRest, and ViewBehaviorTests.testANaNIsReplacedByTheDefault mirroring the Android test through the remote API, asserting the experiment is still stopped. Verified on the iPhone 17 simulator: the whole phyphoxTests target and all five ViewBehaviorTests pass. GraphSnapshotTests and ViewSnapshotTests fail identically on the unmodified tree on this machine (517 golden mismatches both times) and are unrelated. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent f1fa7ff commit a1df02f

4 files changed

Lines changed: 128 additions & 17 deletions

File tree

phyphox-iOS/phyphox/Experiments/Experiment.swift

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -693,9 +693,16 @@ final class Experiment {
693693
///the page the edit field is on.
694694
///
695695
///Only empty buffers are touched, so a value the user set - or one restored with a saved
696-
///state - stays as it is.
696+
///state - stays as it is. The one other case is a NaN at the end of the buffer, which the
697+
///default replaces the same way (input-default-does-not-replace-nan, decided 2026-09-02):
698+
///a control cannot show NaN and the user could never enter it, so the buffer must not hold a
699+
///value the control does not. It got there from an analysis write, and the replacement is
700+
///not user input any more than the seeding of an empty buffer is - replaceValues() reports
701+
///none, so an analysis with onUserInput does not re-run on its own output. The default is
702+
///written as it stands, not clamped to the element's range: a default is deliberate. A saved
703+
///state needs no exception, a NaN in it would have been replaced in the run that saved it.
697704
func seedInputDefaults() {
698-
for (defaultValue, buffer) in inputDefaults where buffer.last == nil {
705+
for (defaultValue, buffer) in inputDefaults where buffer.last?.isNaN ?? true {
699706
buffer.replaceValues([defaultValue])
700707
}
701708

@@ -720,9 +727,10 @@ final class Experiment {
720727
}
721728

722729
///Collected once in init rather than walked per analysis cycle, which is where the seeding
723-
///runs. Deliberately without the slider: the parser gives it its default when the file is
724-
///read and Android never seeds one at all, so putting it back here would be a new divergence
725-
///in the other direction rather than the end of one (slider-default-never-reaches-buffer).
730+
///runs. The slider is in the list since the NaN rule (see seedInputDefaults): Android seeds
731+
///it on every write pass like the other input elements, and its own module only runs while
732+
///its page is on screen. In range mode its two buffers start from the slider's min and max,
733+
///which is what the handles show - the same values the module writes.
726734
private static func collectInputDefaults(_ viewDescriptors: [ExperimentViewCollectionDescriptor]?) -> [(Double, DataBuffer)] {
727735
var found: [(Double, DataBuffer)] = []
728736
for collection in viewDescriptors ?? [] {
@@ -734,6 +742,17 @@ final class Experiment {
734742
found.append((dropdown.defaultValue, dropdown.buffer))
735743
case let toggle as SwitchViewDescriptor:
736744
found.append((toggle.defaultValue, toggle.buffer))
745+
case let slider as SliderViewDescriptor:
746+
if slider.type == .Range {
747+
if let lower = slider.outputBuffers[.LowerValue] {
748+
found.append((slider.minValue, lower))
749+
}
750+
if let upper = slider.outputBuffers[.UpperValue] {
751+
found.append((slider.maxValue, upper))
752+
}
753+
} else if let buffer = slider.outputBuffers[.Empty] {
754+
found.append((slider.defaultValue, buffer))
755+
}
737756
default:
738757
break
739758
}

phyphox-iOS/phyphox/UI/MainView/ExperimentView/ViewModules/Dynamic/ExperimentSliderView.swift

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -244,9 +244,12 @@ final class ExperimentSliderView: UIView, DynamicViewModule, DescriptorBoundView
244244
func update(){
245245
//If a buffer was cleared, write the default back while the slider is not being
246246
//dragged, so the setting is not lost for subsequent analysis cycles (matching
247-
//Android's re-init)
247+
//Android's re-init). Experiment.seedInputDefaults() does the same for every page at
248+
//the moments that matter; this is the on-screen slider catching a cleared buffer
249+
//between two analysis cycles. A NaN counts as no value here as it does there - the
250+
//slider cannot show it, and Float(nan) is nothing to hand a UISlider.
248251
if(SliderType.Normal == descriptor.type){
249-
if let buffer = sliderBuffer, buffer.last == nil, !uiSlider.isTracking {
252+
if let buffer = sliderBuffer, buffer.last?.isNaN ?? true, !uiSlider.isTracking {
250253
buffer.replaceValues([descriptor.defaultValue])
251254
}
252255
uiSlider.value = Float(sliderBuffer?.last ?? descriptor.defaultValue)
@@ -255,10 +258,10 @@ final class ExperimentSliderView: UIView, DynamicViewModule, DescriptorBoundView
255258

256259
if(SliderType.Range == descriptor.type){
257260
if !rangeSlider.isTracking {
258-
if let buffer = rangeSliderLowerBuffer, buffer.last == nil {
261+
if let buffer = rangeSliderLowerBuffer, buffer.last?.isNaN ?? true {
259262
buffer.replaceValues([descriptor.minValue])
260263
}
261-
if let buffer = rangeSliderUpperBuffer, buffer.last == nil {
264+
if let buffer = rangeSliderUpperBuffer, buffer.last?.isNaN ?? true {
262265
buffer.replaceValues([descriptor.maxValue])
263266
}
264267
}

phyphox-iOS/phyphoxTests/DeserializerTests.swift

Lines changed: 78 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1694,30 +1694,41 @@ final class InputDefaultsTests: XCTestCase {
16941694
+ "buffer, rather than writing the position it was showing back into it")
16951695
}
16961696

1697-
///The slider is deliberately left out of the seeding: the parser gives it its default when
1698-
///the file is read, and Android never seeds a slider at all, so re-seeding it here would be a
1699-
///new divergence in the other direction rather than the end of one
1700-
///(slider-default-never-reaches-buffer, open).
1701-
func testTheSliderIsLeftAsItIs() throws {
1697+
///The slider is seeded like the rest since the NaN rule (input-default-does-not-replace-nan,
1698+
///2026-09-02): Android seeds it on every write pass like every other input element, and on
1699+
///iOS its own module only runs while its page is on screen. In range mode the two buffers
1700+
///start from the slider's min and max, the values the handles show.
1701+
func testTheSliderIsSeededLikeTheRest() throws {
17021702
let experiment = try parse("""
17031703
<phyphox version="1.7">
17041704
<title>t</title><category>c</category>
1705-
<data-containers><container size="1" init="">sliderOut</container></data-containers>
1705+
<data-containers>
1706+
<container size="1" init="">sliderOut</container>
1707+
<container size="1" init="">lower</container>
1708+
<container size="1" init="">upper</container>
1709+
</data-containers>
17061710
<views>
17071711
<view label="only">
17081712
<slider label="s" minValue="0" maxValue="10" default="7">
17091713
<output value="value">sliderOut</output>
17101714
</slider>
1715+
<slider label="r" type="range" minValue="2" maxValue="8">
1716+
<output value="lowerValue">lower</output>
1717+
<output value="upperValue">upper</output>
1718+
</slider>
17111719
</view>
17121720
</views>
17131721
</phyphox>
17141722
""")
17151723
XCTAssertEqual(experiment.buffers["sliderOut"]?.last, 7.0,
17161724
"the parser seeds it while reading the file")
17171725
experiment.buffers["sliderOut"]?.clear(reset: true)
1726+
experiment.buffers["lower"]?.replaceValues([Double.nan])
1727+
experiment.buffers["upper"]?.clear(reset: true)
17181728
experiment.seedInputDefaults()
1719-
XCTAssertNil(experiment.buffers["sliderOut"]?.last,
1720-
"and nothing here puts it back, which is today's behaviour on both platforms")
1729+
XCTAssertEqual(experiment.buffers["sliderOut"]?.last, 7.0, "the seeding puts it back")
1730+
XCTAssertEqual(experiment.buffers["lower"]?.last, 2.0, "a range slider's lower buffer starts at its min")
1731+
XCTAssertEqual(experiment.buffers["upper"]?.last, 8.0, "and the upper one at its max")
17211732
}
17221733
}
17231734

@@ -5168,6 +5179,65 @@ final class DuplicateRootMetadataTests: XCTestCase {
51685179
//conformance corpus and the analysis golden vectors). Nothing is copied into the test bundle,
51695180
//where it would drift; without the sibling those tests skip with a notice (a plain clone must
51705181
//still build and test) and CI checks it out explicitly.
5182+
//The starting state of an input element's buffer, over the two fixtures in phyphox-docs
5183+
//fixtures/views/ that exist for it. The UI half (ViewBehaviorTests) asserts the same through the
5184+
//remote API on a running app; this is the rule at its source, Experiment.seedInputDefaults,
5185+
//which runs when the experiment is built - before any page exists.
5186+
final class InputDefaultSeedingTests: XCTestCase {
5187+
private func load(_ fixture: String) throws -> Experiment {
5188+
let directory = try DocsCorpus.docsDirectory("fixtures/views", notTestedNotice: "input defaults")
5189+
return try ExperimentSerialization.readExperimentFromURL(directory.appendingPathComponent("\(fixture).phyphox"))
5190+
}
5191+
5192+
private func assertBuffers(_ experiment: Experiment, _ expected: [(String, Double)],
5193+
file: StaticString = #filePath, line: UInt = #line) throws {
5194+
for (name, value) in expected {
5195+
let values = try experiment.buffers[name].unwrap().toArray()
5196+
XCTAssertEqual(values, [value], name, file: file, line: line)
5197+
}
5198+
}
5199+
5200+
//A default fills an EMPTY buffer and never overwrites one that is not
5201+
func testContainerInitBeatsAControlsDefault() throws {
5202+
try assertBuffers(try load("init-vs-default"), [
5203+
("toggle_init", 1), ("dropdown_init", 2), ("edit_init", 42), ("slider_init", 4),
5204+
("toggle_default", 1), ("dropdown_default", 1), ("edit_default", 7), ("slider_default", 3),
5205+
])
5206+
}
5207+
5208+
//A NaN is replaced by the default as written - the edit's default lies above its max on
5209+
//purpose - for every element, the slider included (input-default-does-not-replace-nan)
5210+
func testANaNIsReplacedByTheDefault() throws {
5211+
try assertBuffers(try load("nan-vs-default"), [
5212+
("toggle_nan", 1), ("dropdown_nan", 2), ("edit_nan", 12), ("slider_nan", 3),
5213+
])
5214+
}
5215+
5216+
//The replacement happens on every seeding, not only when the experiment is built: an
5217+
//analysis writing NaN into an input element's buffer gets the default back before the next
5218+
//cycle, and the buffer never reports the write as user input
5219+
func testANaNWrittenLaterIsReplacedAgain() throws {
5220+
let experiment = try load("nan-vs-default")
5221+
let buffer = try experiment.buffers["edit_nan"].unwrap()
5222+
let observer = UserInputCounter()
5223+
buffer.addObserver(observer)
5224+
5225+
buffer.replaceValues([Double.nan])
5226+
experiment.seedInputDefaults()
5227+
XCTAssertEqual(buffer.toArray(), [12])
5228+
//User input is reported asynchronously on the main queue, so give it the chance
5229+
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.2))
5230+
XCTAssertEqual(observer.userInputs, 0, "replacing a NaN must not read as user input")
5231+
withExtendedLifetime(observer) {}
5232+
}
5233+
5234+
private final class UserInputCounter: DataBufferObserver {
5235+
var userInputs = 0
5236+
func dataBufferUpdated(_ buffer: DataBuffer) {}
5237+
func userInputTriggered(_ buffer: DataBuffer) { userInputs += 1 }
5238+
}
5239+
}
5240+
51715241
enum DocsCorpus {
51725242
//#filePath is resolvable at test time because the suite builds and runs on the same
51735243
//machine, locally as well as on CI

phyphox-iOS/phyphoxUITests/ViewBehaviorTests.swift

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,25 @@ final class ViewBehaviorTests: XCTestCase {
262262
}
263263
}
264264

265+
//A NaN in an input element's buffer is replaced by the element's default, as written and
266+
//not clamped to the element's range (the edit's default lies above its max on purpose), for
267+
//the slider like for the rest - and the replacement is not user input, so an experiment
268+
//that has only been opened is still stopped afterwards (input-default-does-not-replace-nan,
269+
//decided 2026-09-02). Mirrors ViewBehaviorTest.aNanIsReplacedByTheDefault.
270+
// phyphox-test: view-behavior
271+
func testANaNIsReplacedByTheDefault() throws {
272+
_ = try launch(fixture: "nan-vs-default")
273+
274+
expectBuffer("toggle_nan", toEqual: [1], "the toggle's default replaces the NaN")
275+
expectBuffer("dropdown_nan", toEqual: [2], "the dropdown's default replaces the NaN")
276+
expectBuffer("edit_nan", toEqual: [12], "the edit's default replaces the NaN, unclamped")
277+
expectBuffer("slider_nan", toEqual: [3], "the slider's default replaces the NaN")
278+
279+
let status = get("/get")?["status"] as? [String: Any]
280+
XCTAssertNotNil(status, "the status is reported")
281+
XCTAssertEqual(status?["measuring"] as? Bool, false, "replacing a NaN started the experiment")
282+
}
283+
265284
// MARK: - helpers
266285

267286
///Moves a slider and waits for the value the fixture should end up with, repeating the gesture

0 commit comments

Comments
 (0)