Region Activation tests
This commit is contained in:
parent
fbefc2c84e
commit
70923b1478
4 changed files with 293 additions and 8 deletions
|
|
@ -76,7 +76,9 @@ bool sfz::Region::parseOpcode(const Opcode& opcode)
|
||||||
case hash("hibend"): setRangeEndFromOpcode(opcode, bendRange, Default::bendRange); break;
|
case hash("hibend"): setRangeEndFromOpcode(opcode, bendRange, Default::bendRange); break;
|
||||||
case hash("locc"):
|
case hash("locc"):
|
||||||
if (opcode.parameter)
|
if (opcode.parameter)
|
||||||
|
{
|
||||||
setRangeStartFromOpcode(opcode, ccConditions[*opcode.parameter], Default::ccRange);
|
setRangeStartFromOpcode(opcode, ccConditions[*opcode.parameter], Default::ccRange);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case hash("hicc"):
|
case hash("hicc"):
|
||||||
if (opcode.parameter)
|
if (opcode.parameter)
|
||||||
|
|
@ -84,10 +86,19 @@ bool sfz::Region::parseOpcode(const Opcode& opcode)
|
||||||
break;
|
break;
|
||||||
case hash("sw_lokey"): setRangeStartFromOpcode(opcode, keyswitchRange, Default::keyRange); break;
|
case hash("sw_lokey"): setRangeStartFromOpcode(opcode, keyswitchRange, Default::keyRange); break;
|
||||||
case hash("sw_hikey"): setRangeEndFromOpcode(opcode, keyswitchRange, Default::keyRange); break;
|
case hash("sw_hikey"): setRangeEndFromOpcode(opcode, keyswitchRange, Default::keyRange); break;
|
||||||
case hash("sw_last"): setValueFromOpcode(opcode, keyswitch, Default::keyRange); break;
|
case hash("sw_last"):
|
||||||
case hash("sw_down"): setValueFromOpcode(opcode, keyswitchDown, Default::keyRange); break;
|
setValueFromOpcode(opcode, keyswitch, Default::keyRange);
|
||||||
|
keySwitched = false;
|
||||||
|
break;
|
||||||
|
case hash("sw_down"):
|
||||||
|
setValueFromOpcode(opcode, keyswitchDown, Default::keyRange);
|
||||||
|
keySwitched = false;
|
||||||
|
break;
|
||||||
case hash("sw_up"): setValueFromOpcode(opcode, keyswitchUp, Default::keyRange); break;
|
case hash("sw_up"): setValueFromOpcode(opcode, keyswitchUp, Default::keyRange); break;
|
||||||
case hash("sw_previous"): setValueFromOpcode(opcode, previousNote, Default::keyRange); break;
|
case hash("sw_previous"):
|
||||||
|
setValueFromOpcode(opcode, previousNote, Default::keyRange);
|
||||||
|
previousKeySwitched = false;
|
||||||
|
break;
|
||||||
case hash("sw_vel"):
|
case hash("sw_vel"):
|
||||||
switch(hash(opcode.value))
|
switch(hash(opcode.value))
|
||||||
{
|
{
|
||||||
|
|
@ -109,7 +120,10 @@ bool sfz::Region::parseOpcode(const Opcode& opcode)
|
||||||
case hash("lorand"): setRangeStartFromOpcode(opcode, randRange, Default::randRange); break;
|
case hash("lorand"): setRangeStartFromOpcode(opcode, randRange, Default::randRange); break;
|
||||||
case hash("hirand"): setRangeEndFromOpcode(opcode, randRange, Default::randRange); break;
|
case hash("hirand"): setRangeEndFromOpcode(opcode, randRange, Default::randRange); break;
|
||||||
case hash("seq_length"): setValueFromOpcode(opcode, sequenceLength, Default::sequenceRange); break;
|
case hash("seq_length"): setValueFromOpcode(opcode, sequenceLength, Default::sequenceRange); break;
|
||||||
case hash("seq_position"): setValueFromOpcode(opcode, sequencePosition, Default::sequenceRange); break;
|
case hash("seq_position"):
|
||||||
|
setValueFromOpcode(opcode, sequencePosition, Default::sequenceRange);
|
||||||
|
sequenceSwitched = (opcode.value == "1");
|
||||||
|
break;
|
||||||
// Region logic: triggers
|
// Region logic: triggers
|
||||||
case hash("trigger"):
|
case hash("trigger"):
|
||||||
switch(hash(opcode.value))
|
switch(hash(opcode.value))
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@ struct Region
|
||||||
void registerPitchWheel(int channel, int pitch);
|
void registerPitchWheel(int channel, int pitch);
|
||||||
void registerAftertouch(int channel, uint8_t aftertouch);
|
void registerAftertouch(int channel, uint8_t aftertouch);
|
||||||
void registerTempo(float secondsPerQuarter);
|
void registerTempo(float secondsPerQuarter);
|
||||||
bool prepare();
|
|
||||||
bool isStereo() const noexcept;
|
bool isStereo() const noexcept;
|
||||||
bool parseOpcode(const Opcode& opcode);
|
bool parseOpcode(const Opcode& opcode);
|
||||||
// Sound source: sample playback
|
// Sound source: sample playback
|
||||||
|
|
@ -99,7 +98,6 @@ struct Region
|
||||||
SfzCrossfadeCurve crossfadeKeyCurve { Default::crossfadeKeyCurve };
|
SfzCrossfadeCurve crossfadeKeyCurve { Default::crossfadeKeyCurve };
|
||||||
SfzCrossfadeCurve crossfadeVelCurve { Default::crossfadeVelCurve };
|
SfzCrossfadeCurve crossfadeVelCurve { Default::crossfadeVelCurve };
|
||||||
|
|
||||||
|
|
||||||
// Performance parameters: pitch
|
// Performance parameters: pitch
|
||||||
uint8_t pitchKeycenter{Default::pitchKeycenter}; // pitch_keycenter
|
uint8_t pitchKeycenter{Default::pitchKeycenter}; // pitch_keycenter
|
||||||
int pitchKeytrack{ Default::pitchKeytrack }; // pitch_keytrack
|
int pitchKeytrack{ Default::pitchKeytrack }; // pitch_keytrack
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,7 @@ bool sfz::Synth::loadSfzFile(const std::filesystem::path& filename)
|
||||||
for (auto cc = region->keyRange.getStart(); cc <= region->keyRange.getEnd(); cc++)
|
for (auto cc = region->keyRange.getStart(); cc <= region->keyRange.getEnd(); cc++)
|
||||||
ccActivationLists[cc].push_back(*currentRegion);
|
ccActivationLists[cc].push_back(*currentRegion);
|
||||||
|
|
||||||
|
// Defaults
|
||||||
for (int ccIndex = 1; ccIndex < 128; ccIndex++)
|
for (int ccIndex = 1; ccIndex < 128; ccIndex++)
|
||||||
region->registerCC(region->channelRange.getStart(), ccIndex, ccState[ccIndex]);
|
region->registerCC(region->channelRange.getStart(), ccIndex, ccState[ccIndex]);
|
||||||
|
|
||||||
|
|
@ -163,6 +164,10 @@ bool sfz::Synth::loadSfzFile(const std::filesystem::path& filename)
|
||||||
region->registerNoteOn(region->channelRange.getStart(), *defaultSwitch, 127, 1.0);
|
region->registerNoteOn(region->channelRange.getStart(), *defaultSwitch, 127, 1.0);
|
||||||
region->registerNoteOff(region->channelRange.getStart(), *defaultSwitch, 0, 1.0);
|
region->registerNoteOff(region->channelRange.getStart(), *defaultSwitch, 0, 1.0);
|
||||||
}
|
}
|
||||||
|
region->registerPitchWheel(region->channelRange.getStart(), 0);
|
||||||
|
region->registerAftertouch(region->channelRange.getStart(), 0);
|
||||||
|
region->registerTempo(2.0f);
|
||||||
|
|
||||||
currentRegion++;
|
currentRegion++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
268
tests/RegionActivationT.cpp
Normal file
268
tests/RegionActivationT.cpp
Normal file
|
|
@ -0,0 +1,268 @@
|
||||||
|
#include "catch2/catch.hpp"
|
||||||
|
#include "../sources/Region.h"
|
||||||
|
using namespace Catch::literals;
|
||||||
|
|
||||||
|
TEST_CASE("Region activation", "Region tests")
|
||||||
|
{
|
||||||
|
sfz::Region region { };
|
||||||
|
region.parseOpcode({ "sample", "*sine" });
|
||||||
|
SECTION("Basic state")
|
||||||
|
{
|
||||||
|
region.registerCC(1, 4, 0);
|
||||||
|
REQUIRE( region.isSwitchedOn() );
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION("Single CC range")
|
||||||
|
{
|
||||||
|
region.parseOpcode({ "locc4", "56" });
|
||||||
|
region.parseOpcode({ "hicc4", "59" });
|
||||||
|
region.registerCC(1, 4, 0);
|
||||||
|
REQUIRE( !region.isSwitchedOn() );
|
||||||
|
region.registerCC(1, 4, 57);
|
||||||
|
REQUIRE( region.isSwitchedOn() );
|
||||||
|
region.registerCC(1, 4, 56);
|
||||||
|
REQUIRE( region.isSwitchedOn() );
|
||||||
|
region.registerCC(1, 4, 59);
|
||||||
|
REQUIRE( region.isSwitchedOn() );
|
||||||
|
region.registerCC(1, 4, 43);
|
||||||
|
REQUIRE( !region.isSwitchedOn() );
|
||||||
|
region.registerCC(1, 4, 65);
|
||||||
|
REQUIRE( !region.isSwitchedOn() );
|
||||||
|
region.registerCC(1, 6, 57);
|
||||||
|
REQUIRE( !region.isSwitchedOn() );
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION("Multiple CC ranges")
|
||||||
|
{
|
||||||
|
region.parseOpcode({ "locc4", "56" });
|
||||||
|
region.parseOpcode({ "hicc4", "59" });
|
||||||
|
region.parseOpcode({ "locc54", "18" });
|
||||||
|
region.parseOpcode({ "hicc54", "27" });
|
||||||
|
region.registerCC(1, 4, 0);
|
||||||
|
region.registerCC(1, 54, 0);
|
||||||
|
REQUIRE( !region.isSwitchedOn() );
|
||||||
|
region.registerCC(1, 4, 57);
|
||||||
|
REQUIRE( !region.isSwitchedOn() );
|
||||||
|
region.registerCC(1, 54, 19);
|
||||||
|
REQUIRE( region.isSwitchedOn() );
|
||||||
|
region.registerCC(1, 54, 18);
|
||||||
|
REQUIRE( region.isSwitchedOn() );
|
||||||
|
region.registerCC(1, 54, 27);
|
||||||
|
REQUIRE( region.isSwitchedOn() );
|
||||||
|
region.registerCC(1, 4, 56);
|
||||||
|
REQUIRE( region.isSwitchedOn() );
|
||||||
|
region.registerCC(1, 4, 59);
|
||||||
|
REQUIRE( region.isSwitchedOn() );
|
||||||
|
region.registerCC(1, 54, 2);
|
||||||
|
REQUIRE( !region.isSwitchedOn() );
|
||||||
|
region.registerCC(1, 54, 26);
|
||||||
|
REQUIRE( region.isSwitchedOn() );
|
||||||
|
region.registerCC(1, 4, 65);
|
||||||
|
REQUIRE( !region.isSwitchedOn() );
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION("Bend ranges")
|
||||||
|
{
|
||||||
|
region.parseOpcode({ "lobend", "56" });
|
||||||
|
region.parseOpcode({ "hibend", "243" });
|
||||||
|
region.registerPitchWheel(1, 0);
|
||||||
|
REQUIRE( !region.isSwitchedOn() );
|
||||||
|
region.registerPitchWheel(1, 56);
|
||||||
|
REQUIRE( region.isSwitchedOn() );
|
||||||
|
region.registerPitchWheel(1, 243);
|
||||||
|
REQUIRE( region.isSwitchedOn() );
|
||||||
|
region.registerPitchWheel(1, 245);
|
||||||
|
REQUIRE( !region.isSwitchedOn() );
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION("Aftertouch ranges")
|
||||||
|
{
|
||||||
|
region.parseOpcode({ "lochanaft", "56" });
|
||||||
|
region.parseOpcode({ "hichanaft", "68" });
|
||||||
|
region.registerAftertouch(1, 0);
|
||||||
|
REQUIRE( !region.isSwitchedOn() );
|
||||||
|
region.registerAftertouch(1, 56);
|
||||||
|
REQUIRE( region.isSwitchedOn() );
|
||||||
|
region.registerAftertouch(1, 68);
|
||||||
|
REQUIRE( region.isSwitchedOn() );
|
||||||
|
region.registerAftertouch(1, 98);
|
||||||
|
REQUIRE( !region.isSwitchedOn() );
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION("BPM ranges")
|
||||||
|
{
|
||||||
|
region.parseOpcode({ "lobpm", "56" });
|
||||||
|
region.parseOpcode({ "hibpm", "68" });
|
||||||
|
region.registerTempo(2.0f);
|
||||||
|
REQUIRE( !region.isSwitchedOn() );
|
||||||
|
region.registerTempo(0.90f);
|
||||||
|
REQUIRE( region.isSwitchedOn() );
|
||||||
|
region.registerTempo(1.01f);
|
||||||
|
REQUIRE( region.isSwitchedOn() );
|
||||||
|
region.registerTempo(1.1f);
|
||||||
|
REQUIRE( !region.isSwitchedOn() );
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: add keyswitches
|
||||||
|
SECTION("Keyswitches: sw_last")
|
||||||
|
{
|
||||||
|
region.parseOpcode({ "sw_last", "40" });
|
||||||
|
REQUIRE( !region.isSwitchedOn() );
|
||||||
|
region.registerNoteOn(1, 40, 64, 0.5f);
|
||||||
|
REQUIRE( region.isSwitchedOn() );
|
||||||
|
region.registerNoteOff(1, 40, 64, 0.5f);
|
||||||
|
REQUIRE( region.isSwitchedOn() );
|
||||||
|
region.registerNoteOn(1, 41, 64, 0.5f);
|
||||||
|
REQUIRE( !region.isSwitchedOn() );
|
||||||
|
region.registerNoteOff(1, 41, 0, 0.5f);
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION("Keyswitches: sw_last with non-default keyswitch range")
|
||||||
|
{
|
||||||
|
region.parseOpcode({ "sw_lokey", "30" });
|
||||||
|
region.parseOpcode({ "sw_hikey", "50" });
|
||||||
|
region.parseOpcode({ "sw_last", "40" });
|
||||||
|
REQUIRE( !region.isSwitchedOn() );
|
||||||
|
region.registerNoteOn(1, 60, 64, 0.5f);
|
||||||
|
REQUIRE( !region.isSwitchedOn() );
|
||||||
|
region.registerNoteOff(1, 60, 0, 0.5f);
|
||||||
|
REQUIRE( !region.isSwitchedOn() );
|
||||||
|
region.registerNoteOn(1, 40, 64, 0.5f);
|
||||||
|
REQUIRE( region.isSwitchedOn() );
|
||||||
|
region.registerNoteOff(1, 40, 0, 0.5f);
|
||||||
|
REQUIRE( region.isSwitchedOn() );
|
||||||
|
region.registerNoteOn(1, 60, 64, 0.5f);
|
||||||
|
REQUIRE( region.isSwitchedOn() );
|
||||||
|
region.registerNoteOff(1, 60, 0, 0.5f);
|
||||||
|
region.registerNoteOn(1, 41, 64, 0.5f);
|
||||||
|
REQUIRE( !region.isSwitchedOn() );
|
||||||
|
region.registerNoteOff(1, 41, 0, 0.5f);
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION("Keyswitches: sw_down with non-default keyswitch range")
|
||||||
|
{
|
||||||
|
region.parseOpcode({ "sw_lokey", "30" });
|
||||||
|
region.parseOpcode({ "sw_hikey", "50" });
|
||||||
|
region.parseOpcode({ "sw_down", "40" });
|
||||||
|
REQUIRE( !region.isSwitchedOn() );
|
||||||
|
region.registerNoteOn(1, 60, 64, 0.5f);
|
||||||
|
REQUIRE( !region.isSwitchedOn() );
|
||||||
|
region.registerNoteOff(1, 60, 0, 0.5f);
|
||||||
|
REQUIRE( !region.isSwitchedOn() );
|
||||||
|
region.registerNoteOn(1, 40, 64, 0.5f);
|
||||||
|
REQUIRE( region.isSwitchedOn() );
|
||||||
|
region.registerNoteOff(1, 40, 0, 0.5f);
|
||||||
|
REQUIRE( !region.isSwitchedOn() );
|
||||||
|
region.registerNoteOn(1, 60, 64, 0.5f);
|
||||||
|
REQUIRE( !region.isSwitchedOn() );
|
||||||
|
region.registerNoteOff(1, 60, 0, 0.5f);
|
||||||
|
region.registerNoteOn(1, 41, 64, 0.5f);
|
||||||
|
REQUIRE( !region.isSwitchedOn() );
|
||||||
|
region.registerNoteOff(1, 41, 0, 0.5f);
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION("Keyswitches: sw_up with non-default keyswitch range")
|
||||||
|
{
|
||||||
|
region.parseOpcode({ "sw_lokey", "30" });
|
||||||
|
region.parseOpcode({ "sw_hikey", "50" });
|
||||||
|
region.parseOpcode({ "sw_up", "40" });
|
||||||
|
REQUIRE( region.isSwitchedOn() );
|
||||||
|
region.registerNoteOn(1, 40, 64, 0.5f);
|
||||||
|
REQUIRE( !region.isSwitchedOn() );
|
||||||
|
region.registerNoteOff(1, 40, 0, 0.5f);
|
||||||
|
REQUIRE( region.isSwitchedOn() );
|
||||||
|
region.registerNoteOn(1, 41, 64, 0.5f);
|
||||||
|
REQUIRE( region.isSwitchedOn() );
|
||||||
|
region.registerNoteOn(1, 40, 64, 0.5f);
|
||||||
|
REQUIRE( !region.isSwitchedOn() );
|
||||||
|
region.registerNoteOff(1, 40, 0, 0.5f);
|
||||||
|
region.registerNoteOff(1, 41, 0, 0.5f);
|
||||||
|
REQUIRE( region.isSwitchedOn() );
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION("Keyswitches: sw_previous")
|
||||||
|
{
|
||||||
|
region.parseOpcode({ "sw_previous", "40" });
|
||||||
|
REQUIRE( !region.isSwitchedOn() );
|
||||||
|
region.registerNoteOn(1, 40, 64, 0.5f);
|
||||||
|
REQUIRE( region.isSwitchedOn() );
|
||||||
|
region.registerNoteOff(1, 40, 0, 0.5f);
|
||||||
|
REQUIRE( region.isSwitchedOn() );
|
||||||
|
region.registerNoteOn(1, 41, 64, 0.5f);
|
||||||
|
REQUIRE( !region.isSwitchedOn() );
|
||||||
|
region.registerNoteOn(1, 40, 64, 0.5f);
|
||||||
|
REQUIRE( region.isSwitchedOn() );
|
||||||
|
region.registerNoteOff(1, 40, 0, 0.5f);
|
||||||
|
region.registerNoteOff(1, 41, 0, 0.5f);
|
||||||
|
REQUIRE( region.isSwitchedOn() );
|
||||||
|
region.registerNoteOn(1, 41, 64, 0.5f);
|
||||||
|
REQUIRE( !region.isSwitchedOn() );
|
||||||
|
region.registerNoteOff(1, 41, 0, 0.5f);
|
||||||
|
REQUIRE( !region.isSwitchedOn() );
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION("Sequences: length 2, default position")
|
||||||
|
{
|
||||||
|
region.parseOpcode({ "seq_length", "2" });
|
||||||
|
region.parseOpcode({ "seq_position", "1" });
|
||||||
|
region.parseOpcode({ "key", "40" });
|
||||||
|
REQUIRE( region.isSwitchedOn() );
|
||||||
|
region.registerNoteOn(1, 40, 64, 0.5f);
|
||||||
|
REQUIRE( !region.isSwitchedOn() );
|
||||||
|
region.registerNoteOff(1, 40, 0, 0.5f);
|
||||||
|
REQUIRE( !region.isSwitchedOn() );
|
||||||
|
region.registerNoteOn(1, 40, 64, 0.5f);
|
||||||
|
REQUIRE( region.isSwitchedOn() );
|
||||||
|
region.registerNoteOff(1, 40, 0, 0.5f);
|
||||||
|
REQUIRE( region.isSwitchedOn() );
|
||||||
|
region.registerNoteOn(1, 40, 64, 0.5f);
|
||||||
|
REQUIRE( !region.isSwitchedOn() );
|
||||||
|
region.registerNoteOff(1, 40, 0, 0.5f);
|
||||||
|
REQUIRE( !region.isSwitchedOn() );
|
||||||
|
|
||||||
|
}
|
||||||
|
SECTION("Sequences: length 2, position 2")
|
||||||
|
{
|
||||||
|
region.parseOpcode({ "seq_length", "2" });
|
||||||
|
region.parseOpcode({ "seq_position", "2" });
|
||||||
|
region.parseOpcode({ "key", "40" });
|
||||||
|
REQUIRE( !region.isSwitchedOn() );
|
||||||
|
region.registerNoteOn(1, 40, 64, 0.5f);
|
||||||
|
REQUIRE( region.isSwitchedOn() );
|
||||||
|
region.registerNoteOff(1, 40, 0, 0.5f);
|
||||||
|
REQUIRE( region.isSwitchedOn() );
|
||||||
|
region.registerNoteOn(1, 40, 64, 0.5f);
|
||||||
|
REQUIRE( !region.isSwitchedOn() );
|
||||||
|
region.registerNoteOff(1, 40, 0, 0.5f);
|
||||||
|
REQUIRE( !region.isSwitchedOn() );
|
||||||
|
region.registerNoteOn(1, 40, 64, 0.5f);
|
||||||
|
REQUIRE( region.isSwitchedOn() );
|
||||||
|
region.registerNoteOff(1, 40, 0, 0.5f);
|
||||||
|
REQUIRE( region.isSwitchedOn() );
|
||||||
|
|
||||||
|
}
|
||||||
|
SECTION("Sequences: length 3, position 2")
|
||||||
|
{
|
||||||
|
region.parseOpcode({ "seq_length", "3" });
|
||||||
|
region.parseOpcode({ "seq_position", "2" });
|
||||||
|
region.parseOpcode({ "key", "40" });
|
||||||
|
REQUIRE( !region.isSwitchedOn() );
|
||||||
|
region.registerNoteOn(1, 40, 64, 0.5f);
|
||||||
|
REQUIRE( region.isSwitchedOn() );
|
||||||
|
region.registerNoteOff(1, 40, 0, 0.5f);
|
||||||
|
REQUIRE( region.isSwitchedOn() );
|
||||||
|
region.registerNoteOn(1, 40, 64, 0.5f);
|
||||||
|
REQUIRE( !region.isSwitchedOn() );
|
||||||
|
region.registerNoteOff(1, 40, 0, 0.5f);
|
||||||
|
REQUIRE( !region.isSwitchedOn() );
|
||||||
|
region.registerNoteOn(1, 40, 64, 0.5f);
|
||||||
|
REQUIRE( !region.isSwitchedOn() );
|
||||||
|
region.registerNoteOff(1, 40, 0, 0.5f);
|
||||||
|
REQUIRE( !region.isSwitchedOn() );
|
||||||
|
region.registerNoteOn(1, 40, 64, 0.5f);
|
||||||
|
REQUIRE( region.isSwitchedOn() );
|
||||||
|
region.registerNoteOff(1, 40, 0, 0.5f);
|
||||||
|
REQUIRE( region.isSwitchedOn() );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Add table
Reference in a new issue