Skip to content

Commit 8c1dfdb

Browse files
mpangburnps2
authored andcommitted
Testing scenarios (#17)
* Initial scenarios impl * Fix step forward without recommended temp * Active scenario stepping gestures * Testing scenario docs * Fix typo * Fix typo, again math is hard * Save -> Load * Naming updates
1 parent b0efe6e commit 8c1dfdb

16 files changed

+957
-33
lines changed
70.3 KB
Loading
154 KB
Loading
149 KB
Loading
58.7 KB
Loading

Documentation/Testing/Scenarios.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Guide: Testing Scenarios
2+
3+
## Purpose
4+
5+
This document describes how to load data-based scenarios, including glucose values, dose history, and carb entries, into Loop on demand.
6+
7+
## File Format
8+
9+
A scenario consists of a single JSON file containing glucose, basal, bolus, and carb entry histories. Each history corresponds to a property of the scenario JSON object—a list of individual entries. Each entry has one or more properties describing its value (e.g. `unitsPerHourValue` and `duration`) and a _relative_ date offset, in seconds (e.g. 0 means 'right now' and -300 means '5 minutes ago').
10+
11+
For example, a carb entry history might look like this:
12+
13+
```json
14+
"carbEntries": [
15+
{
16+
"gramValue": 30,
17+
"dateOffset": -300,
18+
"absorptionTime": 10800
19+
},
20+
{
21+
"gramValue": 15,
22+
"dateOffset": 900,
23+
"absorptionTime": 7200,
24+
"enteredAtOffset": -900
25+
}
26+
]
27+
```
28+
29+
Carb entries have two date offsets: `dateOffset`, which describes the date at which carbs were consumed, and `enteredAtOffset`, which describes the date at which the carb entry was created. The second carb entry in the example above was entered 30 minutes early.
30+
31+
## Generating Scenarios
32+
33+
A Python script with classes corresponding to the entry types is available at `/Scripts/make_scenario.py`. Running it will generate a sample script, which will allow you to inspect the file format in more detail.
34+
35+
## Loading Scenarios
36+
37+
Launch Loop in the Xcode simulator.
38+
39+
Before loading scenarios, mock pump and CGM managers must be enabled in Loop. From the status screen, tap the settings icon in the bottom-right corner; then, tap on each of the pump and CGM rows and select the Simulator option from the presented action sheets:
40+
41+
![](Images/mock_managers.png)
42+
43+
Next, type 'scenario' in the search bar in the bottom-right corner of the Xcode console with the Loop app running:
44+
45+
![](Images/scenarios_url.png)
46+
47+
The first line will include `[TestingScenariosManager]` and a path to the simulator-specific directory in which to place scenario JSON files.
48+
49+
With one or more scenarios placed in the listed directory, the debug menu can be activated by "shaking" the iPhone: in the simulator, press ^⌘Z. The scenario selection screen will appear:
50+
51+
![](Images/scenarios_menu.png)
52+
53+
Tap on a scenario to select it, then press 'Load' in the top-right corner to load it into Loop.
54+
55+
With the app running, additional scenarios can be added to the scenarios directory; the changes will be detected, and the scenario list reloaded.
56+
57+
## Time Travel
58+
59+
Because all historic date offsets are relative, scenarios can be stepped through one or more loop iterations at a time, so long as the scenario contains sufficient past or future data.
60+
61+
Swiping right or left on a scenario cell reveals the 'rewind' or 'advance' button, respectively:
62+
63+
![](Images/rewind.png)
64+
65+
Tap on the button, and you will be prompted for a number of loop iterations to progress backward or forward in time. Note that advancing forward will run the full algorithm for each step and in turn apply the suggested basal at each decision point.
66+
67+
For convenience, an active scenario can be stepped through without leaving the status screen. Swipe right or left on the toolbar at the bottom of the screen to move one loop iteration into the past or future, respectively.

Loop.xcodeproj/project.pbxproj

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,10 @@
346346
898ECA63218ABD21001E9D35 /* ComplicationChartManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 898ECA62218ABD21001E9D35 /* ComplicationChartManager.swift */; };
347347
898ECA65218ABD9B001E9D35 /* CGRect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 898ECA64218ABD9A001E9D35 /* CGRect.swift */; };
348348
898ECA69218ABDA9001E9D35 /* CLKTextProvider+Compound.m in Sources */ = {isa = PBXBuildFile; fileRef = 898ECA67218ABDA8001E9D35 /* CLKTextProvider+Compound.m */; };
349+
89ADE13B226BFA0F0067222B /* TestingScenariosManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89ADE13A226BFA0F0067222B /* TestingScenariosManager.swift */; };
350+
89CA2B30226C0161004D9350 /* DirectoryObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89CA2B2F226C0161004D9350 /* DirectoryObserver.swift */; };
351+
89CA2B32226C18B8004D9350 /* TestingScenariosTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89CA2B31226C18B8004D9350 /* TestingScenariosTableViewController.swift */; };
352+
89CA2B3D226E6B13004D9350 /* LocalTestingScenariosManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89CA2B3C226E6B13004D9350 /* LocalTestingScenariosManager.swift */; };
349353
C10428971D17BAD400DD539A /* NightscoutUploadKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C10428961D17BAD400DD539A /* NightscoutUploadKit.framework */; };
350354
C10B28461EA9BA5E006EA1FC /* far_future_high_bg_forecast.json in Resources */ = {isa = PBXBuildFile; fileRef = C10B28451EA9BA5E006EA1FC /* far_future_high_bg_forecast.json */; };
351355
C11C87DE1E21EAAD00BB71D3 /* HKUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F526D5E1DF2459000A04910 /* HKUnit.swift */; };
@@ -976,7 +980,11 @@
976980
898ECA66218ABDA8001E9D35 /* WatchApp Extension-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "WatchApp Extension-Bridging-Header.h"; sourceTree = "<group>"; };
977981
898ECA67218ABDA8001E9D35 /* CLKTextProvider+Compound.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "CLKTextProvider+Compound.m"; sourceTree = "<group>"; };
978982
898ECA68218ABDA9001E9D35 /* CLKTextProvider+Compound.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "CLKTextProvider+Compound.h"; sourceTree = "<group>"; };
979-
C10428961D17BAD400DD539A /* NightscoutUploadKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = NightscoutUploadKit.framework; sourceTree = BUILT_PRODUCTS_DIR; };
983+
89ADE13A226BFA0F0067222B /* TestingScenariosManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestingScenariosManager.swift; sourceTree = "<group>"; };
984+
89CA2B2F226C0161004D9350 /* DirectoryObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectoryObserver.swift; sourceTree = "<group>"; };
985+
89CA2B31226C18B8004D9350 /* TestingScenariosTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestingScenariosTableViewController.swift; sourceTree = "<group>"; };
986+
89CA2B3C226E6B13004D9350 /* LocalTestingScenariosManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalTestingScenariosManager.swift; sourceTree = "<group>"; };
987+
C10428961D17BAD400DD539A /* NightscoutUploadKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = NightscoutUploadKit.framework; path = Carthage/Build/iOS/NightscoutUploadKit.framework; sourceTree = SOURCE_ROOT; };
980988
C10B28451EA9BA5E006EA1FC /* far_future_high_bg_forecast.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = far_future_high_bg_forecast.json; sourceTree = "<group>"; };
981989
C12F21A61DFA79CB00748193 /* recommend_temp_basal_very_low_end_in_range.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = recommend_temp_basal_very_low_end_in_range.json; sourceTree = "<group>"; };
982990
C15713811DAC6983005BC4D2 /* MealBolusNightscoutTreatment.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MealBolusNightscoutTreatment.swift; sourceTree = "<group>"; };
@@ -1433,19 +1441,20 @@
14331441
4389916A1E91B689000EEF90 /* ChartSettings+Loop.swift */,
14341442
4F08DE8E1E7BB871006741EA /* CollectionType+Loop.swift */,
14351443
43CE7CDD1CA8B63E003CC1B0 /* Data.swift */,
1444+
892A5D58222F0A27008961AB /* Debug.swift */,
1445+
89CA2B2F226C0161004D9350 /* DirectoryObserver.swift */,
14361446
43D9003221EB258C00AF44BF /* InsulinModelSettings+Loop.swift */,
14371447
C15713811DAC6983005BC4D2 /* MealBolusNightscoutTreatment.swift */,
14381448
438172D81F4E9E37003C3328 /* NewPumpEvent.swift */,
14391449
43CEE6E51E56AFD400CB9116 /* NightscoutUploader.swift */,
14401450
43DACFFF20A2736F000F8529 /* PersistedPumpEvent.swift */,
1451+
892A5D682230C41D008961AB /* RangeReplaceableCollection.swift */,
14411452
C1FB428B217806A300FAB378 /* StateColorPalette.swift */,
14421453
43F41C361D3BF32400C11ED6 /* UIAlertController.swift */,
14431454
43BFF0BB1E45C80600FF19A9 /* UIColor+Loop.swift */,
14441455
437CEEE31CDE5C0A003C8C80 /* UIImage.swift */,
14451456
434FF1ED1CF27EEF000DB779 /* UITableViewCell.swift */,
14461457
430B29922041F5B200BA9F93 /* UserDefaults+Loop.swift */,
1447-
892A5D58222F0A27008961AB /* Debug.swift */,
1448-
892A5D682230C41D008961AB /* RangeReplaceableCollection.swift */,
14491458
);
14501459
path = Extensions;
14511460
sourceTree = "<group>";
@@ -1468,6 +1477,7 @@
14681477
43F5C2DA1B92A5E1003EB13D /* SettingsTableViewController.swift */,
14691478
43E3449E1B9D68E900C85C07 /* StatusTableViewController.swift */,
14701479
4302F4E01D4E9C8900F0FCAF /* TextFieldTableViewController.swift */,
1480+
89CA2B31226C18B8004D9350 /* TestingScenariosTableViewController.swift */,
14711481
);
14721482
path = "View Controllers";
14731483
sourceTree = "<group>";
@@ -1500,12 +1510,14 @@
15001510
4315D2891CA5F45E00589052 /* DiagnosticLogger+LoopKit.swift */,
15011511
43F78D251C8FC000002152D1 /* DoseMath.swift */,
15021512
43E2D8C71D208D5B004DA55F /* KeychainManager+Loop.swift */,
1513+
89CA2B3C226E6B13004D9350 /* LocalTestingScenariosManager.swift */,
15031514
43A567681C94880B00334FAC /* LoopDataManager.swift */,
15041515
C18C8C501D5A351900E043FB /* NightscoutDataManager.swift */,
15051516
43C094491CACCC73001F6403 /* NotificationManager.swift */,
15061517
432E73CA1D24B3D6009AD15D /* RemoteDataManager.swift */,
15071518
43FCEEA8221A615B0013DD30 /* StatusChartsManager.swift */,
15081519
4F70C20F1DE8FAC5006380B7 /* StatusExtensionDataManager.swift */,
1520+
89ADE13A226BFA0F0067222B /* TestingScenariosManager.swift */,
15091521
4328E0341CFC0AE100E199AA /* WatchDataManager.swift */,
15101522
);
15111523
path = Managers;
@@ -2389,6 +2401,7 @@
23892401
4341F4EB1EDB92AC001C936B /* LogglyService.swift in Sources */,
23902402
43CE7CDE1CA8B63E003CC1B0 /* Data.swift in Sources */,
23912403
C1F8B243223E73FD00DD66CF /* BolusProgressTableViewCell.swift in Sources */,
2404+
89CA2B30226C0161004D9350 /* DirectoryObserver.swift in Sources */,
23922405
439A7942211F631C0041B75F /* RootNavigationController.swift in Sources */,
23932406
4F11D3C020DCBEEC006E072C /* GlucoseBackfillRequestUserInfo.swift in Sources */,
23942407
43F5C2DB1B92A5E1003EB13D /* SettingsTableViewController.swift in Sources */,
@@ -2407,6 +2420,7 @@
24072420
4346D1E71C77F5FE00ABAFE3 /* ChartTableViewCell.swift in Sources */,
24082421
437CEEE41CDE5C0A003C8C80 /* UIImage.swift in Sources */,
24092422
43DBF0591C93F73800B3C386 /* CarbEntryTableViewController.swift in Sources */,
2423+
89CA2B32226C18B8004D9350 /* TestingScenariosTableViewController.swift in Sources */,
24102424
43E93FB71E469A5100EAB8DB /* HKUnit.swift in Sources */,
24112425
43C05CAF21EB2C24006FB252 /* NSBundle.swift in Sources */,
24122426
43BFF0BC1E45C80600FF19A9 /* UIColor+Loop.swift in Sources */,
@@ -2448,11 +2462,13 @@
24482462
4328E0331CFC091100E199AA /* WatchContext+LoopKit.swift in Sources */,
24492463
4F526D611DF8D9A900A04910 /* NetBasal.swift in Sources */,
24502464
43C3B6EC20B650A80026CAFA /* SettingsImageTableViewCell.swift in Sources */,
2465+
89ADE13B226BFA0F0067222B /* TestingScenariosManager.swift in Sources */,
24512466
4F7E8ACB20E2ACB500AEA65E /* WatchPredictedGlucose.swift in Sources */,
24522467
436A0DA51D236A2A00104B24 /* LoopError.swift in Sources */,
24532468
4F11D3C220DD80B3006E072C /* WatchHistoricalGlucose.swift in Sources */,
24542469
435CB6231F37967800C320C7 /* InsulinModelSettingsViewController.swift in Sources */,
24552470
4372E490213CFCE70068E043 /* LoopSettingsUserInfo.swift in Sources */,
2471+
89CA2B3D226E6B13004D9350 /* LocalTestingScenariosManager.swift in Sources */,
24562472
43F78D261C8FC000002152D1 /* DoseMath.swift in Sources */,
24572473
438D42F91D7C88BC003244B0 /* PredictionInputEffect.swift in Sources */,
24582474
892A5D692230C41D008961AB /* RangeReplaceableCollection.swift in Sources */,

Loop/Extensions/Debug.swift

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,16 @@
66
// Copyright © 2019 LoopKit Authors. All rights reserved.
77
//
88

9-
func assertingDebugOnly(file: StaticString = #file, line: UInt = #line, _ doIt: () -> Void) {
9+
var debugEnabled: Bool {
1010
#if DEBUG || IOS_SIMULATOR
11-
doIt()
11+
return true
1212
#else
13-
fatalError("\(file):\(line) should never be invoked in release builds", file: file, line: line)
13+
return false
1414
#endif
1515
}
16+
17+
func assertDebugOnly(file: StaticString = #file, line: UInt = #line) {
18+
guard debugEnabled else {
19+
fatalError("\(file):\(line) should never be invoked in release builds", file: file, line: line)
20+
}
21+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
//
2+
// DirectoryObserver.swift
3+
// Loop
4+
//
5+
// Created by Michael Pangburn on 4/20/19.
6+
// Copyright © 2019 LoopKit Authors. All rights reserved.
7+
//
8+
9+
import Foundation
10+
11+
12+
protocol DirectoryObserver {}
13+
typealias DirectoryObservationToken = AnyObject
14+
15+
extension DirectoryObserver {
16+
func observeDirectory(at url: URL, updatingWith notifyOfUpdates: @escaping () -> Void) -> DirectoryObservationToken? {
17+
return DirectoryObservation(url: url, updatingWith: notifyOfUpdates)
18+
}
19+
}
20+
21+
private final class DirectoryObservation {
22+
private let fileDescriptor: CInt
23+
private let source: DispatchSourceFileSystemObject
24+
25+
fileprivate init?(url: URL, updatingWith notifyOfUpdates: @escaping () -> Void) {
26+
fileDescriptor = open(url.path, O_EVTONLY)
27+
guard fileDescriptor != -1 else {
28+
assertionFailure("Unable to open url: \(url)")
29+
return nil
30+
}
31+
source = DispatchSource.makeFileSystemObjectSource(fileDescriptor: fileDescriptor, eventMask: .all)
32+
source.setEventHandler(handler: notifyOfUpdates)
33+
source.activate()
34+
}
35+
36+
deinit {
37+
source.cancel()
38+
close(fileDescriptor)
39+
}
40+
}

Loop/Extensions/UIAlertController.swift

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,3 +130,91 @@ extension UIAlertController {
130130
addAction(UIAlertAction(title: cancel, style: .cancel, handler: handler))
131131
}
132132
}
133+
134+
135+
// Adapted from https://oleb.net/2018/uialertcontroller-textfield/
136+
extension UIAlertController {
137+
public enum TextInputResult {
138+
/// The user tapped Cancel.
139+
case cancel
140+
/// The user tapped the OK button. The payload is the text they entered in the text field.
141+
case ok(String)
142+
}
143+
144+
/// Creates a fully configured alert controller with one text field for text input, a Cancel and
145+
/// and an OK button.
146+
///
147+
/// - Parameters:
148+
/// - title: The title of the alert view.
149+
/// - message: The message of the alert view.
150+
/// - cancelButtonTitle: The title of the Cancel button.
151+
/// - okButtonTitle: The title of the OK button.
152+
/// - isValid: The OK button will be disabled as long as the entered text doesn't pass
153+
/// the validation. By default, all entered text is considered valid.
154+
/// - textFieldConfiguration: Use this to configure the text field (e.g. set placeholder text).
155+
/// - onCompletion: Called when the user closes the alert view. The argument tells you whether
156+
/// the user tapped the Close or the OK button (in which case this delivers the entered text).
157+
public convenience init(title: String, message: String? = nil,
158+
cancelButtonTitle: String, okButtonTitle: String,
159+
validate isValid: @escaping (String) -> Bool = { _ in true },
160+
textFieldConfiguration: ((UITextField) -> Void)? = nil,
161+
onCompletion: @escaping (TextInputResult) -> Void) {
162+
self.init(title: title, message: message, preferredStyle: .alert)
163+
164+
/// Observes a UITextField for various events and reports them via callbacks.
165+
/// Sets itself as the text field's delegate and target-action target.
166+
class TextFieldObserver: NSObject, UITextFieldDelegate {
167+
let textFieldValueChanged: (UITextField) -> Void
168+
let textFieldShouldReturn: (UITextField) -> Bool
169+
170+
init(textField: UITextField, valueChanged: @escaping (UITextField) -> Void, shouldReturn: @escaping (UITextField) -> Bool) {
171+
self.textFieldValueChanged = valueChanged
172+
self.textFieldShouldReturn = shouldReturn
173+
super.init()
174+
textField.delegate = self
175+
textField.addTarget(self, action: #selector(TextFieldObserver.textFieldValueChanged(sender:)), for: .editingChanged)
176+
}
177+
178+
@objc func textFieldValueChanged(sender: UITextField) {
179+
textFieldValueChanged(sender)
180+
}
181+
182+
// MARK: UITextFieldDelegate
183+
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
184+
return textFieldShouldReturn(textField)
185+
}
186+
}
187+
188+
var textFieldObserver: TextFieldObserver?
189+
190+
// Every `UIAlertAction` handler must eventually call this
191+
func finish(result: TextInputResult) {
192+
// Capture the observer to keep it alive while the alert is on screen
193+
textFieldObserver = nil
194+
onCompletion(result)
195+
}
196+
197+
let cancelAction = UIAlertAction(title: cancelButtonTitle, style: .cancel, handler: { _ in
198+
finish(result: .cancel)
199+
})
200+
let okAction = UIAlertAction(title: okButtonTitle, style: .default, handler: { [unowned self] _ in
201+
finish(result: .ok(self.textFields?.first?.text ?? ""))
202+
})
203+
addAction(cancelAction)
204+
addAction(okAction)
205+
preferredAction = okAction
206+
207+
addTextField(configurationHandler: { textField in
208+
textFieldConfiguration?(textField)
209+
textFieldObserver = TextFieldObserver(textField: textField,
210+
valueChanged: { textField in
211+
okAction.isEnabled = isValid(textField.text ?? "")
212+
},
213+
shouldReturn: { textField in
214+
isValid(textField.text ?? "")
215+
})
216+
})
217+
// Start with a disabled OK button if necessary
218+
okAction.isEnabled = isValid(textFields?.first?.text ?? "")
219+
}
220+
}

0 commit comments

Comments
 (0)