github.com/Ilhicas/nomad@v1.0.4-0.20210304152020-e86851182bc3/ui/tests/acceptance/optimize-test.js (about)

     1  import { module, test } from 'qunit';
     2  import { setupApplicationTest } from 'ember-qunit';
     3  import { currentURL, visit } from '@ember/test-helpers';
     4  import { setupMirage } from 'ember-cli-mirage/test-support';
     5  import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit';
     6  import Response from 'ember-cli-mirage/response';
     7  import moment from 'moment';
     8  
     9  import Optimize from 'nomad-ui/tests/pages/optimize';
    10  import Layout from 'nomad-ui/tests/pages/layout';
    11  import JobsList from 'nomad-ui/tests/pages/jobs/list';
    12  
    13  let managementToken, clientToken;
    14  
    15  function getLatestRecommendationSubmitTimeForJob(job) {
    16    const tasks = job.taskGroups.models
    17      .mapBy('tasks.models')
    18      .reduce((tasks, taskModels) => tasks.concat(taskModels), []);
    19    const recommendations = tasks.reduce(
    20      (recommendations, task) => recommendations.concat(task.recommendations.models),
    21      []
    22    );
    23    return Math.max(...recommendations.mapBy('submitTime'));
    24  }
    25  
    26  module('Acceptance | optimize', function(hooks) {
    27    setupApplicationTest(hooks);
    28    setupMirage(hooks);
    29  
    30    hooks.beforeEach(async function() {
    31      server.create('feature', { name: 'Dynamic Application Sizing' });
    32  
    33      server.create('node');
    34  
    35      server.createList('namespace', 2);
    36  
    37      const jobs = server.createList('job', 2, {
    38        createRecommendations: true,
    39        groupsCount: 1,
    40        groupTaskCount: 2,
    41        namespaceId: server.db.namespaces[1].id,
    42      });
    43  
    44      jobs.sort((jobA, jobB) => {
    45        return (
    46          getLatestRecommendationSubmitTimeForJob(jobB) -
    47          getLatestRecommendationSubmitTimeForJob(jobA)
    48        );
    49      });
    50  
    51      [this.job1, this.job2] = jobs;
    52  
    53      managementToken = server.create('token');
    54      clientToken = server.create('token');
    55  
    56      window.localStorage.clear();
    57      window.localStorage.nomadTokenSecret = managementToken.secretId;
    58    });
    59  
    60    test('it passes an accessibility audit', async function(assert) {
    61      await Optimize.visit();
    62      await a11yAudit(assert);
    63    });
    64  
    65    test('lets recommendations be toggled, reports the choices to the recommendations API, and displays task group recommendations serially', async function(assert) {
    66      const currentTaskGroup = this.job1.taskGroups.models[0];
    67      const nextTaskGroup = this.job2.taskGroups.models[0];
    68  
    69      const currentTaskGroupHasCPURecommendation = currentTaskGroup.tasks.models
    70        .mapBy('recommendations.models')
    71        .flat()
    72        .find(r => r.resource === 'CPU');
    73  
    74      // If no CPU recommendation, will not be able to accept recommendation with all memory recommendations turned off
    75  
    76      if (!currentTaskGroupHasCPURecommendation) {
    77        const currentTaskGroupTask = currentTaskGroup.tasks.models[0];
    78        this.server.create('recommendation', {
    79          task: currentTaskGroupTask,
    80          resource: 'CPU',
    81        });
    82      }
    83  
    84      await Optimize.visit();
    85  
    86      assert.equal(Layout.breadcrumbFor('optimize').text, 'Recommendations');
    87  
    88      assert.equal(
    89        Optimize.recommendationSummaries[0].slug,
    90        `${this.job1.name} / ${currentTaskGroup.name}`
    91      );
    92  
    93      assert.equal(
    94        Layout.breadcrumbFor('optimize.summary').text,
    95        `${this.job1.name} / ${currentTaskGroup.name}`
    96      );
    97  
    98      assert.equal(Optimize.recommendationSummaries[0].namespace, this.job1.namespace);
    99  
   100      assert.equal(
   101        Optimize.recommendationSummaries[1].slug,
   102        `${this.job2.name} / ${nextTaskGroup.name}`
   103      );
   104  
   105      const currentRecommendations = currentTaskGroup.tasks.models.reduce(
   106        (recommendations, task) => recommendations.concat(task.recommendations.models),
   107        []
   108      );
   109      const latestSubmitTime = Math.max(...currentRecommendations.mapBy('submitTime'));
   110  
   111      Optimize.recommendationSummaries[0].as(summary => {
   112        assert.equal(
   113          summary.date,
   114          moment(new Date(latestSubmitTime / 1000000)).format('MMM DD HH:mm:ss ZZ')
   115        );
   116  
   117        const currentTaskGroupAllocations = server.schema.allocations.where({
   118          jobId: currentTaskGroup.job.name,
   119          taskGroup: currentTaskGroup.name,
   120        });
   121        assert.equal(summary.allocationCount, currentTaskGroupAllocations.length);
   122  
   123        const { currCpu, currMem } = currentTaskGroup.tasks.models.reduce(
   124          (currentResources, task) => {
   125            currentResources.currCpu += task.resources.CPU;
   126            currentResources.currMem += task.resources.MemoryMB;
   127            return currentResources;
   128          },
   129          { currCpu: 0, currMem: 0 }
   130        );
   131  
   132        const { recCpu, recMem } = currentRecommendations.reduce(
   133          (recommendedResources, recommendation) => {
   134            if (recommendation.resource === 'CPU') {
   135              recommendedResources.recCpu += recommendation.value;
   136            } else {
   137              recommendedResources.recMem += recommendation.value;
   138            }
   139  
   140            return recommendedResources;
   141          },
   142          { recCpu: 0, recMem: 0 }
   143        );
   144  
   145        const cpuDiff = recCpu > 0 ? recCpu - currCpu : 0;
   146        const memDiff = recMem > 0 ? recMem - currMem : 0;
   147  
   148        const cpuSign = cpuDiff > 0 ? '+' : '';
   149        const memSign = memDiff > 0 ? '+' : '';
   150  
   151        const cpuDiffPercent = Math.round((100 * cpuDiff) / currCpu);
   152        const memDiffPercent = Math.round((100 * memDiff) / currMem);
   153  
   154        assert.equal(
   155          summary.cpu,
   156          cpuDiff ? `${cpuSign}${cpuDiff} MHz ${cpuSign}${cpuDiffPercent}%` : ''
   157        );
   158        assert.equal(
   159          summary.memory,
   160          memDiff ? `${memSign}${formattedMemDiff(memDiff)} ${memSign}${memDiffPercent}%` : ''
   161        );
   162  
   163        assert.equal(
   164          summary.aggregateCpu,
   165          cpuDiff ? `${cpuSign}${cpuDiff * currentTaskGroupAllocations.length} MHz` : ''
   166        );
   167  
   168        assert.equal(
   169          summary.aggregateMemory,
   170          memDiff ? `${memSign}${formattedMemDiff(memDiff * currentTaskGroupAllocations.length)}` : ''
   171        );
   172      });
   173  
   174      assert.ok(Optimize.recommendationSummaries[0].isActive);
   175      assert.notOk(Optimize.recommendationSummaries[1].isActive);
   176  
   177      assert.equal(Optimize.card.slug.jobName, this.job1.name);
   178      assert.equal(Optimize.card.slug.groupName, currentTaskGroup.name);
   179  
   180      const summaryMemoryBefore = Optimize.recommendationSummaries[0].memory;
   181  
   182      let toggledAnything = true;
   183  
   184      // Toggle off all memory
   185      if (Optimize.card.togglesTable.toggleAllMemory.isPresent) {
   186        await Optimize.card.togglesTable.toggleAllMemory.toggle();
   187  
   188        assert.notOk(Optimize.card.togglesTable.tasks[0].memory.isActive);
   189        assert.notOk(Optimize.card.togglesTable.tasks[1].memory.isActive);
   190      } else if (!Optimize.card.togglesTable.tasks[0].cpu.isDisabled) {
   191        await Optimize.card.togglesTable.tasks[0].memory.toggle();
   192      } else {
   193        toggledAnything = false;
   194      }
   195  
   196      assert.equal(
   197        Optimize.recommendationSummaries[0].memory,
   198        summaryMemoryBefore,
   199        'toggling recommendations doesn’t affect the summary table diffs'
   200      );
   201  
   202      const currentTaskIds = currentTaskGroup.tasks.models.mapBy('id');
   203      const taskIdFilter = task => currentTaskIds.includes(task.taskId);
   204  
   205      const cpuRecommendationIds = server.schema.recommendations
   206        .where({ resource: 'CPU' })
   207        .models.filter(taskIdFilter)
   208        .mapBy('id');
   209  
   210      const memoryRecommendationIds = server.schema.recommendations
   211        .where({ resource: 'MemoryMB' })
   212        .models.filter(taskIdFilter)
   213        .mapBy('id');
   214  
   215      const appliedIds = toggledAnything ? cpuRecommendationIds : memoryRecommendationIds;
   216      const dismissedIds = toggledAnything ? memoryRecommendationIds : [];
   217  
   218      await Optimize.card.acceptButton.click();
   219  
   220      const request = server.pretender.handledRequests.filterBy('method', 'POST').pop();
   221      const { Apply, Dismiss } = JSON.parse(request.requestBody);
   222  
   223      assert.equal(request.url, '/v1/recommendations/apply');
   224  
   225      assert.deepEqual(Apply, appliedIds);
   226      assert.deepEqual(Dismiss, dismissedIds);
   227  
   228      assert.equal(Optimize.card.slug.jobName, this.job2.name);
   229      assert.equal(Optimize.card.slug.groupName, nextTaskGroup.name);
   230  
   231      assert.ok(Optimize.recommendationSummaries[1].isActive);
   232    });
   233  
   234    test('can navigate between summaries via the table', async function(assert) {
   235      server.createList('job', 10, {
   236        createRecommendations: true,
   237        groupsCount: 1,
   238        groupTaskCount: 2,
   239        namespaceId: server.db.namespaces[1].id,
   240      });
   241  
   242      await Optimize.visit();
   243      await Optimize.recommendationSummaries[1].click();
   244  
   245      assert.equal(
   246        `${Optimize.card.slug.jobName} / ${Optimize.card.slug.groupName}`,
   247        Optimize.recommendationSummaries[1].slug
   248      );
   249      assert.ok(Optimize.recommendationSummaries[1].isActive);
   250    });
   251  
   252    test('can visit a summary directly via URL', async function(assert) {
   253      server.createList('job', 10, {
   254        createRecommendations: true,
   255        groupsCount: 1,
   256        groupTaskCount: 2,
   257        namespaceId: server.db.namespaces[1].id,
   258      });
   259  
   260      await Optimize.visit();
   261  
   262      const lastSummary =
   263        Optimize.recommendationSummaries[Optimize.recommendationSummaries.length - 1];
   264      const collapsedSlug = lastSummary.slug.replace(' / ', '/');
   265  
   266      // preferable to use page object’s visitable but it encodes the slash
   267      await visit(`/optimize/${collapsedSlug}?namespace=${lastSummary.namespace}`);
   268  
   269      assert.equal(
   270        `${Optimize.card.slug.jobName} / ${Optimize.card.slug.groupName}`,
   271        lastSummary.slug
   272      );
   273      assert.ok(lastSummary.isActive);
   274      assert.equal(currentURL(), `/optimize/${collapsedSlug}?namespace=${lastSummary.namespace}`);
   275    });
   276  
   277    test('when a summary is not found, an error message is shown, but the URL persists', async function(assert) {
   278      await visit('/optimize/nonexistent/summary?namespace=anamespace');
   279  
   280      assert.equal(currentURL(), '/optimize/nonexistent/summary?namespace=anamespace');
   281      assert.ok(Optimize.applicationError.isPresent);
   282      assert.equal(Optimize.applicationError.title, 'Not Found');
   283    });
   284  
   285    test('cannot return to already-processed summaries', async function(assert) {
   286      await Optimize.visit();
   287      await Optimize.card.acceptButton.click();
   288  
   289      assert.ok(Optimize.recommendationSummaries[0].isDisabled);
   290  
   291      await Optimize.recommendationSummaries[0].click();
   292  
   293      assert.ok(Optimize.recommendationSummaries[1].isActive);
   294    });
   295  
   296    test('can dismiss a set of recommendations', async function(assert) {
   297      await Optimize.visit();
   298  
   299      const currentTaskGroup = this.job1.taskGroups.models[0];
   300      const currentTaskIds = currentTaskGroup.tasks.models.mapBy('id');
   301      const taskIdFilter = task => currentTaskIds.includes(task.taskId);
   302  
   303      const idsBeforeDismissal = server.schema.recommendations
   304        .all()
   305        .models.filter(taskIdFilter)
   306        .mapBy('id');
   307  
   308      await Optimize.card.dismissButton.click();
   309  
   310      const request = server.pretender.handledRequests.filterBy('method', 'POST').pop();
   311      const { Apply, Dismiss } = JSON.parse(request.requestBody);
   312  
   313      assert.equal(request.url, '/v1/recommendations/apply');
   314  
   315      assert.deepEqual(Apply, []);
   316      assert.deepEqual(Dismiss, idsBeforeDismissal);
   317    });
   318  
   319    test('it displays an error encountered trying to save and proceeds to the next summary when the error is dismissed', async function(assert) {
   320      server.post('/recommendations/apply', function() {
   321        return new Response(500, {}, null);
   322      });
   323  
   324      await Optimize.visit();
   325      await Optimize.card.acceptButton.click();
   326  
   327      assert.ok(Optimize.error.isPresent);
   328      assert.equal(Optimize.error.headline, 'Recommendation error');
   329      assert.equal(
   330        Optimize.error.errors,
   331        'Error: Ember Data Request POST /v1/recommendations/apply returned a 500 Payload (application/json)'
   332      );
   333  
   334      await Optimize.error.dismiss();
   335      assert.equal(Optimize.card.slug.jobName, this.job2.name);
   336    });
   337  
   338    test('it displays an empty message when there are no recommendations', async function(assert) {
   339      server.db.recommendations.remove();
   340      await Optimize.visit();
   341  
   342      assert.ok(Optimize.empty.isPresent);
   343      assert.equal(Optimize.empty.headline, 'No Recommendations');
   344    });
   345  
   346    test('it displays an empty message after all recommendations have been processed', async function(assert) {
   347      await Optimize.visit();
   348  
   349      await Optimize.card.acceptButton.click();
   350      await Optimize.card.acceptButton.click();
   351  
   352      assert.ok(Optimize.empty.isPresent);
   353    });
   354  
   355    test('it redirects to jobs and hides the gutter link when the token lacks permissions', async function(assert) {
   356      window.localStorage.nomadTokenSecret = clientToken.secretId;
   357      await Optimize.visit();
   358  
   359      assert.equal(currentURL(), '/jobs');
   360      assert.ok(Layout.gutter.optimize.isHidden);
   361    });
   362  
   363    test('it reloads partially-loaded jobs', async function(assert) {
   364      await JobsList.visit();
   365      await Optimize.visit();
   366  
   367      assert.equal(Optimize.recommendationSummaries.length, 2);
   368    });
   369  });
   370  
   371  module('Acceptance | optimize search and facets', function(hooks) {
   372    setupApplicationTest(hooks);
   373    setupMirage(hooks);
   374  
   375    hooks.beforeEach(async function() {
   376      server.create('feature', { name: 'Dynamic Application Sizing' });
   377  
   378      server.create('node');
   379  
   380      server.createList('namespace', 2);
   381  
   382      managementToken = server.create('token');
   383  
   384      window.localStorage.clear();
   385      window.localStorage.nomadTokenSecret = managementToken.secretId;
   386    });
   387  
   388    test('search field narrows summary table results, changes the active summary if it no longer matches, and displays a no matches message when there are none', async function(assert) {
   389      server.create('job', {
   390        name: 'zzzzzz',
   391        createRecommendations: true,
   392        groupsCount: 1,
   393        groupTaskCount: 6,
   394      });
   395  
   396      // Ensure this job’s recommendations are sorted to the top of the table
   397      const futureSubmitTime = (Date.now() + 10000) * 1000000;
   398      server.db.recommendations.update({ submitTime: futureSubmitTime });
   399  
   400      server.create('job', {
   401        name: 'oooooo',
   402        createRecommendations: true,
   403        groupsCount: 2,
   404        groupTaskCount: 4,
   405      });
   406  
   407      server.create('job', {
   408        name: 'pppppp',
   409        createRecommendations: true,
   410        groupsCount: 2,
   411        groupTaskCount: 4,
   412      });
   413  
   414      await Optimize.visit();
   415  
   416      assert.equal(Optimize.card.slug.jobName, 'zzzzzz');
   417  
   418      assert.equal(
   419        Optimize.search.placeholder,
   420        `Search ${Optimize.recommendationSummaries.length} recommendations...`
   421      );
   422  
   423      await Optimize.search.fillIn('ooo');
   424  
   425      assert.equal(Optimize.recommendationSummaries.length, 2);
   426      assert.ok(Optimize.recommendationSummaries[0].slug.startsWith('oooooo'));
   427  
   428      assert.equal(Optimize.card.slug.jobName, 'oooooo');
   429      assert.ok(currentURL().includes('oooooo'));
   430  
   431      await Optimize.search.fillIn('qqq');
   432  
   433      assert.notOk(Optimize.card.isPresent);
   434      assert.ok(Optimize.empty.isPresent);
   435      assert.equal(Optimize.empty.headline, 'No Matches');
   436      assert.equal(currentURL(), '/optimize?search=qqq');
   437  
   438      await Optimize.search.fillIn('');
   439  
   440      assert.equal(Optimize.card.slug.jobName, 'zzzzzz');
   441      assert.ok(Optimize.recommendationSummaries[0].isActive);
   442    });
   443  
   444    test('turning off the namespaces toggle narrows summaries to only the current namespace and changes an active summary if it has become filtered out', async function(assert) {
   445      server.create('job', {
   446        name: 'pppppp',
   447        createRecommendations: true,
   448        groupsCount: 1,
   449        groupTaskCount: 4,
   450        namespaceId: server.db.namespaces[1].id,
   451      });
   452  
   453      // Ensure this job’s recommendations are sorted to the top of the table
   454      const futureSubmitTime = (Date.now() + 10000) * 1000000;
   455      server.db.recommendations.update({ submitTime: futureSubmitTime });
   456  
   457      server.create('job', {
   458        name: 'oooooo',
   459        createRecommendations: true,
   460        groupsCount: 1,
   461        groupTaskCount: 6,
   462        namespaceId: server.db.namespaces[0].id,
   463      });
   464  
   465      await Optimize.visit();
   466  
   467      assert.ok(Optimize.allNamespacesToggle.isActive);
   468  
   469      await Optimize.allNamespacesToggle.toggle();
   470  
   471      assert.equal(Optimize.recommendationSummaries.length, 1);
   472      assert.ok(Optimize.recommendationSummaries[0].slug.startsWith('ooo'));
   473      assert.ok(currentURL().includes('all-namespaces=false'));
   474      assert.equal(Optimize.card.slug.jobName, 'oooooo');
   475    });
   476  
   477    test('the namespaces toggle doesn’t show when there aren’t namespaces', async function(assert) {
   478      server.db.namespaces.remove();
   479  
   480      server.create('job', {
   481        createRecommendations: true,
   482        groupsCount: 1,
   483        groupTaskCount: 4,
   484      });
   485  
   486      await Optimize.visit();
   487  
   488      assert.ok(Optimize.allNamespacesToggle.isHidden);
   489    });
   490  
   491    test('processing a summary moves to the next one in the sorted list', async function(assert) {
   492      server.create('job', {
   493        name: 'ooo111',
   494        createRecommendations: true,
   495        groupsCount: 1,
   496        groupTaskCount: 4,
   497      });
   498  
   499      server.create('job', {
   500        name: 'pppppp',
   501        createRecommendations: true,
   502        groupsCount: 1,
   503        groupTaskCount: 4,
   504      });
   505  
   506      server.create('job', {
   507        name: 'ooo222',
   508        createRecommendations: true,
   509        groupsCount: 1,
   510        groupTaskCount: 4,
   511      });
   512  
   513      // Directly set the sorting of the above jobs’s summaries in the table
   514      const futureSubmitTime = (Date.now() + 10000) * 1000000;
   515      const nowSubmitTime = Date.now() * 1000000;
   516      const pastSubmitTime = (Date.now() - 10000) * 1000000;
   517  
   518      const jobNameToRecommendationSubmitTime = {
   519        ooo111: futureSubmitTime,
   520        pppppp: nowSubmitTime,
   521        ooo222: pastSubmitTime,
   522      };
   523  
   524      server.schema.recommendations.all().models.forEach(recommendation => {
   525        const parentJob = recommendation.task.taskGroup.job;
   526        const submitTimeForJob = jobNameToRecommendationSubmitTime[parentJob.name];
   527        recommendation.submitTime = submitTimeForJob;
   528        recommendation.save();
   529      });
   530  
   531      await Optimize.visit();
   532      await Optimize.search.fillIn('ooo');
   533      await Optimize.card.acceptButton.click();
   534  
   535      assert.equal(Optimize.card.slug.jobName, 'ooo222');
   536    });
   537  
   538    test('the optimize page has appropriate faceted search options', async function(assert) {
   539      server.createList('job', 4, {
   540        status: 'running',
   541        createRecommendations: true,
   542        childrenCount: 0,
   543      });
   544  
   545      await Optimize.visit();
   546  
   547      assert.ok(Optimize.facets.type.isPresent, 'Type facet found');
   548      assert.ok(Optimize.facets.status.isPresent, 'Status facet found');
   549      assert.ok(Optimize.facets.datacenter.isPresent, 'Datacenter facet found');
   550      assert.ok(Optimize.facets.prefix.isPresent, 'Prefix facet found');
   551    });
   552  
   553    testFacet('Type', {
   554      facet: Optimize.facets.type,
   555      paramName: 'type',
   556      expectedOptions: ['Service', 'System'],
   557      async beforeEach() {
   558        server.createList('job', 2, {
   559          type: 'service',
   560          createRecommendations: true,
   561          groupsCount: 1,
   562          groupTaskCount: 2,
   563        });
   564  
   565        server.createList('job', 2, {
   566          type: 'system',
   567          createRecommendations: true,
   568          groupsCount: 1,
   569          groupTaskCount: 2,
   570        });
   571        await Optimize.visit();
   572      },
   573      filter(taskGroup, selection) {
   574        let displayType = taskGroup.job.type;
   575        return selection.includes(displayType);
   576      },
   577    });
   578  
   579    testFacet('Status', {
   580      facet: Optimize.facets.status,
   581      paramName: 'status',
   582      expectedOptions: ['Pending', 'Running', 'Dead'],
   583      async beforeEach() {
   584        server.createList('job', 2, {
   585          status: 'pending',
   586          createRecommendations: true,
   587          groupsCount: 1,
   588          groupTaskCount: 2,
   589          childrenCount: 0,
   590        });
   591        server.createList('job', 2, {
   592          status: 'running',
   593          createRecommendations: true,
   594          groupsCount: 1,
   595          groupTaskCount: 2,
   596          childrenCount: 0,
   597        });
   598        server.createList('job', 2, {
   599          status: 'dead',
   600          createRecommendations: true,
   601          childrenCount: 0,
   602        });
   603        await Optimize.visit();
   604      },
   605      filter: (taskGroup, selection) => selection.includes(taskGroup.job.status),
   606    });
   607  
   608    testFacet('Datacenter', {
   609      facet: Optimize.facets.datacenter,
   610      paramName: 'dc',
   611      expectedOptions(jobs) {
   612        const allDatacenters = new Set(
   613          jobs.mapBy('datacenters').reduce((acc, val) => acc.concat(val), [])
   614        );
   615        return Array.from(allDatacenters).sort();
   616      },
   617      async beforeEach() {
   618        server.create('job', {
   619          datacenters: ['pdx', 'lax'],
   620          createRecommendations: true,
   621          groupsCount: 1,
   622          groupTaskCount: 2,
   623          childrenCount: 0,
   624        });
   625        server.create('job', {
   626          datacenters: ['pdx', 'ord'],
   627          createRecommendations: true,
   628          groupsCount: 1,
   629          groupTaskCount: 2,
   630          childrenCount: 0,
   631        });
   632        server.create('job', {
   633          datacenters: ['lax', 'jfk'],
   634          createRecommendations: true,
   635          groupsCount: 1,
   636          groupTaskCount: 2,
   637          childrenCount: 0,
   638        });
   639        server.create('job', {
   640          datacenters: ['jfk', 'dfw'],
   641          createRecommendations: true,
   642          groupsCount: 1,
   643          groupTaskCount: 2,
   644          childrenCount: 0,
   645        });
   646        server.create('job', { datacenters: ['pdx'], createRecommendations: true, childrenCount: 0 });
   647        await Optimize.visit();
   648      },
   649      filter: (taskGroup, selection) => taskGroup.job.datacenters.find(dc => selection.includes(dc)),
   650    });
   651  
   652    testFacet('Prefix', {
   653      facet: Optimize.facets.prefix,
   654      paramName: 'prefix',
   655      expectedOptions: ['hashi (3)', 'nmd (2)', 'pre (2)'],
   656      async beforeEach() {
   657        [
   658          'pre-one',
   659          'hashi_one',
   660          'nmd.one',
   661          'one-alone',
   662          'pre_two',
   663          'hashi.two',
   664          'hashi-three',
   665          'nmd_two',
   666          'noprefix',
   667        ].forEach(name => {
   668          server.create('job', {
   669            name,
   670            createRecommendations: true,
   671            createAllocations: true,
   672            groupsCount: 1,
   673            groupTaskCount: 2,
   674            childrenCount: 0,
   675          });
   676        });
   677        await Optimize.visit();
   678      },
   679      filter: (taskGroup, selection) =>
   680        selection.find(prefix => taskGroup.job.name.startsWith(prefix)),
   681    });
   682  
   683    function testFacet(label, { facet, paramName, beforeEach, filter, expectedOptions }) {
   684      test(`the ${label} facet has the correct options`, async function(assert) {
   685        await beforeEach();
   686        await facet.toggle();
   687  
   688        let expectation;
   689        if (typeof expectedOptions === 'function') {
   690          expectation = expectedOptions(server.db.jobs);
   691        } else {
   692          expectation = expectedOptions;
   693        }
   694  
   695        assert.deepEqual(
   696          facet.options.map(option => option.label.trim()),
   697          expectation,
   698          'Options for facet are as expected'
   699        );
   700      });
   701  
   702      test(`the ${label} facet filters the recommendation summaries by ${label}`, async function(assert) {
   703        let option;
   704  
   705        await beforeEach();
   706        await facet.toggle();
   707  
   708        option = facet.options.objectAt(0);
   709        await option.toggle();
   710  
   711        const selection = [option.key];
   712  
   713        const sortedRecommendations = server.db.recommendations.sortBy('submitTime').reverse();
   714  
   715        const recommendationTaskGroups = server.schema.tasks
   716          .find(sortedRecommendations.mapBy('taskId').uniq())
   717          .models.mapBy('taskGroup')
   718          .uniqBy('id')
   719          .filter(group => filter(group, selection));
   720  
   721        Optimize.recommendationSummaries.forEach((summary, index) => {
   722          const group = recommendationTaskGroups[index];
   723          assert.equal(summary.slug, `${group.job.name} / ${group.name}`);
   724        });
   725      });
   726  
   727      test(`selecting multiple options in the ${label} facet results in a broader search`, async function(assert) {
   728        const selection = [];
   729  
   730        await beforeEach();
   731        await facet.toggle();
   732  
   733        const option1 = facet.options.objectAt(0);
   734        const option2 = facet.options.objectAt(1);
   735        await option1.toggle();
   736        selection.push(option1.key);
   737        await option2.toggle();
   738        selection.push(option2.key);
   739  
   740        const sortedRecommendations = server.db.recommendations.sortBy('submitTime').reverse();
   741  
   742        const recommendationTaskGroups = server.schema.tasks
   743          .find(sortedRecommendations.mapBy('taskId').uniq())
   744          .models.mapBy('taskGroup')
   745          .uniqBy('id')
   746          .filter(group => filter(group, selection));
   747  
   748        Optimize.recommendationSummaries.forEach((summary, index) => {
   749          const group = recommendationTaskGroups[index];
   750          assert.equal(summary.slug, `${group.job.name} / ${group.name}`);
   751        });
   752      });
   753  
   754      test(`selecting options in the ${label} facet updates the ${paramName} query param`, async function(assert) {
   755        const selection = [];
   756  
   757        await beforeEach();
   758        await facet.toggle();
   759  
   760        const option1 = facet.options.objectAt(0);
   761        const option2 = facet.options.objectAt(1);
   762        await option1.toggle();
   763        selection.push(option1.key);
   764        await option2.toggle();
   765        selection.push(option2.key);
   766  
   767        assert.ok(currentURL().includes(encodeURIComponent(JSON.stringify(selection))));
   768      });
   769    }
   770  });
   771  
   772  function formattedMemDiff(memDiff) {
   773    const absMemDiff = Math.abs(memDiff);
   774    const negativeSign = memDiff < 0 ? '-' : '';
   775  
   776    if (absMemDiff >= 1024) {
   777      const gibDiff = absMemDiff / 1024;
   778  
   779      if (Number.isInteger(gibDiff)) {
   780        return `${negativeSign}${gibDiff} GiB`;
   781      } else {
   782        return `${negativeSign}${gibDiff.toFixed(2)} GiB`;
   783      }
   784    } else {
   785      return `${negativeSign}${absMemDiff} MiB`;
   786    }
   787  }