فهرست منبع

refactor tests, move some from scheduler

Adam Shaw 8 سال پیش
والد
کامیت
8898929461
11فایلهای تغییر یافته به همراه568 افزوده شده و 359 حذف شده
  1. 22 0
      tests/globals.js
  2. 0 233
      tests/jasmine-ext.js
  3. 4 50
      tests/legacy/businessHours.js
  4. 2 1
      tests/legacy/nowIndicator.js
  5. 11 0
      tests/lib/day-grid.js
  6. 309 0
      tests/lib/dom-geom.js
  7. 16 60
      tests/lib/dom-utils.js
  8. 57 3
      tests/lib/geom.js
  9. 64 0
      tests/lib/moment.js
  10. 27 0
      tests/lib/segs.js
  11. 56 12
      tests/lib/time-grid.js

+ 22 - 0
tests/globals.js

@@ -180,3 +180,25 @@ window.oneCall = function(func) {
     }
   }
 }
+
+window.spyOnMethod = function(Class, methodName, dontCallThrough) {
+  var origMethod = Class.prototype.hasOwnProperty(methodName)
+    ? Class.prototype[methodName]
+    : null
+
+  var spy = spyOn(Class.prototype, methodName)
+
+  if (!dontCallThrough) {
+    spy = spy.and.callThrough()
+  }
+
+  spy.restore = function() {
+    if (origMethod) {
+      Class.prototype[methodName] = origMethod
+    } else {
+      delete Class.prototype[methodName]
+    }
+  }
+
+  return spy
+}

+ 0 - 233
tests/jasmine-ext.js

@@ -12,242 +12,9 @@ beforeEach(function() {
   // increase the default timeout
   jasmine.DEFAULT_TIMEOUT_INTERVAL = 30000
 
-
-  jasmine.addMatchers({
-
-    // Moment and Duration
-
-    toEqualMoment: function() {
-      return {
-        compare: function(actual, expected) {
-          var actualStr = $.fullCalendar.moment.parseZone(actual).format()
-          var expectedStr = $.fullCalendar.moment.parseZone(expected).format()
-          var result = {
-            pass: actualStr === expectedStr
-          }
-          if (!result.pass) {
-            result.message = 'Moment ' + actualStr + ' does not equal ' + expectedStr
-          }
-          return result
-        }
-      }
-    },
-    toEqualNow: function() {
-      return {
-        compare: function(actual) {
-          var actualMoment = $.fullCalendar.moment.parseZone(actual)
-          var result = {
-            pass: Math.abs(actualMoment - new Date()) < 1000 // within a second of current datetime
-          }
-          if (!result.pass) {
-            result.message = 'Moment ' + actualMoment.format() + ' is not close enough to now'
-          }
-          return result
-        }
-      }
-    },
-    toEqualDuration: function() {
-      return {
-        compare: function(actual, expected) {
-          var actualStr = serializeDuration(moment.duration(actual))
-          var expectedStr = serializeDuration(moment.duration(expected))
-          var result = {
-            pass: actualStr === expectedStr
-          }
-          if (!result.pass) {
-            result.message = 'Duration ' + actualStr + ' does not equal ' + expectedStr
-          }
-          return result
-        }
-      }
-    },
-
-
-    // DOM
-
-    toHaveScrollbars: function() {
-      return {
-        compare: function(actual) {
-          var elm = $(actual)
-          var result = {
-            pass: elm[0].scrollWidth - 1 > elm[0].clientWidth || // -1 !!!
-              elm[0].scrollHeight - 1 > elm[0].clientHeight // -1 !!!
-          }
-          // !!! - IE was reporting a scrollWidth/scrollHeight 1 pixel taller than what it was :(
-          return result
-        }
-      }
-    },
-
-
-    // Geometry
-
-    toBeBoundedBy: function() {
-      return {
-        compare: function(actual, expected) {
-          var outer = getBounds(expected)
-          var inner = getBounds(actual)
-          var result = {
-            pass: outer && inner &&
-              inner.left >= outer.left &&
-              inner.right <= outer.right &&
-              inner.top >= outer.top &&
-              inner.bottom <= outer.bottom
-          }
-          if (!result.pass) {
-            result.message = 'Element does not bound other element'
-          }
-          return result
-        }
-      }
-    },
-    toBeLeftOf: function() {
-      return {
-        compare: function(actual, expected) {
-          var subjectBounds = getBounds(actual)
-          var otherBounds = getBounds(expected)
-          var result = {
-            pass: subjectBounds && otherBounds &&
-              Math.round(subjectBounds.right) <= Math.round(otherBounds.left) + 2
-              // need to round because IE was giving weird fractions
-          }
-          if (!result.pass) {
-            result.message = 'Element is not to the left of the other element'
-          }
-          return result
-        }
-      }
-    },
-    toBeRightOf: function() {
-      return {
-        compare: function(actual, expected) {
-          var subjectBounds = getBounds(actual)
-          var otherBounds = getBounds(expected)
-          var result = {
-            pass: subjectBounds && otherBounds &&
-              Math.round(subjectBounds.left) >= Math.round(otherBounds.right) - 2
-              // need to round because IE was giving weird fractions
-          }
-          if (!result.pass) {
-            result.message = 'Element is not to the right of the other element'
-          }
-          return result
-        }
-      }
-    },
-    toBeAbove: function() {
-      return {
-        compare: function(actual, expected) {
-          var subjectBounds = getBounds(actual)
-          var otherBounds = getBounds(expected)
-          var result = {
-            pass: subjectBounds && otherBounds &&
-              Math.round(subjectBounds.bottom) <= Math.round(otherBounds.top) + 2
-              // need to round because IE was giving weird fractions
-          }
-          if (!result.pass) {
-            result.message = 'Element is not above the other element'
-          }
-          return result
-        }
-      }
-    },
-    toBeBelow: function() {
-      return {
-        compare: function(actual, expected) {
-          var subjectBounds = getBounds(actual)
-          var otherBounds = getBounds(expected)
-          var result = {
-            pass: subjectBounds && otherBounds &&
-              Math.round(subjectBounds.top) >= Math.round(otherBounds.bottom) - 2
-              // need to round because IE was giving weird fractions
-          }
-          if (!result.pass) {
-            result.message = 'Element is not below the other element'
-          }
-          return result
-        }
-      }
-    },
-    toIntersectWith: function() {
-      return {
-        compare: function(actual, expected) {
-          var subjectBounds = getBounds(actual)
-          var otherBounds = getBounds(expected)
-          var result = {
-            pass: subjectBounds && otherBounds &&
-              subjectBounds.right - 1 > otherBounds.left &&
-              subjectBounds.left + 1 < otherBounds.right &&
-              subjectBounds.bottom - 1 > otherBounds.top &&
-              subjectBounds.top + 1 < otherBounds.bottom
-              // +/-1 because of zoom
-          }
-          if (!result.pass) {
-            result.message = 'Element does not intersect with other element'
-          }
-          return result
-        }
-      }
-    }
-
-  })
-
-  function serializeDuration(duration) {
-    return Math.floor(duration.asDays()) + '.' +
-      pad(duration.hours(), 2) + ':' +
-      pad(duration.minutes(), 2) + ':' +
-      pad(duration.seconds(), 2) + '.' +
-      pad(duration.milliseconds(), 3)
-  }
-
-  function pad(n, width) {
-    n = n + ''
-    return n.length >= width ? n : new Array(width - n.length + 1).join('0') + n
-  }
-
-  function getBounds(node) {
-    return $(node)[0].getBoundingClientRect()
-  }
-
 })
 
 
-window.spyOnMethod = function(Class, methodName, dontCallThrough) {
-  var origMethod = Class.prototype.hasOwnProperty(methodName)
-    ? Class.prototype[methodName]
-    : null
-
-  var spy = spyOn(Class.prototype, methodName)
-
-  if (!dontCallThrough) {
-    spy = spy.and.callThrough()
-  }
-
-  spy.restore = function() {
-    if (origMethod) {
-      Class.prototype[methodName] = origMethod
-    } else {
-      delete Class.prototype[methodName]
-    }
-  }
-
-  return spy
-};
-
-
-// fix bug with jQuery 3 returning 0 height for <td> elements in the IE's
-[ 'height', 'outerHeight' ].forEach(function(methodName) {
-  var orig = $.fn[methodName]
-
-  $.fn[methodName] = function() {
-    if (!arguments.length && this.is('td')) {
-      return this[0].getBoundingClientRect().height
-    } else {
-      return orig.apply(this, arguments)
-    }
-  }
-})
-
 // Destroy all calendars afterwards, to prevent memory leaks
 // (not the best place for this)
 afterEach(function() {

+ 4 - 50
tests/legacy/businessHours.js

@@ -1,6 +1,9 @@
 // most other businessHours tests are in background-events.js
 
-import { doElsMatchSegs, getBoundingRect } from '../lib/dom-utils'
+import { getBoundingRect } from '../lib/dom-geom'
+import { doElsMatchSegs } from '../lib/segs'
+import { getTimeGridTop, getTimeGridDayEls } from '../lib/time-grid'
+
 
 describe('businessHours', function() {
   pushOptions({
@@ -220,53 +223,4 @@ describe('businessHours', function() {
     }
   }
 
-  /* copied from other proj...
-  ------------------------------------------------------------------------------------------------------------------ */
-
-  function getTimeGridTop(targetTime) {
-    var i, j, len, prevSlotEl, prevSlotTime, slotEl, slotEls, slotMsDuration, slotTime, topBorderWidth
-    targetTime = moment.duration(targetTime)
-    slotEls = getTimeGridSlotEls(targetTime)
-    topBorderWidth = 1
-    if (slotEls.length === 1) {
-      return slotEls.eq(0).offset().top + topBorderWidth
-    }
-    slotEls = $('.fc-time-grid .fc-slats tr[data-time]')
-    slotTime = null
-    prevSlotTime = null
-    for (i = j = 0, len = slotEls.length; j < len; i = ++j) {
-      slotEl = slotEls[i]
-      slotEl = $(slotEl)
-      prevSlotTime = slotTime
-      slotTime = moment.duration(slotEl.data('time'))
-      if (targetTime < slotTime) {
-        if (!prevSlotTime) {
-          return slotEl.offset().top + topBorderWidth
-        } else {
-          prevSlotEl = slotEls.eq(i - 1)
-          return prevSlotEl.offset().top + topBorderWidth +
-            prevSlotEl.outerHeight() * ((targetTime - prevSlotTime) / (slotTime - prevSlotTime))
-        }
-      }
-    }
-    slotMsDuration = slotTime - prevSlotTime
-    return slotEl.offset().top + topBorderWidth +
-      slotEl.outerHeight() * Math.min(1, (targetTime - slotTime) / slotMsDuration)
-  }
-
-  function getTimeGridDayEls(date) {
-    date = $.fullCalendar.moment.parseZone(date)
-    return $('.fc-time-grid .fc-day[data-date="' + date.format('YYYY-MM-DD') + '"]')
-  }
-
-  function getTimeGridSlotEls(timeDuration) {
-    timeDuration = moment.duration(timeDuration)
-    var date = $.fullCalendar.moment.utc('2016-01-01').time(timeDuration)
-    if (date.date() === 1) { // ensure no time overflow/underflow
-      return $('.fc-time-grid .fc-slats tr[data-time="' + date.format('HH:mm:ss') + '"]')
-    } else {
-      return $()
-    }
-  }
-
 })

+ 2 - 1
tests/legacy/nowIndicator.js

@@ -1,4 +1,5 @@
-import { getBoundingRect, isElWithinRtl } from '../lib/dom-utils'
+import { getBoundingRect } from '../lib/dom-geom'
+import { isElWithinRtl } from '../lib/dom-utils'
 import { getTimeGridLine } from '../lib/time-grid'
 
 describe('now indicator', function() {

+ 11 - 0
tests/lib/day-grid.js

@@ -0,0 +1,11 @@
+
+export function getDayGridDayEls(date) {
+  date = $.fullCalendar.moment.parseZone(date)
+  return $(`.fc-day-grid .fc-day[data-date="${date.format('YYYY-MM-DD')}"]`)
+}
+
+
+// TODO: discourage use
+export function getDayGridDowEls(dayAbbrev) {
+  return $(`.fc-day-grid .fc-row:first-child td.fc-day.fc-${dayAbbrev}`)
+}

+ 309 - 0
tests/lib/dom-geom.js

@@ -0,0 +1,309 @@
+import {
+  isRect, isRectMostlyAbove, isRectMostlyLeft, isRectMostlyBounded,
+  isRectMostlyHBounded, isRectMostlyVBounded
+} from './geom'
+
+
+// fix bug with jQuery 3 returning 0 height for <td> elements in the IE's
+[ 'height', 'outerHeight' ].forEach(function(methodName) {
+  var orig = $.fn[methodName]
+
+  $.fn[methodName] = function() {
+    if (!arguments.length && this.is('td')) {
+      return this[0].getBoundingClientRect().height
+    } else {
+      return orig.apply(this, arguments)
+    }
+  }
+})
+
+
+export function getBoundingRects(els) {
+  return $(el).map(function(i, node) {
+    return getBoundingRect(node)
+  })
+}
+
+
+export function getBoundingRect(el) {
+  el = $(el)
+  return $.extend({}, el[0].getBoundingClientRect(), {
+    node: el // very useful for debugging
+  })
+}
+
+
+export function getLeadingBoundingRect(els, isRTL) {
+  els = $(els)
+  expect(els.length).toBeGreaterThan(0)
+  let best = null
+  els.each(function(i, node) {
+    const rect = getBoundingRect(node)
+    if (!best) {
+      best = rect
+    } else if (isRTL) {
+      if (rect.right > best.right) {
+        best = rect
+      }
+    } else {
+      if (rect.left < best.left) {
+        best = rect
+      }
+    }
+  })
+  return best
+}
+
+
+export function getTrailingBoundingRect(els, isRTL) {
+  els = $(els)
+  expect(els.length).toBeGreaterThan(0)
+  let best = null
+  els.each(function(i, node) {
+    const rect = getBoundingRect(node)
+    if (!best) {
+      best = rect
+    } else if (isRTL) {
+      if (rect.left < best.left) {
+        best = rect
+      }
+    } else {
+      if (rect.right > best.right) {
+        best = rect
+      }
+    }
+  })
+  return best
+}
+
+
+export function sortBoundingRects(els, isRTL) {
+  const rects = els.map(function(i, node) {
+    return getBoundingRect(node)
+  })
+  rects.sort(function(a, b) {
+    if (isRTL) {
+      return b.right - a.right
+    } else {
+      return a.left - b.left
+    }
+  })
+  return rects
+}
+
+
+// given an element, returns its bounding box. given a rect, returns the rect.
+function massageRect(input) {
+  if (isRect(input)) {
+    return input
+  } else {
+    return getBoundingRect(input)
+  }
+}
+
+
+// Jasmine Adapters
+// --------------------------------------------------------------------------------------------------
+
+beforeEach(function() {
+  jasmine.addMatchers({
+
+    toBeMostlyAbove() {
+      return {
+        compare(subject, other) {
+          const result = { pass: isRectMostlyAbove(massageRect(subject), massageRect(other)) }
+          if (!result.pass) {
+            result.message = 'first rect is not mostly above the second'
+          }
+          return result
+        }
+      }
+    },
+
+    toBeMostlyBelow() {
+      return {
+        compare(subject, other) {
+          const result = { pass: !isRectMostlyAbove(massageRect(subject), massageRect(other)) }
+          if (!result.pass) {
+            result.message = 'first rect is not mostly below the second'
+          }
+          return result
+        }
+      }
+    },
+
+    toBeMostlyLeftOf() {
+      return {
+        compare(subject, other) {
+          const result = { pass: isRectMostlyLeft(massageRect(subject), massageRect(other)) }
+          if (!result.pass) {
+            result.message = 'first rect is not mostly left of the second'
+          }
+          return result
+        }
+      }
+    },
+
+    toBeMostlyRightOf() {
+      return {
+        compare(subject, other) {
+          const result = { pass: !isRectMostlyLeft(massageRect(subject), massageRect(other)) }
+          if (!result.pass) {
+            result.message = 'first rect is not mostly right of the second'
+          }
+          return result
+        }
+      }
+    },
+
+    toBeMostlyBoundedBy() {
+      return {
+        compare(subject, other) {
+          const result = { pass: isRectMostlyBounded(massageRect(subject), massageRect(other)) }
+          if (!result.pass) {
+            result.message = 'first rect is not mostly bounded by the second'
+          }
+          return result
+        }
+      }
+    },
+
+    toBeMostlyHBoundedBy() {
+      return {
+        compare(subject, other) {
+          const result = { pass: isRectMostlyHBounded(massageRect(subject), massageRect(other)) }
+          if (!result.pass) {
+            result.message = 'first rect does not mostly horizontally bound the second'
+          }
+          return result
+        }
+      }
+    },
+
+    toBeMostlyVBoundedBy() {
+      return {
+        compare(subject, other) {
+          const result = { pass: isRectMostlyVBounded(massageRect(subject), massageRect(other)) }
+          if (!result.pass) {
+            result.message = 'first rect does not mostly vertically bound the second'
+          }
+          return result
+        }
+      }
+    },
+
+    toBeBoundedBy() {
+      return {
+        compare: function(actual, expected) {
+          var outer = massageRect(expected)
+          var inner = massageRect(actual)
+          var result = {
+            pass: outer && inner &&
+              inner.left >= outer.left &&
+              inner.right <= outer.right &&
+              inner.top >= outer.top &&
+              inner.bottom <= outer.bottom
+          }
+          if (!result.pass) {
+            result.message = 'Element does not bound other element'
+          }
+          return result
+        }
+      }
+    },
+
+    toBeLeftOf() {
+      return {
+        compare: function(actual, expected) {
+          var subjectBounds = massageRect(actual)
+          var otherBounds = massageRect(expected)
+          var result = {
+            pass: subjectBounds && otherBounds &&
+              Math.round(subjectBounds.right) <= Math.round(otherBounds.left) + 2
+              // need to round because IE was giving weird fractions
+          }
+          if (!result.pass) {
+            result.message = 'Element is not to the left of the other element'
+          }
+          return result
+        }
+      }
+    },
+
+    toBeRightOf() {
+      return {
+        compare: function(actual, expected) {
+          var subjectBounds = massageRect(actual)
+          var otherBounds = massageRect(expected)
+          var result = {
+            pass: subjectBounds && otherBounds &&
+              Math.round(subjectBounds.left) >= Math.round(otherBounds.right) - 2
+              // need to round because IE was giving weird fractions
+          }
+          if (!result.pass) {
+            result.message = 'Element is not to the right of the other element'
+          }
+          return result
+        }
+      }
+    },
+
+    toBeAbove() {
+      return {
+        compare: function(actual, expected) {
+          var subjectBounds = massageRect(actual)
+          var otherBounds = massageRect(expected)
+          var result = {
+            pass: subjectBounds && otherBounds &&
+              Math.round(subjectBounds.bottom) <= Math.round(otherBounds.top) + 2
+              // need to round because IE was giving weird fractions
+          }
+          if (!result.pass) {
+            result.message = 'Element is not above the other element'
+          }
+          return result
+        }
+      }
+    },
+
+    toBeBelow() {
+      return {
+        compare: function(actual, expected) {
+          var subjectBounds = massageRect(actual)
+          var otherBounds = massageRect(expected)
+          var result = {
+            pass: subjectBounds && otherBounds &&
+              Math.round(subjectBounds.top) >= Math.round(otherBounds.bottom) - 2
+              // need to round because IE was giving weird fractions
+          }
+          if (!result.pass) {
+            result.message = 'Element is not below the other element'
+          }
+          return result
+        }
+      }
+    },
+
+    toIntersectWith() {
+      return {
+        compare: function(actual, expected) {
+          var subjectBounds = massageRect(actual)
+          var otherBounds = massageRect(expected)
+          var result = {
+            pass: subjectBounds && otherBounds &&
+              subjectBounds.right - 1 > otherBounds.left &&
+              subjectBounds.left + 1 < otherBounds.right &&
+              subjectBounds.bottom - 1 > otherBounds.top &&
+              subjectBounds.top + 1 < otherBounds.bottom
+              // +/-1 because of zoom
+          }
+          if (!result.pass) {
+            result.message = 'Element does not intersect with other element'
+          }
+          return result
+        }
+      }
+    }
+
+  })
+})

+ 16 - 60
tests/lib/dom-utils.js

@@ -56,66 +56,22 @@ export function isElWithinRtl(el) {
 }
 
 
-/* copied from other proj
----------------------------------------------------------------------------------------------------------------------- */
-
-export function doElsMatchSegs(els, segs, segToRectFunc) {
-  var elRect, found, i, j, k, len, len1, seg, segRect, unmatchedRects
-  unmatchedRects = getBoundingRects(els)
-  if (unmatchedRects.length !== segs.length) {
-    return false
-  }
-  for (j = 0, len = segs.length; j < len; j++) {
-    seg = segs[j]
-    segRect = segToRectFunc(seg)
-    found = false
-    for (i = k = 0, len1 = unmatchedRects.length; k < len1; i = ++k) {
-      elRect = unmatchedRects[i]
-      if (isRectsSimilar(elRect, segRect)) {
-        unmatchedRects.splice(i, 1)
-        found = true
-        break
+beforeEach(function() {
+  jasmine.addMatchers({
+
+    toHaveScrollbars() {
+      return {
+        compare: function(actual) {
+          var elm = $(actual)
+          var result = {
+            pass: elm[0].scrollWidth - 1 > elm[0].clientWidth || // -1 !!!
+              elm[0].scrollHeight - 1 > elm[0].clientHeight // -1 !!!
+          }
+          // !!! - IE was reporting a scrollWidth/scrollHeight 1 pixel taller than what it was :(
+          return result
+        }
       }
     }
-    if (!found) {
-      return false
-    }
-  }
-  return true
-}
-
-function getBoundingRects(els) {
-  var node
-  return (function() {
-    var i, len, results
-    results = []
-    for (i = 0, len = els.length; i < len; i++) {
-      node = els[i]
-      results.push(getBoundingRect(node))
-    }
-    return results
-  })()
-}
 
-export function getBoundingRect(el) {
-  var rect
-  el = $(el)
-  expect(el.length).toBe(1)
-  rect = el.offset()
-  rect.right = rect.left + el.outerWidth()
-  rect.bottom = rect.top + el.outerHeight()
-  rect.node = el[0]
-  return rect
-}
-
-function isRectsSimilar(rect1, rect2) {
-  return isRectsHSimilar(rect1, rect2) && isRectsVSimilar(rect1, rect2)
-}
-
-function isRectsHSimilar(rect1, rect2) {
-  return Math.abs(rect1.left - rect2.left) <= 2 && Math.abs(rect1.right - rect2.right) <= 2
-}
-
-function isRectsVSimilar(rect1, rect2) {
-  return Math.abs(rect1.top - rect2.top) <= 2 && Math.abs(rect1.bottom - rect2.bottom) <= 2
-}
+  })
+})

+ 57 - 3
tests/lib/geom.js

@@ -15,7 +15,16 @@ export function intersectRects(rect0, rect1) {
   )
 }
 
-export function buildRectViaDims(left, top, width, height) {
+export function joinRects(rect1, rect2) {
+  return {
+    left: Math.min(rect1.left, rect2.left),
+    right: Math.max(rect1.right, rect2.right),
+    top: Math.min(rect1.top, rect2.top),
+    bottom: Math.max(rect1.bottom, rect2.bottom)
+  }
+}
+
+function buildRectViaDims(left, top, width, height) {
   return {
     left: left,
     top: top,
@@ -26,7 +35,7 @@ export function buildRectViaDims(left, top, width, height) {
   }
 }
 
-export function buildRectViaEdges(left, top, right, bottom) {
+function buildRectViaEdges(left, top, right, bottom) {
   return {
     left: left,
     top: top,
@@ -37,7 +46,7 @@ export function buildRectViaEdges(left, top, right, bottom) {
   }
 }
 
-export function buildPoint(left, top) {
+function buildPoint(left, top) {
   return {
     left: left,
     top: top
@@ -61,3 +70,48 @@ export function addPoints(point0, point1) {
 export function getRectTopLeft(rect) {
   return buildPoint(rect.left, rect.top)
 }
+
+export function isRect(input) {
+  return 'left' in input && 'right' in input && 'top' in input && 'bottom' in input
+}
+
+export function isRectMostlyAbove(subjectRect, otherRect) {
+  return (subjectRect.bottom - otherRect.top) < // overlap is less than
+    ((subjectRect.bottom - subjectRect.top) / 2) // half the height
+}
+
+export function isRectMostlyLeft(subjectRect, otherRect) {
+  return (subjectRect.right - otherRect.left) < // overlap is less then
+    ((subjectRect.right - subjectRect.left) / 2) // half the width
+}
+
+export function isRectMostlyBounded(subjectRect, boundRect) {
+  return isRectMostlyHBounded(subjectRect, boundRect) &&
+    isRectMostlyVBounded(subjectRect, boundRect)
+}
+
+export function isRectMostlyHBounded(subjectRect, boundRect) {
+  return (Math.min(subjectRect.right, boundRect.right) -
+    Math.max(subjectRect.left, boundRect.left)) > // overlap area is greater than
+      ((subjectRect.right - subjectRect.left) / 2) // half the width
+}
+
+export function isRectMostlyVBounded(subjectRect, boundRect) {
+  return (Math.min(subjectRect.bottom, boundRect.bottom) -
+    Math.max(subjectRect.top, boundRect.top)) > // overlap area is greater than
+      ((subjectRect.bottom - subjectRect.top) / 2) // half the height
+}
+
+export function isRectsSimilar(rect1, rect2) {
+  return isRectsHSimilar(rect1, rect2) && isRectsVSimilar(rect1, rect2)
+}
+
+function isRectsHSimilar(rect1, rect2) {
+  // 1, because of possible borders
+  return (Math.abs(rect1.left - rect2.left) <= 2) && (Math.abs(rect1.right - rect2.right) <= 2)
+}
+
+function isRectsVSimilar(rect1, rect2) {
+  // 1, because of possible borders
+  return (Math.abs(rect1.top - rect2.top) <= 2) && (Math.abs(rect1.bottom - rect2.bottom) <= 2)
+}

+ 64 - 0
tests/lib/moment.js

@@ -0,0 +1,64 @@
+
+function serializeDuration(duration) {
+  return Math.floor(duration.asDays()) + '.' +
+    pad(duration.hours(), 2) + ':' +
+    pad(duration.minutes(), 2) + ':' +
+    pad(duration.seconds(), 2) + '.' +
+    pad(duration.milliseconds(), 3)
+}
+
+function pad(n, width) {
+  n = n + ''
+  return n.length >= width ? n : new Array(width - n.length + 1).join('0') + n
+}
+
+beforeEach(function() {
+  jasmine.addMatchers({
+
+    toEqualMoment() {
+      return {
+        compare: function(actual, expected) {
+          var actualStr = $.fullCalendar.moment.parseZone(actual).format()
+          var expectedStr = $.fullCalendar.moment.parseZone(expected).format()
+          var result = {
+            pass: actualStr === expectedStr
+          }
+          if (!result.pass) {
+            result.message = 'Moment ' + actualStr + ' does not equal ' + expectedStr
+          }
+          return result
+        }
+      }
+    },
+    toEqualNow() {
+      return {
+        compare: function(actual) {
+          var actualMoment = $.fullCalendar.moment.parseZone(actual)
+          var result = {
+            pass: Math.abs(actualMoment - new Date()) < 1000 // within a second of current datetime
+          }
+          if (!result.pass) {
+            result.message = 'Moment ' + actualMoment.format() + ' is not close enough to now'
+          }
+          return result
+        }
+      }
+    },
+    toEqualDuration() {
+      return {
+        compare: function(actual, expected) {
+          var actualStr = serializeDuration(moment.duration(actual))
+          var expectedStr = serializeDuration(moment.duration(expected))
+          var result = {
+            pass: actualStr === expectedStr
+          }
+          if (!result.pass) {
+            result.message = 'Duration ' + actualStr + ' does not equal ' + expectedStr
+          }
+          return result
+        }
+      }
+    }
+
+  })
+})

+ 27 - 0
tests/lib/segs.js

@@ -0,0 +1,27 @@
+import { isRectsSimilar } from './geom'
+import { getBoundingRects } from './dom-geom'
+
+export function doElsMatchSegs(els, segs, segToRectFunc) {
+  var elRect, found, i, j, k, len, len1, seg, segRect, unmatchedRects
+  unmatchedRects = getBoundingRects(els)
+  if (unmatchedRects.length !== segs.length) {
+    return false
+  }
+  for (j = 0, len = segs.length; j < len; j++) {
+    seg = segs[j]
+    segRect = segToRectFunc(seg)
+    found = false
+    for (i = k = 0, len1 = unmatchedRects.length; k < len1; i = ++k) {
+      elRect = unmatchedRects[i]
+      if (isRectsSimilar(elRect, segRect)) {
+        unmatchedRects.splice(i, 1)
+        found = true
+        break
+      }
+    }
+    if (!found) {
+      return false
+    }
+  }
+  return true
+}

+ 56 - 12
tests/lib/time-grid.js

@@ -1,6 +1,4 @@
-// TODO: consolidate with scheduler
-
-import { getBoundingRect } from '../lib/dom-utils'
+import { getBoundingRect } from '../lib/dom-geom'
 
 
 export function dragTimeGridEvent(eventEl, dropDate) {
@@ -47,7 +45,7 @@ export function selectTimeGrid(start, inclusiveEnd) {
 }
 
 
-function getTimeGridPoint(date) {
+export function getTimeGridPoint(date) {
   date = $.fullCalendar.moment.parseZone(date)
   var top = getTimeGridTop(date.time())
   var dayEls = getTimeGridDayEls(date)
@@ -81,13 +79,56 @@ export function getTimeGridLine(date) { // not in Scheduler
 }
 
 
-export function getTimeGridTop(time) {
-  time = moment.duration(time)
-  var slotEls = getTimeGridSlotEls(time)
+/*
+targetTime is a time (duration) that can be in between slots
+*/
+export function getTimeGridTop(targetTime) {
+  let slotEl
+  targetTime = moment.duration(targetTime)
+  let slotEls = getTimeGridSlotEls(targetTime)
+  const topBorderWidth = 1 // TODO: kill
+
+  // exact slot match
+  if (slotEls.length === 1) {
+    return slotEls.eq(0).offset().top + topBorderWidth
+  }
 
-  expect(slotEls.length).toBe(1)
+  slotEls = $('.fc-time-grid .fc-slats tr[data-time]') // all slots
+  let slotTime = null
+  let prevSlotTime = null
+
+  for (let i = 0; i < slotEls.length; i++) { // traverse earlier to later
+    slotEl = slotEls[i]
+    slotEl = $(slotEl)
+
+    prevSlotTime = slotTime
+    slotTime = moment.duration(slotEl.data('time'))
+
+    // is target time between start of previous slot but before this one?
+    if (targetTime < slotTime) {
+      // before first slot
+      if (!prevSlotTime) {
+        return slotEl.offset().top + topBorderWidth
+      } else {
+        const prevSlotEl = slotEls.eq(i - 1)
+        return prevSlotEl.offset().top + // previous slot top
+          topBorderWidth +
+          (prevSlotEl.outerHeight() *
+          ((targetTime - prevSlotTime) / (slotTime - prevSlotTime)))
+      }
+    }
+  }
 
-  return slotEls.offset().top + 1 // +1 make sure after border
+  // target time must be after the start time of the last slot.
+  // `slotTime` is set to the start time of the last slot.
+
+  // guess the duration of the last slot, based on previous duration
+  const slotMsDuration = slotTime - prevSlotTime
+
+  return slotEl.offset().top + // last slot's top
+    topBorderWidth +
+    (slotEl.outerHeight() *
+    Math.min(1, (targetTime - slotTime) / slotMsDuration)) // don't go past end of last slot
 }
 
 
@@ -100,7 +141,10 @@ export function getTimeGridDayEls(date) {
 
 export function getTimeGridSlotEls(timeDuration) {
   timeDuration = moment.duration(timeDuration)
-  var date = $.fullCalendar.moment.utc('2016-01-01').time(timeDuration)
-
-  return $('.fc-time-grid .fc-slats tr[data-time="' + date.format('HH:mm:ss') + '"]')
+  const date = $.fullCalendar.moment.utc('2016-01-01').time(timeDuration)
+  if (date.date() === 1) { // ensure no time overflow/underflow
+    return $(`.fc-time-grid .fc-slats tr[data-time="${date.format('HH:mm:ss')}"]`)
+  } else {
+    return $()
+  }
 }