<commit_before>#!/usr/bin/env node
"use strict";

process.on("unhandledRejection", exn => { throw exn; });

var TIMEOUT_EXTRA_FACTOR = +process.env.TIMEOUT_EXTRA_FACTOR || 1;
var MAX_PARALLEL_TESTS = +process.env.MAX_PARALLEL_TESTS || 4;
var TEST_NAME = process.env.TEST_NAME;

try
{
    var V86 = require("../../build/libv86.js").V86;
}
catch(e)
{
    console.error("Failed to import build/libv86.js. Run `make build/libv86.js first.");
    process.exit(1);
}

var cluster = require("cluster");
var os = require("os");
var fs = require("fs");
var root_path = __dirname + "/../..";

var SCREEN_WIDTH = 80;

function get_line(screen, y)
{
    return screen.subarray(y * SCREEN_WIDTH, (y + 1) * SCREEN_WIDTH);
}

function line_to_text(screen, y)
{
    return bytearray_to_string(get_line(screen, y));
}

function string_to_bytearray(str)
{
    return new Uint8Array(str.split("").map(chr => chr.charCodeAt(0)));
}

function bytearray_to_string(arr)
{
    return String.fromCharCode.apply(String, arr);
}

function screen_to_text(s)
{
    var result = [];
    result.push("+==================================== SCREEN ====================================+");

    for(var i = 0; i < 25; i++)
    {
        var line = line_to_text(s, i);
        result.push("|" + line + "|");
    }

    result.push("+================================================================================+");

    return result.join("\n");
}

function send_work_to_worker(worker, message)
{
    if(current_test < tests.length)
    {
        worker.send(tests[current_test]);
        current_test++;
    }
    else
    {
        worker.disconnect();
    }
}

if(cluster.isMaster)
{
    var tests = [
        {
            name: "FreeDOS boot",
            fda: root_path + "/images/freedos722.img",
            timeout: 10,
            expected_texts: [
                "Welcome to FreeDOS",
            ],
        },
        {
            name: "FreeDOS boot with Bochs BIOS",
            fda: root_path + "/images/freedos722.img",
            timeout: 10,
            alternative_bios: true,
            expected_texts: [
                "Welcome to FreeDOS",
            ],
        },
        {
            name: "Windows 1.01 boot",
            fda: root_path + "/images/windows101.img",
            timeout: 10,
            expect_graphical_mode: true,
            expect_mouse_registered: true,
        },
        {
            name: "Sol OS",
            fda: root_path + "/images/os8.dsk",
            timeout: 20,
            expect_graphical_mode: true,
            expect_mouse_registered: true,
            actions: [
                {
                    on_text: " or press",
                    run: "\n"
                },
            ],
        },
        {
            name: "Linux",
            cdrom: root_path + "/images/linux.iso",
            timeout: 90,
            expected_texts: [
                "/root%",
                "test passed",
            ],
            actions: [
                {
                    on_text: "/root%",
                    run: "cd tests; ./test-i386 > emu.test; diff emu.test reference.test > /dev/null && echo test pas''sed || echo failed\n",
                },
            ],
        },
        //{
        //    name: "Windows 98",
        //    hda: root_path + "/images/windows98.img",
        //    timeout: 60,
        //    expect_graphical_mode: true,
        //    expect_graphical_size: [800, 600],
        //    expect_mouse_registered: true,
        //    skip_if_disk_image_missing: true,
        //},
        //{
        //    name: "Oberon",
        //    hda: root_path + "/images/oberon.dsk",
        //    fda: root_path + "/images/oberon-boot.dsk",
        //    timeout: 30,
        //    expect_graphical_mode: true,
        //    expect_mouse_registered: true,
        //},
        {
            name: "Linux 3",
            cdrom: root_path + "/images/linux3.iso",
            timeout: 200,
            expected_texts: [
                "test passed",
            ],
            actions: [
                {
                    on_text: "~%",
                    run: "head -c 10000 /dev/urandom > rand; echo test pas''sed\n"
                },
            ],
        },
        {
            name: "KolibriOS",
            fda: root_path + "/images/kolibri.img",
            timeout: 120,
            expect_graphical_mode: true,
            expect_mouse_registered: true,
        },
        {
            name: "Linux with Bochs BIOS",
            cdrom: root_path + "/images/linux.iso",
            timeout: 90,
            expected_texts: [
                "/root%",
                "test passed",
            ],
            alternative_bios: true,
            actions: [
                {
                    on_text: "/root%",
                    run: "cd tests; ./test-i386 > emu.test; diff emu.test reference.test > /dev/null && echo test pas''sed || echo failed\n",
                },
            ],
        },
        {
            name: "OpenBSD",
            fda: root_path + "/images/openbsd.img",
            timeout: 180,
            expected_texts: ["(I)nstall, (U)pgrade or (S)hell"],
        },
        {
            name: "Windows 3.0",
            skip_if_disk_image_missing: true,
            timeout: 120,
            cdrom: root_path + "/../v86-images/os/Win30.iso",
            expected_texts: [
                "Press any key to continue",
                "              **************************************************",
            ],
            expect_graphical_mode: true,
            expect_mouse_registered: true,
            actions: [
                {
                    on_text: "Press any key to continue . . .",
                    after: 1000,
                    run: "x",
                },
                {
                    on_text: "              **************************************************",
                    after: 1000,
                    run: "x",
                },
                {
                    on_text: "C> ",
                    after: 1000,
                    run: "win\n",
                },
            ],
        },
        {
            name: "FreeBSD",
            skip_if_disk_image_missing: true,
            timeout: 240,
            hda: root_path + "/../v86-images/os/freebsd.img",
            expected_texts: ["FreeBSD/i386 (nyu) (ttyv0)"],
            actions: [
                {
                    on_text: "   Autoboot in ",
                    run: "\n",
                }
            ],
        },
        // TODO: Check serial text
        {
            name: "Haiku",
            skip_if_disk_image_missing: true,
            timeout: 60 * 15,
            hda: root_path + "/../v86-images/os/haiku-nightly-anyboot.image",
            expected_serial_text: ["cx23882: init_hardware()"],
            expect_graphical_mode: true,
            expect_mouse_registered: true,
        },
        {
            name: "HelenOS",
            skip_if_disk_image_missing: true,
            timeout: 2 * 60,
            cdrom: root_path + "/../v86-images/os/HelenOS-0.5.0-ia32.iso",
            expect_graphical_mode: true,
            expect_mouse_registered: true,
        },
        {
            name: "Minix",
            skip_if_disk_image_missing: true,
            timeout: 60,
            hda: root_path + "/../v86-images/os/minix2hd.img",
            actions: [
                {
                    on_text: "    =  Start Minix",
                    run: "=",
                },
                {
                    on_text: "noname login:",
                    run: "root\n",
                },
            ],
            expected_texts: ["noname login:", "# "],
        },
        {
            name: "Mobius",
            skip_if_disk_image_missing: true,
            timeout: 2 * 60,
            fda: root_path + "/../v86-images/os/mobius-fd-release5.img",
            expect_graphical_mode: true,
            actions: [
                {
                    on_text: "   The highlighted entry will be booted automatically",
                    run: "\n",
                },
            ],
        },
    ];

    if(TEST_NAME)
    {
        tests = tests.filter(test => test.name === TEST_NAME);
    }

    var nr_of_cpus = Math.min(Math.round(os.cpus().length / 2) || 1, tests.length, MAX_PARALLEL_TESTS);
    console.log("Using %d cpus", nr_of_cpus);

    var current_test = 0;

    for(var i = 0; i < nr_of_cpus; i++)
    {
        var worker = cluster.fork();

        worker.on("message", send_work_to_worker.bind(null, worker));
        worker.on("online", send_work_to_worker.bind(null, worker));

        worker.on("exit", function(code, signal)
        {
            if(code !== 0)
            {
                process.exit(code);
            }
        });

        worker.on("error", function(error)
        {
            console.error("Worker error: ", error.toString(), error);
            process.exit(1);
        });
    }
}
else
{
    cluster.worker.on("message", function(test_case)
    {
        run_test(test_case, function()
        {
            process.send("I'm done");
        });
    });
}

function bytearray_starts_with(arr, search)
{
    for(var i = 0; i < search.length; i++)
    {
        if(arr[i] !== search[i])
        {
            return false;
        }
    }
    return true;
}

function run_test(test, done)
{
    console.log("Starting test: %s", test.name);

    let image = test.fda || test.hda || test.cdrom;
    console.assert(image, "Bootable drive expected");

    if(!fs.existsSync(image))
    {
        if(test.skip_if_disk_image_missing)
        {
            console.warn("Missing disk image: " + image + ", test skipped");
            console.warn();

            done();
            return;
        }
        else
        {
            console.warn("Missing disk image: " + image);
            process.exit(1);
        }
    }

    if(test.alternative_bios)
    {
        var bios = root_path + "/bios/bochs-bios.bin";
        var vga_bios = root_path + "/bios/bochs-vgabios.bin";
    }
    else
    {
        var bios = root_path + "/bios/seabios.bin";
        var vga_bios = root_path + "/bios/vgabios.bin";
    }

    var settings = {
        bios: { url: bios },
        vga_bios: { url: vga_bios },
        autostart: true,
        memory_size: 128 * 1024 * 1024,
        log_level: 0,
    };

    if(test.cdrom)
    {
        settings.cdrom = { url: test.cdrom };
    }
    if(test.fda)
    {
        settings.fda = { url: test.fda };
    }
    if(test.hda)
    {
        settings.hda = { url: test.hda, async: true, };
    }

    if(test.expected_texts)
    {
        test.expected_texts = test.expected_texts.map(string_to_bytearray);
    }
    else
    {
        test.expected_texts = [];
    }

    var emulator = new V86(settings);
    var screen = new Uint8Array(SCREEN_WIDTH * 25);

    function check_text_test_done()
    {
        return test.expected_texts.length === 0;
    }

    var mouse_test_done = false;
    function check_mouse_test_done()
    {
        return !test.expect_mouse_registered || mouse_test_done;
    }

    var graphical_test_done = false;
    var size_test_done = false;
    function check_grapical_test_done()
    {
        return !test.expect_graphical_mode || (graphical_test_done && (!test.expect_graphical_size ||  size_test_done));
    }

    var test_start = Date.now();

    var timeout_seconds = test.timeout * TIMEOUT_EXTRA_FACTOR;
    var timeout = setTimeout(check_test_done, (timeout_seconds + 1) * 1000);

    var on_text = [];
    var stopped = false;

    function check_test_done()
    {
        if(stopped)
        {
            return;
        }

        if(check_text_test_done() && check_mouse_test_done() && check_grapical_test_done())
        {
            var end = Date.now();

            clearTimeout(timeout);
            stopped = true;

            emulator.stop();

            console.warn("Passed test: %s (took %ds)", test.name, (end - test_start) / 1000);
            console.warn();

            done();
        }
        else if(Date.now() >= test_start + timeout_seconds * 1000)
        {
            clearTimeout(timeout);
            stopped = true;

            emulator.stop();
            emulator.destroy();

            console.warn(screen_to_text(screen));
            console.warn("Test failed: %s\n", test.name);

            if(!check_text_test_done())
            {
                console.warn('Expected text "%s" after %d seconds.', bytearray_to_string(test.expected_texts[0]), timeout_seconds);
            }

            if(!check_grapical_test_done())
            {
                console.warn("Expected graphical mode after %d seconds.", timeout_seconds);
            }

            if(!check_mouse_test_done())
            {
                console.warn("Expected mouse activation after %d seconds.", timeout_seconds);
            }

            process.exit(1);
        }
    }

    emulator.add_listener("mouse-enable", function()
    {
        mouse_test_done = true;
        check_test_done();
    });

    emulator.add_listener("screen-set-mode", function(is_graphical)
    {
        graphical_test_done = is_graphical;
        check_test_done();
    });

    emulator.add_listener("screen-set-size-graphical", function(size)
    {
        if(test.expect_graphical_size)
        {
            size_test_done = size[0] === test.expect_graphical_size[0] &&
                             size[1] === test.expect_graphical_size[1];
            check_test_done();
        }
    });

    emulator.add_listener("screen-put-char", function(chr)
    {
        var y = chr[0];
        var x = chr[1];
        var code = chr[2];
        screen[x + SCREEN_WIDTH * y] = code;

        var line = get_line(screen, y);

        if(!check_text_test_done())
        {
            let expected = test.expected_texts[0];
            if(x < expected.length && bytearray_starts_with(line, expected))
            {
                test.expected_texts.shift();
                check_test_done();
            }
        }

        if(on_text.length)
        {
            let expected = on_text[0].text;

            if(x < expected.length && bytearray_starts_with(line, expected))
            {
                var action = on_text.shift();

                if(action.after)
                {
                    setTimeout(() => emulator.keyboard_send_text(action.run), action.after);
                }
                else
                {
                    emulator.keyboard_send_text(action.run);
                }
            }
        }
    });

    test.actions && test.actions.forEach(function(action)
    {
        if(action.on_text)
        {
            on_text.push({ text: string_to_bytearray(action.on_text), run: action.run, after: action.after, });
        }
    });
}
<commit_msg>Full test: Add extra time for more stability<commit_after>#!/usr/bin/env node
"use strict";

process.on("unhandledRejection", exn => { throw exn; });

var TIMEOUT_EXTRA_FACTOR = +process.env.TIMEOUT_EXTRA_FACTOR || 1;
var MAX_PARALLEL_TESTS = +process.env.MAX_PARALLEL_TESTS || 4;
var TEST_NAME = process.env.TEST_NAME;

try
{
    var V86 = require("../../build/libv86.js").V86;
}
catch(e)
{
    console.error("Failed to import build/libv86.js. Run `make build/libv86.js first.");
    process.exit(1);
}

var cluster = require("cluster");
var os = require("os");
var fs = require("fs");
var root_path = __dirname + "/../..";

var SCREEN_WIDTH = 80;

function get_line(screen, y)
{
    return screen.subarray(y * SCREEN_WIDTH, (y + 1) * SCREEN_WIDTH);
}

function line_to_text(screen, y)
{
    return bytearray_to_string(get_line(screen, y));
}

function string_to_bytearray(str)
{
    return new Uint8Array(str.split("").map(chr => chr.charCodeAt(0)));
}

function bytearray_to_string(arr)
{
    return String.fromCharCode.apply(String, arr);
}

function screen_to_text(s)
{
    var result = [];
    result.push("+==================================== SCREEN ====================================+");

    for(var i = 0; i < 25; i++)
    {
        var line = line_to_text(s, i);
        result.push("|" + line + "|");
    }

    result.push("+================================================================================+");

    return result.join("\n");
}

function send_work_to_worker(worker, message)
{
    if(current_test < tests.length)
    {
        worker.send(tests[current_test]);
        current_test++;
    }
    else
    {
        worker.disconnect();
    }
}

if(cluster.isMaster)
{
    var tests = [
        {
            name: "FreeDOS boot",
            fda: root_path + "/images/freedos722.img",
            timeout: 10,
            expected_texts: [
                "Welcome to FreeDOS",
            ],
        },
        {
            name: "FreeDOS boot with Bochs BIOS",
            fda: root_path + "/images/freedos722.img",
            timeout: 10,
            alternative_bios: true,
            expected_texts: [
                "Welcome to FreeDOS",
            ],
        },
        {
            name: "Windows 1.01 boot",
            fda: root_path + "/images/windows101.img",
            timeout: 10,
            expect_graphical_mode: true,
            expect_mouse_registered: true,
        },
        {
            name: "Sol OS",
            fda: root_path + "/images/os8.dsk",
            timeout: 20,
            expect_graphical_mode: true,
            expect_mouse_registered: true,
            actions: [
                {
                    on_text: " or press",
                    run: "\n"
                },
            ],
        },
        {
            name: "Linux",
            cdrom: root_path + "/images/linux.iso",
            timeout: 90,
            expected_texts: [
                "/root%",
                "test passed",
            ],
            actions: [
                {
                    on_text: "/root%",
                    run: "cd tests; ./test-i386 > emu.test; diff emu.test reference.test > /dev/null && echo test pas''sed || echo failed\n",
                },
            ],
        },
        //{
        //    name: "Windows 98",
        //    hda: root_path + "/images/windows98.img",
        //    timeout: 60,
        //    expect_graphical_mode: true,
        //    expect_graphical_size: [800, 600],
        //    expect_mouse_registered: true,
        //    skip_if_disk_image_missing: true,
        //},
        //{
        //    name: "Oberon",
        //    hda: root_path + "/images/oberon.dsk",
        //    fda: root_path + "/images/oberon-boot.dsk",
        //    timeout: 30,
        //    expect_graphical_mode: true,
        //    expect_mouse_registered: true,
        //},
        {
            name: "Linux 3",
            cdrom: root_path + "/images/linux3.iso",
            timeout: 200,
            expected_texts: [
                "test passed",
            ],
            actions: [
                {
                    on_text: "~%",
                    run: "head -c 10000 /dev/urandom > rand; echo test pas''sed\n",
                    after: 1000,
                },
            ],
        },
        {
            name: "KolibriOS",
            fda: root_path + "/images/kolibri.img",
            timeout: 120,
            expect_graphical_mode: true,
            expect_mouse_registered: true,
        },
        {
            name: "Linux with Bochs BIOS",
            cdrom: root_path + "/images/linux.iso",
            timeout: 90,
            expected_texts: [
                "/root%",
                "test passed",
            ],
            alternative_bios: true,
            actions: [
                {
                    on_text: "/root%",
                    run: "cd tests; ./test-i386 > emu.test; diff emu.test reference.test > /dev/null && echo test pas''sed || echo failed\n",
                },
            ],
        },
        {
            name: "OpenBSD",
            fda: root_path + "/images/openbsd.img",
            timeout: 180,
            expected_texts: ["(I)nstall, (U)pgrade or (S)hell"],
        },
        {
            name: "Windows 3.0",
            skip_if_disk_image_missing: true,
            timeout: 5 * 60,
            cdrom: root_path + "/../v86-images/os/Win30.iso",
            expected_texts: [
                "Press any key to continue",
                "              **************************************************",
            ],
            expect_graphical_mode: true,
            expect_mouse_registered: true,
            actions: [
                {
                    on_text: "Press any key to continue . . .",
                    after: 1000,
                    run: "x",
                },
                {
                    on_text: "              **************************************************",
                    after: 1000,
                    run: "x",
                },
                {
                    on_text: "C> ",
                    after: 1000,
                    run: "win\n",
                },
            ],
        },
        {
            name: "FreeBSD",
            skip_if_disk_image_missing: true,
            timeout: 240,
            hda: root_path + "/../v86-images/os/freebsd.img",
            expected_texts: ["FreeBSD/i386 (nyu) (ttyv0)"],
            actions: [
                {
                    on_text: "   Autoboot in ",
                    run: "\n",
                }
            ],
        },
        // TODO: Check serial text
        {
            name: "Haiku",
            skip_if_disk_image_missing: true,
            timeout: 60 * 15,
            hda: root_path + "/../v86-images/os/haiku-nightly-anyboot.image",
            expected_serial_text: ["cx23882: init_hardware()"],
            expect_graphical_mode: true,
            expect_mouse_registered: true,
        },
        {
            name: "HelenOS",
            skip_if_disk_image_missing: true,
            timeout: 2 * 60,
            cdrom: root_path + "/../v86-images/os/HelenOS-0.5.0-ia32.iso",
            expect_graphical_mode: true,
            expect_mouse_registered: true,
        },
        {
            name: "Minix",
            skip_if_disk_image_missing: true,
            timeout: 60,
            hda: root_path + "/../v86-images/os/minix2hd.img",
            actions: [
                {
                    on_text: "    =  Start Minix",
                    run: "=",
                },
                {
                    on_text: "noname login:",
                    run: "root\n",
                },
            ],
            expected_texts: ["noname login:", "# "],
        },
        {
            name: "Mobius",
            skip_if_disk_image_missing: true,
            timeout: 2 * 60,
            fda: root_path + "/../v86-images/os/mobius-fd-release5.img",
            expect_graphical_mode: true,
            actions: [
                {
                    on_text: "   The highlighted entry will be booted automatically",
                    run: "\n",
                },
            ],
        },
    ];

    if(TEST_NAME)
    {
        tests = tests.filter(test => test.name === TEST_NAME);
    }

    var nr_of_cpus = Math.min(Math.round(os.cpus().length / 2) || 1, tests.length, MAX_PARALLEL_TESTS);
    console.log("Using %d cpus", nr_of_cpus);

    var current_test = 0;

    for(var i = 0; i < nr_of_cpus; i++)
    {
        var worker = cluster.fork();

        worker.on("message", send_work_to_worker.bind(null, worker));
        worker.on("online", send_work_to_worker.bind(null, worker));

        worker.on("exit", function(code, signal)
        {
            if(code !== 0)
            {
                process.exit(code);
            }
        });

        worker.on("error", function(error)
        {
            console.error("Worker error: ", error.toString(), error);
            process.exit(1);
        });
    }
}
else
{
    cluster.worker.on("message", function(test_case)
    {
        run_test(test_case, function()
        {
            process.send("I'm done");
        });
    });
}

function bytearray_starts_with(arr, search)
{
    for(var i = 0; i < search.length; i++)
    {
        if(arr[i] !== search[i])
        {
            return false;
        }
    }
    return true;
}

function run_test(test, done)
{
    console.log("Starting test: %s", test.name);

    let image = test.fda || test.hda || test.cdrom;
    console.assert(image, "Bootable drive expected");

    if(!fs.existsSync(image))
    {
        if(test.skip_if_disk_image_missing)
        {
            console.warn("Missing disk image: " + image + ", test skipped");
            console.warn();

            done();
            return;
        }
        else
        {
            console.warn("Missing disk image: " + image);
            process.exit(1);
        }
    }

    if(test.alternative_bios)
    {
        var bios = root_path + "/bios/bochs-bios.bin";
        var vga_bios = root_path + "/bios/bochs-vgabios.bin";
    }
    else
    {
        var bios = root_path + "/bios/seabios.bin";
        var vga_bios = root_path + "/bios/vgabios.bin";
    }

    var settings = {
        bios: { url: bios },
        vga_bios: { url: vga_bios },
        autostart: true,
        memory_size: 128 * 1024 * 1024,
        log_level: 0,
    };

    if(test.cdrom)
    {
        settings.cdrom = { url: test.cdrom };
    }
    if(test.fda)
    {
        settings.fda = { url: test.fda };
    }
    if(test.hda)
    {
        settings.hda = { url: test.hda, async: true, };
    }

    if(test.expected_texts)
    {
        test.expected_texts = test.expected_texts.map(string_to_bytearray);
    }
    else
    {
        test.expected_texts = [];
    }

    var emulator = new V86(settings);
    var screen = new Uint8Array(SCREEN_WIDTH * 25);

    function check_text_test_done()
    {
        return test.expected_texts.length === 0;
    }

    var mouse_test_done = false;
    function check_mouse_test_done()
    {
        return !test.expect_mouse_registered || mouse_test_done;
    }

    var graphical_test_done = false;
    var size_test_done = false;
    function check_grapical_test_done()
    {
        return !test.expect_graphical_mode || (graphical_test_done && (!test.expect_graphical_size ||  size_test_done));
    }

    var test_start = Date.now();

    var timeout_seconds = test.timeout * TIMEOUT_EXTRA_FACTOR;
    var timeout = setTimeout(check_test_done, (timeout_seconds + 1) * 1000);

    var on_text = [];
    var stopped = false;

    function check_test_done()
    {
        if(stopped)
        {
            return;
        }

        if(check_text_test_done() && check_mouse_test_done() && check_grapical_test_done())
        {
            var end = Date.now();

            clearTimeout(timeout);
            stopped = true;

            emulator.stop();

            console.warn("Passed test: %s (took %ds)", test.name, (end - test_start) / 1000);
            console.warn();

            done();
        }
        else if(Date.now() >= test_start + timeout_seconds * 1000)
        {
            clearTimeout(timeout);
            stopped = true;

            emulator.stop();
            emulator.destroy();

            console.warn(screen_to_text(screen));
            console.warn("Test failed: %s\n", test.name);

            if(!check_text_test_done())
            {
                console.warn('Expected text "%s" after %d seconds.', bytearray_to_string(test.expected_texts[0]), timeout_seconds);
            }

            if(!check_grapical_test_done())
            {
                console.warn("Expected graphical mode after %d seconds.", timeout_seconds);
            }

            if(!check_mouse_test_done())
            {
                console.warn("Expected mouse activation after %d seconds.", timeout_seconds);
            }

            process.exit(1);
        }
    }

    emulator.add_listener("mouse-enable", function()
    {
        mouse_test_done = true;
        check_test_done();
    });

    emulator.add_listener("screen-set-mode", function(is_graphical)
    {
        graphical_test_done = is_graphical;
        check_test_done();
    });

    emulator.add_listener("screen-set-size-graphical", function(size)
    {
        if(test.expect_graphical_size)
        {
            size_test_done = size[0] === test.expect_graphical_size[0] &&
                             size[1] === test.expect_graphical_size[1];
            check_test_done();
        }
    });

    emulator.add_listener("screen-put-char", function(chr)
    {
        var y = chr[0];
        var x = chr[1];
        var code = chr[2];
        screen[x + SCREEN_WIDTH * y] = code;

        var line = get_line(screen, y);

        if(!check_text_test_done())
        {
            let expected = test.expected_texts[0];
            if(x < expected.length && bytearray_starts_with(line, expected))
            {
                test.expected_texts.shift();
                check_test_done();
            }
        }

        if(on_text.length)
        {
            let expected = on_text[0].text;

            if(x < expected.length && bytearray_starts_with(line, expected))
            {
                var action = on_text.shift();

                if(action.after)
                {
                    setTimeout(() => emulator.keyboard_send_text(action.run), action.after);
                }
                else
                {
                    emulator.keyboard_send_text(action.run);
                }
            }
        }
    });

    test.actions && test.actions.forEach(function(action)
    {
        if(action.on_text)
        {
            on_text.push({ text: string_to_bytearray(action.on_text), run: action.run, after: action.after, });
        }
    });
}
<|endoftext|>


<commit_before>import forEach from 'lodash.foreach'
import {BaseError} from 'make-error'
import {EventEmitter} from 'events'

// ===================================================================

function isNotEmpty (map) {
  /* eslint no-unused-vars: 0*/

  for (let key in map) {
    return true
  }
  return false
}

const {hasOwnProperty} = Object

// ===================================================================

export class BufferAlreadyFlushed extends BaseError {
  constructor () {
    super('buffer flush already requested')
  }
}

export class DuplicateIndex extends BaseError {
  constructor (name) {
    super('there is already an index with the name ' + name)
  }
}

export class DuplicateItem extends BaseError {
  constructor (key) {
    super('there is already a item with the key ' + key)
  }
}

export class IllegalTouch extends BaseError {
  constructor (value) {
    super('only an object value can be touched (found a ' + typeof value + ')')
  }
}

export class InvalidKey extends BaseError {
  constructor (key) {
    super('invalid key of type ' + typeof key)
  }
}

export class NoSuchIndex extends BaseError {
  constructor (key) {
    super('there is no index with the name ' + name)
  }
}

export class NoSuchItem extends BaseError {
  constructor (key) {
    super('there is no item with the key ' + key)
  }
}

// -------------------------------------------------------------------

export default class Collection extends EventEmitter {
  constructor () {
    super()

    this._buffer = Object.create(null)
    this._buffering = 0
    this._indexes = Object.create(null)
    this._indexedItems = Object.create(null)
    this._items = Object.create(null)
    this._size = 0
  }

  // Overridable method used to compute the key of an item when
  // unspecified.
  //
  // Default implementation returns the `id` property.
  getKey (value) {
    return value && value.id
  }

  // -----------------------------------------------------------------
  // Properties
  // -----------------------------------------------------------------

  get all () {
    return this._items
  }

  get indexes () {
    return this._indexedItems
  }

  get size () {
    return this._size
  }

  // -----------------------------------------------------------------
  // Manipulation
  // -----------------------------------------------------------------

  add (keyOrObjectWithId, valueIfKey = undefined) {
    const [key, value] = this._resolveItem(keyOrObjectWithId, valueIfKey)
    this._assertHasNot(key)

    this._items[key] = value
    this._size++
    this._touch('add', key)
  }

  clear () {
    forEach(this._items, (_, key) => {
      delete this._items[key]
      this._size--
      this._touch('remove', key)
    })
  }

  remove (keyOrObjectWithId) {
    const [key] = this._resolveItem(keyOrObjectWithId)
    this._assertHas(key)

    delete this._items[key]
    this._size--
    this._touch('remove', key)
  }

  set (keyOrObjectWithId, valueIfKey = undefined) {
    const [key, value] = this._resolveItem(keyOrObjectWithId, valueIfKey)

    const action = this.has(key) ? 'update' : 'add'
    this._items[key] = value
    if (action === 'add') {
      this._size++
    }
    this._touch(action, key)
  }

  touch (keyOrObjectWithId) {
    const [key] = this._resolveItem(keyOrObjectWithId)
    this._assertHas(key)
    const value = this.get(key)
    if (typeof value !== 'object' || value === null) {
      throw new IllegalTouch(value)
    }

    this._touch('update', key)

    return this.get(key)
  }

  update (keyOrObjectWithId, valueIfKey = undefined) {
    const [key, value] = this._resolveItem(keyOrObjectWithId, valueIfKey)
    this._assertHas(key)

    this._items[key] = value
    this._touch('update', key)
  }

  // -----------------------------------------------------------------
  // Query
  // -----------------------------------------------------------------

  get (key, defaultValue) {
    if (this.has(key)) {
      return this._items[key]
    }

    if (arguments.length > 1) {
      return defaultValue
    }

    // Throws a NoSuchItem.
    this._assertHas(key)
  }

  has (key) {
    return hasOwnProperty.call(this._items, key)
  }

  // -----------------------------------------------------------------
  // Indexes
  // -----------------------------------------------------------------

  createIndex (name, index) {
    index._attachCollection(this)

    const {_indexes: indexes} = this
    if (hasOwnProperty.call(indexes, name)) {
      throw new DuplicateIndex(name)
    }
    indexes[name] = index
    this._indexedItems[name] = index.itemsByHash
  }

  deleteIndex (name) {
    const {_indexes: indexes} = this
    if (!hasOwnProperty.call(indexes, name)) {
      throw new NoSuchIndex(name)
    }

    const index = indexes[name]
    delete indexes[name]
    delete this._indexedItems[name]

    index._detachCollection()
  }

  // -----------------------------------------------------------------
  // Iteration
  // -----------------------------------------------------------------

  * [Symbol.iterator] () {
    const {_items: items} = this

    for (let key in items) {
      yield [key, items[key]]
    }
  }

  * keys () {
    const {_items: items} = this

    for (let key in items) {
      yield key
    }
  }

  * values () {
    const {_items: items} = this

    for (let key in items) {
      yield items[key]
    }
  }

  // -----------------------------------------------------------------
  // Events buffering
  // -----------------------------------------------------------------

  bufferEvents () {
    ++this._buffering

    let called = false
    return () => {
      if (called) {
        throw new BufferAlreadyFlushed()
      }
      called = true

      if (--this._buffering) {
        return
      }

      const data = {
        add: Object.create(null),
        remove: Object.create(null),
        update: Object.create(null)
      }

      for (let key in this._buffer) {
        data[this._buffer[key]][key] = this._items[key]
      }

      forEach(['add', 'update', 'remove'], action => {
        const items = data[action]

        if (isNotEmpty(items)) {
          this.emit(action, items)
        }
      })

      this._buffer = Object.create(null)
    }
  }

  // =================================================================

  _assertHas (key) {
    if (!this.has(key)) {
      throw new NoSuchItem(key)
    }
  }

  _assertHasNot (key) {
    if (this.has(key)) {
      throw new DuplicateItem(key)
    }
  }

  _assertValidKey (key) {
    if (!this._isValidKey(key)) {
      throw new InvalidKey(key)
    }
  }

  _isValidKey (key) {
    return typeof key === 'number' || typeof key === 'string'
  }

  _resolveItem (keyOrObjectWithId, valueIfKey = undefined) {
    if (valueIfKey !== undefined) {
      this._assertValidKey(keyOrObjectWithId)

      return [keyOrObjectWithId, valueIfKey]
    }

    if (this._isValidKey(keyOrObjectWithId)) {
      return [keyOrObjectWithId]
    }

    const key = this.getKey(keyOrObjectWithId)
    this._assertValidKey(key)

    return [key, keyOrObjectWithId]
  }

  _touch (action, key) {
    if (this._buffering === 0) {
      const flush = this.bufferEvents()

      process.nextTick(flush)
    }

    if (action === 'add') {
      this._buffer[key] = this._buffer[key] ? 'update' : 'add'
    } else if (action === 'remove') {
      if (this._buffer[key] === 'add') {
        delete this._buffer[key]
      } else {
        this._buffer[key] = 'remove'
      }
    } else { // update
      if (!this._buffer[key]) {
        this._buffer[key] = 'update'
      }
    }
  }
}
<commit_msg>Minor fixes.<commit_after>import forEach from 'lodash.foreach'
import {BaseError} from 'make-error'
import {EventEmitter} from 'events'

// ===================================================================

function isNotEmpty (map) {
  /* eslint no-unused-vars: 0*/

  for (let key in map) {
    return true
  }
  return false
}

const {hasOwnProperty} = Object

// ===================================================================

export class BufferAlreadyFlushed extends BaseError {
  constructor () {
    super('buffer flush already requested')
  }
}

export class DuplicateIndex extends BaseError {
  constructor (name) {
    super('there is already an index with the name ' + name)
  }
}

export class DuplicateItem extends BaseError {
  constructor (key) {
    super('there is already a item with the key ' + key)
  }
}

export class IllegalTouch extends BaseError {
  constructor (value) {
    super('only an object value can be touched (found a ' + typeof value + ')')
  }
}

export class InvalidKey extends BaseError {
  constructor (key) {
    super('invalid key of type ' + typeof key)
  }
}

export class NoSuchIndex extends BaseError {
  constructor (name) {
    super('there is no index with the name ' + name)
  }
}

export class NoSuchItem extends BaseError {
  constructor (key) {
    super('there is no item with the key ' + key)
  }
}

// -------------------------------------------------------------------

export default class Collection extends EventEmitter {
  constructor () {
    super()

    this._buffer = Object.create(null)
    this._buffering = 0
    this._indexes = Object.create(null)
    this._indexedItems = Object.create(null)
    this._items = Object.create(null)
    this._size = 0
  }

  // Overridable method used to compute the key of an item when
  // unspecified.
  //
  // Default implementation returns the `id` property.
  getKey (value) {
    return value && value.id
  }

  // -----------------------------------------------------------------
  // Properties
  // -----------------------------------------------------------------

  get all () {
    return this._items
  }

  get indexes () {
    return this._indexedItems
  }

  get size () {
    return this._size
  }

  // -----------------------------------------------------------------
  // Manipulation
  // -----------------------------------------------------------------

  add (keyOrObjectWithId, valueIfKey = undefined) {
    const [key, value] = this._resolveItem(keyOrObjectWithId, valueIfKey)
    this._assertHasNot(key)

    this._items[key] = value
    this._size++
    this._touch('add', key)
  }

  clear () {
    forEach(this._items, (_, key) => {
      delete this._items[key]
      this._size--
      this._touch('remove', key)
    })
  }

  remove (keyOrObjectWithId) {
    const [key] = this._resolveItem(keyOrObjectWithId)
    this._assertHas(key)

    delete this._items[key]
    this._size--
    this._touch('remove', key)
  }

  set (keyOrObjectWithId, valueIfKey = undefined) {
    const [key, value] = this._resolveItem(keyOrObjectWithId, valueIfKey)

    const action = this.has(key) ? 'update' : 'add'
    this._items[key] = value
    if (action === 'add') {
      this._size++
    }
    this._touch(action, key)
  }

  touch (keyOrObjectWithId) {
    const [key] = this._resolveItem(keyOrObjectWithId)
    this._assertHas(key)
    const value = this.get(key)
    if (typeof value !== 'object' || value === null) {
      throw new IllegalTouch(value)
    }

    this._touch('update', key)

    return this.get(key)
  }

  update (keyOrObjectWithId, valueIfKey = undefined) {
    const [key, value] = this._resolveItem(keyOrObjectWithId, valueIfKey)
    this._assertHas(key)

    this._items[key] = value
    this._touch('update', key)
  }

  // -----------------------------------------------------------------
  // Query
  // -----------------------------------------------------------------

  get (key, defaultValue) {
    if (this.has(key)) {
      return this._items[key]
    }

    if (arguments.length > 1) {
      return defaultValue
    }

    // Throws a NoSuchItem.
    this._assertHas(key)
  }

  has (key) {
    return hasOwnProperty.call(this._items, key)
  }

  // -----------------------------------------------------------------
  // Indexes
  // -----------------------------------------------------------------

  createIndex (name, index) {
    index._attachCollection(this)

    const {_indexes: indexes} = this
    if (hasOwnProperty.call(indexes, name)) {
      throw new DuplicateIndex(name)
    }
    indexes[name] = index
    this._indexedItems[name] = index.itemsByHash
  }

  deleteIndex (name) {
    const {_indexes: indexes} = this
    if (!hasOwnProperty.call(indexes, name)) {
      throw new NoSuchIndex(name)
    }

    const index = indexes[name]
    delete indexes[name]
    delete this._indexedItems[name]

    index._detachCollection(this)
  }

  // -----------------------------------------------------------------
  // Iteration
  // -----------------------------------------------------------------

  * [Symbol.iterator] () {
    const {_items: items} = this

    for (let key in items) {
      yield [key, items[key]]
    }
  }

  * keys () {
    const {_items: items} = this

    for (let key in items) {
      yield key
    }
  }

  * values () {
    const {_items: items} = this

    for (let key in items) {
      yield items[key]
    }
  }

  // -----------------------------------------------------------------
  // Events buffering
  // -----------------------------------------------------------------

  bufferEvents () {
    ++this._buffering

    let called = false
    return () => {
      if (called) {
        throw new BufferAlreadyFlushed()
      }
      called = true

      if (--this._buffering) {
        return
      }

      const data = {
        add: Object.create(null),
        remove: Object.create(null),
        update: Object.create(null)
      }

      for (let key in this._buffer) {
        data[this._buffer[key]][key] = this._items[key]
      }

      forEach(['add', 'update', 'remove'], action => {
        const items = data[action]

        if (isNotEmpty(items)) {
          this.emit(action, items)
        }
      })

      this._buffer = Object.create(null)
    }
  }

  // =================================================================

  _assertHas (key) {
    if (!this.has(key)) {
      throw new NoSuchItem(key)
    }
  }

  _assertHasNot (key) {
    if (this.has(key)) {
      throw new DuplicateItem(key)
    }
  }

  _assertValidKey (key) {
    if (!this._isValidKey(key)) {
      throw new InvalidKey(key)
    }
  }

  _isValidKey (key) {
    return typeof key === 'number' || typeof key === 'string'
  }

  _resolveItem (keyOrObjectWithId, valueIfKey = undefined) {
    if (valueIfKey !== undefined) {
      this._assertValidKey(keyOrObjectWithId)

      return [keyOrObjectWithId, valueIfKey]
    }

    if (this._isValidKey(keyOrObjectWithId)) {
      return [keyOrObjectWithId]
    }

    const key = this.getKey(keyOrObjectWithId)
    this._assertValidKey(key)

    return [key, keyOrObjectWithId]
  }

  _touch (action, key) {
    if (this._buffering === 0) {
      const flush = this.bufferEvents()

      process.nextTick(flush)
    }

    if (action === 'add') {
      this._buffer[key] = this._buffer[key] ? 'update' : 'add'
    } else if (action === 'remove') {
      if (this._buffer[key] === 'add') {
        delete this._buffer[key]
      } else {
        this._buffer[key] = 'remove'
      }
    } else { // update
      if (!this._buffer[key]) {
        this._buffer[key] = 'update'
      }
    }
  }
}
<|endoftext|>


<commit_before>import cuid from './cuid.js';

import {CHANGES_DB_STORE_NAME} from './constants.js';
import * as IndexedDB from './indexeddb_connector.js';
import {createUpdateChangeObject, createRemoveChangeObject} from './change_object_helpers.js';

export default class Collection {
  constructor(collectionName, dbName, dbCollections) {
    this.collectionName = collectionName;
    this.dbName = dbName;
    this.dbCollections = dbCollections;
  }

  save(data) {
    return new Promise((resolve, reject) => {
      IndexedDB.open(this.dbName, this.dbCollections).then((openDB) => {
        const requestErrors = [];

        function onTransactionError(e) {
          requestErrors.push(e);
          openDB.close();
          reject(requestErrors);
        }

        function onTransactionComplete() {
          openDB.close();
          resolve(data._id);
        }

        const transaction = IndexedDB.createReadWriteTransaction(openDB, [CHANGES_DB_STORE_NAME, this.collectionName], onTransactionComplete, onTransactionError);

        const changeObjectsStore = transaction.objectStore(CHANGES_DB_STORE_NAME);
        const objectStore = transaction.objectStore(this.collectionName);

        if (!data._id) {
          data._id = cuid();
        }
        const changeObject = createUpdateChangeObject(this.collectionName, data);

        const changeObjectPromise = IndexedDB.save(changeObjectsStore, changeObject);
        const savePromise = IndexedDB.save(objectStore, data);

        Promise.all([changeObjectPromise, savePromise]).catch((e) => {
          requestErrors.push(e);
        });
      }).catch(function(e) {
        reject(e);
      });
    });
  }

  remove(id) {
    return new Promise((resolve, reject) => {
      IndexedDB.open(this.dbName, this.dbCollections).then((openDB) => {
        const requestErrors = [];

        function onTransactionError(e) {
          requestErrors.push(e);
          openDB.close();
          reject(requestErrors);
        }

        function onTransactionComplete() {
          openDB.close();
          resolve();
        }

        const transaction = IndexedDB.createReadWriteTransaction(openDB, [CHANGES_DB_STORE_NAME, this.collectionName], onTransactionComplete, onTransactionError);

        const changeObjectsStore = transaction.objectStore(CHANGES_DB_STORE_NAME);
        const objectStore = transaction.objectStore(this.collectionName);

        const changeObject = createRemoveChangeObject(this.collectionName, id);

        const changeObjectPromise = IndexedDB.save(changeObjectsStore, changeObject);
        const removePromise = IndexedDB.remove(objectStore, id);
        Promise.all([changeObjectPromise, removePromise]).catch((e) => {
          requestErrors.push(e);
        });
      });
    });
  }

  getAll() {
    return IndexedDB.open(this.dbName, this.dbCollections).then((openDB) => {
      const transaction = IndexedDB.createReadTransaction(openDB, [this.collectionName]);
      const objectStore = transaction.objectStore(this.collectionName);
      return IndexedDB.getAll(objectStore).then((data) => {
        openDB.clos();
        return data;
      }).catch((err) => {
        openDB.clos();
        return err;
      });
    });
  }

  getOne(id) {
    return IndexedDB.open(this.dbName, this.dbCollections).then((openDB) => {
      const transaction = IndexedDB.createReadTransaction(openDB, [this.collectionName]);
      const objectStore = transaction.objectStore(this.collectionName);
      return IndexedDB.getOne(objectStore, id).then((data) => {
        openDB.close();
        return data;
      }).catch((err) => {
        openDB.close();
        return err;
      });
    });
  }
}
<commit_msg>Refactor<commit_after>import cuid from './cuid.js';

import {CHANGES_DB_STORE_NAME} from './constants.js';
import * as IndexedDB from './indexeddb_connector.js';
import {createUpdateChangeObject, createRemoveChangeObject} from './change_object_helpers.js';

export default class Collection {
  constructor(collectionName, dbName, dbCollectionNames) {
    const containsCollectionName = dbCollectionNames.indexOf(collectionName);
    const containsChangeCollectionName = dbCollectionNames.indexOf(CHANGES_DB_STORE_NAME);

    if (containsCollectionName === -1) {
      throw Error('Collection name is not in the collections list');
    } else if (containsChangeCollectionName === -1) {
      throw Error('Change collection is not in the collections list');
    } else {
      this.collectionName = collectionName;
      this.dbName = dbName;
      this.dbCollectionNames = dbCollectionNames;
    }
  }

  save(data) {
    return new Promise((resolve, reject) => {
      IndexedDB.open(this.dbName, this.dbCollectionNames).then((openDB) => {
        const requestErrors = [];

        function onTransactionError(e) {
          requestErrors.push(e);
          openDB.close();
          reject(requestErrors);
        }

        function onTransactionComplete() {
          openDB.close();
          resolve(data._id);
        }

        const transaction = IndexedDB.createReadWriteTransaction(openDB, [CHANGES_DB_STORE_NAME, this.collectionName], onTransactionComplete, onTransactionError);

        const changeObjectsStore = transaction.objectStore(CHANGES_DB_STORE_NAME);
        const objectStore = transaction.objectStore(this.collectionName);

        if (!data._id) {
          data._id = cuid();
        }
        const changeObject = createUpdateChangeObject(this.collectionName, data);

        const changeObjectPromise = IndexedDB.save(changeObjectsStore, changeObject);
        const savePromise = IndexedDB.save(objectStore, data);

        Promise.all([changeObjectPromise, savePromise]).catch((e) => {
          requestErrors.push(e);
        });
      }).catch(function(e) {
        reject(e);
      });
    });
  }

  remove(id) {
    return new Promise((resolve, reject) => {
      IndexedDB.open(this.dbName, this.dbCollectionNames).then((openDB) => {
        const requestErrors = [];

        function onTransactionError(e) {
          requestErrors.push(e);
          openDB.close();
          reject(requestErrors);
        }

        function onTransactionComplete() {
          openDB.close();
          resolve();
        }

        const transaction = IndexedDB.createReadWriteTransaction(openDB, [CHANGES_DB_STORE_NAME, this.collectionName], onTransactionComplete, onTransactionError);

        const changeObjectsStore = transaction.objectStore(CHANGES_DB_STORE_NAME);
        const objectStore = transaction.objectStore(this.collectionName);

        const changeObject = createRemoveChangeObject(this.collectionName, id);

        const changeObjectPromise = IndexedDB.save(changeObjectsStore, changeObject);
        const removePromise = IndexedDB.remove(objectStore, id);
        Promise.all([changeObjectPromise, removePromise]).catch((e) => {
          requestErrors.push(e);
        });
      });
    });
  }

  getAll() {
    return IndexedDB.open(this.dbName, this.dbCollectionNames).then((openDB) => {
      const transaction = IndexedDB.createReadTransaction(openDB, [this.collectionName]);
      const objectStore = transaction.objectStore(this.collectionName);
      return IndexedDB.getAll(objectStore).then((data) => {
        openDB.close();
        return data;
      }).catch((err) => {
        openDB.close();
        return Promise.reject(err);
      });
    });
  }

  getOne(id) {
    return IndexedDB.open(this.dbName, this.dbCollectionNames).then((openDB) => {
      const transaction = IndexedDB.createReadTransaction(openDB, [this.collectionName]);
      const objectStore = transaction.objectStore(this.collectionName);
      return IndexedDB.getOne(objectStore, id).then((data) => {
        openDB.close();
        return data;
      }).catch((err) => {
        openDB.close();
        return Promise.reject(err);
      });
    });
  }
}
<|endoftext|>


<commit_before>import _ from 'lodash';

import { Container } from 'aurelia-dependency-injection';
import { json } from 'aurelia-fetch-client';

import { Config } from './config';

/**
 * Collection class. A collection of models that are
 * related to a backend data store and accessible through REST API.
 */
export class Collection {

  /**
   * Configure the collection.
   * @param  {String} key : the key provided when the collection has been
   * registered
   * @param  {String} [defaultRoute] : route to use when performing the backend
   * http requests, to which the model id is to be appended where expected.
   * @param  {Function} [modelClass] : function to be called to be called to
   * create a model.
   * @param  {String} [modelid] : the key to use for uniqueness of the models in
   * order to store and search for them.
   */
  configure(key, modelClass, defaultRoute, modelid = '_id') {
    this.container = Container.instance;

    if (_.isUndefined(defaultRoute)) {
      defaultRoute = '/api/' + key + '/';
    }

    this.modelid = modelid;
    this.defaultRoute = defaultRoute;
    this.modelClass = modelClass;
    this.collection = [];

    this._httpClient = null;
  }

  /**
   * Creates or syncs a model from data.
   * @param  {Object} data : the content of the model to use for its creation
   * or its sync.
   * @param  {Object} options : modifiers for the method:
   * - ignoreCollection attribute can prevent the model from being stored in the
   * collection when created.
   * - force attribute forces the sync of a model (when it already exists) even
   * if it is complete.
   * @return {Promise} a promise containg the resolved model created or got
   * from the models set saved within the collection. If data is null or
   * undefined, it resolves to null.
   */
  fromJSON(data, options) {
    if (_.isNil(data)) {
      return Promise.resolve(null);
    }

    options = _.defaults(options, {
      ignoreCollection: false,
      force: false
    });

    let model = this._getFromCollection(data[this.modelid]);

    if (_.isUndefined(model)) {
      model = this.container.invoke(this.modelClass, data);

      if (!options.ignoreCollection) {
        this.collection.push(model);
      }
    } else if (!this.isComplete(model) || options.force) {
      this._syncFrom(model, data);
    }
    return Promise.resolve(model);
  }

  /**
   * Converts a model to a literal object. It usually calls the model's `toJSON`
   * method.
   * @param  {Model} model : the instance to be converted.
   * @param {Object} options : a placeholder argument that may be used when the
   * function is overloaded.
   * @return {Object} : the literal object made of the model's content.
   */
  toJSON(model, options) {
    return _.isFunction(model.toJSON) ? model.toJSON() : model;
  }

  /**
   * Clears the set from the models it contained.
   */
  flush() {
    this.collection = [];
  }

  /**
   * Checks if a model is complete, that is whether all the required fields
   * have been filled. If a model is not complete, then it can be fetched
   * again and then `_syncFrom`ed.
   * @param  {Model}  model : the model to check for completeness.
   * @return {Boolean} true if a model is considered as complete, false.
   * otherwise
   */
  isComplete(model) {
    return true;
  }

  /**
   * Sets the content of a model
   * @param  {String or Object} model : if it is a string, model is treated as
   * a model's id, otherwise it is treated as the content of the model to set.
   * @param  {Object} options : options to be provided to the get method, with
   * `force` attribute set to true to force the request to the backend and the
   * assignement of the received data to the model.
   * @return {Promise} a promise when the sync is done with the synced model.
   */
  sync(model, options) {
    return this.get(_.isString(model) ? model : model[this.modelid], _.merge({}, options, { force: true }));
  }

  /**
   * The models the collection collects may have references to other models
   * in other collections (i.e. collections).
   * This method defines which keys may contain references, the name of
   * the key when received from the backend's data, and its name in the model
   * class (i.e. `frontendKey`)
   * @return {Array} an array of objects which should be made of 3 keys:
   * - a backendKey, the `old` key in the data.
   * - a frontendKey, the `new` key in the model.
   * - and a collection, the collection from which the items will be 'got'.
   */
  refKeys() {
    return [];
  }

  /**
   * Set the http client to be used for the requests made by the collection.
   * @param {Object} httpClient : an http client supporting the fetch API.
   */
  _setHttpClient(httpClient) {
    this._httpClient = httpClient;
  }

  /**
   * Affects the content of data to a model.
   * @param  {Model} model : the instance to be modified.
   * @param  {Object} data : the data to set to model.
   */
  _syncFrom(model, data) {
    _.merge(model, data);
  }

  /**
   * Get a model by id.
   * @param  {String} id : the id of the model to be searched for.
   * @return {Model} : the matched model or undefined otherwise.
   */
  _getFromCollection(id) {
    let obj = {};
    obj[this.modelid] = id;
    return _.find(this.collection, obj);
  }

  /**
   * Remove a model from the collection by id.
   * @param  {String} id : the id of the model to be removed.
   */
  _removeFromCollection(id) {
    let obj = {};
    obj[this.modelid] = id;
    _.remove(this.collection, obj);
  }

  /**
   * [_getById description]
   * @param  {[type]} id    [description]
   * @param  {[type]} force [description]
   * @param  {[type]} options [description]
   * @return {[type]}       [description]
   */
  _getById(id, options) {
    const opts = options || {};
    const apiRoute = opts.route || this.defaultRoute + id;
    let model = this._getFromCollection(id);

    if (_.isUndefined(model) || !this.isComplete(model) || opts.force) {
      return this._httpClient
        .fetch(apiRoute)
        .then(response => response.json())
        .then(data => {
          return this.fromJSON(data, { force: opts.force });
        });
    }

    return Promise.resolve(model);
  }

  /**
   * [create description]
   * @param  {[type]} jsonModel [description]
   * @param  {[type]} options   [description]
   * @return {[type]}           [description]
   */
  create(jsonModel, options) {
    const opts = options || {};
    const apiRoute = opts.route || this.defaultRoute.slice(0, -1);

    return this._httpClient
      .fetch(apiRoute, {
        method: 'post',
        body: json(jsonModel)
      }).then(response => response.json())
      .then(data => this.get(data));
  }

  /**
   * [destroy description]
   * @param  {[type]} id    [description]
   * @param  {[type]} route [description]
   * @return {[type]}       [description]
   */
  destroy(id, options) {
    const opts = options || {};
    const apiRoute = opts.route || this.defaultRoute + id;

    this._removeFromCollection(id);
    return this._httpClient.fetch(apiRoute, {
      method: 'delete'
    }).then(response => response.json());
  }

  /**
   * [get description]
   * @param  {[type]} data    [description]
   * @param  {[type]} options [description]
   * @return {[type]}         [description]
   */
  get(data, options) {
    options = _.defaults(options, {
      _child: false,
      force: false,
      recursive: false,
      populate: false
    });

    let modelPromise = null;

    if (_.isEmpty(data) || _.isUndefined(data)) { // you cannot get nothing
      return Promise.resolve(data);
    } else if (_.isArray(data)) { // you iterate on the array, and go one level deeper (for ID or JSON array)
      return modelPromise = Promise.all(_.map(data, item => this.get(item, options)));
    } else if (_.isObject(data)) { // you already have the json data, just instanciate the model
      modelPromise = this.fromJSON(data);
    } else { // If we end up here, then data is string. We'd better test whether it is an id, but we consider a string as a type of an id.
      if (!options._child) { // we are the root level, we want the model no matter what.
        modelPromise = this._getById(data, options);
      } else {
        if (options.populate === true) { // we are a child, so populate means we want the model level root + 1
          modelPromise = this._getById(data, options);
        } else { // otherwise, we want to keep the backend data (the reference IDs)
          modelPromise = Promise.resolve(null);
        }
      }
    }

    return modelPromise
      .then(model => {
        // we don't need to inspect our children, as we are empty
        if (_.isNil(model)) {
          return model;
        }

        // if we are a grand child or more, we suppose that recursive take ownership of population for all the decendants levels
        let childOpt = _.cloneDeep(options);

        // Removal of the route options since it is not supposed to be relevant for children paths.
        delete childOpt.route;

        if (childOpt._child) {
          childOpt.populate = childOpt.recursive = (childOpt.recursive === true);
        }
        childOpt._child = true;

        // each reference attribute (described by the model class) is replaced by the matching model instance if we ask for population
        return Promise.all(
          _.map(this.refKeys(model), item => {
            item = _.defaults(item, {
              backendKey: null,
              collection: null,
              frontendKey: null,
              backendKeyDeletion: true
            });

            // collection and backendKey have to be defined.
            let collection = this.container.get(Config).getCollection(item.collection);
            if (_.isNil(item.backendKey)) {
              return;
            }

            // If frontendKey has not been specified,
            // we consider it as equal to backendKey
            if (_.isNil(item.frontendKey)) {
              item.frontendKey = item.backendKey;
            }

            let itemData = model[item.backendKey];

            let itemDataPromise = Promise.resolve(null);

            // item.collection can be null if we want to keep JSON data.
            if (_.isNull(item.collection)) {
              itemDataPromise = Promise.resolve(itemData);
            } else if (!_.isNil(collection)) {
              itemDataPromise = collection.get(itemData, childOpt);
            }

            return itemDataPromise
              .then(childrenItems => {
                // Replace the model key if necessary.
                if (!_.isNil(childrenItems) && isNotNullArray(childrenItems)) {
                  if (item.backendKeyDeletion === true) {
                    delete model[item.backendKey];
                  }

                  return model[item.frontendKey] = _.pull(childrenItems, null, undefined);
                }
              });
          }))
          .then(() => model);
      });
  }

  /**
   * Allow to send a PUT method to the backend to update the model, and expect a response with the updated model.
   * @param  {[type]} model   [The model instance to update]
   * @param  {[type]} attr    [A literal object of attribute that should be updated]
   * @param  {[type]} options [{mergeStrategy: 'replace|array|all|ignore'}, mergeStrategy to correctly merge data recieved]
   * @return {[type]}         [A Promise resolving with the updated model instance]
   */
  update(model, attr, options) {
    const opts = options || {};
    const apiRoute = opts.route || this.defaultRoute + model[this.modelid];

    if (_.has(options, 'fireAndForget') && options.fireAndForget) {
      return Promise.resolve(attr);
    }

    return this._frontToBackend(attr, opts)
      .then(backAttr => {
        return this._httpClient
          .fetch(apiRoute, {
            method: 'put',
            headers: { 'Content-Type': 'application/json' },
            body: json(backAttr)
          }).then(response => response.json())
          .then(attributes => this._backToFrontend(attributes, backAttr, model, opts));
      }).then(() => model);
  }

  /**
   * [_frontToBackend description]
   * @param  {[type]} attributes [description]
   * @param  {[type]} options    [description]
   * @return {[type]}            [description]
   */
  _frontToBackend(attributes, options) {
    const refKeys = this.refKeys();

    let _getIdFromData = (data) => {
      if (_.isString(data)) {
        return data;
      } else if (_.isArray(data)) {
        return _.map(data, _getIdFromData);
      } else if (_.isObject(data)) {
        return data[this.modelid];
      }
      return null;
    };

    _.each(attributes, (value, field) => {
      let item = _.find(refKeys, { frontendKey: field });
      // If undefined, nothing to convert.
      if (_.isUndefined(item)) {
        return;
      }

      item = _.defaults(item, {
        backendKey: null,
        frontendKey: null,
        backendKeyDeletion: true
      });

      if (item.backendKeyDeletion) {
        delete attributes[item.frontendKey];
      }

      // browser request filter undefined fields, we need to explicitely set it to null to be sent to the backend. (in case of reseting the field)
      let id = _getIdFromData(value);
      attributes[item.backendKey] = _.isUndefined(id) ? null : id;
    });

    return Promise.resolve(attributes);
  }

  /**
   * [_backToFrontend description]
   * @param  {[type]} attributes [description]
   * @param  {[type]} backAttr   [description]
   * @param  {[type]} model      [description]
   * @param  {[type]} options    [description]
   * @return {[type]}            [description]
   */
  _backToFrontend(attributes, backAttr, model, options) {
    const opts = options || {};
    const refKeys = this.refKeys();

    return Promise.all(_.map(backAttr, (value, field) => {
      let frontendKey = field;
      let backendKey = field;
      let frontendValue = Promise.resolve(attributes[backendKey]);

      // The current field is a frontend type of key.
      let item = _.find(refKeys, { backendKey: field });
      if (!_.isUndefined(item)) {
        item = _.defaults(item, {
          backendKey: null,
          frontendKey: null,
          collection: null,
          backendKeyDeletion: true
        });

        frontendKey = item.frontendKey;
        backendKey = item.backendKey;

        // item.collection can be null if we want to keep JSON data.
        if (!_.isNull(item.collection)) {
          frontendValue = this.container.get(Config).getCollection(item.collection).get(attributes[backendKey]);
        }
      }

      // Update the right key in the model, with updateStrategy to replace, merge only arrays or merge all the attribute.
      return frontendValue.then(result => {
        if (!_.has(opts.mergeStrategy) || opts.mergeStrategy === 'replace') {
          model[frontendKey] = result;
        } else if (opts.mergeStrategy === 'array') {
          if (_.isArray(model[frontendKey])) {
            model[frontendKey] = _.union(model[frontendKey], result);
          } else {
            model[frontendKey] = result;
          }
        } else {
          model[frontendKey] = _.merge(model[frontendKey], result);
        }
        return Promise.resolve(model);
      });
    }));
  }

}

/**
 * Helper function to check that an object is not an `array filled with null
 * values`.
 * @param  {Any}  arr : the `object` to check.
 * @return {Boolean} false it is an array filled with `null` values, true
 * otherwise.
 */
function isNotNullArray(arr) {
  // If the arr is an empty array, it is NOT a null array.
  // Therefore we can't compact it and check if it is empty.
  return !_.isArray(arr) || _.isEmpty(arr) || _.some(arr, _.negate(_.isNil));
}
<commit_msg>fix(update): remove useless fire&forget snippet<commit_after>import _ from 'lodash';

import { Container } from 'aurelia-dependency-injection';
import { json } from 'aurelia-fetch-client';

import { Config } from './config';

/**
 * Collection class. A collection of models that are
 * related to a backend data store and accessible through REST API.
 */
export class Collection {

  /**
   * Configure the collection.
   * @param  {String} key : the key provided when the collection has been
   * registered
   * @param  {String} [defaultRoute] : route to use when performing the backend
   * http requests, to which the model id is to be appended where expected.
   * @param  {Function} [modelClass] : function to be called to be called to
   * create a model.
   * @param  {String} [modelid] : the key to use for uniqueness of the models in
   * order to store and search for them.
   */
  configure(key, modelClass, defaultRoute, modelid = '_id') {
    this.container = Container.instance;

    if (_.isUndefined(defaultRoute)) {
      defaultRoute = '/api/' + key + '/';
    }

    this.modelid = modelid;
    this.defaultRoute = defaultRoute;
    this.modelClass = modelClass;
    this.collection = [];

    this._httpClient = null;
  }

  /**
   * Creates or syncs a model from data.
   * @param  {Object} data : the content of the model to use for its creation
   * or its sync.
   * @param  {Object} options : modifiers for the method:
   * - ignoreCollection attribute can prevent the model from being stored in the
   * collection when created.
   * - force attribute forces the sync of a model (when it already exists) even
   * if it is complete.
   * @return {Promise} a promise containg the resolved model created or got
   * from the models set saved within the collection. If data is null or
   * undefined, it resolves to null.
   */
  fromJSON(data, options) {
    if (_.isNil(data)) {
      return Promise.resolve(null);
    }

    options = _.defaults(options, {
      ignoreCollection: false,
      force: false
    });

    let model = this._getFromCollection(data[this.modelid]);

    if (_.isUndefined(model)) {
      model = this.container.invoke(this.modelClass, data);

      if (!options.ignoreCollection) {
        this.collection.push(model);
      }
    } else if (!this.isComplete(model) || options.force) {
      this._syncFrom(model, data);
    }
    return Promise.resolve(model);
  }

  /**
   * Converts a model to a literal object. It usually calls the model's `toJSON`
   * method.
   * @param  {Model} model : the instance to be converted.
   * @param {Object} options : a placeholder argument that may be used when the
   * function is overloaded.
   * @return {Object} : the literal object made of the model's content.
   */
  toJSON(model, options) {
    return _.isFunction(model.toJSON) ? model.toJSON() : model;
  }

  /**
   * Clears the set from the models it contained.
   */
  flush() {
    this.collection = [];
  }

  /**
   * Checks if a model is complete, that is whether all the required fields
   * have been filled. If a model is not complete, then it can be fetched
   * again and then `_syncFrom`ed.
   * @param  {Model}  model : the model to check for completeness.
   * @return {Boolean} true if a model is considered as complete, false.
   * otherwise
   */
  isComplete(model) {
    return true;
  }

  /**
   * Sets the content of a model
   * @param  {String or Object} model : if it is a string, model is treated as
   * a model's id, otherwise it is treated as the content of the model to set.
   * @param  {Object} options : options to be provided to the get method, with
   * `force` attribute set to true to force the request to the backend and the
   * assignement of the received data to the model.
   * @return {Promise} a promise when the sync is done with the synced model.
   */
  sync(model, options) {
    return this.get(_.isString(model) ? model : model[this.modelid], _.merge({}, options, { force: true }));
  }

  /**
   * The models the collection collects may have references to other models
   * in other collections (i.e. collections).
   * This method defines which keys may contain references, the name of
   * the key when received from the backend's data, and its name in the model
   * class (i.e. `frontendKey`)
   * @return {Array} an array of objects which should be made of 3 keys:
   * - a backendKey, the `old` key in the data.
   * - a frontendKey, the `new` key in the model.
   * - and a collection, the collection from which the items will be 'got'.
   */
  refKeys() {
    return [];
  }

  /**
   * Set the http client to be used for the requests made by the collection.
   * @param {Object} httpClient : an http client supporting the fetch API.
   */
  _setHttpClient(httpClient) {
    this._httpClient = httpClient;
  }

  /**
   * Affects the content of data to a model.
   * @param  {Model} model : the instance to be modified.
   * @param  {Object} data : the data to set to model.
   */
  _syncFrom(model, data) {
    _.merge(model, data);
  }

  /**
   * Get a model by id.
   * @param  {String} id : the id of the model to be searched for.
   * @return {Model} : the matched model or undefined otherwise.
   */
  _getFromCollection(id) {
    let obj = {};
    obj[this.modelid] = id;
    return _.find(this.collection, obj);
  }

  /**
   * Remove a model from the collection by id.
   * @param  {String} id : the id of the model to be removed.
   */
  _removeFromCollection(id) {
    let obj = {};
    obj[this.modelid] = id;
    _.remove(this.collection, obj);
  }

  /**
   * [_getById description]
   * @param  {[type]} id    [description]
   * @param  {[type]} force [description]
   * @param  {[type]} options [description]
   * @return {[type]}       [description]
   */
  _getById(id, options) {
    const opts = options || {};
    const apiRoute = opts.route || this.defaultRoute + id;
    let model = this._getFromCollection(id);

    if (_.isUndefined(model) || !this.isComplete(model) || opts.force) {
      return this._httpClient
        .fetch(apiRoute)
        .then(response => response.json())
        .then(data => {
          return this.fromJSON(data, { force: opts.force });
        });
    }

    return Promise.resolve(model);
  }

  /**
   * [create description]
   * @param  {[type]} jsonModel [description]
   * @param  {[type]} options   [description]
   * @return {[type]}           [description]
   */
  create(jsonModel, options) {
    const opts = options || {};
    const apiRoute = opts.route || this.defaultRoute.slice(0, -1);

    return this._httpClient
      .fetch(apiRoute, {
        method: 'post',
        body: json(jsonModel)
      }).then(response => response.json())
      .then(data => this.get(data));
  }

  /**
   * [destroy description]
   * @param  {[type]} id    [description]
   * @param  {[type]} route [description]
   * @return {[type]}       [description]
   */
  destroy(id, options) {
    const opts = options || {};
    const apiRoute = opts.route || this.defaultRoute + id;

    this._removeFromCollection(id);
    return this._httpClient.fetch(apiRoute, {
      method: 'delete'
    }).then(response => response.json());
  }

  /**
   * [get description]
   * @param  {[type]} data    [description]
   * @param  {[type]} options [description]
   * @return {[type]}         [description]
   */
  get(data, options) {
    options = _.defaults(options, {
      _child: false,
      force: false,
      recursive: false,
      populate: false
    });

    let modelPromise = null;

    if (_.isEmpty(data) || _.isUndefined(data)) { // you cannot get nothing
      return Promise.resolve(data);
    } else if (_.isArray(data)) { // you iterate on the array, and go one level deeper (for ID or JSON array)
      return modelPromise = Promise.all(_.map(data, item => this.get(item, options)));
    } else if (_.isObject(data)) { // you already have the json data, just instanciate the model
      modelPromise = this.fromJSON(data);
    } else { // If we end up here, then data is string. We'd better test whether it is an id, but we consider a string as a type of an id.
      if (!options._child) { // we are the root level, we want the model no matter what.
        modelPromise = this._getById(data, options);
      } else {
        if (options.populate === true) { // we are a child, so populate means we want the model level root + 1
          modelPromise = this._getById(data, options);
        } else { // otherwise, we want to keep the backend data (the reference IDs)
          modelPromise = Promise.resolve(null);
        }
      }
    }

    return modelPromise
      .then(model => {
        // we don't need to inspect our children, as we are empty
        if (_.isNil(model)) {
          return model;
        }

        // if we are a grand child or more, we suppose that recursive take ownership of population for all the decendants levels
        let childOpt = _.cloneDeep(options);

        // Removal of the route options since it is not supposed to be relevant for children paths.
        delete childOpt.route;

        if (childOpt._child) {
          childOpt.populate = childOpt.recursive = (childOpt.recursive === true);
        }
        childOpt._child = true;

        // each reference attribute (described by the model class) is replaced by the matching model instance if we ask for population
        return Promise.all(
          _.map(this.refKeys(model), item => {
            item = _.defaults(item, {
              backendKey: null,
              collection: null,
              frontendKey: null,
              backendKeyDeletion: true
            });

            // collection and backendKey have to be defined.
            let collection = this.container.get(Config).getCollection(item.collection);
            if (_.isNil(item.backendKey)) {
              return;
            }

            // If frontendKey has not been specified,
            // we consider it as equal to backendKey
            if (_.isNil(item.frontendKey)) {
              item.frontendKey = item.backendKey;
            }

            let itemData = model[item.backendKey];

            let itemDataPromise = Promise.resolve(null);

            // item.collection can be null if we want to keep JSON data.
            if (_.isNull(item.collection)) {
              itemDataPromise = Promise.resolve(itemData);
            } else if (!_.isNil(collection)) {
              itemDataPromise = collection.get(itemData, childOpt);
            }

            return itemDataPromise
              .then(childrenItems => {
                // Replace the model key if necessary.
                if (!_.isNil(childrenItems) && isNotNullArray(childrenItems)) {
                  if (item.backendKeyDeletion === true) {
                    delete model[item.backendKey];
                  }

                  return model[item.frontendKey] = _.pull(childrenItems, null, undefined);
                }
              });
          }))
          .then(() => model);
      });
  }

  /**
   * Allow to send a PUT method to the backend to update the model, and expect a response with the updated model.
   * @param  {[type]} model   [The model instance to update]
   * @param  {[type]} attr    [A literal object of attribute that should be updated]
   * @param  {[type]} options [{mergeStrategy: 'replace|array|all|ignore'}, mergeStrategy to correctly merge data recieved]
   * @return {[type]}         [A Promise resolving with the updated model instance]
   */
  update(model, attr, options) {
    const opts = options || {};
    const apiRoute = opts.route || this.defaultRoute + model[this.modelid];

    return this._frontToBackend(attr, opts)
      .then(backAttr => {
        return this._httpClient
          .fetch(apiRoute, {
            method: 'put',
            headers: { 'Content-Type': 'application/json' },
            body: json(backAttr)
          }).then(response => response.json())
          .then(attributes => this._backToFrontend(attributes, backAttr, model, opts));
      }).then(() => model);
  }

  /**
   * [_frontToBackend description]
   * @param  {[type]} attributes [description]
   * @param  {[type]} options    [description]
   * @return {[type]}            [description]
   */
  _frontToBackend(attributes, options) {
    const refKeys = this.refKeys();

    let _getIdFromData = (data) => {
      if (_.isString(data)) {
        return data;
      } else if (_.isArray(data)) {
        return _.map(data, _getIdFromData);
      } else if (_.isObject(data)) {
        return data[this.modelid];
      }
      return null;
    };

    _.each(attributes, (value, field) => {
      let item = _.find(refKeys, { frontendKey: field });
      // If undefined, nothing to convert.
      if (_.isUndefined(item)) {
        return;
      }

      item = _.defaults(item, {
        backendKey: null,
        frontendKey: null,
        backendKeyDeletion: true
      });

      if (item.backendKeyDeletion) {
        delete attributes[item.frontendKey];
      }

      // browser request filter undefined fields, we need to explicitely set it to null to be sent to the backend. (in case of reseting the field)
      let id = _getIdFromData(value);
      attributes[item.backendKey] = _.isUndefined(id) ? null : id;
    });

    return Promise.resolve(attributes);
  }

  /**
   * [_backToFrontend description]
   * @param  {[type]} attributes [description]
   * @param  {[type]} backAttr   [description]
   * @param  {[type]} model      [description]
   * @param  {[type]} options    [description]
   * @return {[type]}            [description]
   */
  _backToFrontend(attributes, backAttr, model, options) {
    const opts = options || {};
    const refKeys = this.refKeys();

    return Promise.all(_.map(backAttr, (value, field) => {
      let frontendKey = field;
      let backendKey = field;
      let frontendValue = Promise.resolve(attributes[backendKey]);

      // The current field is a frontend type of key.
      let item = _.find(refKeys, { backendKey: field });
      if (!_.isUndefined(item)) {
        item = _.defaults(item, {
          backendKey: null,
          frontendKey: null,
          collection: null,
          backendKeyDeletion: true
        });

        frontendKey = item.frontendKey;
        backendKey = item.backendKey;

        // item.collection can be null if we want to keep JSON data.
        if (!_.isNull(item.collection)) {
          frontendValue = this.container.get(Config).getCollection(item.collection).get(attributes[backendKey]);
        }
      }

      // Update the right key in the model, with updateStrategy to replace, merge only arrays or merge all the attribute.
      return frontendValue.then(result => {
        if (!_.has(opts.mergeStrategy) || opts.mergeStrategy === 'replace') {
          model[frontendKey] = result;
        } else if (opts.mergeStrategy === 'ignore') {
          return Promise.resolve(model);
        } else if (opts.mergeStrategy === 'array') {
          if (_.isArray(model[frontendKey])) {
            model[frontendKey] = _.union(model[frontendKey], result);
          } else {
            model[frontendKey] = result;
          }
        } else {
          model[frontendKey] = _.merge(model[frontendKey], result);
        }
        return Promise.resolve(model);
      });
    }));
  }

}

/**
 * Helper function to check that an object is not an `array filled with null
 * values`.
 * @param  {Any}  arr : the `object` to check.
 * @return {Boolean} false it is an array filled with `null` values, true
 * otherwise.
 */
function isNotNullArray(arr) {
  // If the arr is an empty array, it is NOT a null array.
  // Therefore we can't compact it and check if it is empty.
  return !_.isArray(arr) || _.isEmpty(arr) || _.some(arr, _.negate(_.isNil));
}
<|endoftext|>