diff --git a/apps/dashboard/app/javascript/packs/batchConnect.js b/apps/dashboard/app/javascript/packs/batchConnect.js new file mode 100644 index 0000000000..d731a60700 --- /dev/null +++ b/apps/dashboard/app/javascript/packs/batchConnect.js @@ -0,0 +1,579 @@ +'use strict'; + +const bcPrefix = 'batch_connect_session_context'; +const shortNameRex = new RegExp(`${bcPrefix}_([\\w\\-]+)`); +const tokenRex = /([A-Z][a-z]+){1}([\w\-]+)/; + +// @example ['NodeType', 'Cluster'] +const formTokens = []; + +// simple lookup table to indicate that the change handler is setup between two +// elements. I.e., {'cluster': [ 'node_type' ] } means that changes to cluster +// trigger changes to node_type +const optionForHandlerCache = {}; + + +// simples array of string ids for elements that have a handler +const minMaxHandlerCache = []; +const setHandlerCache = []; +const hideHandlerCache = []; + +// Lookup tables for setting min & max values +// for different directives. +const minMaxLookup = {}; +const setValueLookup = {}; +const hideLookup = {}; + +function bcElement(name) { + return `${bcPrefix}_${name.toLowerCase()}`; +}; + +// here the simple name for 'batch_connect_session_context_cluster' +// is just 'cluster'. +function shortId(elementId) { + const match = elementId.match(shortNameRex); + + if (match.length >= 1) { + return match[1]; + } else { + return ''; + }; +}; + +/** + * Mountain case the words from a string, by tokenizing on [-_]. In the + * simplest case it simple capitalizes. + * + * @param {string} str The word string to capitalize + * + * @example given 'foo' this returns 'Foo' + * @example given 'foo-bar' this returns 'FooBar' + */ + function mountainCaseWords(str) { + let camelCase = ""; + let capitalize = true; + + str.split('').forEach((c) => { + if (capitalize) { + camelCase += c.toUpperCase(); + capitalize = false; + } else if(c == '-' || c == '_') { + capitalize = true; + } else { + camelCase += c; + } + }); + + return camelCase; +} + +function snakeCaseWords(str) { + if(str === undefined) return undefined; + + let snakeCase = ""; + let first = true; + + str.split('').forEach((c) => { + if (first) { + first = false; + snakeCase += c.toLowerCase(); + } else if(c === '-' || c === '_') { + snakeCase += '_'; + } else if(c == c.toUpperCase() && !(c >= '0' && c <= '9')) { + snakeCase += `_${c.toLowerCase()}`; + } else { + snakeCase += c; + } + }); + + return snakeCase; +} + +/** + * + * @param {Array} elements + */ +function memorizeElements(elements) { + elements.each((_i, ele) => { + formTokens.push(mountainCaseWords(shortId(ele['id']))); + optionForHandlerCache[ele['id']] = []; + }); +}; + +function makeChangeHandlers(){ + const allElements = $(`[id^=${bcPrefix}]`); + memorizeElements(allElements); + + allElements.each((_i, element) => { + if (element['type'] == "select-one"){ + let optionSearch = `#${element['id']} option`; + let options = $(optionSearch); + options.each((_i, opt) => { + // the variable 'opt' is just a data structure, not a jQuery result. + // it has no attr, data, show or hide methods so we have to query + // for it again + let data = $(`${optionSearch}[value='${opt.value}']`).data(); + let keys = Object.keys(data); + if(keys.length !== 0) { + keys.forEach((key) => { + if(key.startsWith('optionFor')) { + let token = key.replace(/^optionFor/,''); + addOptionForHandler(idFromToken(token), element['id']); + } else if(key.startsWith('max') || key.startsWith('min')) { + addMinMaxForHandler(element['id'], opt.value, key, data[key]); + } else if(key.startsWith('set')) { + addSetHandler(element['id'], opt.value, key, data[key]); + } else if(key.startsWith('hide')) { + addHideHandler(element['id'], opt.value, key, data[key]); + } + }); + } + }); + } + }); +}; + +function addHideHandler(optionId, option, key, configValue) { + const changeId = idFromToken(key.replace(/^hide/,'')); + + if(hideLookup[optionId] === undefined) hideLookup[optionId] = new Table(changeId, undefined); + const table = hideLookup[optionId]; + table.put(option, undefined, configValue); + + if(!hideHandlerCache.includes(optionId)) { + const changeElement = $(`#${optionId}`); + + changeElement.on('change', (event) => { + updateVisibility(event, changeId); + }); + + hideHandlerCache.push(optionId); + } + + updateVisibility({ target: document.querySelector(`#${optionId}`) }, changeId); +} + +/** + * + * @param {*} optionId batch_connect_session_context_node_type + * @param {*} option gpu + * @param {*} key maxNumCoresForClusterAnnieOakley + * @param {*} configValue 42 + * + * node_type: + * widget: select + * options: + * - [ + * 'gpu', + * data-max-num-cores-for-cluster-annie-oakley: 42 + * ] + */ +function addMinMaxForHandler(optionId, option, key, configValue) { + optionId = String(optionId || ''); + + const configObj = parseMinMaxFor(key); + const id = configObj['subjectId']; + // this is the id of the target object we're setting the min/max for. + // if it's undefined - there's nothing to do, it was likely configured wrong. + if(id === undefined) return; + + const secondDimId = configObj['predicateId']; + const secondDimValue = configObj['predicateValue']; + + if(minMaxLookup[id] === undefined) minMaxLookup[id] = new Table(optionId, secondDimId); + const table = minMaxLookup[id]; + table.put(option, secondDimValue, {[minOrMax(key)] : configValue }); + + let cacheKey = `${optionId}_${secondDimId}`; + if(!minMaxHandlerCache.includes(cacheKey)) { + const changeElement = $(`#${optionId}`); + + changeElement.on('change', (event) => { + toggleMinMax(event, id, secondDimId); + }); + + minMaxHandlerCache.push(cacheKey); + } + + cacheKey = `${secondDimId}_${optionId}`; + if(secondDimId !== undefined && !minMaxHandlerCache.includes(cacheKey)){ + const secondEle = $(`#${secondDimId}`); + + secondEle.on('change', (event) => { + toggleMinMax(event, id, optionId); + }); + + minMaxHandlerCache.push(cacheKey); + } +} + +/** + * + * @param {*} optionId batch_connect_session_context_classroom + * @param {*} option 'PHY_9000' + * @param {*} key setAccount + * @param {*} configValue 'phy3005' + * + * classroom: + * widget: select + * options: + * - [ + * 'Physics Maximum', 'PHY_9000', + * data-set-account: 'phy3005' + * ] + */ +function addSetHandler(optionId, option, key, configValue) { + const k = key.replace(/^set/,''); + const id = String(idFromToken(k)); + if(id === 'undefined') return; + + // id is account. optionId is classroom + let cacheKey = `${id}_${optionId}` + if(setValueLookup[cacheKey] === undefined) setValueLookup[cacheKey] = new Table(optionId, undefined); + const table = setValueLookup[cacheKey]; + table.put(option, undefined, configValue); + + if(!setHandlerCache.includes(cacheKey)) { + const changeElement = $(`#${optionId}`); + + changeElement.on('change', (event) => { + setValue(event, id); + }); + + setHandlerCache.push(cacheKey); + } + + setValue({ target: document.querySelector(`#${optionId}`) }, id); +} + +function setValue(event, changeId) { + const chosenVal = event.target.value; + const cacheKey = `${changeId}_${event.target['id']}` + const table = setValueLookup[cacheKey]; + if (table === undefined) return; + + const changeVal = table.get(chosenVal, undefined); + + if(changeVal !== undefined) { + const innerElement = $(`#${changeId}`); + innerElement.attr('value', changeVal); + innerElement.val(changeVal); + } +} + +/** + * + * This is a simple table class to describe the relationship between + * two different element types as a table with named columns. + * + * table.get('gpu','owens') would return the value shown. + * + * 'oakley' | | | + * 'owens' | { min: 3, max: 42} | | + * | 'gpu' | 'hugemem' | + * + * In the simple case, it's a 1d vector instead of a 2d matrix. This + * allows for, say, gpu to have the same min & max across clusters. + */ +class Table { + constructor(x, y) { + // FIXME: probably need to make Vector class? Wouldn't want to add a flag to the constructor. + // we don't use x or y internally, though x is used externally. + this.x = x; + this.xIdxLookup = {}; + + this.y = y; + this.yIdxLookup = {}; + this.table = y === undefined ? [] : [[]]; + } + + put(x, y, value) { + if(!x) return; + x = snakeCaseWords(x); + y = snakeCaseWords(y); + + if(this.xIdxLookup[x] === undefined) this.xIdxLookup[x] = Object.keys(this.xIdxLookup).length; + if(y && this.yIdxLookup[y] === undefined) this.yIdxLookup[y] = Object.keys(this.yIdxLookup).length; + + const xIdx = this.xIdxLookup[x]; + const yIdx = this.yIdxLookup[y]; + + if(this.table[xIdx] === undefined ){ + this.table[xIdx] = y === undefined ? undefined : []; + } + + // if y's index is defined, then it's a 2d matrix. Otherwise a 1d vector. + if(yIdx === undefined) { + if(this.table[xIdx] === undefined){ + this.table[xIdx] = value; + } else { + const prev = this.table[xIdx]; + const newer = value; + this.table[xIdx] = Object.assign(prev, newer); + } + } else { + if(this.table[xIdx][yIdx] === undefined){ + this.table[xIdx][yIdx] = value; + } else { + const prev = this.table[xIdx][yIdx]; + const newer = value; + this.table[xIdx][yIdx] = Object.assign(prev, newer); + } + } + } + + get(x, y) { + const xIdx = this.xIdxLookup[snakeCaseWords(x)]; + const yIdx = this.yIdxLookup[snakeCaseWords(y)]; + + if(this.table[xIdx] === undefined){ + return undefined; + }else if(y === undefined){ + return this.table[xIdx]; + }else { + return this.table[xIdx][yIdx]; + } + } +} + +/** + * Update the visibility of `changeId` based on the + * event and what's in the hideLookup table. + */ +function updateVisibility(event, changeId) { + const val = event.target.value; + const id = event.target['id']; + const changeElement = $(`#${changeId}`).parent(); + + if (changeElement.size() <= 0) return; + + // safe to access directly? + const hide = hideLookup[id].get(val, undefined); + if(hide === undefined) { + changeElement.show(); + }else if(hide === true) { + changeElement.hide(); + } +} + +function toggleMinMax(event, changeId, otherId) { + let x = undefined, y = undefined; + + // in the example of cluster & node_type, either element can trigger a change + // so let's figure out the axis' based on the change element's id. + if(event.target['id'] == minMaxLookup[changeId].x) { + x = snakeCaseWords(event.target.value); + y = snakeCaseWords($(`#${otherId}`).val()); + } else { + y = snakeCaseWords(event.target.value); + x = snakeCaseWords($(`#${otherId}`).val()); + } + + const changeElement = $(`#${changeId}`); + const mm = minMaxLookup[changeId].get(x, y); + const prev = { + min: changeElement.attr('min'), + max: changeElement.attr('max'), + val: changeElement.val() + }; + + [ 'max', 'min' ].forEach((dim) => { + if(mm && mm[dim] !== undefined) { + changeElement.attr(dim, mm[dim]); + + let val = clamp(prev['val'], prev[dim], mm[dim], dim) + if (val !== undefined) { + changeElement.attr('value', val); + changeElement.val(val); + } + } + }); +} + +function clamp(previous, previousBoundary, currentBoundary, comparator){ + const pb = parseInt(previousBoundary) || undefined; + const p = parseInt(previous) || undefined; + + if(comparator == 'min' && p <= pb) { + return currentBoundary; + } else if(comparator == 'max' && p >= pb) { + return currentBoundary; + } else { + return undefined; + } +} + +function addOptionForHandler(causeId, targetId) { + const changeId = String(causeId || ''); + + if(changeId.length == 0 || optionForHandlerCache[causeId].includes(targetId)) { + // nothing to do. invalid causeId or we already have a handler between the 2 + return; + } + + let causeElement = $(`#${causeId}`); + + if(targetId && causeElement) { + // cache the fact that there's a new handler here + optionForHandlerCache[causeId].push(targetId); + + causeElement.on('change', (event) => { + toggleOptionsFor(event, targetId); + }); + + // fake an event to initialize + toggleOptionsFor({ target: document.querySelector(`#${causeId}`) }, targetId); + } +}; + +/** + * + * @param {*} key minNumCoresForClusterAnnieOakley + * @returns + * + * { + * 'subjectId': 'batch_connect_session_context_num_cores', + * 'predicateId': 'batch_connect_session_context_cluster', + * 'predicateValue': 'annie_oakley' + * } + */ +function parseMinMaxFor(key) { + let k = undefined; + let predicateId = undefined; + let predicateValue = undefined; + let subjectId = undefined; + + if(key.startsWith('min')) { + k = key.replace(/^min/,''); + } else if(key.startsWith('max')) { + k = key.replace(/^max/, '') + } + + //trying to parse maxNumCoresForClusterOwens + const tokens = k.match(/^(\w+)For(\w+)$/); + + if(tokens == null) { + // the key is likely just maxNumCores with no For clause + subjectId = idFromToken(k); + + } else if(tokens.length == 3) { + const subject = tokens[1]; + const predicateFull = tokens[2]; + subjectId = idFromToken(subject); + + const predicateTokens = predicateFull.split(/(?=[A-Z])/); + if(predicateTokens && predicateTokens.length >= 2) { + + // if there are only 2 tokens then it's like 'ClusterOwens' which is easy + if(predicateTokens.length == 2) { + predicateId = idFromToken(predicateTokens[0]); + predicateValue = predicateTokens[1]; + + // else it's like NodeTypeFooBar, so it's a little more difficult + } else { + let tokenString = ''; + let done = false; + predicateTokens.forEach((pt, idx) => { + if(done) { return; } + + tokenString = `${tokenString}${pt}` + let tokenId = idFromToken(tokenString); + if(tokenId !== undefined) { + done = true; + predicateId = tokenId; + predicateValue = predicateTokens.slice(idx+1).join(''); + } + }) + } + } + } + + return { + 'subjectId': subjectId, + 'predicateId': predicateId, + 'predicateValue': snakeCaseWords(predicateValue), + } +} + +function minOrMax(key) { + if(key.startsWith('min')){ + return 'min'; + } else if(key.startsWith('max')){ + return 'max'; + } else { + return null; + } +} + +/** + * Turn a MountainCase token into a form element id + * + * @example + * NodeType -> batch_connect_session_context_node_type + * + * @param {*} str + * @returns + */ + +function idFromToken(str) { + return formTokens.map((token) => { + let match = str.match(`^${token}{1}`); + + if (match && match.length >= 1) { + let ele = snakeCaseWords(match[0]); + return bcElement(ele); + } + }).filter((id) => { + return id !== undefined; + })[0]; +} + +/** + * Hide or show options of an element based on which cluster is + * currently selected and the data-option-for-CLUSTER attributes + * for each option + * + * @param {string} element_name The name of the element with options to toggle + */ + function toggleOptionsFor(event, elementId) { + const options = $(`#${elementId} option`); + + // If I'm changing cluster to 'oakely', optionFor is 'Cluster' + // and optionTo is 'Oakley'. + const optionTo = mountainCaseWords(event.target.value); + const optionFor = optionForEvent(event.target); + + options.each(function(_i, option) { + // the variable 'option' is just a data structure. it has no attr, data, show + // or hide methods so we have to query for it again + let optionElement = $(`#${elementId} option[value='${option.value}']`); + let data = optionElement.data(); + let hide = data[`optionFor${optionFor}${optionTo}`] === false; + + if(hide) { + optionElement.hide(); + + if(optionElement.prop('selected')) { + optionElement.prop('selected', false); + + // when de-selecting something, the default is to fallback to the very first + // option. But there's an edge case where you want to hide the very first option, + // and deselecting it does nothing. + if(optionElement.next()){ + optionElement.next().prop('selected', true); + } + } + } else { + optionElement.show(); + } + }); +}; + +function optionForEvent(target) { + let simpleName = shortId(target['id']); + return mountainCaseWords(simpleName); +}; + +jQuery(function() { + makeChangeHandlers(); +}); diff --git a/apps/dashboard/app/views/batch_connect/session_contexts/_form.html.erb b/apps/dashboard/app/views/batch_connect/session_contexts/_form.html.erb index bbd715825f..56c68914d3 100644 --- a/apps/dashboard/app/views/batch_connect/session_contexts/_form.html.erb +++ b/apps/dashboard/app/views/batch_connect/session_contexts/_form.html.erb @@ -6,6 +6,8 @@ <%= f.submit t('dashboard.batch_connect_form_launch'), class: "btn btn-primary btn-block" %> <% end %> +<%= javascript_pack_tag 'batchConnect' if Configuration.bc_dynamic_js? %> + <% @app.custom_javascript_files.each do |jsfile| %> <%= javascript_tag "(function(){\n" + jsfile.read + "\n}());" %> <% end %> diff --git a/apps/dashboard/config/configuration_singleton.rb b/apps/dashboard/config/configuration_singleton.rb index 88f7aafa88..aab5fbdf42 100644 --- a/apps/dashboard/config/configuration_singleton.rb +++ b/apps/dashboard/config/configuration_singleton.rb @@ -378,6 +378,14 @@ def open_apps_in_new_window? end end + def bc_dynamic_js? + if ENV['OOD_BC_DYNAMIC_JS'] + to_bool(ENV['OOD_BC_DYNAMIC_JS']) + else + to_bool(config.fetch(:bc_dynamic_js, false)) + end + end + private def can_access_core_app?(name) diff --git a/apps/dashboard/test/application_system_test_case.rb b/apps/dashboard/test/application_system_test_case.rb index 7bf4a5729c..145faa98c5 100644 --- a/apps/dashboard/test/application_system_test_case.rb +++ b/apps/dashboard/test/application_system_test_case.rb @@ -10,4 +10,20 @@ class ApplicationSystemTestCase < ActionDispatch::SystemTestCase driven_by :selenium, using: :headless_chrome, screen_size: [1400, 1400] Capybara.server = :webrick + + def find_option_style(ele, opt) + find("##{bc_ele_id(ele)} option[value='#{opt}']")['style'].to_s + end + + def find_max(ele) + find("##{bc_ele_id(ele)}")['max'].to_i + end + + def find_min(ele) + find("##{bc_ele_id(ele)}")['min'].to_i + end + + def find_value(ele, visible: false) + find("##{bc_ele_id(ele)}", visible: visible).value + end end diff --git a/apps/dashboard/test/fixtures/sys_with_gateway_apps/bc_jupyter/form.yml b/apps/dashboard/test/fixtures/sys_with_gateway_apps/bc_jupyter/form.yml index d8a0b41105..11fd632ac5 100644 --- a/apps/dashboard/test/fixtures/sys_with_gateway_apps/bc_jupyter/form.yml +++ b/apps/dashboard/test/fixtures/sys_with_gateway_apps/bc_jupyter/form.yml @@ -1,29 +1,115 @@ --- title: "Jupyter Notebook" -cluster: "owens" +cluster: + - "owens" + - "oakley" description: | - This app will launch a Jupyter Notebook server on one or more Owens nodes. + This is a test jupyter app. attributes: - bc_account: - help: "You can leave this blank if **not** in multiple projects." + mode: + widget: "radio" + label: "The Mode" + value: "1" + options: + - ["1","Jupyter Lab"] + - ["0", "Jupyter Notebook"] + hidden_change_thing: + widget: 'hidden_field' + value: 'default' + bc_num_slots: + max: 20 + min: 1 node_type: widget: select label: "Node type" - help: | - - **any** - (*28 cores*) Chooses anyone of the available Owens nodes. - This reduces the wait time as you have no requirements. - - **gpu** - (*28 cores*) This node includes an NVIDIA Tesla P100 GPU - allowing for CUDA computations. There are currently only 160 of these - nodes on Owens. - - **hugemem** - (*48 cores*) This Owens node has 1.5TB of available RAM - as well as 48 cores. There are 16 of these nodes on Owens. options: - - ["any", ":ppn=28"] - - ["gpu", ":ppn=28:gpus=1"] - - ["hugemem", ":ppn=48:hugemem"] + - [ + "any", + data-hide-cuda-version: true, + data-minnn-bc-not-found-for-cluster-mistype: 30, + data-max-bc-not-found-for-cluster-mistype: 30, + data-maximum-bc-not-found-for-cluster-mistype: 30 + ] + - [ + "gpu", + # this bad option is kept here so that in testing, it doesn't throw errors + data-option-for-not-real-choice: false, + data-max-some-element-for-3rd-element-value: 10, + data-max-bc-num-slots-for-cluster-owens: 28, + data-min-bc-num-slots-for-cluster-owens: 2, + data-max-bc-num-slots-for-cluster-oakley: 40, + data-min-bc-num-slots-for-cluster-oakley: 3, + ] + - [ + "hugemem", + data-option-for-cluster-oakley: false, + data-hide-cuda-version: true, + data-max-bc-num-slots-for-cluster-owens: 42, + data-min-bc-num-slots-for-cluster-owens: 42, + ] + - [ + "advanced", + data-option-for-cluster-oakley: false, + data-hide-cuda-version: true, + data-max-bc-num-slots-for-cluster-oakley: 9001 + ] + # this node type is the same for both clusters, so there's no 'for-cluster-...' clause + # TODO: this currently breaks tests + - [ + "same", + data-hide-cuda-version: true, + data-min-bc-num-slots: 100, + data-max-bc-num-slots: 200 + ] + - [ + "other-40ish-option", + data-max-bc-num-slots-for-cluster-owens: 40, + data-max-bc-num-slots-for-cluster-oakley: 48, + ] + python_version: + # let's set the account used by the python version for some reason + widget: select + options: + - [ + "2.7", + data-option-for-node-type-advanced: false, + data-set-bc-account: 'python27' + ] + - [ + "3.1", + data-option-for-node-type-advanced: false, + data-set-bc-account: 'python31' + ] + - [ + "3.2", + data-option-for-node-type-advanced: false, + data-set-bc-account: 'python32' + ] + - [ + "3.6", + data-set-hidden-change-thing: 'python36' + ] + - [ + "3.7", + data-set-hidden-change-thing: 'python37', + ] + - [ + "4.0.nightly", + data-set-hidden-change-thing: 'python4nightly', + ] + cuda_version: + widget: select + options: + - "4" + - "7" + - "38" form: - bc_num_hours - bc_num_slots + - mode - node_type - bc_account - bc_email_on_started + - python_version + - cuda_version + - hidden_change_thing diff --git a/apps/dashboard/test/system/batch_connect_test.rb b/apps/dashboard/test/system/batch_connect_test.rb new file mode 100644 index 0000000000..a603a250c5 --- /dev/null +++ b/apps/dashboard/test/system/batch_connect_test.rb @@ -0,0 +1,275 @@ +# frozen_string_literal: true + +require 'application_system_test_case' + +class BatchConnectTest < ApplicationSystemTestCase + def setup + OodAppkit.stubs(:clusters).returns(OodCore::Clusters.load_file('test/fixtures/config/clusters.d')) + SysRouter.stubs(:base_path).returns(Rails.root.join('test/fixtures/sys_with_gateway_apps')) + Configuration.stubs(:bc_dynamic_js?).returns(true) + end + + test 'cluster choice changes node types' do + visit new_batch_connect_session_context_url('sys/bc_jupyter') + + # select oakley and 2 node types should be hidden + select('oakley', from: bc_ele_id('cluster')) + + # FIXME: no idea why .visible? doesn't work here. Selenium/chrome native still shows element as visible? + assert_equal 'display: none;', find_option_style('node_type', 'advanced') + assert_equal 'display: none;', find_option_style('node_type', 'hugemem') + + # select owens and now they're available + select('owens', from: bc_ele_id('cluster')) + assert_equal '', find_option_style('node_type', 'advanced') + assert_equal '', find_option_style('node_type', 'hugemem') + end + + test 'node type choice changes python versions' do + visit new_batch_connect_session_context_url('sys/bc_jupyter') + + # select python 2.7 to initialize things + select('owens', from: bc_ele_id('cluster')) + select('any', from: bc_ele_id('node_type')) + select('2.7', from: bc_ele_id('python_version')) + assert_equal '', find_option_style('python_version', '2.7') + + # now switch node type and find that 2.7, and more, are hidden and 3.6 is the choice now + select('advanced', from: bc_ele_id('node_type')) + assert_equal 'display: none;', find_option_style('python_version', '2.7') + assert_equal 'display: none;', find_option_style('python_version', '3.1') + assert_equal 'display: none;', find_option_style('python_version', '3.2') + assert_equal '3.6', find("##{bc_ele_id('python_version')}").value + end + + test 'changing node type changes mins & maxs' do + # max starts out at 20 + visit new_batch_connect_session_context_url('sys/bc_jupyter') + assert_equal 20, find_max('bc_num_slots') + assert_equal 1, find_min('bc_num_slots') + select('owens', from: bc_ele_id('cluster')) + + # change the node type and we should have some new min/max & value + select('gpu', from: bc_ele_id('node_type')) + assert_equal 28, find_max('bc_num_slots') + assert_equal 2, find_min('bc_num_slots') + assert_equal '2', find_value('bc_num_slots') + + select('hugemem', from: bc_ele_id('node_type')) + assert_equal 42, find_max('bc_num_slots') + assert_equal 42, find_min('bc_num_slots') + assert_equal '42', find_value('bc_num_slots') + end + + test 'changing the cluster changes max' do + # max starts out at 20 + visit new_batch_connect_session_context_url('sys/bc_jupyter') + assert_equal 20, find_max('bc_num_slots') + select('owens', from: bc_ele_id('cluster')) + + select('gpu', from: bc_ele_id('node_type')) + assert_equal 28, find_max('bc_num_slots') + + # changing the cluster changes the max + select('oakley', from: bc_ele_id('cluster')) + assert_equal 40, find_max('bc_num_slots') + end + + test 'using same node sets min/max' do + # max starts out at 20 + visit new_batch_connect_session_context_url('sys/bc_jupyter') + assert_equal 20, find_max('bc_num_slots') + + select('same', from: bc_ele_id('node_type')) + assert_equal 100, find_min('bc_num_slots') + assert_equal 200, find_max('bc_num_slots') + assert_equal '100', find_value('bc_num_slots') + + # toggle the cluster back and forth and it's still the same + select('oakley', from: bc_ele_id('cluster')) + select('owens', from: bc_ele_id('cluster')) + assert_equal 100, find_min('bc_num_slots') + assert_equal 200, find_max('bc_num_slots') + assert_equal '100', find_value('bc_num_slots') + + select('oakley', from: bc_ele_id('cluster')) + assert_equal 100, find_min('bc_num_slots') + assert_equal 200, find_max('bc_num_slots') + assert_equal '100', find_value('bc_num_slots') + end + + test 'nothing applied to any node type' do + visit new_batch_connect_session_context_url('sys/bc_jupyter') + assert_equal 20, find_max('bc_num_slots') + assert_equal 1, find_min('bc_num_slots') + assert_equal '1', find_value('bc_num_slots') + + # changing clusters does nothing. + select('owens', from: bc_ele_id('cluster')) + select('any', from: bc_ele_id('node_type')) + assert_equal 20, find_max('bc_num_slots') + assert_equal 1, find_min('bc_num_slots') + assert_equal '1', find_value('bc_num_slots') + + select('oakley', from: bc_ele_id('cluster')) + assert_equal 20, find_max('bc_num_slots') + assert_equal 1, find_min('bc_num_slots') + assert_equal '1', find_value('bc_num_slots') + + # choose same to get a min & max set. Change back to + # any and we keep the same min & max from same. + # TODO this is _current_ behaviour, will probably break + select('same', from: bc_ele_id('node_type')) + assert_equal 200, find_max('bc_num_slots') + assert_equal 100, find_min('bc_num_slots') + assert_equal '100', find_value('bc_num_slots') + select('any', from: bc_ele_id('node_type')) + assert_equal 200, find_max('bc_num_slots') + assert_equal 100, find_min('bc_num_slots') + assert_equal '100', find_value('bc_num_slots') + end + + test 'clamp min values' do + visit new_batch_connect_session_context_url('sys/bc_jupyter') + assert_equal '1', find_value('bc_num_slots') + + select('owens', from: bc_ele_id('cluster')) + select('gpu', from: bc_ele_id('node_type')) + # value gets set to the new min + assert_equal '2', find_value('bc_num_slots') + + # change clusters and it bumps up again + select('oakley', from: bc_ele_id('cluster')) + assert_equal '3', find_value('bc_num_slots') + + # edit the values, then change the cluster to ensure + # the change overwrites the edit + fill_in bc_ele_id('bc_num_slots'), with: 1 + assert_equal '1', find_value('bc_num_slots') + select('owens', from: bc_ele_id('cluster')) + assert_equal '2', find_value('bc_num_slots') + end + + test 'clamp max values' do + visit new_batch_connect_session_context_url('sys/bc_jupyter') + assert_equal '1', find_value('bc_num_slots') + # this tests filling values by design, bc we have to set a giant max right off the bat + fill_in bc_ele_id('bc_num_slots'), with: 1000 + assert_equal '1000', find_value('bc_num_slots') + + select('owens', from: bc_ele_id('cluster')) + select('gpu', from: bc_ele_id('node_type')) + # value gets set to the new max + assert_equal '28', find_value('bc_num_slots') + + # change clusters and it bumps up again + select('oakley', from: bc_ele_id('cluster')) + assert_equal '40', find_value('bc_num_slots') + end + + test 'python choice sets account' do + visit new_batch_connect_session_context_url('sys/bc_jupyter') + assert_equal 'python27', find_value('bc_account') + + select('3.1', from: bc_ele_id('python_version')) + assert_equal 'python31', find_value('bc_account') + + select('2.7', from: bc_ele_id('python_version')) + assert_equal 'python27', find_value('bc_account') + + select('3.2', from: bc_ele_id('python_version')) + assert_equal 'python32', find_value('bc_account') + + # 3.7 isn't configured to change the account, so it stays 3.2 + select('3.7', from: bc_ele_id('python_version')) + assert_equal 'python32', find_value('bc_account') + end + + test 'python choice sets hidden change thing' do + visit new_batch_connect_session_context_url('sys/bc_jupyter') + select('advanced', from: bc_ele_id('node_type')) + assert_equal 'default', find_value('hidden_change_thing', visible: false) + + select('3.1', from: bc_ele_id('python_version')) + assert_equal 'python31', find_value('bc_account') + assert_equal 'default', find_value('hidden_change_thing', visible: false) + + select('3.6', from: bc_ele_id('python_version')) + assert_equal 'python36', find_value('hidden_change_thing', visible: false) + + select('3.7', from: bc_ele_id('python_version')) + assert_equal 'python37', find_value('hidden_change_thing', visible: false) + + select('4.0.nightly', from: bc_ele_id('python_version')) + assert_equal 'python4nightly', find_value('hidden_change_thing', visible: false) + end + + test 'inline edits dont affect updating values' do + visit new_batch_connect_session_context_url('sys/bc_jupyter') + assert_equal 'python27', find_value('bc_account') + + select('3.1', from: bc_ele_id('python_version')) + assert_equal 'python31', find_value('bc_account') + + # insert some text into the field + account_element = find("##{bc_ele_id('bc_account')}") + account_element.send_keys " & some typed value" + assert_equal 'python31 & some typed value', find_value('bc_account') + + # now change it and confirm the value + select('3.2', from: bc_ele_id('python_version')) + assert_equal 'python32', find_value('bc_account') + end + + test 'inline changes to hidden fields get overwritten too' do + visit new_batch_connect_session_context_url('sys/bc_jupyter') + assert_equal 'default', find_value('hidden_change_thing') + + select('3.7', from: bc_ele_id('python_version')) + assert_equal 'python37', find_value('hidden_change_thing', visible: false) + + update_script = <<~JAVASCRIPT + let ele = $('#batch_connect_session_context_hidden_change_thing'); + ele.val('some new value'); + ele.attr('value', 'some new value'); + JAVASCRIPT + + execute_script(update_script) + assert_equal 'some new value', find_value('hidden_change_thing', visible: false) + + select('4.0.nightly', from: bc_ele_id('python_version')) + assert_equal 'python4nightly', find_value('hidden_change_thing', visible: false) + end + + test 'hiding cuda version' do + visit new_batch_connect_session_context_url('sys/bc_jupyter') + + # default is any, so we can't see cuda_version + assert_equal 'any', find_value('node_type') + assert !find("##{bc_ele_id('cuda_version')}", visible: false).visible? + + # select gpu and you can + select('gpu', from: bc_ele_id('node_type')) + assert find("##{bc_ele_id('cuda_version')}").visible? + + # toggle back to 'same' and it's gone + select('same', from: bc_ele_id('node_type')) + assert !find("##{bc_ele_id('cuda_version')}", visible: false).visible? + end + + test 'options with hyphens' do + visit new_batch_connect_session_context_url('sys/bc_jupyter') + + # defaults + assert_equal 'owens', find_value('cluster') + assert_equal 'any', find_value('node_type') + assert_equal 20, find_max('bc_num_slots') + + select('other-40ish-option', from: bc_ele_id('node_type')) + assert_equal 40, find_max('bc_num_slots') + + # now change the cluster and the max changes + select('oakley', from: bc_ele_id('cluster')) + assert_equal 48, find_max('bc_num_slots') + end +end diff --git a/apps/dashboard/test/test_helper.rb b/apps/dashboard/test/test_helper.rb index f0af2075a6..2ab71f0af4 100644 --- a/apps/dashboard/test/test_helper.rb +++ b/apps/dashboard/test/test_helper.rb @@ -15,7 +15,7 @@ class Application < Rails::Application class ActiveSupport::TestCase # Add more helper methods to be used by all tests here... - UserDouble = Struct.new(:name) + UserDouble = Struct.new(:name, :groups) def with_modified_env(options, &block) ClimateControl.modify(options, &block) @@ -30,8 +30,8 @@ def exit_failure(exit_status=1) end def stub_usr_router - OodSupport::Process.stubs(:user).returns(UserDouble.new('me')) - OodSupport::User.stubs(:new).returns(UserDouble.new('me')) + OodSupport::Process.stubs(:user).returns(UserDouble.new('me', ['me'])) + OodSupport::User.stubs(:new).returns(UserDouble.new('me', ['me'])) UsrRouter.stubs(:base_path).with(:owner => "me").returns(Pathname.new("test/fixtures/usr/me")) UsrRouter.stubs(:base_path).with(:owner => 'shared').returns(Pathname.new("test/fixtures/usr/shared")) @@ -39,6 +39,11 @@ def stub_usr_router UsrRouter.stubs(:owners).returns(['me', 'shared', 'cant_see']) end + def stub_sys_apps + OodAppkit.stubs(:clusters).returns(OodCore::Clusters.load_file('test/fixtures/config/clusters.d')) + SysRouter.stubs(:base_path).returns(Rails.root.join('test/fixtures/sys_with_gateway_apps')) + end + def setup_usr_fixtures FileUtils.chmod 0000, 'test/fixtures/usr/cant_see/' end @@ -46,6 +51,10 @@ def setup_usr_fixtures def teardown_usr_fixtures FileUtils.chmod 0755, 'test/fixtures/usr/cant_see/' end + + def bc_ele_id(ele) + "batch_connect_session_context_#{ele}" + end end require 'mocha/minitest'