podojo-cli 1.5.2__tar.gz → 1.7.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {podojo_cli-1.5.2 → podojo_cli-1.7.0}/CHANGELOG.md +10 -0
- {podojo_cli-1.5.2 → podojo_cli-1.7.0}/PKG-INFO +13 -1
- {podojo_cli-1.5.2 → podojo_cli-1.7.0}/README.md +12 -0
- {podojo_cli-1.5.2 → podojo_cli-1.7.0}/pyproject.toml +1 -1
- {podojo_cli-1.5.2 → podojo_cli-1.7.0}/src/podojo_cli/commands/aiinterviews.py +65 -1
- {podojo_cli-1.5.2 → podojo_cli-1.7.0}/src/podojo_cli/commands/usertests.py +66 -0
- {podojo_cli-1.5.2 → podojo_cli-1.7.0}/tests/test_aiinterviews.py +83 -0
- {podojo_cli-1.5.2 → podojo_cli-1.7.0}/tests/test_usertests.py +95 -0
- {podojo_cli-1.5.2 → podojo_cli-1.7.0}/uv.lock +1 -1
- {podojo_cli-1.5.2 → podojo_cli-1.7.0}/.github/workflows/publish.yml +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.7.0}/.gitignore +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.7.0}/CLAUDE.md +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.7.0}/LICENSE +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.7.0}/src/podojo_cli/__init__.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.7.0}/src/podojo_cli/client.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.7.0}/src/podojo_cli/commands/__init__.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.7.0}/src/podojo_cli/commands/auth.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.7.0}/src/podojo_cli/commands/interviews.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.7.0}/src/podojo_cli/commands/projects.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.7.0}/src/podojo_cli/commands/showreel.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.7.0}/src/podojo_cli/commands/synth.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.7.0}/src/podojo_cli/commands/transcripts.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.7.0}/src/podojo_cli/commands/videos.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.7.0}/src/podojo_cli/config.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.7.0}/src/podojo_cli/main.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.7.0}/src/podojo_cli/synth/__init__.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.7.0}/src/podojo_cli/synth/driver.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.7.0}/src/podojo_cli/synth/session.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.7.0}/src/podojo_cli/version_check.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.7.0}/src/podojo_cli/video/__init__.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.7.0}/src/podojo_cli/video/showreel.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.7.0}/tests/conftest.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.7.0}/tests/test_auth.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.7.0}/tests/test_interviews.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.7.0}/tests/test_projects.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.7.0}/tests/test_showreel.py +0 -0
- {podojo_cli-1.5.2 → podojo_cli-1.7.0}/tests/test_transcripts.py +0 -0
|
@@ -5,6 +5,16 @@ All notable changes to the Podojo CLI will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org).
|
|
7
7
|
|
|
8
|
+
## [1.7.0] - 2026-07-04
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- User tests support an optional participant screener: `screening_questions` (single-select, options flagged `qualifies: true`) and `rejection_message`, mirroring AI interviews. `validate`/`create` check the screener shape, and `example` shows the new fields.
|
|
12
|
+
|
|
13
|
+
## [1.6.0] - 2026-07-03
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- AI interviews support an optional participant screener: `screening_questions` (single-select, options flagged `qualifies: true`) and `rejection_message`. `validate`/`create` check the screener shape, and `example` shows the new fields.
|
|
17
|
+
|
|
8
18
|
## [1.5.2] - 2026-07-02
|
|
9
19
|
|
|
10
20
|
### Changed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: podojo-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.7.0
|
|
4
4
|
Summary: CLI for the Podojo user research platform
|
|
5
5
|
Project-URL: Homepage, https://github.com/podojo/cli-podojo
|
|
6
6
|
Project-URL: Source, https://github.com/podojo/cli-podojo
|
|
@@ -123,6 +123,12 @@ podojo usertests delete checkout-usability-v1
|
|
|
123
123
|
podojo usertests snippet # recorder script for self-hosted prototypes
|
|
124
124
|
```
|
|
125
125
|
|
|
126
|
+
A user test can open with an optional participant screener: on-screen
|
|
127
|
+
single-select `screening_questions` whose options carry `qualifies: true`
|
|
128
|
+
flags. Participants must pick a qualifying option on every question; everyone
|
|
129
|
+
else sees the test's `rejection_message` and never reaches the recorded test.
|
|
130
|
+
See `podojo usertests example` for the exact shape.
|
|
131
|
+
|
|
126
132
|
### AI interviews
|
|
127
133
|
|
|
128
134
|
An AI interview is a self-serve voice conversation: an AI interviewer asks
|
|
@@ -143,6 +149,12 @@ podojo aiinterviews delete checkout-experience-v1
|
|
|
143
149
|
base URL can be overridden via `ai_interviews_url` in `~/.podojo.toml` or
|
|
144
150
|
`PODOJO_AI_INTERVIEWS_URL`.
|
|
145
151
|
|
|
152
|
+
A study can open with an optional participant screener: on-screen single-select
|
|
153
|
+
`screening_questions` whose options carry `qualifies: true` flags. Participants
|
|
154
|
+
must pick a qualifying option on every question; everyone else sees the study's
|
|
155
|
+
`rejection_message` and never reaches the voice interview. See
|
|
156
|
+
`podojo aiinterviews example` for the exact shape.
|
|
157
|
+
|
|
146
158
|
### Synthetic participants
|
|
147
159
|
|
|
148
160
|
The `synth` group drives a Playwright browser through a user test preview so an
|
|
@@ -92,6 +92,12 @@ podojo usertests delete checkout-usability-v1
|
|
|
92
92
|
podojo usertests snippet # recorder script for self-hosted prototypes
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
+
A user test can open with an optional participant screener: on-screen
|
|
96
|
+
single-select `screening_questions` whose options carry `qualifies: true`
|
|
97
|
+
flags. Participants must pick a qualifying option on every question; everyone
|
|
98
|
+
else sees the test's `rejection_message` and never reaches the recorded test.
|
|
99
|
+
See `podojo usertests example` for the exact shape.
|
|
100
|
+
|
|
95
101
|
### AI interviews
|
|
96
102
|
|
|
97
103
|
An AI interview is a self-serve voice conversation: an AI interviewer asks
|
|
@@ -112,6 +118,12 @@ podojo aiinterviews delete checkout-experience-v1
|
|
|
112
118
|
base URL can be overridden via `ai_interviews_url` in `~/.podojo.toml` or
|
|
113
119
|
`PODOJO_AI_INTERVIEWS_URL`.
|
|
114
120
|
|
|
121
|
+
A study can open with an optional participant screener: on-screen single-select
|
|
122
|
+
`screening_questions` whose options carry `qualifies: true` flags. Participants
|
|
123
|
+
must pick a qualifying option on every question; everyone else sees the study's
|
|
124
|
+
`rejection_message` and never reaches the voice interview. See
|
|
125
|
+
`podojo aiinterviews example` for the exact shape.
|
|
126
|
+
|
|
115
127
|
### Synthetic participants
|
|
116
128
|
|
|
117
129
|
The `synth` group drives a Playwright browser through a user test preview so an
|
|
@@ -24,13 +24,21 @@ EXAMPLE_YAML = """\
|
|
|
24
24
|
#
|
|
25
25
|
# Required fields: interview_id, title, questions, closing_message
|
|
26
26
|
# Optional fields: language (default en-US), project_name, overview,
|
|
27
|
-
# decision, live
|
|
27
|
+
# decision, screening_questions, rejection_message, live
|
|
28
28
|
#
|
|
29
29
|
# Each question:
|
|
30
30
|
# text (required) the main question, asked verbatim-ish in order
|
|
31
31
|
# section (optional) researcher-facing grouping label
|
|
32
32
|
# max_follow_ups (optional, default 2) adaptive follow-up budget
|
|
33
33
|
# probe_for (optional) what a concrete answer must cover — drives follow-ups
|
|
34
|
+
#
|
|
35
|
+
# Each screening question (optional participant screener, answered on screen
|
|
36
|
+
# before the voice interview starts):
|
|
37
|
+
# text (required) single-select multiple-choice question
|
|
38
|
+
# options (required, at least 2) each with `text` and an optional
|
|
39
|
+
# `qualifies: true` — participants must pick a qualifying
|
|
40
|
+
# option on every question, otherwise they see the
|
|
41
|
+
# rejection_message and the interview never starts
|
|
34
42
|
|
|
35
43
|
interview_id: checkout-experience-v1
|
|
36
44
|
title: Checkout Experience Research
|
|
@@ -57,6 +65,30 @@ decision: >
|
|
|
57
65
|
# Optional: set interview live (default: false)
|
|
58
66
|
# live: true
|
|
59
67
|
|
|
68
|
+
# Optional: participant screener — shown on screen (no audio) before the
|
|
69
|
+
# conversation. Answers are captured alongside the session's recording.
|
|
70
|
+
screening_questions:
|
|
71
|
+
- text: How often do you shop online?
|
|
72
|
+
options:
|
|
73
|
+
- text: Rarely or never
|
|
74
|
+
- text: A few times a year
|
|
75
|
+
- text: At least once a month
|
|
76
|
+
qualifies: true
|
|
77
|
+
- text: Weekly or more
|
|
78
|
+
qualifies: true
|
|
79
|
+
|
|
80
|
+
- text: Have you abandoned an online purchase at checkout in the past 3 months?
|
|
81
|
+
options:
|
|
82
|
+
- text: "Yes"
|
|
83
|
+
qualifies: true
|
|
84
|
+
- text: "No"
|
|
85
|
+
- text: Not sure
|
|
86
|
+
|
|
87
|
+
# Optional: shown to participants whose screener answers don't qualify
|
|
88
|
+
rejection_message: >
|
|
89
|
+
Thank you for your time, you did not meet the research criteria for this
|
|
90
|
+
study!
|
|
91
|
+
|
|
60
92
|
questions:
|
|
61
93
|
- section: Shopping Habits
|
|
62
94
|
text: >
|
|
@@ -118,6 +150,38 @@ def validate_ai_interview_data(data: dict) -> list[str]:
|
|
|
118
150
|
errors.append(
|
|
119
151
|
f"Question {i}: 'max_follow_ups' must be an integer >= 0, got '{max_follow_ups}'"
|
|
120
152
|
)
|
|
153
|
+
|
|
154
|
+
screening_questions = data.get("screening_questions")
|
|
155
|
+
if screening_questions is not None:
|
|
156
|
+
if not isinstance(screening_questions, list):
|
|
157
|
+
errors.append("'screening_questions' must be a list")
|
|
158
|
+
else:
|
|
159
|
+
for i, question in enumerate(screening_questions, 1):
|
|
160
|
+
if not isinstance(question, dict) or "text" not in question:
|
|
161
|
+
errors.append(f"Screening question {i}: must be a mapping with 'text'")
|
|
162
|
+
continue
|
|
163
|
+
options = question.get("options")
|
|
164
|
+
if not isinstance(options, list) or len(options) < 2:
|
|
165
|
+
errors.append(f"Screening question {i}: 'options' must list at least 2 options")
|
|
166
|
+
continue
|
|
167
|
+
qualifying = 0
|
|
168
|
+
for j, option in enumerate(options, 1):
|
|
169
|
+
if not isinstance(option, dict) or "text" not in option:
|
|
170
|
+
errors.append(
|
|
171
|
+
f"Screening question {i}, option {j}: must be a mapping with 'text'"
|
|
172
|
+
)
|
|
173
|
+
continue
|
|
174
|
+
qualifies = option.get("qualifies", False)
|
|
175
|
+
if not isinstance(qualifies, bool):
|
|
176
|
+
errors.append(
|
|
177
|
+
f"Screening question {i}, option {j}: 'qualifies' must be true or false"
|
|
178
|
+
)
|
|
179
|
+
elif qualifies:
|
|
180
|
+
qualifying += 1
|
|
181
|
+
if qualifying == 0:
|
|
182
|
+
errors.append(
|
|
183
|
+
f"Screening question {i}: needs at least one option with 'qualifies: true'"
|
|
184
|
+
)
|
|
121
185
|
return errors
|
|
122
186
|
|
|
123
187
|
|
|
@@ -37,7 +37,16 @@ EXAMPLE_YAML = """\
|
|
|
37
37
|
#
|
|
38
38
|
# Required fields: usertest_id, title, logo, prototype_url, steps
|
|
39
39
|
# Optional fields: welcome_text, promo_code, promo_code_info,
|
|
40
|
+
# screening_questions, rejection_message,
|
|
40
41
|
# project_name, live, collect_contact
|
|
42
|
+
#
|
|
43
|
+
# Each screening question (optional participant screener, answered on screen
|
|
44
|
+
# before consent — screening is never recorded):
|
|
45
|
+
# text (required) single-select multiple-choice question
|
|
46
|
+
# options (required, at least 2) each with `text` and an optional
|
|
47
|
+
# `qualifies: true` — participants must pick a qualifying
|
|
48
|
+
# option on every question, otherwise they see the
|
|
49
|
+
# rejection_message and the test never starts
|
|
41
50
|
|
|
42
51
|
usertest_id: checkout-usability-v1
|
|
43
52
|
title: Checkout Flow Usability Test
|
|
@@ -66,6 +75,31 @@ project_name: checkout-redesign-q1
|
|
|
66
75
|
# Optional: collect participant name/email on the final screen (default: false)
|
|
67
76
|
# collect_contact: true
|
|
68
77
|
|
|
78
|
+
# Optional: participant screener — shown on screen before consent and
|
|
79
|
+
# recording. Only qualified participants reach the test; screen-out answers
|
|
80
|
+
# are still captured for funnel metrics.
|
|
81
|
+
screening_questions:
|
|
82
|
+
- text: How often do you shop online?
|
|
83
|
+
options:
|
|
84
|
+
- text: Rarely or never
|
|
85
|
+
- text: A few times a year
|
|
86
|
+
- text: At least once a month
|
|
87
|
+
qualifies: true
|
|
88
|
+
- text: Weekly or more
|
|
89
|
+
qualifies: true
|
|
90
|
+
|
|
91
|
+
- text: Have you abandoned an online purchase at checkout in the past 3 months?
|
|
92
|
+
options:
|
|
93
|
+
- text: "Yes"
|
|
94
|
+
qualifies: true
|
|
95
|
+
- text: "No"
|
|
96
|
+
- text: Not sure
|
|
97
|
+
|
|
98
|
+
# Optional: shown to participants whose screener answers don't qualify
|
|
99
|
+
rejection_message: >
|
|
100
|
+
Thank you for your time, you did not meet the research criteria for this
|
|
101
|
+
study!
|
|
102
|
+
|
|
69
103
|
# Steps define what participants see and do
|
|
70
104
|
# Each step requires: type ("screen" or "prototype") and title
|
|
71
105
|
# Screen steps should have a variant: "question" (open-ended), "task" (action-based),
|
|
@@ -143,6 +177,38 @@ def validate_usertest_data(data: dict) -> list[str]:
|
|
|
143
177
|
errors.append(
|
|
144
178
|
f"Step {i}: 'variant' must be one of {sorted(VALID_STEP_VARIANTS)}, got '{variant}'"
|
|
145
179
|
)
|
|
180
|
+
|
|
181
|
+
screening_questions = data.get("screening_questions")
|
|
182
|
+
if screening_questions is not None:
|
|
183
|
+
if not isinstance(screening_questions, list):
|
|
184
|
+
errors.append("'screening_questions' must be a list")
|
|
185
|
+
else:
|
|
186
|
+
for i, question in enumerate(screening_questions, 1):
|
|
187
|
+
if not isinstance(question, dict) or "text" not in question:
|
|
188
|
+
errors.append(f"Screening question {i}: must be a mapping with 'text'")
|
|
189
|
+
continue
|
|
190
|
+
options = question.get("options")
|
|
191
|
+
if not isinstance(options, list) or len(options) < 2:
|
|
192
|
+
errors.append(f"Screening question {i}: 'options' must list at least 2 options")
|
|
193
|
+
continue
|
|
194
|
+
qualifying = 0
|
|
195
|
+
for j, option in enumerate(options, 1):
|
|
196
|
+
if not isinstance(option, dict) or "text" not in option:
|
|
197
|
+
errors.append(
|
|
198
|
+
f"Screening question {i}, option {j}: must be a mapping with 'text'"
|
|
199
|
+
)
|
|
200
|
+
continue
|
|
201
|
+
qualifies = option.get("qualifies", False)
|
|
202
|
+
if not isinstance(qualifies, bool):
|
|
203
|
+
errors.append(
|
|
204
|
+
f"Screening question {i}, option {j}: 'qualifies' must be true or false"
|
|
205
|
+
)
|
|
206
|
+
elif qualifies:
|
|
207
|
+
qualifying += 1
|
|
208
|
+
if qualifying == 0:
|
|
209
|
+
errors.append(
|
|
210
|
+
f"Screening question {i}: needs at least one option with 'qualifies: true'"
|
|
211
|
+
)
|
|
146
212
|
return errors
|
|
147
213
|
|
|
148
214
|
|
|
@@ -305,6 +305,87 @@ def test_validate_invalid(runner, tmp_path):
|
|
|
305
305
|
assert "Missing required field" in result.output
|
|
306
306
|
|
|
307
307
|
|
|
308
|
+
SCREENING_YAML = VALID_AI_INTERVIEW_YAML + """\
|
|
309
|
+
screening_questions:
|
|
310
|
+
- text: Do you currently drive for a ridehailing platform?
|
|
311
|
+
options:
|
|
312
|
+
- text: "Yes"
|
|
313
|
+
qualifies: true
|
|
314
|
+
- text: "No"
|
|
315
|
+
- text: How many rides did you complete last month?
|
|
316
|
+
options:
|
|
317
|
+
- text: 0 rides
|
|
318
|
+
- text: 1-10 rides
|
|
319
|
+
qualifies: true
|
|
320
|
+
- text: More than 10 rides
|
|
321
|
+
qualifies: true
|
|
322
|
+
rejection_message: You did not meet the research criteria for this study.
|
|
323
|
+
"""
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def test_validate_with_screening(runner, tmp_path):
|
|
327
|
+
yaml_file = tmp_path / "interview.yaml"
|
|
328
|
+
yaml_file.write_text(SCREENING_YAML)
|
|
329
|
+
|
|
330
|
+
result = runner.invoke(app, ["aiinterviews", "validate", str(yaml_file)])
|
|
331
|
+
|
|
332
|
+
assert result.exit_code == 0
|
|
333
|
+
assert "Valid AI interview config" in result.output
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def test_validate_screening_question_needs_two_options(runner, tmp_path):
|
|
337
|
+
yaml_file = tmp_path / "interview.yaml"
|
|
338
|
+
yaml_file.write_text(
|
|
339
|
+
VALID_AI_INTERVIEW_YAML
|
|
340
|
+
+ "screening_questions:\n"
|
|
341
|
+
+ " - text: Only one way to answer?\n"
|
|
342
|
+
+ " options:\n"
|
|
343
|
+
+ " - text: \"Yes\"\n"
|
|
344
|
+
+ " qualifies: true\n"
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
result = runner.invoke(app, ["aiinterviews", "validate", str(yaml_file)])
|
|
348
|
+
|
|
349
|
+
assert result.exit_code == 1
|
|
350
|
+
assert "'options' must list at least 2 options" in result.output
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def test_validate_screening_question_needs_a_qualifying_option(runner, tmp_path):
|
|
354
|
+
yaml_file = tmp_path / "interview.yaml"
|
|
355
|
+
yaml_file.write_text(
|
|
356
|
+
VALID_AI_INTERVIEW_YAML
|
|
357
|
+
+ "screening_questions:\n"
|
|
358
|
+
+ " - text: Nobody can pass this one\n"
|
|
359
|
+
+ " options:\n"
|
|
360
|
+
+ " - text: A\n"
|
|
361
|
+
+ " - text: B\n"
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
result = runner.invoke(app, ["aiinterviews", "validate", str(yaml_file)])
|
|
365
|
+
|
|
366
|
+
assert result.exit_code == 1
|
|
367
|
+
assert "needs at least one option with 'qualifies: true'" in result.output
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def test_validate_screening_option_qualifies_must_be_bool(runner, tmp_path):
|
|
371
|
+
yaml_file = tmp_path / "interview.yaml"
|
|
372
|
+
yaml_file.write_text(
|
|
373
|
+
VALID_AI_INTERVIEW_YAML
|
|
374
|
+
+ "screening_questions:\n"
|
|
375
|
+
+ " - text: Q\n"
|
|
376
|
+
+ " options:\n"
|
|
377
|
+
+ " - text: A\n"
|
|
378
|
+
+ " qualifies: yep\n"
|
|
379
|
+
+ " - text: B\n"
|
|
380
|
+
+ " qualifies: true\n"
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
result = runner.invoke(app, ["aiinterviews", "validate", str(yaml_file)])
|
|
384
|
+
|
|
385
|
+
assert result.exit_code == 1
|
|
386
|
+
assert "'qualifies' must be true or false" in result.output
|
|
387
|
+
|
|
388
|
+
|
|
308
389
|
def test_example(runner):
|
|
309
390
|
result = runner.invoke(app, ["aiinterviews", "example"])
|
|
310
391
|
|
|
@@ -313,6 +394,8 @@ def test_example(runner):
|
|
|
313
394
|
assert "questions:" in result.output
|
|
314
395
|
assert "closing_message:" in result.output
|
|
315
396
|
assert "max_follow_ups:" in result.output
|
|
397
|
+
assert "screening_questions:" in result.output
|
|
398
|
+
assert "rejection_message:" in result.output
|
|
316
399
|
|
|
317
400
|
|
|
318
401
|
def test_example_validates(runner, tmp_path):
|
|
@@ -292,6 +292,87 @@ def test_validate_invalid(runner, tmp_path):
|
|
|
292
292
|
assert "Missing required field" in result.output
|
|
293
293
|
|
|
294
294
|
|
|
295
|
+
SCREENING_YAML = VALID_USERTEST_YAML + """\
|
|
296
|
+
screening_questions:
|
|
297
|
+
- text: Do you shop online?
|
|
298
|
+
options:
|
|
299
|
+
- text: "Yes"
|
|
300
|
+
qualifies: true
|
|
301
|
+
- text: "No"
|
|
302
|
+
- text: How often do you shop online?
|
|
303
|
+
options:
|
|
304
|
+
- text: Never
|
|
305
|
+
- text: Monthly
|
|
306
|
+
qualifies: true
|
|
307
|
+
- text: Weekly
|
|
308
|
+
qualifies: true
|
|
309
|
+
rejection_message: You did not meet the research criteria for this study.
|
|
310
|
+
"""
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def test_validate_with_screening(runner, tmp_path):
|
|
314
|
+
yaml_file = tmp_path / "usertest.yaml"
|
|
315
|
+
yaml_file.write_text(SCREENING_YAML)
|
|
316
|
+
|
|
317
|
+
result = runner.invoke(app, ["usertests", "validate", str(yaml_file)])
|
|
318
|
+
|
|
319
|
+
assert result.exit_code == 0
|
|
320
|
+
assert "Valid user test config" in result.output
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def test_validate_screening_question_needs_two_options(runner, tmp_path):
|
|
324
|
+
yaml_file = tmp_path / "usertest.yaml"
|
|
325
|
+
yaml_file.write_text(
|
|
326
|
+
VALID_USERTEST_YAML
|
|
327
|
+
+ "screening_questions:\n"
|
|
328
|
+
+ " - text: Only one way to answer?\n"
|
|
329
|
+
+ " options:\n"
|
|
330
|
+
+ " - text: \"Yes\"\n"
|
|
331
|
+
+ " qualifies: true\n"
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
result = runner.invoke(app, ["usertests", "validate", str(yaml_file)])
|
|
335
|
+
|
|
336
|
+
assert result.exit_code == 1
|
|
337
|
+
assert "'options' must list at least 2 options" in result.output
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def test_validate_screening_question_needs_a_qualifying_option(runner, tmp_path):
|
|
341
|
+
yaml_file = tmp_path / "usertest.yaml"
|
|
342
|
+
yaml_file.write_text(
|
|
343
|
+
VALID_USERTEST_YAML
|
|
344
|
+
+ "screening_questions:\n"
|
|
345
|
+
+ " - text: Nobody can pass this one\n"
|
|
346
|
+
+ " options:\n"
|
|
347
|
+
+ " - text: A\n"
|
|
348
|
+
+ " - text: B\n"
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
result = runner.invoke(app, ["usertests", "validate", str(yaml_file)])
|
|
352
|
+
|
|
353
|
+
assert result.exit_code == 1
|
|
354
|
+
assert "needs at least one option with 'qualifies: true'" in result.output
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def test_validate_screening_option_qualifies_must_be_bool(runner, tmp_path):
|
|
358
|
+
yaml_file = tmp_path / "usertest.yaml"
|
|
359
|
+
yaml_file.write_text(
|
|
360
|
+
VALID_USERTEST_YAML
|
|
361
|
+
+ "screening_questions:\n"
|
|
362
|
+
+ " - text: Q\n"
|
|
363
|
+
+ " options:\n"
|
|
364
|
+
+ " - text: A\n"
|
|
365
|
+
+ " qualifies: yep\n"
|
|
366
|
+
+ " - text: B\n"
|
|
367
|
+
+ " qualifies: true\n"
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
result = runner.invoke(app, ["usertests", "validate", str(yaml_file)])
|
|
371
|
+
|
|
372
|
+
assert result.exit_code == 1
|
|
373
|
+
assert "'qualifies' must be true or false" in result.output
|
|
374
|
+
|
|
375
|
+
|
|
295
376
|
def test_example(runner):
|
|
296
377
|
result = runner.invoke(app, ["usertests", "example"])
|
|
297
378
|
|
|
@@ -300,6 +381,20 @@ def test_example(runner):
|
|
|
300
381
|
assert "steps:" in result.output
|
|
301
382
|
assert "prototype" in result.output
|
|
302
383
|
assert "screen" in result.output
|
|
384
|
+
assert "screening_questions:" in result.output
|
|
385
|
+
assert "rejection_message:" in result.output
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def test_example_validates(runner, tmp_path):
|
|
389
|
+
from podojo_cli.commands.usertests import EXAMPLE_YAML
|
|
390
|
+
|
|
391
|
+
yaml_file = tmp_path / "example.yaml"
|
|
392
|
+
yaml_file.write_text(EXAMPLE_YAML)
|
|
393
|
+
|
|
394
|
+
result = runner.invoke(app, ["usertests", "validate", str(yaml_file)])
|
|
395
|
+
|
|
396
|
+
assert result.exit_code == 0
|
|
397
|
+
assert "Valid user test config" in result.output
|
|
303
398
|
|
|
304
399
|
|
|
305
400
|
def test_snippet(runner):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|