2025-04-24 13:11:28 +08:00

388 lines
11 KiB
TypeScript

import { expect } from 'chai'
import fetchMock from 'fetch-mock'
import { ProjectSnapshot } from '@/infrastructure/project-snapshot'
describe('ProjectSnapshot', function () {
let snapshot: ProjectSnapshot
const projectId = 'project-id'
beforeEach(function () {
snapshot = new ProjectSnapshot(projectId)
})
describe('before initialization', function () {
describe('getDocPaths()', function () {
it('returns an empty string', function () {
expect(snapshot.getDocPaths()).to.deep.equal([])
})
})
describe('getDocContents()', function () {
it('returns null', function () {
expect(snapshot.getDocContents('main.tex')).to.be.null
})
})
})
const files = {
'main.tex': {
contents: '\\documentclass{article}\netc.',
hash: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
},
'hello.txt': {
contents: 'Hello history!',
hash: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
},
'goodbye.txt': {
contents: "We're done here",
hash: 'dddddddddddddddddddddddddddddddddddddddd',
},
}
const chunk = {
history: {
snapshot: {
files: {},
},
changes: [
{
operations: [
{
pathname: 'hello.txt',
file: {
hash: files['hello.txt'].hash,
stringLength: files['hello.txt'].contents.length,
},
},
{
pathname: 'main.tex',
file: {
hash: files['main.tex'].hash,
stringLength: files['main.tex'].contents.length,
},
},
{
pathname: 'frog.jpg',
file: {
hash: 'cccccccccccccccccccccccccccccccccccccccc',
byteLength: 97080,
},
},
],
timestamp: '2025-01-01T12:00:00.000Z',
},
],
},
startVersion: 0,
}
const changes = [
{
operations: [
{
pathname: 'hello.txt',
textOperation: ['Quote: ', files['hello.txt'].contents.length],
},
{
pathname: 'goodbye.txt',
file: {
hash: files['goodbye.txt'].hash,
stringLength: files['goodbye.txt'].contents.length,
},
},
],
timestamp: '2025-01-01T13:00:00.000Z',
},
]
function mockFlush(
opts: { repeat?: number; failOnCall?: (call: number) => boolean } = {}
) {
let currentCall = 0
const getResponse = () => {
currentCall += 1
return opts.failOnCall?.(currentCall) ? 500 : 200
}
fetchMock.post(`/project/${projectId}/flush`, getResponse, {
name: 'flush',
repeat: opts.repeat ?? 1,
})
}
function mockLatestChunk() {
fetchMock.getOnce(
`/project/${projectId}/latest/history`,
{ chunk },
{ name: 'latest-chunk' }
)
}
function mockChanges() {
fetchMock.getOnce(`/project/${projectId}/changes?since=1`, changes, {
name: 'changes-1',
})
fetchMock.get(`/project/${projectId}/changes?since=2`, [], {
name: 'changes-2',
})
}
function mockBlobs(paths = Object.keys(files) as (keyof typeof files)[]) {
for (const path of paths) {
const file = files[path]
fetchMock.get(`/project/${projectId}/blob/${file.hash}`, file.contents)
}
}
async function initializeSnapshot() {
mockFlush()
mockLatestChunk()
mockBlobs(['main.tex', 'hello.txt'])
await snapshot.refresh()
fetchMock.removeRoutes().clearHistory()
}
describe('after initialization', function () {
beforeEach(initializeSnapshot)
describe('getDocPaths()', function () {
it('returns the editable docs', function () {
expect(snapshot.getDocPaths()).to.have.members([
'main.tex',
'hello.txt',
])
})
})
describe('getDocContents()', function () {
it('returns the doc contents', function () {
expect(snapshot.getDocContents('main.tex')).to.equal(
files['main.tex'].contents
)
})
it('returns null for binary files', function () {
expect(snapshot.getDocContents('frog.jpg')).to.be.null
})
it('returns null for inexistent files', function () {
expect(snapshot.getDocContents('does-not-exist.txt')).to.be.null
})
})
})
async function refreshSnapshot() {
mockFlush()
mockChanges()
mockBlobs(['goodbye.txt'])
await snapshot.refresh()
fetchMock.removeRoutes().clearHistory()
}
describe('after refresh', function () {
beforeEach(initializeSnapshot)
beforeEach(refreshSnapshot)
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
describe('getDocPaths()', function () {
it('returns the editable docs', function () {
expect(snapshot.getDocPaths()).to.have.members([
'main.tex',
'hello.txt',
'goodbye.txt',
])
})
})
describe('getDocContents()', function () {
it('returns the up to date content', function () {
expect(snapshot.getDocContents('hello.txt')).to.equal(
`Quote: ${files['hello.txt'].contents}`
)
})
it('returns contents of new files', function () {
expect(snapshot.getDocContents('goodbye.txt')).to.equal(
files['goodbye.txt'].contents
)
})
})
})
describe('concurrency', function () {
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
specify('two concurrent inits', async function () {
mockFlush({ repeat: 2 })
mockLatestChunk()
mockChanges()
mockBlobs()
await Promise.all([snapshot.refresh(), snapshot.refresh()])
// The first request initializes, the second request loads changes
expect(fetchMock.callHistory.calls('flush')).to.have.length(2)
expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
})
specify('three concurrent inits', async function () {
mockFlush({ repeat: 2 })
mockLatestChunk()
mockChanges()
mockBlobs()
await Promise.all([
snapshot.refresh(),
snapshot.refresh(),
snapshot.refresh(),
])
// The first request initializes, the second and third are combined and
// load changes
expect(fetchMock.callHistory.calls('flush')).to.have.length(2)
expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
})
specify('two concurrent inits - first fails', async function () {
mockFlush({ repeat: 2, failOnCall: call => call === 1 })
mockLatestChunk()
mockBlobs()
const results = await Promise.allSettled([
snapshot.refresh(),
snapshot.refresh(),
])
// The first init fails, but the second succeeds
expect(results.filter(r => r.status === 'fulfilled')).to.have.length(1)
expect(fetchMock.callHistory.calls('flush')).to.have.length(2)
expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
expect(fetchMock.callHistory.calls('changes-1')).to.have.length(0)
})
specify('three concurrent inits - second fails', async function () {
mockFlush({ repeat: 4, failOnCall: call => call === 2 })
mockLatestChunk()
mockChanges()
mockBlobs()
const results = await Promise.allSettled([
snapshot.refresh(),
snapshot.refresh(),
snapshot.refresh(),
])
// Another request afterwards
await snapshot.refresh()
// The first init succeeds, the two queued requests fail, the last request
// succeeds
expect(results.filter(r => r.status === 'fulfilled')).to.have.length(1)
expect(fetchMock.callHistory.calls('flush')).to.have.length(3)
expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
expect(fetchMock.callHistory.calls('changes-2')).to.have.length(0)
})
specify('two concurrent load changes', async function () {
mockFlush({ repeat: 3 })
mockLatestChunk()
mockChanges()
mockBlobs()
// Initialize
await snapshot.refresh()
// Two concurrent load changes
await Promise.all([snapshot.refresh(), snapshot.refresh()])
// One init, two load changes
expect(fetchMock.callHistory.calls('flush')).to.have.length(3)
expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
expect(fetchMock.callHistory.calls('changes-2')).to.have.length(1)
})
specify('three concurrent load changes', async function () {
mockFlush({ repeat: 3 })
mockLatestChunk()
mockChanges()
mockBlobs()
// Initialize
await snapshot.refresh()
// Three concurrent load changes
await Promise.all([
snapshot.refresh(),
snapshot.refresh(),
snapshot.refresh(),
])
// One init, two load changes (the two last are queued and combined)
expect(fetchMock.callHistory.calls('flush')).to.have.length(3)
expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
expect(fetchMock.callHistory.calls('changes-2')).to.have.length(1)
})
specify('two concurrent load changes - first fails', async function () {
mockFlush({ repeat: 3, failOnCall: call => call === 2 })
mockLatestChunk()
mockChanges()
mockBlobs()
// Initialize
await snapshot.refresh()
// Two concurrent load changes
const results = await Promise.allSettled([
snapshot.refresh(),
snapshot.refresh(),
])
// One init, one load changes fails, the second succeeds
expect(results.filter(r => r.status === 'fulfilled')).to.have.length(1)
expect(fetchMock.callHistory.calls('flush')).to.have.length(3)
expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
expect(fetchMock.callHistory.calls('changes-2')).to.have.length(0)
})
specify('three concurrent load changes - second fails', async function () {
mockFlush({ repeat: 4, failOnCall: call => call === 3 })
mockLatestChunk()
mockChanges()
mockBlobs()
// Initialize
await snapshot.refresh()
// Two concurrent load changes
const results = await Promise.allSettled([
snapshot.refresh(),
snapshot.refresh(),
snapshot.refresh(),
])
// Another request afterwards
await snapshot.refresh()
// One init, one load changes succeeds, the second and third are combined
// and fail, the last request succeeds
expect(results.filter(r => r.status === 'fulfilled')).to.have.length(1)
expect(fetchMock.callHistory.calls('flush')).to.have.length(4)
expect(fetchMock.callHistory.calls('latest-chunk')).to.have.length(1)
expect(fetchMock.callHistory.calls('changes-1')).to.have.length(1)
expect(fetchMock.callHistory.calls('changes-2')).to.have.length(1)
})
})
})