]> jaekl.net Git - quanweb.git/commitdiff
Adds unit test framework and a first unit test.
authorChris Jaekl <cejaekl@yahoo.com>
Mon, 8 Jan 2018 08:38:15 +0000 (17:38 +0900)
committerChris Jaekl <cejaekl@yahoo.com>
Mon, 8 Jan 2018 08:38:15 +0000 (17:38 +0900)
16 files changed:
js/BooksModel.js [deleted file]
js/BooksView.js [deleted file]
js/Gruntfile.js
js/Main.js
js/PagingController.js [deleted file]
js/README [new file with mode: 0644]
js/SearchController.js [deleted file]
js/ToolTip.js [deleted file]
js/karma.conf.js [new file with mode: 0644]
js/package.json
js/src/BooksModel.js [new file with mode: 0644]
js/src/BooksView.js [new file with mode: 0644]
js/src/PagingController.js [new file with mode: 0644]
js/src/SearchController.js [new file with mode: 0644]
js/src/ToolTip.js [new file with mode: 0644]
js/test/BooksModelTest.js [new file with mode: 0644]

diff --git a/js/BooksModel.js b/js/BooksModel.js
deleted file mode 100644 (file)
index 75e0651..0000000
+++ /dev/null
@@ -1,96 +0,0 @@
-// ==========
-// BooksModel
-
-var BooksModel = (function() {
-    
-    var my = {};
-    
-    // =================
-    // Private variables
-    
-    var listeners = [];
-    
-    // ================
-    // Public variables
-    
-    my.cache = [];
-    my.count = 0,
-    my.first = 0,
-    my.ids = [],
-    my.last = (-1),
-    my.map = {},    // map from book.Id to index into cache[]
-    my.pageSize = 20;
-
-    // ==============
-    // Public methods
-    
-    my.adjustPos = function(setting) {
-
-        var value = parseInt(setting);
-
-        var prev = {
-            first: my.first,
-            last: my.last,
-        };
-        
-        var maxFirst = Math.max(0, my.count - my.pageSize);
-        
-        if (value < 0) {
-            my.first = 0;
-        } else if (value > maxFirst) {
-            my.first = maxFirst;
-        } else {
-            my.first = value;
-        }
-    
-        my.last = my.first + my.pageSize - 1;
-        if (my.last >= my.count) {
-            my.last = my.count - 1;
-        }
-        
-        if (prev.first !== my.first || prev.last !== my.last) {
-            my.refreshData();
-        }
-    };
-    
-    my.listen = function(subscriber) {
-        listeners.push(subscriber);
-    };
-    
-    my.refreshData = function () {
-        var i;
-        var url = '/info/?ids=';
-        my.map = {};
-        for (i = my.first; i <= my.last; ++i) {
-            if (i > my.first) {
-                url += ',';
-            }
-            var id = my.ids[i];
-            url += id;
-            my.map[id] = i - my.first;
-        }
-
-        fetch(url, {method:'GET', cache:'default'})
-            .then(function(response) {return response.json();})
-            .then(function(jsonValue) {
-                my.cache = jsonValue;
-                notifyAll();    // inform all subscribers that the model has been updated
-            })
-            .catch(function(err) {
-                var msg = 'Error fetching book details via URL:  ' + url + ': ' + err;
-                console.log(msg, err.stack);
-                report(msg);
-            });
-    };
-    
-    // ===============
-    // Private methods
-    
-    function notifyAll() {
-        for (var i in listeners) {
-            listeners[i].notify();
-        }
-    }
-    
-    return my;
-})();
diff --git a/js/BooksView.js b/js/BooksView.js
deleted file mode 100644 (file)
index 1bbb8ae..0000000
+++ /dev/null
@@ -1,68 +0,0 @@
-// =========
-// BooksView
-
-var BooksView = (function() {
-    var my = {};
-    
-    // ========================
-    // Private member variables
-    var booksModel = undefined;
-    
-    // ==============
-    // Public Methods
-    
-    my.init = function(linkedBooksModel) {
-        booksModel = linkedBooksModel;
-        
-        booksModel.listen(my);
-    };
-    
-    // Called when the model changes
-    my.notify = function () {
-        var i;
-        var html = '';
-        var limit = BooksModel.last - BooksModel.first;
-        for (i = 0; i <= limit; ++i) {
-            var book = BooksModel.cache[i];
-            html += bookHtml(book);
-        }
-    
-        document.getElementById('books').innerHTML = html;
-        document.getElementById('first').innerHTML = (BooksModel.first + 1);
-        document.getElementById('last').innerHTML = (BooksModel.last + 1);
-        document.getElementById('count').innerHTML = BooksModel.count;
-    };
-    
-    // ===============
-    // Private methods
-    
-    function bookHtml(book) {
-        var result = '<div class="book">'
-            +   '<table>'
-            +     '<tr>'
-            +       '<td><a href="/book/' + book.Id + '">';
-        if (0 == book.CoverId) {
-            result +=          '(No cover available)';
-        } else {
-            result +=          '<img class="cover-thumb" src="/download/' + book.CoverId + '"/>';
-        }
-        result     +=       '</a></td>'
-            +       '<td onclick="displayDetails(' + book.Id + ');" '
-            +          ' onmouseover="ToolTip.startTooltipTimer(' + book.Id + ');"'
-            +          ' onmouseleave="ToolTip.stopTooltipTimer();">'
-            +         '<p><b>' + book.Title + '</b></p>'
-            +         '<p>'
-            +           '<i>' + book.AuthorReading + '</i>';
-        if (typeof(book.SeriesName) !== 'undefined' && book.SeriesName.length > 0) {
-            result +=          '<br/><i>' + book.SeriesName + ' ' + book.Volume + '</i>';
-        }
-        result     +=         '</p>'
-            +       '</td>'
-            +     '</tr>'
-            +   '</table>'
-            + '</div>';
-        return result;
-    }
-    
-    return my;
-})();
index c6ebaa933d7f787c77f73ab189d1a86aea7ee7c8..f6fee4d5ee1375b330d4f53cfccf66295bcd7050 100644 (file)
@@ -1,25 +1,38 @@
 module.exports = function(grunt) {
 
-  // Project configuration.
-  grunt.initConfig({
-    pkg: grunt.file.readJSON('package.json'),
-    uglify: {
-      options: {
-        banner: '/*! <%= pkg.name %> <%= grunt.template.today("yyyy-mm-dd") %> */\n',
-        mangle: false
-      },
-      app: {
-        files: {
-          '../app/lib.min.js': ['BooksModel.js', 'BooksView.js', 'PagingController.js', 'SearchController.js', 'ToolTip.js', 'Main.js']
-        }
-      }
-    }
-  });
+    // Project configuration.
+    grunt.initConfig({
+        concat: {
+            all: {
+                src: ['src/**/*.js', 'Main.js'],
+                dest:  '../app/all.js',
+                nonull: true
+            },
+            tests: {
+                src: ['src/**/*.js', 'test/**/*.js'],
+                dest: 'all_tests.js',
+                nonull:  true
+            },
+        },
+
+        pkg: grunt.file.readJSON('package.json'),
 
-  // Load the plugin that provides the "uglify" task.
-  grunt.loadNpmTasks('grunt-contrib-uglify');
+        uglify: {
+            options: {
+                banner: '/*! <%= pkg.name %> <%= grunt.template.today("yyyy-mm-dd") %> */\n',
+                mangle: false
+            },
+            app: {
+                files: {
+                  '../app/lib.min.js': ['src/**/*.js', 'Main.js']
+                }
+            }
+        }
+    });
 
-  // Default task(s).
-  grunt.registerTask('default', ['uglify']);
+    grunt.loadNpmTasks('grunt-contrib-concat');
+    grunt.loadNpmTasks('grunt-contrib-uglify');
 
+    // Default task(s).
+    grunt.registerTask('default', ['concat', 'uglify']);
 };
index 5f253bcc1e9fe486362d1106232d40030dee82a5..22e7dd6b734212fc54fc79f9a8807318e2f7be5f 100644 (file)
@@ -14,7 +14,7 @@ var g_state = {
 // ==============
 // Initialization
 
-document.onmousemove = onMouseMove;
+Browser.setOnMouseMove(onMouseMove);
 
 BooksView.init(BooksModel);
 PagingController.init(BooksModel);
@@ -36,7 +36,7 @@ else {
 // TODO:  refactor this to compartmentalize more functionality.
 
 function report(message) {
-    document.getElementById('books').innerHTML = message;
+    Browser.getElementById('books').innerHTML = message;
 }
 
 function onMouseMove(event) {
diff --git a/js/PagingController.js b/js/PagingController.js
deleted file mode 100644 (file)
index c5bfb2d..0000000
+++ /dev/null
@@ -1,33 +0,0 @@
-// ================
-// PagingController
-
-var PagingController = (function() {
-    var my = {};
-    
-    var booksModel = undefined;
-    
-    // ==============
-    // Public Methods
-    
-    my.init = function(linkedBooksModel) {
-        booksModel = linkedBooksModel;
-        booksModel.listen(my);
-    };
-    
-    my.adjustPos = function(setting) {
-        var value = parseInt(setting);
-    
-        if (booksModel.first === value) {
-            // No change
-            return;
-        }
-        
-        booksModel.adjustPos(setting);
-    };
-    
-    my.notify = function() {
-        document.getElementById('slider').value = booksModel.first;
-    };
-
-    return my;
-})();
diff --git a/js/README b/js/README
new file mode 100644 (file)
index 0000000..7f8b60c
--- /dev/null
+++ b/js/README
@@ -0,0 +1,8 @@
+QuanWeb
+Web service to provide accces to a QuanLib database of e-books
+
+On the off chance that you want this code, you're welcome to it, subject to the 
+GNU General Public Licence version 3 (or, at your option, any later version).
+
+Chris Jaekl
+cejaekl@yahoo.com
diff --git a/js/SearchController.js b/js/SearchController.js
deleted file mode 100644 (file)
index b95c951..0000000
+++ /dev/null
@@ -1,87 +0,0 @@
-// ================
-// SearchController
-
-var SearchController = (function () {
-    var my = {},
-        booksModel = undefined;
-
-    const terms = ['aut', 'tit', 'ser'];
-
-    // ==============
-    // Public methods
-    
-    my.init = function(linkedBooksModel) {
-        booksModel = linkedBooksModel;
-        
-        for (var idx in terms) {
-            addEnterListener(terms[idx]);
-        }
-    };
-    
-    my.onSearch = function() {
-        var url = constructSearchUrl();
-
-        fetch(url, {method:'GET', cache:'default'})
-            .then(function(response) {return response.json();})
-            .then(function(jsonValue) {
-                // console.log('JSON response:  ', jsonValue);
-                booksModel.ids = jsonValue;
-                booksModel.count = booksModel.ids.length;
-                booksModel.first = (-1);
-        
-                var elem = document.getElementById('slider');
-                elem.max = booksModel.count;
-        
-                PagingController.adjustPos(0);
-            })
-            .catch(function(err) { 
-                var msg = 'Error fetching JSON from URL:  ' + url + ': ' + err + ':' + err.stack;
-                console.log(msg);
-                report(msg);
-            });
-    };
-
-    // ===============
-    // Private methods
-    
-    // KeyUp listener.  If the key is [Enter], then trigger a click on the [Search] button.
-    function addEnterListener(ctrlId) {
-        document.getElementById(ctrlId).addEventListener('keyup', function(event) {
-            event.preventDefault();
-            if (event.keyCode === 13) {
-                document.getElementById('search').click();
-            }
-        });
-    }
-    
-    function constructSearchUrl() {
-        var url = window.location.protocol + '//' + window.location.host + '/search/';
-
-        var firstTime = true;
-
-        for (var idx in terms) {
-            var term = terms[idx];
-            var elem = document.getElementById(term);
-            if (null === elem) {
-                console.log('Error:  could not find form element for search term "' + term + '".');
-                continue;
-            }
-
-            var value = elem.value;
-            if (value.length > 0) {
-                if (firstTime) {
-                    url += '?';
-                    firstTime = false;
-                }
-                else {
-                    url += '&';
-                }
-                url += term + '=' + encodeURIComponent('%' + value + '%');
-            }
-        }
-
-        return url;
-    }
-
-    return my;
-})();
diff --git a/js/ToolTip.js b/js/ToolTip.js
deleted file mode 100644 (file)
index 5e88238..0000000
+++ /dev/null
@@ -1,123 +0,0 @@
-// =======
-// ToolTip
-
-var ToolTip = (function () {
-    // =================
-    // Private variables
-    var my = {},
-        bookId = undefined,
-        milliSecs = 500,     // time to wait before displaying tooltip
-        mousePos = {
-            x: undefined,
-            y: undefined
-        },
-        threshold = 10,        // number of pixels that mouse can move before tip is dismissed
-        timer = undefined;
-
-    // ================
-    // Public variables
-    my.booksModel = undefined;
-
-    // ==============
-    // Public Methods
-    
-    // Set the book ID for the details pane, and then show it
-    my.displayDetails = function (newBookId) {
-        bookId = newBookId;
-        my.showDetails();
-    };
-
-    // Hide the details pane, if it is currently visible
-    my.hideDetails = function () {
-        mousePos.x = undefined;
-        mousePos.y = undefined;
-        
-        var elem = document.getElementById('details');
-        elem.innerHTML = '';
-        elem.style.display = 'none';
-    };
-    
-    my.mouseMoved = function (x, y) {
-        // Is there an active tooltip?
-        if (typeof mousePos.x === 'undefined') {
-            // No active tooltip, so nothing further to do
-            return;
-        }
-        
-        var deltaX = Math.abs(x - mousePos.x);
-        var deltaY = Math.abs(y - mousePos.y);
-
-        if (  (deltaX > threshold)
-           || (deltaY > threshold) )
-        {
-            my.stopTooltipTimer();
-            my.hideDetails();
-        }
-    };
-
-    // Show the details pane
-    my.showDetails = function () {
-        var id = bookId;
-        var elem = document.getElementById('details');
-        var index = BooksModel.map[id];
-        var book = BooksModel.cache[index];
-        var html = '<div><p><b>' + book.Title + '</b></p>'
-        + '<p><i>' + ce(book.AuthorReading) + '<br/>' + ce(book.Series) + ' ' + ce(book.Volume) + '</i></p></div><div>'
-        + ce(book.Description)
-        + '</div>';
-
-        // Remember the current mouse (x,y).
-        // If we move the mouse too far from this point, that will trigger hiding the tooltip.
-        mousePos.x = g_state.mousePos.x;
-        mousePos.y = g_state.mousePos.y;
-
-        elem.innerHTML = html;
-
-        elem.style.display = 'block';    // show, and calculate size, so that we can query it below
-        
-        var x = mousePos.x;
-        var y = mousePos.y;
-        
-        var bcr = elem.getBoundingClientRect();
-        
-        var width = bcr.width;
-        var height = bcr.height;
-        
-        x = Math.max(x - (width / 2), 0);
-        y = Math.max(y - (height / 2), 0);
-        
-        elem.style.left = x + 'px';
-        elem.style.top = y + 'px';
-    };
-
-    my.startTooltipTimer = function (newBookId) {
-        if (typeof timer !== 'undefined') {
-            clearTimeout(timer);
-        }
-        bookId = newBookId;
-        timer = setTimeout(my.showDetails, milliSecs);
-    };
-
-    my.stopTooltipTimer = function () {
-        if (typeof timer === 'undefined') {
-            return;
-        }
-        
-        clearTimeout(timer);
-        timer = undefined;
-    };
-    
-    // ===============
-    // Private methods
-    
-    // ce(s):  "clear if empty()"
-    //         return s, unless it's undefined, in which case return an empty ("clear") string
-    function ce(s) {
-        if (typeof s !== 'undefined') {
-            return s;
-        }
-        return '';
-    }
-    
-    return my;
-})();
diff --git a/js/karma.conf.js b/js/karma.conf.js
new file mode 100644 (file)
index 0000000..f0d3c21
--- /dev/null
@@ -0,0 +1,69 @@
+// Karma configuration
+// Generated on Sun Dec 31 2017 15:08:47 GMT+0900 (JST)
+
+module.exports = function(config) {
+  config.set({
+
+    // base path that will be used to resolve all patterns (eg. files, exclude)
+    basePath: '',
+
+
+    // frameworks to use
+    // available frameworks: https://npmjs.org/browse/keyword/karma-adapter
+    frameworks: ['jasmine'],
+
+
+    // list of files / patterns to load in the browser
+    files: [
+      'all_tests.js'
+    ],
+
+
+    // list of files / patterns to exclude
+    exclude: [
+    ],
+
+
+    // preprocess matching files before serving them to the browser
+    // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor
+    preprocessors: {
+    },
+
+
+    // test results reporter to use
+    // possible values: 'dots', 'progress'
+    // available reporters: https://npmjs.org/browse/keyword/karma-reporter
+    reporters: ['progress'],
+
+
+    // web server port
+    port: 9876,
+
+
+    // enable / disable colors in the output (reporters and logs)
+    colors: true,
+
+
+    // level of logging
+    // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
+    logLevel: config.LOG_INFO,
+
+
+    // enable / disable watching file and executing tests whenever any file changes
+    autoWatch: true,
+
+
+    // start these browsers
+    // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher
+    browsers: ['Chrome'],
+
+
+    // Continuous Integration mode
+    // if true, Karma captures browsers, runs the tests and exits
+    singleRun: false,
+
+    // Concurrency level
+    // how many browser should be started simultaneous
+    concurrency: Infinity
+  })
+}
index 2cf9e61fa309a40c38e2b4920a83378656f52615..3836a14823cfad707e113820f3f79ca4bcb5b705 100644 (file)
@@ -1,11 +1,23 @@
 {
   "name": "quanweb",
   "version": "0.0.1",
+  "description": "E-Book Library Web User Interface",
   "license": "GPL-3.0+",
   "devDependencies": {
     "grunt": "^1.0.1",
+    "grunt-contrib-concat": "^1.0.1",
     "grunt-contrib-jshint": "~0.10.0",
     "grunt-contrib-nodeunit": "~0.4.1",
-    "grunt-contrib-uglify": "git://github.com/gruntjs/grunt-contrib-uglify.git#harmony"
+    "grunt-contrib-uglify": "git://github.com/gruntjs/grunt-contrib-uglify.git#harmony",
+    "grunt-mocha-test": "^0.13.3",
+    "jasmine": "^2.8.0",
+    "jasmine-core": "^2.8.0",
+    "karma": "^2.0.0",
+    "karma-chrome-launcher": "^2.2.0",
+    "karma-jasmine": "^1.1.1",
+    "mocha": "^4.0.1",
+  },
+  "scripts": {
+    "test": "./node_modules/karma/bin/karma start karma.conf.js"
   }
 }
diff --git a/js/src/BooksModel.js b/js/src/BooksModel.js
new file mode 100644 (file)
index 0000000..1626d29
--- /dev/null
@@ -0,0 +1,96 @@
+// ==========
+// BooksModel
+
+var BooksModel = (function() {
+    
+    var my = {};
+    
+    // =================
+    // Private variables
+    
+    var listeners = [];
+    
+    // ================
+    // Public variables
+    
+    my.cache = [];
+    my.count = 0,    // number of books available to be paged through
+    my.first = 0,    // first book to be displayed in current page (0-indexed)
+    my.ids = [],
+    my.last = (-1),  // last book to be displayed in the current page (0-indexed)
+    my.map = {},     // map from book.Id to index into cache[]
+    my.pageSize = 20;
+
+    // ==============
+    // Public methods
+    
+    my.adjustPos = function(setting) {
+
+        var value = parseInt(setting);
+
+        var prev = {
+            first: my.first,
+            last: my.last,
+        };
+        
+        var maxFirst = Math.max(0, my.count - my.pageSize);
+        
+        if (value < 0) {
+            my.first = 0;
+        } else if (value > maxFirst) {
+            my.first = maxFirst;
+        } else {
+            my.first = value;
+        }
+    
+        my.last = my.first + my.pageSize - 1;
+        if (my.last >= my.count) {
+            my.last = my.count - 1;
+        }
+        
+        if (prev.first !== my.first || prev.last !== my.last) {
+            my.refreshData();
+        }
+    };
+    
+    my.listen = function(subscriber) {
+        listeners.push(subscriber);
+    };
+    
+    my.refreshData = function () {
+        var i;
+        var url = '/info/?ids=';
+        my.map = {};
+        for (i = my.first; i <= my.last; ++i) {
+            if (i > my.first) {
+                url += ',';
+            }
+            var id = my.ids[i];
+            url += id;
+            my.map[id] = i - my.first;
+        }
+
+        fetch(url, {method:'GET', cache:'default'})
+            .then(function(response) {return response.json();})
+            .then(function(jsonValue) {
+                my.cache = jsonValue;
+                notifyAll();    // inform all subscribers that the model has been updated
+            })
+            .catch(function(err) {
+                var msg = 'Error fetching book details via URL:  ' + url + ': ' + err;
+                console.log(msg, err.stack);
+                report(msg);
+            });
+    };
+    
+    // ===============
+    // Private methods
+    
+    function notifyAll() {
+        for (var i in listeners) {
+            listeners[i].notify();
+        }
+    }
+    
+    return my;
+})();
diff --git a/js/src/BooksView.js b/js/src/BooksView.js
new file mode 100644 (file)
index 0000000..0259edd
--- /dev/null
@@ -0,0 +1,68 @@
+// =========
+// BooksView
+
+var BooksView = (function() {
+    var my = {};
+    
+    // ========================
+    // Private member variables
+    var booksModel = undefined;
+    
+    // ==============
+    // Public Methods
+    
+    my.init = function(linkedBooksModel) {
+        booksModel = linkedBooksModel;
+        
+        booksModel.listen(my);
+    };
+    
+    // Called when the model changes
+    my.notify = function () {
+        var i;
+        var html = '';
+        var limit = BooksModel.last - BooksModel.first;
+        for (i = 0; i <= limit; ++i) {
+            var book = BooksModel.cache[i];
+            html += bookHtml(book);
+        }
+    
+        Browser.getElementById('books').innerHTML = html;
+        Browser.getElementById('first').innerHTML = (BooksModel.first + 1);
+        Browser.getElementById('last').innerHTML = (BooksModel.last + 1);
+        Browser.getElementById('count').innerHTML = BooksModel.count;
+    };
+    
+    // ===============
+    // Private methods
+    
+    function bookHtml(book) {
+        var result = '<div class="book">'
+            +   '<table>'
+            +     '<tr>'
+            +       '<td><a href="/book/' + book.Id + '">';
+        if (0 == book.CoverId) {
+            result +=          '(No cover available)';
+        } else {
+            result +=          '<img class="cover-thumb" src="/download/' + book.CoverId + '"/>';
+        }
+        result     +=       '</a></td>'
+            +       '<td onclick="displayDetails(' + book.Id + ');" '
+            +          ' onmouseover="ToolTip.startTooltipTimer(' + book.Id + ');"'
+            +          ' onmouseleave="ToolTip.stopTooltipTimer();">'
+            +         '<p><b>' + book.Title + '</b></p>'
+            +         '<p>'
+            +           '<i>' + book.AuthorReading + '</i>';
+        if (typeof(book.SeriesName) !== 'undefined' && book.SeriesName.length > 0) {
+            result +=          '<br/><i>' + book.SeriesName + ' ' + book.Volume + '</i>';
+        }
+        result     +=         '</p>'
+            +       '</td>'
+            +     '</tr>'
+            +   '</table>'
+            + '</div>';
+        return result;
+    }
+    
+    return my;
+})();
diff --git a/js/src/PagingController.js b/js/src/PagingController.js
new file mode 100644 (file)
index 0000000..c043dc1
--- /dev/null
@@ -0,0 +1,33 @@
+// ================
+// PagingController
+
+var PagingController = (function() {
+    var my = {};
+    
+    var booksModel = undefined;
+    
+    // ==============
+    // Public Methods
+    
+    my.init = function(linkedBooksModel) {
+        booksModel = linkedBooksModel;
+        booksModel.listen(my);
+    };
+    
+    my.adjustPos = function(setting) {
+        var value = parseInt(setting);
+    
+        if (booksModel.first === value) {
+            // No change
+            return;
+        }
+        
+        booksModel.adjustPos(setting);
+    };
+    
+    my.notify = function() {
+        Browser.getElementById('slider').value = booksModel.first;
+    };
+
+    return my;
+})();
diff --git a/js/src/SearchController.js b/js/src/SearchController.js
new file mode 100644 (file)
index 0000000..0f13194
--- /dev/null
@@ -0,0 +1,87 @@
+// ================
+// SearchController
+
+var SearchController = (function () {
+    var my = {},
+        booksModel = undefined;
+
+    const terms = ['aut', 'tit', 'ser'];
+
+    // ==============
+    // Public methods
+    
+    my.init = function(linkedBooksModel) {
+        booksModel = linkedBooksModel;
+        
+        for (var idx in terms) {
+            addEnterListener(terms[idx]);
+        }
+    };
+    
+    my.onSearch = function() {
+        var url = constructSearchUrl();
+
+        fetch(url, {method:'GET', cache:'default'})
+            .then(function(response) {return response.json();})
+            .then(function(jsonValue) {
+                // console.log('JSON response:  ', jsonValue);
+                booksModel.ids = jsonValue;
+                booksModel.count = booksModel.ids.length;
+                booksModel.first = (-1);
+        
+                var elem = Browser.getElementById('slider');
+                elem.max = booksModel.count;
+        
+                PagingController.adjustPos(0);
+            })
+            .catch(function(err) { 
+                var msg = 'Error fetching JSON from URL:  ' + url + ': ' + err + ':' + err.stack;
+                console.log(msg);
+                report(msg);
+            });
+    };
+
+    // ===============
+    // Private methods
+    
+    // KeyUp listener.  If the key is [Enter], then trigger a click on the [Search] button.
+    function addEnterListener(ctrlId) {
+        Browser.getElementById(ctrlId).addEventListener('keyup', function(event) {
+            event.preventDefault();
+            if (event.keyCode === 13) {
+                Browser.getElementById('search').click();
+            }
+        });
+    }
+    
+    function constructSearchUrl() {
+        var url = window.location.protocol + '//' + window.location.host + '/search/';
+
+        var firstTime = true;
+
+        for (var idx in terms) {
+            var term = terms[idx];
+            var elem = Browser.getElementById(term);
+            if (null === elem) {
+                console.log('Error:  could not find form element for search term "' + term + '".');
+                continue;
+            }
+
+            var value = elem.value;
+            if (value.length > 0) {
+                if (firstTime) {
+                    url += '?';
+                    firstTime = false;
+                }
+                else {
+                    url += '&';
+                }
+                url += term + '=' + encodeURIComponent('%' + value + '%');
+            }
+        }
+
+        return url;
+    }
+
+    return my;
+})();
diff --git a/js/src/ToolTip.js b/js/src/ToolTip.js
new file mode 100644 (file)
index 0000000..e622e2a
--- /dev/null
@@ -0,0 +1,123 @@
+// =======
+// ToolTip
+
+var ToolTip = (function () {
+    // =================
+    // Private variables
+    var my = {},
+        bookId = undefined,
+        milliSecs = 500,     // time to wait before displaying tooltip
+        mousePos = {
+            x: undefined,
+            y: undefined
+        },
+        threshold = 10,        // number of pixels that mouse can move before tip is dismissed
+        timer = undefined;
+
+    // ================
+    // Public variables
+    my.booksModel = undefined;
+
+    // ==============
+    // Public Methods
+    
+    // Set the book ID for the details pane, and then show it
+    my.displayDetails = function (newBookId) {
+        bookId = newBookId;
+        my.showDetails();
+    };
+
+    // Hide the details pane, if it is currently visible
+    my.hideDetails = function () {
+        mousePos.x = undefined;
+        mousePos.y = undefined;
+        
+        var elem = Browser.getElementById('details');
+        elem.innerHTML = '';
+        elem.style.display = 'none';
+    };
+    
+    my.mouseMoved = function (x, y) {
+        // Is there an active tooltip?
+        if (typeof mousePos.x === 'undefined') {
+            // No active tooltip, so nothing further to do
+            return;
+        }
+        
+        var deltaX = Math.abs(x - mousePos.x);
+        var deltaY = Math.abs(y - mousePos.y);
+
+        if (  (deltaX > threshold)
+           || (deltaY > threshold) )
+        {
+            my.stopTooltipTimer();
+            my.hideDetails();
+        }
+    };
+
+    // Show the details pane
+    my.showDetails = function () {
+        var id = bookId;
+        var elem = Browser.getElementById('details');
+        var index = BooksModel.map[id];
+        var book = BooksModel.cache[index];
+        var html = '<div><p><b>' + book.Title + '</b></p>'
+        + '<p><i>' + ce(book.AuthorReading) + '<br/>' + ce(book.Series) + ' ' + ce(book.Volume) + '</i></p></div><div>'
+        + ce(book.Description)
+        + '</div>';
+
+        // Remember the current mouse (x,y).
+        // If we move the mouse too far from this point, that will trigger hiding the tooltip.
+        mousePos.x = g_state.mousePos.x;
+        mousePos.y = g_state.mousePos.y;
+
+        elem.innerHTML = html;
+
+        elem.style.display = 'block';    // show, and calculate size, so that we can query it below
+        
+        var x = mousePos.x;
+        var y = mousePos.y;
+        
+        var bcr = elem.getBoundingClientRect();
+        
+        var width = bcr.width;
+        var height = bcr.height;
+        
+        x = Math.max(x - (width / 2), 0);
+        y = Math.max(y - (height / 2), 0);
+        
+        elem.style.left = x + 'px';
+        elem.style.top = y + 'px';
+    };
+
+    my.startTooltipTimer = function (newBookId) {
+        if (typeof timer !== 'undefined') {
+            clearTimeout(timer);
+        }
+        bookId = newBookId;
+        timer = setTimeout(my.showDetails, milliSecs);
+    };
+
+    my.stopTooltipTimer = function () {
+        if (typeof timer === 'undefined') {
+            return;
+        }
+        
+        clearTimeout(timer);
+        timer = undefined;
+    };
+    
+    // ===============
+    // Private methods
+    
+    // ce(s):  "clear if empty()"
+    //         return s, unless it's undefined, in which case return an empty ("clear") string
+    function ce(s) {
+        if (typeof s !== 'undefined') {
+            return s;
+        }
+        return '';
+    }
+    
+    return my;
+})();
diff --git a/js/test/BooksModelTest.js b/js/test/BooksModelTest.js
new file mode 100644 (file)
index 0000000..38997e4
--- /dev/null
@@ -0,0 +1,49 @@
+'use strict';
+
+describe('Scroll forward should not advance beyond end', function() {
+    var bm = BooksModel;
+    var calledRefreshData;
+
+    //       [first, last, pageSize, count, setting, expectFirst, expectLast, expectRefresh]
+    data = [ [    0,  19 ,       20,   100,      20,         20 ,        39 ,          true ],
+             [    0,  19 ,       20,   100,     100,         80 ,        99 ,          true ],
+             [    0,  19 ,       20,   100,      80,         80 ,        99 ,          true ],
+             [    0,  19 ,       20,   100,      79,         79 ,        98 ,          true ],
+             [   79,  99 ,       20,   100,      50,         50 ,        69 ,          true ],
+             [    0,  11 ,       20,    12,      19,          0 ,        11 ,         false ],
+             [    0, (-1),       20,     0,      20,          0 ,       (-1),         false ]
+           ];
+
+    function doTest(datum) {
+        bm.first = datum[0];
+        bm.last = datum[1];
+        bm.pageSize = datum[2];
+        bm.count = datum[3];
+
+        var oldFunc = bm.refreshData;
+        calledRefreshData = false;
+        try {
+            bm.refreshData = function() {
+                calledRefreshData = true;
+            };
+
+            bm.adjustPos(datum[4]);
+        }
+        finally {
+            bm.refreshData = oldFunc;
+        }
+    }
+
+    it('should stay within valid range, and call refreshData() if bm.first changes', function() {
+        var i = 4;
+        for (i = 0; i < data.length; i += 1) {
+            datum = data[i];
+            doTest(datum);
+            expect(bm.first).toBe(datum[5]);
+            expect(bm.last).toBe(datum[6]);
+            expect(calledRefreshData).toBe(datum[7]);
+        }
+    });
+});
+
+