Anklang-0.3.0-1416-gfa8ce8d2 anklang-0.3.0-1416-gfa8ce8d2
ASE — Anklang Sound Engine (C++)

« « « Anklang Documentation
Loading...
Searching...
No Matches
proptests.cc
Go to the documentation of this file.
1 // This Source Code Form is licensed MPL-2.0: http://mozilla.org/MPL/2.0
2#include <ase/project.hh>
3#include <ase/jsonapi.hh>
4#include <ase/testing.hh>
5
6namespace { // Anon
7using namespace Ase;
8
9// Properties that fail the loop, with the reason. A listed property that passes fails the test.
10static const std::map<String,String> exempt_properties = {
11 { "Project.is_playing", "Transport state, playback has no undo." },
12 { "Track.muted", "Tracktion's AudioTrack binds IDs::mute without the undo manager." },
13 { "Track.solo", "Tracktion's AudioTrack binds IDs::solo without the undo manager." },
14};
15
16using Values = std::map<String,String>;
17// Undo can recreate wrappers; compare values by position and notifications by live instance.
18struct Snapshot { Values values, properties; };
19static const String test_note = "{\"note_number\":60,\"start_beat\":0.25,\"length_beats\":0.5,\"velocity\":90}";
20
21static void
22normalize_refs (Jsonipc::JsonValue &value)
23{
24 if (value.IsArray())
25 for (auto &element : value.GetArray())
26 normalize_refs (element);
27 else if (value.IsObject())
28 {
29 for (auto &member : value.GetObject())
30 if (member.name == "$id")
31 member.value.SetInt (0);
32 else
33 normalize_refs (member.value);
34 }
35}
36
37// A fresh project whose objects are read and written by name through Jsonipc, like the UI does.
38struct LoopProject {
39 struct Object { String label, self; StringS props, read_only; bool assign; };
41 Jsonipc::IpcDispatcher dispatcher;
42 ProjectImplP project = ProjectImpl::create ("PropertyLoop");
43 ClipP audio_clip;
44 std::vector<Object> objects;
45 std::set<String> notified;
46 std::map<String,String> property_labels;
48 LoopProject()
49 {
50 TrackP track = project->create_track();
51 ClipP midi_clip = track->create_midi_clip ("MidiClip", 1, 2);
52 audio_clip = track->create_audio_clip ("AudioClip", 4, 2);
53 PluginP plugin = track->create_plugin ("4osc");
54 PluginP text_plugin = track->create_plugin ("text");
55 PluginP insert_plugin = track->create_plugin ("insert");
56 add ("Project", ProjectP (project));
57 add ("MasterTrack", project->master_track());
58 add ("Track", track);
59 add ("MidiClip", midi_clip);
60 add ("AudioClip", audio_clip);
61 add ("Plugin", plugin);
62 add ("TextPlugin", text_plugin);
63 add ("InsertPlugin", insert_plugin);
64 for (TrackP t : project->all_tracks())
65 {
66 add (t->is_control_track() && t->name() == "Chord" ? "ChordTrack" : "", t);
67 for (ClipP c : t->launcher_clips())
68 add ("", c);
69 for (PluginP p : t->list_plugins())
70 add ("", p);
71 }
72 call ("change_batch", ref ("MidiClip"), "[" + test_note + "],\"Initial Note\"");
73 project->clear_undo();
74 }
75 ~LoopProject()
76 {
77 project->discard();
78 }
79 // Add `obj` with its get/set pairs, properties of objects without label are only compared.
80 template<class T> const Object&
81 add (String label, const std::shared_ptr<T> &obj)
82 {
83 TASSERT (obj);
84 const bool assign = !label.empty();
85 Jsonipc::Scope scope (imap);
86 rapidjson::Document doc;
87 const Jsonipc::JsonValue ref = Jsonipc::to_json (obj, doc.GetAllocator());
88 const String self = Jsonipc::jsonvalue_to_string (ref);
89 for (const auto &o : objects)
90 if (o.self == self)
91 return o;
92 Jsonipc::InstanceMap::Wrapper *wrapper = Jsonipc::InstanceMap::scope_lookup_wrapper (ref);
93 if (label.empty())
94 label = Jsonipc::short_name (wrapper->classname()) + "#" + std::to_string (objects.size());
95 const StringS names = wrapper->list_methods();
96 StringS props;
97 for (const String &name : names)
98 if (string_startswith (name, "get/") && std::count (names.begin(), names.end(), "set/" + name.substr (4)))
99 props.push_back (name.substr (4));
100 connections.push_back (obj->on_event ("notify", [this, self] (const Event &event) {
101 notified.insert (self + "." + event.detail());
102 }));
103 objects.push_back ({ label, self, props, obj->read_only_properties(), assign });
104 return objects.back();
105 }
106 // Call a method by name, e.g. {"id":1,"method":"get/bpm","params":[{"$id":1}]}
107 String
108 call (const String &method, const String &self, const String &arg = "")
109 {
110 Jsonipc::Scope scope (imap);
111 const String msg = "{\"id\":1,\"method\":\"" + method + "\",\"params\":[" + self + (arg.empty() ? "" : "," + arg) + "]}";
112 const String reply = dispatcher.dispatch_message (msg);
113 rapidjson::Document doc;
114 doc.Parse (reply.c_str());
115 if (doc.HasParseError() || !doc.HasMember ("result"))
116 printerr ("%s: %s\n", msg, reply);
117 TASSERT (!doc.HasParseError() && doc.HasMember ("result"));
118 return Jsonipc::jsonvalue_to_string (doc["result"]);
119 }
120 String
121 ref (const String &label) const
122 {
123 for (const auto &o : objects)
124 if (o.label == label)
125 return o.self;
126 return "";
127 }
128 void
129 read (Snapshot &snapshot, const String &label, const String &self, const String &prop, const String &method, bool notify = false)
130 {
131 const String key = self + "." + prop, value = call (method, self);
132 property_labels[key] = label + "." + prop;
133 if (notify)
134 snapshot.properties[key] = value;
135 rapidjson::Document doc;
136 doc.Parse (value.c_str());
137 normalize_refs (doc);
138 snapshot.values[label + "." + prop] = Jsonipc::jsonvalue_to_string (doc);
139 }
140 template<class T> String
141 read_object (Snapshot &snapshot, const String &label, const std::shared_ptr<T> &obj)
142 {
143 const Object &o = add ("", obj);
144 for (const auto &p : o.props)
145 read (snapshot, label, o.self, p, "get/" + p, true);
146 return o.self;
147 }
148 Snapshot
149 snapshot()
150 {
151 Snapshot values;
152 read_object (values, "Project", ProjectP (project));
153 read (values, "Project", ref ("Project"), "all_tracks", "all_tracks");
154 size_t ti = 0;
155 for (const auto &track : project->all_tracks())
156 {
157 const String label = string_format ("Track[%u]", uint (ti++));
158 const String self = read_object (values, label, track);
159 read (values, label, self, "list_plugins", "list_plugins");
160 size_t ci = 0, pi = 0;
161 for (const auto &clip : track->launcher_clips())
162 {
163 const String clabel = string_format ("%s.Clip[%u]", label, uint (ci++));
164 const String cself = read_object (values, clabel, clip);
165 read (values, clabel, cself, "notes", "list_all_notes", true);
166 }
167 for (const auto &plugin : track->list_plugins())
168 read_object (values, string_format ("%s.Plugin[%u]", label, uint (pi++)), plugin);
169 }
170 read_object (values, "AudioClip", audio_clip);
171 return values;
172 }
173};
174
175using Change = std::function<void (LoopProject&)>;
176static const std::map<String,Change> property_changes = {
177 { "MidiClip.all_notes", [] (LoopProject &lp) {
178 lp.call ("set/all_notes", lp.ref ("MidiClip"), "[{\"note_number\":67,\"length_beats\":1,\"velocity\":45}]");
179 } },
180 { "MidiClip.position", [] (LoopProject &lp) {
181 lp.call ("set/position", lp.ref ("MidiClip"), "{\"time\":{\"start\":1.25,\"end\":3.25},\"offset\":0.5}");
182 } },
183 { "AudioClip.position", [] (LoopProject &lp) {
184 lp.call ("set/position", lp.ref ("AudioClip"), "{\"time\":{\"start\":4.25,\"end\":6.25},\"offset\":0.5}");
185 } },
186};
187
188// Another value of the same JSON type, "" for lists and objects.
189static String
190nudge (const String &v, int dir)
191{
192 if (v == "true")
193 return "false";
194 if (v == "false")
195 return "true";
196 if (!v.empty() && v[0] == '"')
197 return v.substr (0, v.size() - 1) + "x\"";
198 if (!v.empty() && (isdigit (v[0]) || v[0] == '-'))
199 return string_format ("%.17g", string_to_double (v) + dir);
200 return "";
201}
202
203static StringS
204changed (const Values &a, const Values &b)
205{
206 StringS keys;
207 for (const auto &[k, v] : a)
208 if (!b.count (k) || b.at (k) != v)
209 keys.push_back (k);
210 for (const auto &[k, v] : b)
211 if (!a.count (k))
212 keys.push_back (k);
213 return keys;
214}
215
216static StringS
217check_undo (LoopProject &lp, const Snapshot &before, const Snapshot &after)
218{
219 StringS problems;
220 auto require_notifies = [&] (const char *step, const Snapshot &a, const Snapshot &b) {
221 for (const auto &[key, value] : a.properties)
222 if (b.properties.count (key) && b.properties.at (key) != value && !lp.notified.count (key))
223 problems.push_back (string_format ("%s: %s changed without notify", step, lp.property_labels.at (key)));
224 };
225 if (before.values == after.values)
226 problems.push_back ("set: no effect");
227 if (!lp.project->can_undo())
228 problems.push_back ("set: adds no undo step");
229 require_notifies ("set", before, after);
230 lp.notified.clear();
231 lp.project->undo();
232 if (lp.project->can_undo())
233 problems.push_back ("undo: more than one undo step");
234 const Snapshot undone = lp.snapshot();
235 for (const auto &k : changed (before.values, undone.values))
236 problems.push_back ("undo: " + k + " not restored");
237 require_notifies ("undo", after, undone);
238 lp.notified.clear();
239 lp.project->redo();
240 if (lp.project->can_redo())
241 problems.push_back ("redo: more than one redo step");
242 const Snapshot redone = lp.snapshot();
243 for (const auto &k : changed (after.values, redone.values))
244 problems.push_back ("redo: " + k + " not re-applied");
245 require_notifies ("redo", undone, redone);
246 return problems;
247}
248
249// Assign the property `key` in a fresh project, undo and redo, return the problems.
250static StringS
251check_property (const String &key, int dir = +1)
252{
253 LoopProject lp;
254 const String label = key.substr (0, key.find ('.')), prop = key.substr (key.find ('.') + 1);
255 const Snapshot before = lp.snapshot();
256 const String target = lp.ref (label) + "." + prop;
257 const String old = before.properties.at (target), value = nudge (old, dir);
258 lp.notified.clear();
259 if (property_changes.count (key))
260 property_changes.at (key) (lp);
261 else
262 lp.call ("set/" + prop, lp.ref (label), value);
263 const Snapshot after = lp.snapshot();
264 if (after.properties.at (target) == old && dir > 0)
265 {
266 StringS problems = check_property (key, -1);
267 for (const auto &k : changed (before.values, after.values))
268 problems.push_back (string_format ("set %s: changes %s, the property stays %s", value, k, old));
269 return problems;
270 }
271 StringS problems = check_undo (lp, before, after);
272 if (after.properties.at (target) == old)
273 problems.push_back ("set: no effect, still " + old);
274 return problems;
275}
276
277static void
278property_loop()
279{
280 StringS failures, checks;
281 {
282 LoopProject lp;
283 for (const auto &o : lp.objects)
284 {
285 for (const auto &p : o.read_only)
286 if (!std::count (o.props.begin(), o.props.end(), p))
287 failures.push_back (o.label + "." + p + ": read-only, but there is no such property");
288 for (const auto &p : o.props)
289 {
290 const String key = o.label + "." + p;
291 // Read-only properties are only compared.
292 if (o.assign && !std::count (o.read_only.begin(), o.read_only.end(), p))
293 {
294 if (property_changes.count (key) || !nudge (lp.call ("get/" + p, o.self), +1).empty())
295 checks.push_back (key);
296 else
297 failures.push_back (key + ": add a test lambda to property_changes");
298 }
299 }
300 }
301 }
302 for (const auto &[key, reason] : exempt_properties)
303 if (!std::count (checks.begin(), checks.end(), key))
304 failures.push_back (key + ": not assigned, remove it from exempt_properties");
305 for (const auto &[key, change] : property_changes)
306 if (!std::count (checks.begin(), checks.end(), key))
307 failures.push_back (key + ": not assigned, remove it from property_changes");
308 for (const auto &key : checks)
309 {
310 const StringS problems = check_property (key);
311 if (!exempt_properties.count (key))
312 for (const auto &p : problems)
313 failures.push_back (key + ": " + p);
314 else if (problems.empty())
315 failures.push_back (key + ": passes, remove it from exempt_properties");
316 }
317 for (const auto &f : failures)
318 printerr ("%s\n", f);
319 TASSERT (failures.empty());
320}
321TEST_ADD (property_loop);
322
323static const std::map<String,Change> method_checks = {
324 { "Project.create_track", [] (LoopProject &lp) { lp.call ("create_track", lp.ref ("Project")); } },
325 { "Project.group_undo", [] (LoopProject &lp) {
326 lp.call ("group_undo", lp.ref ("Project"), "\"Rename Clips\"");
327 lp.call ("set/name", lp.ref ("MidiClip"), "\"Renamed MIDI\"");
328 lp.call ("set/name", lp.ref ("AudioClip"), "\"Renamed Audio\"");
329 lp.call ("ungroup_undo", lp.ref ("Project"));
330 } },
331 { "Track.create_midi_clip", [] (LoopProject &lp) {
332 lp.call ("create_midi_clip", lp.ref ("Track"), "\"New MIDI Clip\",1,2");
333 } },
334 { "Track.create_audio_clip", [] (LoopProject &lp) {
335 lp.call ("create_audio_clip", lp.ref ("Track"), "\"New Audio Clip\",8,2");
336 } },
337 { "Track.create_plugin", [] (LoopProject &lp) { lp.call ("create_plugin", lp.ref ("Track"), "\"4osc\""); } },
338 { "Gadget.remove_self/Track", [] (LoopProject &lp) { lp.call ("remove_self", lp.ref ("Track")); } },
339 { "Gadget.remove_self/MidiClip", [] (LoopProject &lp) { lp.call ("remove_self", lp.ref ("MidiClip")); } },
340 { "Gadget.remove_self/AudioClip", [] (LoopProject &lp) { lp.call ("remove_self", lp.ref ("AudioClip")); } },
341 { "Gadget.remove_self/Plugin", [] (LoopProject &lp) { lp.call ("remove_self", lp.ref ("Plugin")); } },
342};
343
344static const std::map<String,String> exempt_methods = {
345 { "Clip.change_batch", "Special note-editing flow, deferred to its planned rewrite in #141." },
346 { "Track.create_audio_clip", "Audio clips have no API list, so the loop sees no change." },
347 { "Gadget.remove_self/Track", "Audio clip handles cannot be reacquired on undo." },
348 { "Gadget.remove_self/AudioClip", "Audio clips have no API list, so restored clips cannot be reacquired." },
349 { "Gadget.remove_self/Plugin", "Undo reuses the plugin's invalidated API handle." },
350 { "Emittable.emit_event", "Event delivery, not project data." },
351 { "Emittable.emit_notify", "Event delivery, not project data." },
352 { "Emittable.js_trigger", "Event subscription, not project data." },
353 { "Gadget.type_nick", "Type query." },
354 { "Gadget.list_properties", "Property metadata query." },
355 { "Gadget.access_property", "Property handle query." },
356 { "Gadget.access_properties", "Property handle query." },
357 { "Gadget.get_value", "Property value query." },
358 { "Gadget.set_value", "Uses Property handles; these objects expose no writable Property handles yet." },
359 { "Gadget.read_only_properties", "Property metadata query." },
360 { "Gadget.set_data", "Session data, intentionally outside the Edit and undo." },
361 { "Gadget.get_data", "Session data query." },
362 { "Device.device_info", "Device metadata query." },
363 { "Plugin.plugin_type", "Plugin type query." },
364 { "Clip.list_all_notes", "Query, compared as notes after every change." },
365 { "Clip.telemetry", "Telemetry memory location query." },
366 { "Track.is_master", "Track type query." },
367 { "Track.is_control_track", "Track type query." },
368 { "Track.list_plugins", "Query, compared after every change." },
369 { "Track.access_device", "Device handle query, currently returns nullptr." },
370 { "Track.create_monitor", "Unimplemented, currently returns nullptr." },
371 { "Track.telemetry", "Telemetry memory location query." },
372 { "Monitor.get_output", "Output device query." },
373 { "Monitor.get_ochannel", "Output channel query." },
374 { "Monitor.get_mix_freq", "Sample rate query." },
375 { "Monitor.get_frame_duration", "Frame duration query." },
376 { "Project.discard", "Project lifetime, not an undoable edit." },
377 { "Project.start_playback", "Transport state, playback has no undo." },
378 { "Project.pause_playback", "Transport state, playback has no undo." },
379 { "Project.stop_playback", "Transport state, playback has no undo." },
380 { "Project.all_tracks", "Query, compared after every change." },
381 { "Project.master_track", "Master track handle query." },
382 { "Project.save_project", "File I/O, covered by step 7." },
383 { "Project.saved_filename", "File name query." },
384 { "Project.load_project", "File I/O, covered by step 7." },
385 { "Project.telemetry", "Telemetry memory location query." },
386 { "Project.group_undo", "Clip setters and change_batch start their own transactions, splitting explicit undo groups." },
387 { "Project.ungroup_undo", "Undo history control, not a project mutation." },
388 { "Project.undo", "Undo history control, exercised by the loop." },
389 { "Project.can_undo", "Undo history query, exercised by the loop." },
390 { "Project.redo", "Undo history control, exercised by the loop." },
391 { "Project.can_redo", "Undo history query, exercised by the loop." },
392 { "Project.length", "Query, read-only property coverage belongs to step 6." },
393 { "Project.match_serialized", "Serialized state query." },
394};
395
396template<class T, class Base> static void
397list_methods (const String &cls, std::set<String> &methods)
398{
400 for (const auto &name : Jsonipc::Class<T>::list_methods())
401 if (name.find ('/') == name.npos && !std::count (inherited.begin(), inherited.end(), name))
402 methods.insert (cls + "." + name);
403}
404
405static void
406method_loop()
407{
408 std::set<String> methods;
409 // Server, Property and ResourceCrawler hold no project data and are exempt as classes.
410 list_methods<Emittable, SharedBase> ("Emittable", methods);
411 list_methods<Object, Emittable> ("Object", methods);
412 list_methods<Gadget, Object> ("Gadget", methods);
413 list_methods<Device, Gadget> ("Device", methods);
414 list_methods<Project, Device> ("Project", methods);
415 list_methods<Track, Device> ("Track", methods);
416 list_methods<Clip, Gadget> ("Clip", methods);
417 list_methods<Plugin, Device> ("Plugin", methods);
418 list_methods<Monitor, Gadget> ("Monitor", methods);
419 StringS failures;
420 for (const auto &key : methods)
421 if (!method_checks.count (key) && !exempt_methods.count (key) &&
422 !std::any_of (method_checks.begin(), method_checks.end(), [&] (const auto &entry) {
423 return string_startswith (entry.first, key + "/");
424 }))
425 failures.push_back (key + ": add a test lambda to method_checks or an exempt_methods line with a reason");
426 for (const auto &[key, reason] : exempt_methods)
427 if ((!methods.count (key) && !method_checks.count (key)) || reason.empty())
428 failures.push_back (key + ": remove the stale exemption or give a reason");
429 for (const auto &[key, change] : method_checks)
430 {
431 if (!methods.count (key.substr (0, key.find ('/'))) || !change)
432 {
433 failures.push_back (key + ": no such method or no test lambda");
434 continue;
435 }
436 LoopProject lp;
437 const Snapshot before = lp.snapshot();
438 lp.notified.clear();
439 change (lp);
440 const Snapshot after = lp.snapshot();
441 const StringS problems = check_undo (lp, before, after);
442 if (!exempt_methods.count (key))
443 for (const auto &problem : problems)
444 failures.push_back (key + ": " + problem);
445 else if (problems.empty())
446 failures.push_back (key + ": passes, remove it from exempt_methods");
447 }
448 for (const auto &failure : failures)
449 printerr ("%s\n", failure);
450 TASSERT (failures.empty());
451}
452TEST_ADD (method_loop);
453
454} // Anon
T any_of(T... args)
T begin(T... args)
Base type for classes with Property interfaces.
Definition api.hh:166
Maps C++ shared_ptr instances to JSON-wrapped objects with unique IDs, supporting polymorphic upcasti...
Definition jsonipc.hh:539
Keep track of temporary instances during IpcDispatcher::dispatch_message().
Definition jsonipc.hh:163
T count(T... args)
T empty(T... args)
T end(T... args)
T find(T... args)
isdigit
The Anklang C++ API namespace.
Definition api.hh:8
std::string string_format(const char *format, const Args &...args) __attribute__((__format__(__printf__
Format a string similar to sprintf(3) with support for std::string and std::ostringstream convertible...
double string_to_double(const String &string)
Parse a double from a string, trying locale specific characters and POSIX/C formatting.
Definition strings.cc:674
uint32_t uint
Provide 'uint' as convenience type.
Definition cxxaux.hh:17
bool string_startswith(const String &string, const String &fragment)
Returns whether string starts with fragment.
Definition strings.cc:846
read
T push_back(T... args)
T ref(T... args)
T size(T... args)
Structure for callback based notifications.
Definition value.hh:112
static std::vector< std::string > list_methods()
List the method names registered for T and its base classes, accessors as "get/name" and "set/name".
Definition jsonipc.hh:1482
T substr(T... args)
#define TASSERT(cond)
Unconditional test assertion, enters breakpoint if not fullfilled.
Definition testing.hh:23
#define TEST_ADD(fun)
Register a function to run as part of the unit test suite.
Definition testing.hh:30
T to_string(T... args)