import QUnit from 'qunit';
import {
  default as PlaylistLoader,
  updateSegments,
  updateMaster,
  setupMediaPlaylists,
  resolveMediaGroupUris,
  refreshDelay
} from '../src/playlist-loader';
import xhrFactory from '../src/xhr';
import { useFakeEnvironment } from './test-helpers';
import window from 'global/window';

// Attempts to produce an absolute URL to a given relative path
// based on window.location.href
const urlTo = function(path) {
  return window.location.href
    .split('/')
    .slice(0, -1)
    .concat([path])
    .join('/');
};

QUnit.module('Playlist Loader', {
  beforeEach(assert) {
    this.env = useFakeEnvironment(assert);
    this.clock = this.env.clock;
    this.requests = this.env.requests;
    this.fakeHls = {
      xhr: xhrFactory()
    };
  },
  afterEach() {
    this.env.restore();
  }
});

QUnit.test('updateSegments copies over properties', function(assert) {
  assert.deepEqual(
    [
      { uri: 'test-uri-0', startTime: 0, endTime: 10 },
      {
        uri: 'test-uri-1',
        startTime: 10,
        endTime: 20,
        map: { someProp: 99, uri: '4' }
      }
    ],
    updateSegments(
      [
        { uri: 'test-uri-0', startTime: 0, endTime: 10 },
        { uri: 'test-uri-1', startTime: 10, endTime: 20, map: { someProp: 1 } }
      ],
      [
        { uri: 'test-uri-0' },
        { uri: 'test-uri-1', map: { someProp: 99, uri: '4' } }
      ],
      0),
    'retains properties from original segment');

  assert.deepEqual(
    [
      { uri: 'test-uri-0', map: { someProp: 100 } },
      { uri: 'test-uri-1', map: { someProp: 99, uri: '4' } }
    ],
    updateSegments(
      [
        { uri: 'test-uri-0' },
        { uri: 'test-uri-1', map: { someProp: 1 } }
      ],
      [
        { uri: 'test-uri-0', map: { someProp: 100 } },
        { uri: 'test-uri-1', map: { someProp: 99, uri: '4' } }
      ],
      0),
    'copies over/overwrites properties without offset');

  assert.deepEqual(
    [
      { uri: 'test-uri-1', map: { someProp: 1 } },
      { uri: 'test-uri-2', map: { someProp: 100, uri: '2' } }
    ],
    updateSegments(
      [
        { uri: 'test-uri-0' },
        { uri: 'test-uri-1', map: { someProp: 1 } }
      ],
      [
        { uri: 'test-uri-1' },
        { uri: 'test-uri-2', map: { someProp: 100, uri: '2' } }
      ],
      1),
    'copies over/overwrites properties with offset of 1');

  assert.deepEqual(
    [
      { uri: 'test-uri-2' },
      { uri: 'test-uri-3', map: { someProp: 100, uri: '2' } }
    ],
    updateSegments(
      [
        { uri: 'test-uri-0' },
        { uri: 'test-uri-1', map: { someProp: 1 } }
      ],
      [
        { uri: 'test-uri-2' },
        { uri: 'test-uri-3', map: { someProp: 100, uri: '2' } }
      ],
      2),
    'copies over/overwrites properties with offset of 2');
});

QUnit.test('updateMaster returns null when no playlists', function(assert) {
  const master = {
    playlists: []
  };
  const media = {};

  assert.deepEqual(updateMaster(master, media), null, 'returns null when no playlists');
});

QUnit.test('updateMaster returns null when no change', function(assert) {
  const master = {
    playlists: [{
      mediaSequence: 0,
      attributes: {
        BANDWIDTH: 9
      },
      uri: 'playlist-0-uri',
      resolvedUri: urlTo('playlist-0-uri'),
      segments: [{
        duration: 10,
        uri: 'segment-0-uri',
        resolvedUri: urlTo('segment-0-uri')
      }]
    }]
  };
  const media = {
    mediaSequence: 0,
    attributes: {
      BANDWIDTH: 9
    },
    uri: 'playlist-0-uri',
    segments: [{
      duration: 10,
      uri: 'segment-0-uri'
    }]
  };

  assert.deepEqual(updateMaster(master, media), null, 'returns null');
});

QUnit.test('updateMaster updates master when new media sequence', function(assert) {
  const master = {
    playlists: [{
      mediaSequence: 0,
      attributes: {
        BANDWIDTH: 9
      },
      uri: 'playlist-0-uri',
      resolvedUri: urlTo('playlist-0-uri'),
      segments: [{
        duration: 10,
        uri: 'segment-0-uri',
        resolvedUri: urlTo('segment-0-uri')
      }]
    }]
  };
  const media = {
    mediaSequence: 1,
    attributes: {
      BANDWIDTH: 9
    },
    uri: 'playlist-0-uri',
    segments: [{
      duration: 10,
      uri: 'segment-0-uri'
    }]
  };

  assert.deepEqual(
    updateMaster(master, media),
    {
      playlists: [{
        mediaSequence: 1,
        attributes: {
          BANDWIDTH: 9
        },
        uri: 'playlist-0-uri',
        resolvedUri: urlTo('playlist-0-uri'),
        segments: [{
          duration: 10,
          uri: 'segment-0-uri',
          resolvedUri: urlTo('segment-0-uri')
        }]
      }]
    },
    'updates master when new media sequence');
});

QUnit.test('updateMaster retains top level values in master', function(assert) {
  const master = {
    mediaGroups: {
      AUDIO: {
        'GROUP-ID': {
          default: true,
          uri: 'audio-uri'
        }
      }
    },
    playlists: [{
      mediaSequence: 0,
      attributes: {
        BANDWIDTH: 9
      },
      uri: 'playlist-0-uri',
      resolvedUri: urlTo('playlist-0-uri'),
      segments: [{
        duration: 10,
        uri: 'segment-0-uri',
        resolvedUri: urlTo('segment-0-uri')
      }]
    }]
  };
  const media = {
    mediaSequence: 1,
    attributes: {
      BANDWIDTH: 9
    },
    uri: 'playlist-0-uri',
    segments: [{
      duration: 10,
      uri: 'segment-0-uri'
    }]
  };

  assert.deepEqual(
    updateMaster(master, media),
    {
      mediaGroups: {
        AUDIO: {
          'GROUP-ID': {
            default: true,
            uri: 'audio-uri'
          }
        }
      },
      playlists: [{
        mediaSequence: 1,
        attributes: {
          BANDWIDTH: 9
        },
        uri: 'playlist-0-uri',
        resolvedUri: urlTo('playlist-0-uri'),
        segments: [{
          duration: 10,
          uri: 'segment-0-uri',
          resolvedUri: urlTo('segment-0-uri')
        }]
      }]
    },
    'retains top level values in master');
});

QUnit.test('updateMaster adds new segments to master', function(assert) {
  const master = {
    mediaGroups: {
      AUDIO: {
        'GROUP-ID': {
          default: true,
          uri: 'audio-uri'
        }
      }
    },
    playlists: [{
      mediaSequence: 0,
      attributes: {
        BANDWIDTH: 9
      },
      uri: 'playlist-0-uri',
      resolvedUri: urlTo('playlist-0-uri'),
      segments: [{
        duration: 10,
        uri: 'segment-0-uri',
        resolvedUri: urlTo('segment-0-uri')
      }]
    }]
  };
  const media = {
    mediaSequence: 1,
    attributes: {
      BANDWIDTH: 9
    },
    uri: 'playlist-0-uri',
    segments: [{
      duration: 10,
      uri: 'segment-0-uri'
    }, {
      duration: 9,
      uri: 'segment-1-uri'
    }]
  };

  assert.deepEqual(
    updateMaster(master, media),
    {
      mediaGroups: {
        AUDIO: {
          'GROUP-ID': {
            default: true,
            uri: 'audio-uri'
          }
        }
      },
      playlists: [{
        mediaSequence: 1,
        attributes: {
          BANDWIDTH: 9
        },
        uri: 'playlist-0-uri',
        resolvedUri: urlTo('playlist-0-uri'),
        segments: [{
          duration: 10,
          uri: 'segment-0-uri',
          resolvedUri: urlTo('segment-0-uri')
        }, {
          duration: 9,
          uri: 'segment-1-uri',
          resolvedUri: urlTo('segment-1-uri')
        }]
      }]
    },
    'adds new segment to master');
});

QUnit.test('updateMaster changes old values', function(assert) {
  const master = {
    mediaGroups: {
      AUDIO: {
        'GROUP-ID': {
          default: true,
          uri: 'audio-uri'
        }
      }
    },
    playlists: [{
      mediaSequence: 0,
      attributes: {
        BANDWIDTH: 9
      },
      uri: 'playlist-0-uri',
      resolvedUri: urlTo('playlist-0-uri'),
      segments: [{
        duration: 10,
        uri: 'segment-0-uri',
        resolvedUri: urlTo('segment-0-uri')
      }]
    }]
  };
  const media = {
    mediaSequence: 1,
    attributes: {
      BANDWIDTH: 8,
      newField: 1
    },
    uri: 'playlist-0-uri',
    segments: [{
      duration: 8,
      uri: 'segment-0-uri'
    }, {
      duration: 10,
      uri: 'segment-1-uri'
    }]
  };

  assert.deepEqual(
    updateMaster(master, media),
    {
      mediaGroups: {
        AUDIO: {
          'GROUP-ID': {
            default: true,
            uri: 'audio-uri'
          }
        }
      },
      playlists: [{
        mediaSequence: 1,
        attributes: {
          BANDWIDTH: 8,
          newField: 1
        },
        uri: 'playlist-0-uri',
        resolvedUri: urlTo('playlist-0-uri'),
        segments: [{
          duration: 8,
          uri: 'segment-0-uri',
          resolvedUri: urlTo('segment-0-uri')
        }, {
          duration: 10,
          uri: 'segment-1-uri',
          resolvedUri: urlTo('segment-1-uri')
        }]
      }]
    },
    'changes old values');
});

QUnit.test('updateMaster retains saved segment values', function(assert) {
  const master = {
    playlists: [{
      mediaSequence: 0,
      uri: 'playlist-0-uri',
      resolvedUri: urlTo('playlist-0-uri'),
      segments: [{
        duration: 10,
        uri: 'segment-0-uri',
        resolvedUri: urlTo('segment-0-uri'),
        startTime: 0,
        endTime: 10
      }]
    }]
  };
  const media = {
    mediaSequence: 0,
    uri: 'playlist-0-uri',
    segments: [{
      duration: 8,
      uri: 'segment-0-uri'
    }, {
      duration: 10,
      uri: 'segment-1-uri'
    }]
  };

  assert.deepEqual(
    updateMaster(master, media),
    {
      playlists: [{
        mediaSequence: 0,
        uri: 'playlist-0-uri',
        resolvedUri: urlTo('playlist-0-uri'),
        segments: [{
          duration: 8,
          uri: 'segment-0-uri',
          resolvedUri: urlTo('segment-0-uri'),
          startTime: 0,
          endTime: 10
        }, {
          duration: 10,
          uri: 'segment-1-uri',
          resolvedUri: urlTo('segment-1-uri')
        }]
      }]
    },
    'retains saved segment values');
});

QUnit.test('updateMaster resolves key and map URIs', function(assert) {
  const master = {
    playlists: [{
      mediaSequence: 0,
      attributes: {
        BANDWIDTH: 9
      },
      uri: 'playlist-0-uri',
      resolvedUri: urlTo('playlist-0-uri'),
      segments: [{
        duration: 10,
        uri: 'segment-0-uri',
        resolvedUri: urlTo('segment-0-uri')
      }, {
        duration: 10,
        uri: 'segment-1-uri',
        resolvedUri: urlTo('segment-1-uri')
      }]
    }]
  };
  const media = {
    mediaSequence: 3,
    attributes: {
      BANDWIDTH: 9
    },
    uri: 'playlist-0-uri',
    segments: [{
      duration: 9,
      uri: 'segment-2-uri',
      key: {
        uri: 'key-2-uri'
      },
      map: {
        uri: 'map-2-uri'
      }
    }, {
      duration: 11,
      uri: 'segment-3-uri',
      key: {
        uri: 'key-3-uri'
      },
      map: {
        uri: 'map-3-uri'
      }
    }]
  };

  assert.deepEqual(
    updateMaster(master, media),
    {
      playlists: [{
        mediaSequence: 3,
        attributes: {
          BANDWIDTH: 9
        },
        uri: 'playlist-0-uri',
        resolvedUri: urlTo('playlist-0-uri'),
        segments: [{
          duration: 9,
          uri: 'segment-2-uri',
          resolvedUri: urlTo('segment-2-uri'),
          key: {
            uri: 'key-2-uri',
            resolvedUri: urlTo('key-2-uri')
          },
          map: {
            uri: 'map-2-uri',
            resolvedUri: urlTo('map-2-uri')
          }
        }, {
          duration: 11,
          uri: 'segment-3-uri',
          resolvedUri: urlTo('segment-3-uri'),
          key: {
            uri: 'key-3-uri',
            resolvedUri: urlTo('key-3-uri')
          },
          map: {
            uri: 'map-3-uri',
            resolvedUri: urlTo('map-3-uri')
          }
        }]
      }]
    },
    'resolves key and map URIs');
});

QUnit.test('setupMediaPlaylists does nothing if no playlists', function(assert) {
  const master = {
    playlists: []
  };

  setupMediaPlaylists(master);

  assert.deepEqual(master, {
    playlists: []
  }, 'master remains unchanged');
});

QUnit.test('setupMediaPlaylists adds URI keys for each playlist', function(assert) {
  const master = {
    uri: 'master-uri',
    playlists: [{
      uri: 'uri-0'
    }, {
      uri: 'uri-1'
    }]
  };
  const expectedPlaylist0 = {
    attributes: {},
    resolvedUri: urlTo('uri-0'),
    uri: 'uri-0'
  };
  const expectedPlaylist1 = {
    attributes: {},
    resolvedUri: urlTo('uri-1'),
    uri: 'uri-1'
  };

  setupMediaPlaylists(master);

  assert.deepEqual(master.playlists[0], expectedPlaylist0, 'retained playlist indices');
  assert.deepEqual(master.playlists[1], expectedPlaylist1, 'retained playlist indices');
  assert.deepEqual(master.playlists['uri-0'], expectedPlaylist0, 'added playlist key');
  assert.deepEqual(master.playlists['uri-1'], expectedPlaylist1, 'added playlist key');

  assert.equal(this.env.log.warn.calls, 2, 'logged two warnings');
  assert.equal(this.env.log.warn.args[0],
    'Invalid playlist STREAM-INF detected. Missing BANDWIDTH attribute.',
    'logged a warning');
  assert.equal(this.env.log.warn.args[1],
    'Invalid playlist STREAM-INF detected. Missing BANDWIDTH attribute.',
    'logged a warning');
});

QUnit.test('setupMediaPlaylists adds attributes objects if missing', function(assert) {
  const master = {
    uri: 'master-uri',
    playlists: [{
      uri: 'uri-0'
    }, {
      uri: 'uri-1'
    }]
  };

  setupMediaPlaylists(master);

  assert.ok(master.playlists[0].attributes, 'added attributes object');
  assert.ok(master.playlists[1].attributes, 'added attributes object');

  assert.equal(this.env.log.warn.calls, 2, 'logged two warnings');
  assert.equal(this.env.log.warn.args[0],
    'Invalid playlist STREAM-INF detected. Missing BANDWIDTH attribute.',
    'logged a warning');
  assert.equal(this.env.log.warn.args[1],
    'Invalid playlist STREAM-INF detected. Missing BANDWIDTH attribute.',
    'logged a warning');
});

QUnit.test('setupMediaPlaylists resolves playlist URIs', function(assert) {
  const master = {
    uri: 'master-uri',
    playlists: [{
      attributes: { BANDWIDTH: 10 },
      uri: 'uri-0'
    }, {
      attributes: { BANDWIDTH: 100 },
      uri: 'uri-1'
    }]
  };

  setupMediaPlaylists(master);

  assert.equal(master.playlists[0].resolvedUri, urlTo('uri-0'), 'resolves URI');
  assert.equal(master.playlists[1].resolvedUri, urlTo('uri-1'), 'resolves URI');
});

QUnit.test('resolveMediaGroupUris does nothing when no media groups', function(assert) {
  const master = {
    uri: 'master-uri',
    playlists: [],
    mediaGroups: []
  };

  resolveMediaGroupUris(master);
  assert.deepEqual(master, {
    uri: 'master-uri',
    playlists: [],
    mediaGroups: []
  }, 'does nothing when no media groups');
});

QUnit.test('resolveMediaGroupUris resolves media group URIs', function(assert) {
  const master = {
    uri: 'master-uri',
    playlists: [{
      attributes: { BANDWIDTH: 10 },
      uri: 'playlist-0'
    }],
    mediaGroups: {
      // CLOSED-CAPTIONS will never have a URI
      'CLOSED-CAPTIONS': {
        cc1: {
          English: {}
        }
      },
      'AUDIO': {
        low: {
          // audio doesn't need a URI if it is a label for muxed
          main: {},
          commentary: {
            uri: 'audio-low-commentary-uri'
          }
        },
        high: {
          main: {},
          commentary: {
            uri: 'audio-high-commentary-uri'
          }
        }
      },
      'SUBTITLES': {
        sub1: {
          english: {
            uri: 'subtitles-1-english-uri'
          },
          spanish: {
            uri: 'subtitles-1-spanish-uri'
          }
        },
        sub2: {
          english: {
            uri: 'subtitles-2-english-uri'
          },
          spanish: {
            uri: 'subtitles-2-spanish-uri'
          }
        },
        sub3: {
          english: {
            uri: 'subtitles-3-english-uri'
          },
          spanish: {
            uri: 'subtitles-3-spanish-uri'
          }
        }
      }
    }
  };

  resolveMediaGroupUris(master);

  assert.deepEqual(master, {
    uri: 'master-uri',
    playlists: [{
      attributes: { BANDWIDTH: 10 },
      uri: 'playlist-0'
    }],
    mediaGroups: {
      // CLOSED-CAPTIONS will never have a URI
      'CLOSED-CAPTIONS': {
        cc1: {
          English: {}
        }
      },
      'AUDIO': {
        low: {
          // audio doesn't need a URI if it is a label for muxed
          main: {},
          commentary: {
            uri: 'audio-low-commentary-uri',
            resolvedUri: urlTo('audio-low-commentary-uri')
          }
        },
        high: {
          main: {},
          commentary: {
            uri: 'audio-high-commentary-uri',
            resolvedUri: urlTo('audio-high-commentary-uri')
          }
        }
      },
      'SUBTITLES': {
        sub1: {
          english: {
            uri: 'subtitles-1-english-uri',
            resolvedUri: urlTo('subtitles-1-english-uri')
          },
          spanish: {
            uri: 'subtitles-1-spanish-uri',
            resolvedUri: urlTo('subtitles-1-spanish-uri')
          }
        },
        sub2: {
          english: {
            uri: 'subtitles-2-english-uri',
            resolvedUri: urlTo('subtitles-2-english-uri')
          },
          spanish: {
            uri: 'subtitles-2-spanish-uri',
            resolvedUri: urlTo('subtitles-2-spanish-uri')
          }
        },
        sub3: {
          english: {
            uri: 'subtitles-3-english-uri',
            resolvedUri: urlTo('subtitles-3-english-uri')
          },
          spanish: {
            uri: 'subtitles-3-spanish-uri',
            resolvedUri: urlTo('subtitles-3-spanish-uri')
          }
        }
      }
    }
  }, 'resolved URIs of certain media groups');
});

QUnit.test('uses last segment duration for refresh delay', function(assert) {
  const media = { targetDuration: 7, segments: [] };

  assert.equal(refreshDelay(media, true), 3500,
    'used half targetDuration when no segments');

  media.segments = [ { duration: 6}, { duration: 4 }, { } ];
  assert.equal(refreshDelay(media, true), 3500,
    'used half targetDuration when last segment duration cannot be determined');

  media.segments = [ { duration: 6}, { duration: 4}, { duration: 5 } ];
  assert.equal(refreshDelay(media, true), 5000, 'used last segment duration for delay');

  assert.equal(refreshDelay(media, false), 3500,
    'used half targetDuration when update is false');
});

QUnit.test('throws if the playlist url is empty or undefined', function(assert) {
  assert.throws(function() {
    PlaylistLoader();
  }, 'requires an argument');
  assert.throws(function() {
    PlaylistLoader('');
  }, 'does not accept the empty string');
});

QUnit.test('starts without any metadata', function(assert) {
  let loader = new PlaylistLoader('master.m3u8', this.fakeHls);

  loader.load();

  assert.strictEqual(loader.state, 'HAVE_NOTHING', 'no metadata has loaded yet');
});

QUnit.test('requests the initial playlist immediately', function(assert) {
  let loader = new PlaylistLoader('master.m3u8', this.fakeHls);

  loader.load();

  assert.strictEqual(this.requests.length, 1, 'made a request');
  assert.strictEqual(this.requests[0].url,
                    'master.m3u8',
                    'requested the initial playlist');
});

QUnit.test('moves to HAVE_MASTER after loading a master playlist', function(assert) {
  let loader = new PlaylistLoader('master.m3u8', this.fakeHls);
  let state;

  loader.load();

  loader.on('loadedplaylist', function() {
    state = loader.state;
  });
  this.requests.pop().respond(200, null,
                              '#EXTM3U\n' +
                              '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
                              'media.m3u8\n');
  assert.ok(loader.master, 'the master playlist is available');
  assert.strictEqual(state, 'HAVE_MASTER', 'the state at loadedplaylist correct');
});

QUnit.test('logs warning for master playlist with invalid STREAM-INF', function(assert) {
  let loader = new PlaylistLoader('master.m3u8', this.fakeHls);

  loader.load();

  this.requests.pop().respond(200, null,
                              '#EXTM3U\n' +
                              '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
                              'video1/media.m3u8\n' +
                              '#EXT-X-STREAM-INF:\n' +
                              'video2/media.m3u8\n');

  assert.ok(loader.master, 'infers a master playlist');
  assert.equal(loader.master.playlists[1].uri, 'video2/media.m3u8',
    'parsed invalid stream');
  assert.ok(loader.master.playlists[1].attributes, 'attached attributes property');
  assert.equal(this.env.log.warn.calls, 1, 'logged a warning');
  assert.equal(this.env.log.warn.args[0],
    'Invalid playlist STREAM-INF detected. Missing BANDWIDTH attribute.',
    'logged a warning');
});

QUnit.test('jumps to HAVE_METADATA when initialized with a media playlist',
function(assert) {
  let loadedmetadatas = 0;
  let loader = new PlaylistLoader('media.m3u8', this.fakeHls);

  loader.load();

  loader.on('loadedmetadata', function() {
    loadedmetadatas++;
  });
  this.requests.pop().respond(200, null,
                             '#EXTM3U\n' +
                             '#EXTINF:10,\n' +
                             '0.ts\n' +
                             '#EXT-X-ENDLIST\n');
  assert.ok(loader.master, 'infers a master playlist');
  assert.ok(loader.media(), 'sets the media playlist');
  assert.ok(loader.media().uri, 'sets the media playlist URI');
  assert.ok(loader.media().attributes, 'sets the media playlist attributes');
  assert.strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct');
  assert.strictEqual(this.requests.length, 0, 'no more requests are made');
  assert.strictEqual(loadedmetadatas, 1, 'fired one loadedmetadata');
});

QUnit.test('resolves relative media playlist URIs', function(assert) {
  let loader = new PlaylistLoader('master.m3u8', this.fakeHls);

  loader.load();

  this.requests.shift().respond(200, null,
                                '#EXTM3U\n' +
                                '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
                                'video/media.m3u8\n');
  assert.equal(loader.master.playlists[0].resolvedUri, urlTo('video/media.m3u8'),
              'resolved media URI');
});

QUnit.test('resolves media initialization segment URIs', function(assert) {
  let loader = new PlaylistLoader('video/fmp4.m3u8', this.fakeHls);

  loader.load();
  this.requests.shift().respond(200, null,
                                '#EXTM3U\n' +
                                '#EXT-X-MAP:URI="main.mp4",BYTERANGE="720@0"\n' +
                                '#EXTINF:10,\n' +
                                '0.ts\n' +
                                '#EXT-X-ENDLIST\n');

  assert.equal(loader.media().segments[0].map.resolvedUri, urlTo('video/main.mp4'),
              'resolved init segment URI');
});

QUnit.test('recognizes absolute URIs and requests them unmodified', function(assert) {
  let loader = new PlaylistLoader('manifest/media.m3u8', this.fakeHls);

  loader.load();

  this.requests.shift().respond(200, null,
                                '#EXTM3U\n' +
                                '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
                                'http://example.com/video/media.m3u8\n');
  assert.equal(loader.master.playlists[0].resolvedUri,
              'http://example.com/video/media.m3u8', 'resolved media URI');

  this.requests.shift().respond(200, null,
                                '#EXTM3U\n' +
                                '#EXTINF:10,\n' +
                                'http://example.com/00001.ts\n' +
                                '#EXT-X-ENDLIST\n');
  assert.equal(loader.media().segments[0].resolvedUri,
              'http://example.com/00001.ts', 'resolved segment URI');
});

QUnit.test('recognizes domain-relative URLs', function(assert) {
  let loader = new PlaylistLoader('manifest/media.m3u8', this.fakeHls);

  loader.load();

  this.requests.shift().respond(200, null,
                                '#EXTM3U\n' +
                                '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
                                '/media.m3u8\n');
  assert.equal(loader.master.playlists[0].resolvedUri,
              window.location.protocol + '//' +
              window.location.host + '/media.m3u8',
              'resolved media URI');

  this.requests.shift().respond(200, null,
                                '#EXTM3U\n' +
                                '#EXTINF:10,\n' +
                                '/00001.ts\n' +
                                '#EXT-X-ENDLIST\n');
  assert.equal(loader.media().segments[0].resolvedUri,
              window.location.protocol + '//' +
              window.location.host + '/00001.ts',
              'resolved segment URI');
});

QUnit.test('recognizes redirect, when manifest requested', function(assert) {
  let loader = new PlaylistLoader('manifest/media.m3u8', this.fakeHls, {
    handleManifestRedirects: true
  });

  loader.load();

  const manifestRequest = this.requests.shift();

  manifestRequest.responseURL = window.location.protocol + '//' +
                                'foo-bar.com/manifest/media.m3u8';
  manifestRequest.respond(200, null,
                          '#EXTM3U\n' +
                          '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
                          '/media.m3u8\n');
  assert.equal(loader.master.playlists[0].resolvedUri,
              window.location.protocol + '//' +
              'foo-bar.com/media.m3u8',
              'resolved media URI');

  this.requests.shift().respond(200, null,
                                '#EXTM3U\n' +
                                '#EXTINF:10,\n' +
                                '/00001.ts\n' +
                                '#EXT-X-ENDLIST\n');
  assert.equal(loader.media().segments[0].resolvedUri,
              window.location.protocol + '//' +
              'foo-bar.com/00001.ts',
              'resolved segment URI');
});

QUnit.test('recognizes redirect, when media requested', function(assert) {
  let loader = new PlaylistLoader('manifest/media.m3u8', this.fakeHls, {
    handleManifestRedirects: true
  });

  loader.load();

  this.requests.shift().respond(200, null,
                                '#EXTM3U\n' +
                                '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
                                '/media.m3u8\n');
  assert.equal(loader.master.playlists[0].resolvedUri,
              window.location.protocol + '//' +
              window.location.host + '/media.m3u8',
              'resolved media URI');

  const mediaRequest = this.requests.shift();

  mediaRequest.responseURL = window.location.protocol + '//' +
                             'foo-bar.com/media.m3u8';
  mediaRequest.respond(200, null,
                       '#EXTM3U\n' +
                       '#EXTINF:10,\n' +
                       '/00001.ts\n' +
                       '#EXT-X-ENDLIST\n');
  assert.equal(loader.media().segments[0].resolvedUri,
              window.location.protocol + '//' +
              'foo-bar.com/00001.ts',
              'resolved segment URI');
});

QUnit.test('recognizes key URLs relative to master and playlist', function(assert) {
  let loader = new PlaylistLoader('/video/media-encrypted.m3u8', this.fakeHls);

  loader.load();

  this.requests.shift().respond(200, null,
                                '#EXTM3U\n' +
                                '#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=17\n' +
                                'playlist/playlist.m3u8\n' +
                                '#EXT-X-ENDLIST\n');
  assert.equal(loader.master.playlists[0].resolvedUri,
        window.location.protocol + '//' +
        window.location.host + '/video/playlist/playlist.m3u8',
        'resolved media URI');

  this.requests.shift().respond(200, null,
                                '#EXTM3U\n' +
                                '#EXT-X-TARGETDURATION:15\n' +
                                '#EXT-X-KEY:METHOD=AES-128,URI="keys/key.php"\n' +
                                '#EXTINF:2.833,\n' +
                                'http://example.com/000001.ts\n' +
                                '#EXT-X-ENDLIST\n');
  assert.equal(loader.media().segments[0].key.resolvedUri,
        window.location.protocol + '//' +
        window.location.host + '/video/playlist/keys/key.php',
        'resolved multiple relative paths for key URI');
});

QUnit.test('trigger an error event when a media playlist 404s', function(assert) {
  let count = 0;
  let loader = new PlaylistLoader('manifest/master.m3u8', this.fakeHls);

  loader.load();

  loader.on('error', function() {
    count += 1;
  });

  // master
  this.requests.shift().respond(200, null,
                                '#EXTM3U\n' +
                                '#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=17\n' +
                                'playlist/playlist.m3u8\n' +
                                '#EXT-X-STREAM-INF:PROGRAM-ID=2,BANDWIDTH=170\n' +
                                'playlist/playlist2.m3u8\n' +
                                '#EXT-X-ENDLIST\n');
  assert.equal(count, 0,
    'error not triggered before requesting playlist');

  // playlist
  this.requests.shift().respond(404);

  assert.equal(count, 1,
    'error triggered after playlist 404');
});

QUnit.test('recognizes absolute key URLs', function(assert) {
  let loader = new PlaylistLoader('/video/media-encrypted.m3u8', this.fakeHls);

  loader.load();

  this.requests.shift().respond(200, null,
                                '#EXTM3U\n' +
                                '#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=17\n' +
                                'playlist/playlist.m3u8\n' +
                                '#EXT-X-ENDLIST\n');
  assert.equal(loader.master.playlists[0].resolvedUri,
        window.location.protocol + '//' +
        window.location.host + '/video/playlist/playlist.m3u8',
        'resolved media URI');

  this.requests.shift().respond(
    200,
    null,
    '#EXTM3U\n' +
    '#EXT-X-TARGETDURATION:15\n' +
    '#EXT-X-KEY:METHOD=AES-128,URI="http://example.com/keys/key.php"\n' +
    '#EXTINF:2.833,\n' +
    'http://example.com/000001.ts\n' +
    '#EXT-X-ENDLIST\n'
  );
  assert.equal(loader.media().segments[0].key.resolvedUri,
              'http://example.com/keys/key.php', 'resolved absolute path for key URI');
});

QUnit.test('jumps to HAVE_METADATA when initialized with a live media playlist',
function(assert) {
  let loader = new PlaylistLoader('media.m3u8', this.fakeHls);

  loader.load();

  this.requests.pop().respond(200, null,
                              '#EXTM3U\n' +
                              '#EXTINF:10,\n' +
                              '0.ts\n');
  assert.ok(loader.master, 'infers a master playlist');
  assert.ok(loader.media(), 'sets the media playlist');
  assert.ok(loader.media().attributes, 'sets the media playlist attributes');
  assert.strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct');
});

QUnit.test('moves to HAVE_METADATA after loading a media playlist', function(assert) {
  let loadedPlaylist = 0;
  let loadedMetadata = 0;
  let loader = new PlaylistLoader('master.m3u8', this.fakeHls);

  loader.load();

  loader.on('loadedplaylist', function() {
    loadedPlaylist++;
  });
  loader.on('loadedmetadata', function() {
    loadedMetadata++;
  });
  this.requests.pop().respond(200, null,
                              '#EXTM3U\n' +
                              '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
                              'media.m3u8\n' +
                              '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
                              'alt.m3u8\n');
  assert.strictEqual(loadedPlaylist, 1, 'fired loadedplaylist once');
  assert.strictEqual(loadedMetadata, 0, 'did not fire loadedmetadata');
  assert.strictEqual(this.requests.length, 1, 'requests the media playlist');
  assert.strictEqual(this.requests[0].method, 'GET', 'GETs the media playlist');
  assert.strictEqual(this.requests[0].url,
                    urlTo('media.m3u8'),
                    'requests the first playlist');

  this.requests.pop().respond(200, null,
                              '#EXTM3U\n' +
                              '#EXTINF:10,\n' +
                              '0.ts\n');
  assert.ok(loader.master, 'sets the master playlist');
  assert.ok(loader.media(), 'sets the media playlist');
  assert.strictEqual(loadedPlaylist, 2, 'fired loadedplaylist twice');
  assert.strictEqual(loadedMetadata, 1, 'fired loadedmetadata once');
  assert.strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct');
});

QUnit.test('defaults missing media groups for a media playlist', function(assert) {
  let loader = new PlaylistLoader('master.m3u8', this.fakeHls);

  loader.load();
  this.requests.pop().respond(200, null,
                              '#EXTM3U\n' +
                              '#EXTINF:10,\n' +
                              '0.ts\n');

  assert.ok(loader.master.mediaGroups.AUDIO, 'defaulted audio');
  assert.ok(loader.master.mediaGroups.VIDEO, 'defaulted video');
  assert.ok(loader.master.mediaGroups['CLOSED-CAPTIONS'], 'defaulted closed captions');
  assert.ok(loader.master.mediaGroups.SUBTITLES, 'defaulted subtitles');
});

QUnit.test('moves to HAVE_CURRENT_METADATA when refreshing the playlist',
function(assert) {
  let loader = new PlaylistLoader('live.m3u8', this.fakeHls);

  loader.load();

  this.requests.pop().respond(200, null,
                              '#EXTM3U\n' +
                              '#EXTINF:10,\n' +
                              '0.ts\n');
  // 10s, one target duration
  this.clock.tick(10 * 1000);
  assert.strictEqual(loader.state, 'HAVE_CURRENT_METADATA', 'the state is correct');
  assert.strictEqual(this.requests.length, 1, 'requested playlist');
  assert.strictEqual(this.requests[0].url,
                    urlTo('live.m3u8'),
                    'refreshes the media playlist');
});

QUnit.test('returns to HAVE_METADATA after refreshing the playlist', function(assert) {
  let loader = new PlaylistLoader('live.m3u8', this.fakeHls);

  loader.load();

  this.requests.pop().respond(200, null,
                              '#EXTM3U\n' +
                              '#EXTINF:10,\n' +
                              '0.ts\n');
  // 10s, one target duration
  this.clock.tick(10 * 1000);
  this.requests.pop().respond(200, null,
                              '#EXTM3U\n' +
                              '#EXTINF:10,\n' +
                              '1.ts\n');
  assert.strictEqual(loader.state, 'HAVE_METADATA', 'the state is correct');
});

QUnit.test('refreshes the playlist after last segment duration', function(assert) {
  let loader = new PlaylistLoader('live.m3u8', this.fakeHls);
  let refreshes = 0;

  loader.on('mediaupdatetimeout', () => refreshes++);

  loader.load();

  this.requests.pop().respond(200, null,
                              '#EXTM3U\n' +
                              '#EXT-X-TARGETDURATION:10\n' +
                              '#EXTINF:10,\n' +
                              '0.ts\n' +
                              '#EXTINF:4\n' +
                              '1.ts\n');
  // 4s, last segment duration
  this.clock.tick(4 * 1000);

  assert.equal(refreshes, 1, 'refreshed playlist after last segment duration');
});

QUnit.test('emits an error when an initial playlist request fails', function(assert) {
  let errors = [];
  let loader = new PlaylistLoader('master.m3u8', this.fakeHls);

  loader.load();

  loader.on('error', function() {
    errors.push(loader.error);
  });
  this.requests.pop().respond(500);

  assert.strictEqual(errors.length, 1, 'emitted one error');
  assert.strictEqual(errors[0].status, 500, 'http status is captured');
});

QUnit.test('errors when an initial media playlist request fails', function(assert) {
  let errors = [];
  let loader = new PlaylistLoader('master.m3u8', this.fakeHls);

  loader.load();

  loader.on('error', function() {
    errors.push(loader.error);
  });
  this.requests.pop().respond(200, null,
                              '#EXTM3U\n' +
                              '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
                              'media.m3u8\n');

  assert.strictEqual(errors.length, 0, 'emitted no errors');

  this.requests.pop().respond(500);

  assert.strictEqual(errors.length, 1, 'emitted one error');
  assert.strictEqual(errors[0].status, 500, 'http status is captured');
});

// http://tools.ietf.org/html/draft-pantos-http-live-streaming-12#section-6.3.4
QUnit.test('halves the refresh timeout if a playlist is unchanged since the last reload',
function(assert) {
  let loader = new PlaylistLoader('live.m3u8', this.fakeHls);

  loader.load();

  this.requests.pop().respond(200, null,
                              '#EXTM3U\n' +
                              '#EXT-X-MEDIA-SEQUENCE:0\n' +
                              '#EXTINF:10,\n' +
                              '0.ts\n');
  // trigger a refresh
  this.clock.tick(10 * 1000);
  this.requests.pop().respond(200, null,
                              '#EXTM3U\n' +
                              '#EXT-X-MEDIA-SEQUENCE:0\n' +
                              '#EXTINF:10,\n' +
                              '0.ts\n');
  // half the default target-duration
  this.clock.tick(5 * 1000);

  assert.strictEqual(this.requests.length, 1, 'sent a request');
  assert.strictEqual(this.requests[0].url,
                    urlTo('live.m3u8'),
                    'requested the media playlist');
});

QUnit.test('preserves segment metadata across playlist refreshes', function(assert) {
  let loader = new PlaylistLoader('live.m3u8', this.fakeHls);
  let segment;

  loader.load();

  this.requests.pop().respond(200, null,
                              '#EXTM3U\n' +
                              '#EXT-X-MEDIA-SEQUENCE:0\n' +
                              '#EXTINF:10,\n' +
                              '0.ts\n' +
                              '#EXTINF:10,\n' +
                              '1.ts\n' +
                              '#EXTINF:10,\n' +
                              '2.ts\n');
  // add PTS info to 1.ts
  segment = loader.media().segments[1];
  segment.minVideoPts = 14;
  segment.maxAudioPts = 27;
  segment.preciseDuration = 10.045;

  // trigger a refresh
  this.clock.tick(10 * 1000);
  this.requests.pop().respond(200, null,
                              '#EXTM3U\n' +
                              '#EXT-X-MEDIA-SEQUENCE:1\n' +
                              '#EXTINF:10,\n' +
                              '1.ts\n' +
                              '#EXTINF:10,\n' +
                              '2.ts\n');

  assert.deepEqual(loader.media().segments[0], segment, 'preserved segment attributes');
});

QUnit.test('clears the update timeout when switching quality', function(assert) {
  let loader = new PlaylistLoader('live-master.m3u8', this.fakeHls);
  let refreshes = 0;

  loader.load();

  // track the number of playlist refreshes triggered
  loader.on('mediaupdatetimeout', function() {
    refreshes++;
  });
  // deliver the master
  this.requests.pop().respond(200, null,
                              '#EXTM3U\n' +
                              '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
                              'live-low.m3u8\n' +
                              '#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
                              'live-high.m3u8\n');
  // deliver the low quality playlist
  this.requests.pop().respond(200, null,
                              '#EXTM3U\n' +
                              '#EXT-X-MEDIA-SEQUENCE:0\n' +
                              '#EXTINF:10,\n' +
                              'low-0.ts\n');
  // change to a higher quality playlist
  loader.media('live-high.m3u8');
  this.requests.pop().respond(200, null,
                              '#EXTM3U\n' +
                              '#EXT-X-MEDIA-SEQUENCE:0\n' +
                              '#EXTINF:10,\n' +
                              'high-0.ts\n');
  // trigger a refresh
  this.clock.tick(10 * 1000);

  assert.equal(1, refreshes, 'only one refresh was triggered');
});

QUnit.test('media-sequence updates are considered a playlist change', function(assert) {
  let loader = new PlaylistLoader('live.m3u8', this.fakeHls);

  loader.load();

  this.requests.pop().respond(200, null,
                              '#EXTM3U\n' +
                              '#EXT-X-MEDIA-SEQUENCE:0\n' +
                              '#EXTINF:10,\n' +
                              '0.ts\n');
  // trigger a refresh
  this.clock.tick(10 * 1000);
  this.requests.pop().respond(200, null,
                              '#EXTM3U\n' +
                              '#EXT-X-MEDIA-SEQUENCE:1\n' +
                              '#EXTINF:10,\n' +
                              '0.ts\n');
  // half the default target-duration
  this.clock.tick(5 * 1000);

  assert.strictEqual(this.requests.length, 0, 'no request is sent');
});

QUnit.test('emits an error if a media refresh fails', function(assert) {
  let errors = 0;
  let errorResponseText = 'custom error message';
  let loader = new PlaylistLoader('live.m3u8', this.fakeHls);

  loader.load();

  loader.on('error', function() {
    errors++;
  });
  this.requests.pop().respond(200, null,
                              '#EXTM3U\n' +
                              '#EXT-X-MEDIA-SEQUENCE:0\n' +
                              '#EXTINF:10,\n' +
                              '0.ts\n');
  // trigger a refresh
  this.clock.tick(10 * 1000);
  this.requests.pop().respond(500, null, errorResponseText);

  assert.strictEqual(errors, 1, 'emitted an error');
  assert.strictEqual(loader.error.status, 500, 'captured the status code');
  assert.strictEqual(loader.error.responseText,
                    errorResponseText,
                    'captured the responseText');
});

QUnit.test('switches media playlists when requested', function(assert) {
  let loader = new PlaylistLoader('master.m3u8', this.fakeHls);

  loader.load();

  this.requests.pop().respond(200, null,
                              '#EXTM3U\n' +
                              '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
                              'low.m3u8\n' +
                              '#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
                              'high.m3u8\n');
  this.requests.pop().respond(200, null,
                              '#EXTM3U\n' +
                              '#EXT-X-MEDIA-SEQUENCE:0\n' +
                              '#EXTINF:10,\n' +
                              'low-0.ts\n');

  loader.media(loader.master.playlists[1]);
  assert.strictEqual(loader.state, 'SWITCHING_MEDIA', 'updated the state');

  this.requests.pop().respond(200, null,
                              '#EXTM3U\n' +
                              '#EXT-X-MEDIA-SEQUENCE:0\n' +
                              '#EXTINF:10,\n' +
                              'high-0.ts\n');
  assert.strictEqual(loader.state, 'HAVE_METADATA', 'switched active media');
  assert.strictEqual(loader.media(),
                    loader.master.playlists[1],
                    'updated the active media');
});

QUnit.test('can switch playlists immediately after the master is downloaded',
function(assert) {
  let loader = new PlaylistLoader('master.m3u8', this.fakeHls);

  loader.load();

  loader.on('loadedplaylist', function() {
    loader.media('high.m3u8');
  });
  this.requests.pop().respond(200, null,
                              '#EXTM3U\n' +
                              '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
                              'low.m3u8\n' +
                              '#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
                              'high.m3u8\n');
  assert.equal(this.requests[0].url, urlTo('high.m3u8'), 'switched variants immediately');
});

QUnit.test('can switch media playlists based on URI', function(assert) {
  let loader = new PlaylistLoader('master.m3u8', this.fakeHls);

  loader.load();

  this.requests.pop().respond(200, null,
                              '#EXTM3U\n' +
                              '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
                              'low.m3u8\n' +
                              '#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
                              'high.m3u8\n');
  this.requests.pop().respond(200, null,
                              '#EXTM3U\n' +
                              '#EXT-X-MEDIA-SEQUENCE:0\n' +
                              '#EXTINF:10,\n' +
                              'low-0.ts\n');

  loader.media('high.m3u8');
  assert.strictEqual(loader.state, 'SWITCHING_MEDIA', 'updated the state');

  this.requests.pop().respond(200, null,
                              '#EXTM3U\n' +
                              '#EXT-X-MEDIA-SEQUENCE:0\n' +
                              '#EXTINF:10,\n' +
                              'high-0.ts\n');
  assert.strictEqual(loader.state, 'HAVE_METADATA', 'switched active media');
  assert.strictEqual(loader.media(),
                    loader.master.playlists[1],
                    'updated the active media');
});

QUnit.test('aborts in-flight playlist refreshes when switching', function(assert) {
  let loader = new PlaylistLoader('master.m3u8', this.fakeHls);

  loader.load();

  this.requests.pop().respond(200, null,
                              '#EXTM3U\n' +
                              '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
                              'low.m3u8\n' +
                              '#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
                              'high.m3u8\n');
  this.requests.pop().respond(200, null,
                              '#EXTM3U\n' +
                              '#EXT-X-MEDIA-SEQUENCE:0\n' +
                              '#EXTINF:10,\n' +
                              'low-0.ts\n');
  this.clock.tick(10 * 1000);
  loader.media('high.m3u8');
  assert.strictEqual(this.requests[0].aborted, true, 'aborted refresh request');
  assert.ok(!this.requests[0].onreadystatechange,
           'onreadystatechange handlers should be removed on abort');
  assert.strictEqual(loader.state,
                     'HAVE_METADATA',
                     'the state is set accoring to the startingState');
});

QUnit.test('switching to the active playlist is a no-op', function(assert) {
  let loader = new PlaylistLoader('master.m3u8', this.fakeHls);

  loader.load();

  this.requests.pop().respond(200, null,
                              '#EXTM3U\n' +
                              '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
                              'low.m3u8\n' +
                              '#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
                              'high.m3u8\n');
  this.requests.pop().respond(200, null,
                              '#EXTM3U\n' +
                              '#EXT-X-MEDIA-SEQUENCE:0\n' +
                              '#EXTINF:10,\n' +
                              'low-0.ts\n' +
                              '#EXT-X-ENDLIST\n');
  loader.media('low.m3u8');

  assert.strictEqual(this.requests.length, 0, 'no requests are sent');
});

QUnit.test('switching to the active live playlist is a no-op', function(assert) {
  let loader = new PlaylistLoader('master.m3u8', this.fakeHls);

  loader.load();

  this.requests.pop().respond(200, null,
                              '#EXTM3U\n' +
                              '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
                              'low.m3u8\n' +
                              '#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
                              'high.m3u8\n');
  this.requests.pop().respond(200, null,
                              '#EXTM3U\n' +
                              '#EXT-X-MEDIA-SEQUENCE:0\n' +
                              '#EXTINF:10,\n' +
                              'low-0.ts\n');
  loader.media('low.m3u8');

  assert.strictEqual(this.requests.length, 0, 'no requests are sent');
});

QUnit.test('switches back to loaded playlists without re-requesting them',
function(assert) {
  let loader = new PlaylistLoader('master.m3u8', this.fakeHls);

  loader.load();

  this.requests.pop().respond(200, null,
                              '#EXTM3U\n' +
                              '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
                              'low.m3u8\n' +
                              '#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
                              'high.m3u8\n');
  this.requests.pop().respond(200, null,
                              '#EXTM3U\n' +
                              '#EXT-X-MEDIA-SEQUENCE:0\n' +
                              '#EXTINF:10,\n' +
                              'low-0.ts\n' +
                              '#EXT-X-ENDLIST\n');
  loader.media('high.m3u8');
  this.requests.pop().respond(200, null,
                              '#EXTM3U\n' +
                              '#EXT-X-MEDIA-SEQUENCE:0\n' +
                              '#EXTINF:10,\n' +
                              'high-0.ts\n' +
                              '#EXT-X-ENDLIST\n');
  loader.media('low.m3u8');

  assert.strictEqual(this.requests.length, 0, 'no outstanding requests');
  assert.strictEqual(loader.state, 'HAVE_METADATA', 'returned to loaded playlist');
});

QUnit.test('aborts outstanding requests if switching back to an already loaded playlist',
function(assert) {
  let loader = new PlaylistLoader('master.m3u8', this.fakeHls);

  loader.load();

  this.requests.pop().respond(200, null,
                              '#EXTM3U\n' +
                              '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
                              'low.m3u8\n' +
                              '#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
                              'high.m3u8\n');
  this.requests.pop().respond(200, null,
                              '#EXTM3U\n' +
                              '#EXT-X-MEDIA-SEQUENCE:0\n' +
                              '#EXTINF:10,\n' +
                              'low-0.ts\n' +
                              '#EXT-X-ENDLIST\n');
  loader.media('high.m3u8');
  loader.media('low.m3u8');

  assert.strictEqual(this.requests.length,
                    1,
                    'requested high playlist');
  assert.ok(this.requests[0].aborted,
          'aborted playlist request');
  assert.ok(!this.requests[0].onreadystatechange,
           'onreadystatechange handlers should be removed on abort');
  assert.strictEqual(loader.state,
                    'HAVE_METADATA',
                    'returned to loaded playlist');
  assert.strictEqual(loader.media(),
                    loader.master.playlists[0],
                    'switched to loaded playlist');
});

QUnit.test('does not abort requests when the same playlist is re-requested',
function(assert) {
  let loader = new PlaylistLoader('master.m3u8', this.fakeHls);

  loader.load();

  this.requests.pop().respond(200, null,
                              '#EXTM3U\n' +
                              '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
                              'low.m3u8\n' +
                              '#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
                              'high.m3u8\n');
  this.requests.pop().respond(200, null,
                              '#EXTM3U\n' +
                              '#EXT-X-MEDIA-SEQUENCE:0\n' +
                              '#EXTINF:10,\n' +
                              'low-0.ts\n' +
                              '#EXT-X-ENDLIST\n');
  loader.media('high.m3u8');
  loader.media('high.m3u8');

  assert.strictEqual(this.requests.length, 1, 'made only one request');
  assert.ok(!this.requests[0].aborted, 'request not aborted');
});

QUnit.test('throws an error if a media switch is initiated too early', function(assert) {
  let loader = new PlaylistLoader('master.m3u8', this.fakeHls);

  loader.load();

  assert.throws(function() {
    loader.media('high.m3u8');
  }, 'threw an error from HAVE_NOTHING');

  this.requests.pop().respond(200, null,
                              '#EXTM3U\n' +
                              '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
                              'low.m3u8\n' +
                              '#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
                              'high.m3u8\n');
});

QUnit.test('throws an error if a switch to an unrecognized playlist is requested',
function(assert) {
  let loader = new PlaylistLoader('master.m3u8', this.fakeHls);

  loader.load();

  this.requests.pop().respond(200, null,
                              '#EXTM3U\n' +
                              '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
                              'media.m3u8\n');

  assert.throws(function() {
    loader.media('unrecognized.m3u8');
  }, 'throws an error');
});

QUnit.test('dispose cancels the refresh timeout', function(assert) {
  let loader = new PlaylistLoader('live.m3u8', this.fakeHls);

  loader.load();

  this.requests.pop().respond(200, null,
                              '#EXTM3U\n' +
                              '#EXT-X-MEDIA-SEQUENCE:0\n' +
                              '#EXTINF:10,\n' +
                              '0.ts\n');
  loader.dispose();
  // a lot of time passes...
  this.clock.tick(15 * 1000);

  assert.strictEqual(this.requests.length, 0, 'no refresh request was made');
});

QUnit.test('dispose aborts pending refresh requests', function(assert) {
  let loader = new PlaylistLoader('live.m3u8', this.fakeHls);

  loader.load();

  this.requests.pop().respond(200, null,
                              '#EXTM3U\n' +
                              '#EXT-X-MEDIA-SEQUENCE:0\n' +
                              '#EXTINF:10,\n' +
                              '0.ts\n');
  this.clock.tick(10 * 1000);

  loader.dispose();
  assert.ok(this.requests[0].aborted, 'refresh request aborted');
  assert.ok(!this.requests[0].onreadystatechange,
           'onreadystatechange handler should not exist after dispose called'
  );
});

QUnit.test('errors if requests take longer than 45s', function(assert) {
  let loader = new PlaylistLoader('media.m3u8', this.fakeHls);
  let errors = 0;

  loader.load();

  loader.on('error', function() {
    errors++;
  });
  this.clock.tick(45 * 1000);

  assert.strictEqual(errors, 1, 'fired one error');
  assert.strictEqual(loader.error.code, 2, 'fired a network error');
});

QUnit.test('triggers an event when the active media changes', function(assert) {
  let loader = new PlaylistLoader('master.m3u8', this.fakeHls);
  let mediaChanges = 0;
  let mediaChangings = 0;

  loader.load();

  loader.on('mediachange', function() {
    mediaChanges++;
  });
  loader.on('mediachanging', function() {
    mediaChangings++;
  });
  this.requests.pop().respond(200, null,
                              '#EXTM3U\n' +
                              '#EXT-X-STREAM-INF:BANDWIDTH=1\n' +
                              'low.m3u8\n' +
                              '#EXT-X-STREAM-INF:BANDWIDTH=2\n' +
                              'high.m3u8\n');
  this.requests.shift().respond(200, null,
                                '#EXTM3U\n' +
                                '#EXT-X-MEDIA-SEQUENCE:0\n' +
                                '#EXTINF:10,\n' +
                                'low-0.ts\n' +
                                '#EXT-X-ENDLIST\n');
  assert.strictEqual(mediaChangings, 0, 'initial selection is not a media changing');
  assert.strictEqual(mediaChanges, 0, 'initial selection is not a media change');

  loader.media('high.m3u8');
  assert.strictEqual(mediaChangings, 1, 'mediachanging fires immediately');
  assert.strictEqual(mediaChanges, 0, 'mediachange does not fire immediately');

  this.requests.shift().respond(200, null,
                                '#EXTM3U\n' +
                                '#EXT-X-MEDIA-SEQUENCE:0\n' +
                                '#EXTINF:10,\n' +
                                'high-0.ts\n' +
                                '#EXT-X-ENDLIST\n');
  assert.strictEqual(mediaChangings, 1, 'still one mediachanging');
  assert.strictEqual(mediaChanges, 1, 'fired a mediachange');

  // switch back to an already loaded playlist
  loader.media('low.m3u8');
  assert.strictEqual(mediaChangings, 2, 'mediachanging fires');
  assert.strictEqual(mediaChanges, 2, 'fired a mediachange');

  // trigger a no-op switch
  loader.media('low.m3u8');
  assert.strictEqual(mediaChangings, 2, 'mediachanging ignored the no-op');
  assert.strictEqual(mediaChanges, 2, 'ignored a no-op media change');
});

QUnit.test('does not misintrepret playlists missing newlines at the end',
function(assert) {
  let loader = new PlaylistLoader('media.m3u8', this.fakeHls);

  loader.load();

  // no newline
  this.requests.shift().respond(200, null,
                                '#EXTM3U\n' +
                                '#EXT-X-MEDIA-SEQUENCE:0\n' +
                                '#EXTINF:10,\n' +
                                'low-0.ts\n' +
                                '#EXT-X-ENDLIST');
  assert.ok(loader.media().endList, 'flushed the final line of input');
});