syntropy 0.41.0 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -0
  3. data/TODO.md +334 -31
  4. data/cmd/new/template/app/_lib/storage.rb +2 -2
  5. data/cmd/new/template/config/test.rb +1 -1
  6. data/examples/agent/config/test.rb +1 -1
  7. data/examples/blog/app/_lib/posts.rb +10 -6
  8. data/examples/blog/app/_lib/storage.rb +2 -2
  9. data/examples/blog/app/posts/[id]/index.rb +2 -2
  10. data/examples/blog/config/test.rb +1 -1
  11. data/lib/syntropy/collection.rb +206 -0
  12. data/lib/syntropy/connection_pool.rb +118 -0
  13. data/lib/syntropy/controller_extensions.rb +12 -0
  14. data/lib/syntropy/http/server_connection.rb +15 -28
  15. data/lib/syntropy/json_api.rb +3 -12
  16. data/lib/syntropy/kv_store.rb +57 -0
  17. data/lib/syntropy/logger.rb +28 -48
  18. data/lib/syntropy/markdown.rb +1 -1
  19. data/lib/syntropy/module_loader.rb +53 -14
  20. data/lib/syntropy/schema.rb +95 -0
  21. data/lib/syntropy/storage.rb +42 -4
  22. data/lib/syntropy/test.rb +1 -1
  23. data/lib/syntropy/version.rb +1 -1
  24. data/lib/syntropy.rb +2 -1
  25. data/syntropy.gemspec +2 -2
  26. data/test/fixtures/app/_lib/dep_invalidate.rb +5 -0
  27. data/test/fixtures/collection/basic/_articles/2026-06-30-foo.md +6 -0
  28. data/test/fixtures/collection/basic/_articles/2026-07-13-bar.md +6 -0
  29. data/test/fixtures/collection/basic/_articles/2026-08-07-baz.md +6 -0
  30. data/test/fixtures/collection/basic/index+.rb +23 -0
  31. data/test/fixtures/collection/docs/_pages/01-foo/01-foo.md +4 -0
  32. data/test/fixtures/collection/docs/_pages/01-foo/02-bar.md +4 -0
  33. data/test/fixtures/collection/docs/_pages/01-foo/_index.yml +1 -0
  34. data/test/fixtures/collection/docs/_pages/02-bar/01-bar.md +4 -0
  35. data/test/fixtures/collection/docs/_pages/02-bar/02-baz.md +4 -0
  36. data/test/fixtures/collection/docs/_pages/02-bar/_index.yml +1 -0
  37. data/test/fixtures/collection/docs/index+.rb +16 -0
  38. data/test/test_app.rb +7 -7
  39. data/test/test_cmd_new.rb +38 -0
  40. data/test/test_collection.rb +164 -0
  41. data/test/test_connection_pool.rb +79 -3
  42. data/test/test_http_server_connection.rb +6 -6
  43. data/test/test_json_api.rb +4 -4
  44. data/test/test_kv_store.rb +6 -31
  45. data/test/test_markdown.rb +3 -3
  46. data/test/test_module_loader.rb +15 -0
  47. data/test/test_schema.rb +4 -4
  48. data/test/test_storage.rb +103 -0
  49. metadata +24 -11
  50. data/lib/syntropy/storage/connection_pool.rb +0 -106
  51. data/lib/syntropy/storage/kv_store.rb +0 -68
  52. data/lib/syntropy/storage/prepared_query.rb +0 -36
  53. data/lib/syntropy/storage/schema.rb +0 -92
  54. data/lib/syntropy/storage/store.rb +0 -33
  55. data/test/test_store.rb +0 -24
@@ -38,6 +38,7 @@ module Syntropy
38
38
  @loading = Set.new
39
39
  @lock = UM::Mutex.new
40
40
  @lock_holder = nil
41
+ @invalidation_patterns = Hash.new { |h, k| h[k] = [] }
41
42
  end
42
43
 
43
44
  # Loads a module (if not already loaded) and returns its export value.
@@ -80,12 +81,27 @@ module Syntropy
80
81
  ref = @fn_map[fn]
81
82
  invalidate_ref(ref) if ref
82
83
  invalidate_collection_modules
84
+ invalidate_by_pattern(fn)
83
85
  end
84
86
  end
85
87
 
88
+ # Adds an invalidation pattern. When a ref has been invalidated (using
89
+ # #invalidate_fn), the loader will check for matches against any
90
+ # invalidation patterns, and will invalidate any corresponding refs. This
91
+ # mechanism allows hot reload for collections.
92
+ #
93
+ # @param ref [String] module ref
94
+ # @param pattern [String] pattern normalized to app root
95
+ # @return [void]
96
+ def invalidate_on_file_change(ref, pattern)
97
+ # pattern should be absolute in order to match against fn
98
+ pattern = File.join(@app_root, pattern)
99
+ @invalidation_patterns[pattern] << ref
100
+ end
101
+
86
102
  private
87
103
 
88
- # Synchronizes access to the module loader state
104
+ # Synchronizes access to the module loader state.
89
105
  #
90
106
  # @return [any] block return value
91
107
  def lock
@@ -107,6 +123,11 @@ module Syntropy
107
123
  # @param ref [String] module reference
108
124
  # @return [void]
109
125
  def invalidate_ref(ref)
126
+ if ref == '/'
127
+ invalidate_ref('/index')
128
+ invalidate_ref('/index+')
129
+ end
130
+
110
131
  entry = @modules.delete(ref)
111
132
  return if !entry
112
133
 
@@ -125,6 +146,19 @@ module Syntropy
125
146
  refs.each { invalidate_ref(it) }
126
147
  end
127
148
 
149
+ # Iterates through invalidation patterns and for each match invalidates the
150
+ # corresponding refs.
151
+ #
152
+ # @param fn [String] filename
153
+ # @return [void]
154
+ def invalidate_by_pattern(fn)
155
+ invalidated = Set.new
156
+ @invalidation_patterns.each do |pat, refs|
157
+ refs.each { invalidated << it } if File.fnmatch(pat, fn)
158
+ end
159
+ invalidated.each { invalidate_ref(it) }
160
+ end
161
+
128
162
  # Registers reverse dependencies for the given module reference.
129
163
  #
130
164
  # @param ref [String] module reference
@@ -165,7 +199,7 @@ module Syntropy
165
199
  def do_load_module(ref, fn, raise_on_missing_export:)
166
200
  @loading << ref
167
201
  @fn_map[fn] = ref
168
- code = read_file(fn)
202
+ code = @machine.file_read(fn)
169
203
  env = @env.merge(module_loader: self, ref: clean_ref(ref))
170
204
  mod = Syntropy::ModuleContext.new(env, code, fn, @extensions)
171
205
  add_dependencies(ref, mod.__dependencies__)
@@ -187,18 +221,6 @@ module Syntropy
187
221
  @loading.delete(ref)
188
222
  end
189
223
 
190
- # Returns a file's content.
191
- #
192
- # @param fn [String] filename
193
- # @return [String] file content
194
- def read_file(fn)
195
- @machine.open(fn, UM::O_RDONLY) { |fd|
196
- buf = +''
197
- res = @machine.read(fd, buf, 1 << 20)
198
- buf
199
- }
200
- end
201
-
202
224
  # Cleans up a module reference specifier, turning /index into /
203
225
  #
204
226
  # @param ref [String] input ref
@@ -270,6 +292,11 @@ module Syntropy
270
292
  @module_loader = env[:module_loader]
271
293
  @app = env[:app]
272
294
  @ref = env[:ref]
295
+
296
+ mp = env[:mount_path]
297
+ @url = mp && (@ref == '/' ? mp : File.join(mp, @ref))
298
+ @fn = fn
299
+
273
300
  @logger = env[:logger]
274
301
  @__dependencies__ = []
275
302
  singleton_class.const_set(:MODULE, self)
@@ -320,6 +347,18 @@ module Syntropy
320
347
  self
321
348
  end
322
349
 
350
+ # Causes the module to be invalidated (reloaded) when a change is detected
351
+ # in any file that matches the given pattern. The pattern is relative to the
352
+ # current module's ref. The pattern normally includes a wildcard:
353
+ #
354
+ # invalidate_on_file_change('_articles/*')
355
+ #
356
+ # @param pattern [String] pattern in relative path
357
+ # @return [void]
358
+ def invalidate_on_file_change(pattern)
359
+ @module_loader.invalidate_on_file_change(@ref, normalize_import_ref(pattern))
360
+ end
361
+
323
362
  # Normalize an import reference, turning a relative path into an absolute one.
324
363
  #
325
364
  # @param ref [String] input ref
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Syntropy
4
+ class Schema
5
+ def initialize(module_loader: nil, schema_root: '_schema', &)
6
+ @migrations = {}
7
+ @module_loader = module_loader
8
+ @schema_root = schema_root
9
+ load_schema_from_modules if @module_loader
10
+ run_schema_dsl(&) if block_given?
11
+ end
12
+
13
+ def apply(connection_pool)
14
+ execute_migrations(connection_pool)
15
+ end
16
+
17
+ def current_version(connection_pool)
18
+ connection_pool.with_db do |db|
19
+ get_schema_version(db)
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def load_schema_from_modules
26
+ modules = @module_loader.list(@schema_root)
27
+ modules.each do |name|
28
+ @migrations[File.basename(name)] = @module_loader.load(name)
29
+ end
30
+ end
31
+
32
+ class SchemaDSLRunner
33
+ def initialize(migrations)
34
+ @migrations = migrations
35
+ end
36
+
37
+ def run(&)
38
+ instance_eval(&)
39
+ end
40
+
41
+ private
42
+
43
+ def initial(&block)
44
+ @migrations['0000'] = block
45
+ end
46
+
47
+ def version(key, &block)
48
+ @migrations[key] = block
49
+ end
50
+ end
51
+
52
+ def run_schema_dsl(&)
53
+ SchemaDSLRunner.new(@migrations).run(&)
54
+ end
55
+
56
+ def execute_migrations(connection_pool)
57
+ connection_pool.with_db do |db|
58
+ current_version = get_schema_version(db)
59
+ migrations_keys = @migrations.keys.sort
60
+ migrations_keys.select! { it > current_version } if current_version
61
+
62
+ migrations_keys.each do |key|
63
+ db.transaction do
64
+ @migrations[key].(db)
65
+ set_schema_version(db, key)
66
+ current_version = key
67
+ end
68
+ end
69
+
70
+ current_version
71
+ end
72
+ end
73
+
74
+ def get_schema_version(db)
75
+ db.execute <<~SQL
76
+ create table if not exists __syntropy_schema__(
77
+ k text primary key,
78
+ v text
79
+ );
80
+ SQL
81
+ db.query_single_splat <<~SQL
82
+ select v from __syntropy_schema__
83
+ where k = 'version'
84
+ SQL
85
+ end
86
+
87
+ def set_schema_version(db, version)
88
+ db.execute <<~SQL, version
89
+ insert into __syntropy_schema__ (k, v)
90
+ values ('version', $1)
91
+ on conflict(k) do update set v = $1
92
+ SQL
93
+ end
94
+ end
95
+ end
@@ -1,6 +1,44 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'syntropy/storage/connection_pool'
4
- require 'syntropy/storage/schema'
5
- require 'syntropy/storage/store'
6
- require 'syntropy/storage/prepared_query'
3
+ require 'extralite'
4
+ require 'syntropy/connection_pool'
5
+ require 'syntropy/schema'
6
+ require 'syntropy/kv_store'
7
+
8
+ module Syntropy
9
+ class Storage
10
+ attr_reader :connection_pool, :schema
11
+
12
+ def initialize(machine, module_loader, config)
13
+ @machine = machine
14
+ @module_loader = module_loader
15
+ @config = config
16
+
17
+ raise Syntropy::Error, 'Missing storage config' if !config
18
+ raise Syntropy::Error, 'Missing storage config' if !config[:path]
19
+
20
+ @connection_pool ||= ConnectionPool.new(
21
+ @machine,
22
+ config[:path],
23
+ config[:concurrency] || 4
24
+ )
25
+
26
+ @schema = Schema.new(
27
+ module_loader: @module_loader,
28
+ schema_root: @config[:schema_root] || '_schema'
29
+ )
30
+ end
31
+
32
+ def query(*, **, &)
33
+ connection_pool.with_db { it.query(*, **, &) }
34
+ end
35
+
36
+ def query_single_value(*, **, &)
37
+ connection_pool.with_db { it.query_splat(*, **, &) }
38
+ end
39
+
40
+ def execute(*, **)
41
+ connection_pool.with_db { it.execute(*, **) }
42
+ end
43
+ end
44
+ end
data/lib/syntropy/test.rb CHANGED
@@ -235,7 +235,7 @@ module Syntropy
235
235
  # @return [any] parsed JSON object
236
236
  def response_json
237
237
  raise if response_content_type != 'application/json'
238
- JSON.parse(response_body)
238
+ JSON.parse(response_body, symbolize_names: true)
239
239
  end
240
240
 
241
241
  # Returns the response content MIME type.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Syntropy
4
- VERSION = '0.41.0'
4
+ VERSION = '0.43.0'
5
5
  end
data/lib/syntropy.rb CHANGED
@@ -17,6 +17,7 @@ require 'syntropy/papercraft_extensions'
17
17
  require 'syntropy/routing_tree'
18
18
  require 'syntropy/json_api'
19
19
  require 'syntropy/side_run'
20
+ require 'syntropy/collection'
20
21
  require 'syntropy/version'
21
22
 
22
23
  # Syntropy is a web framework for building web apps in Ruby. Syntropy uses
@@ -86,7 +87,7 @@ module Syntropy
86
87
  logger: nil,
87
88
  machine: UM.new(size: 8)
88
89
  )
89
- loader = ModuleLoader.new(loader_env)
90
+ loader = ModuleLoader.new(loader_env, extensions: ControllerExtensions)
90
91
  if (config = loader.load(env[:mode], raise_on_missing_export: false))
91
92
  env[:config] = config
92
93
  end
data/syntropy.gemspec CHANGED
@@ -21,9 +21,9 @@ Gem::Specification.new do |s|
21
21
  s.required_ruby_version = '>= 3.4'
22
22
  s.executables = ['syntropy']
23
23
 
24
- s.add_dependency 'extralite', '~>3.0.1'
24
+ s.add_dependency 'extralite', '~>3.1.3'
25
25
  s.add_dependency 'papercraft', '~>3.2.2'
26
- s.add_dependency 'uringmachine', '~>1.0.4'
26
+ s.add_dependency 'uringmachine', '~>1.0.6'
27
27
 
28
28
  s.add_dependency 'json'
29
29
  s.add_dependency 'base64'
@@ -0,0 +1,5 @@
1
+ Foo = import '/_lib/self'
2
+
3
+ invalidate_on_file_change('circular/**')
4
+
5
+ export self
@@ -0,0 +1,6 @@
1
+ ---
2
+ title: FooFoo
3
+ category: [a, b]
4
+ author: Bar Baz
5
+ ---
6
+ Foo foo foo.
@@ -0,0 +1,6 @@
1
+ ---
2
+ title: BarBar
3
+ category: [b, c]
4
+ author: Baz Foo
5
+ ---
6
+ Bar bar bar.
@@ -0,0 +1,6 @@
1
+ ---
2
+ title: BazBaz
3
+ category: [c, a]
4
+ author: Foo Bar
5
+ ---
6
+ Foo foo foo.
@@ -0,0 +1,23 @@
1
+ begin
2
+ @articles = collection('_articles', url_base: @url)
3
+ rescue => e
4
+ p e
5
+ p e.backtrace
6
+ exit!
7
+ end
8
+
9
+ export ->(req) do
10
+ if req.path == @url
11
+ list = @articles.list.map { it[:ref] }
12
+ req.respond_json(list)
13
+ elsif (article = @articles.get(req.path))
14
+ o = article.slice(:fn, :ref, :url, :type, :attributes, :body)
15
+ req.respond_json(o)
16
+ else
17
+ raise Error.not_found if !article
18
+ end
19
+ rescue => e
20
+ p e
21
+ p e.backtrace
22
+ exit!
23
+ end
@@ -0,0 +1,4 @@
1
+ ---
2
+ title: FooFoo Foo
3
+ ---
4
+ Foo foo foo.
@@ -0,0 +1,4 @@
1
+ ---
2
+ title: FooFoo Bar
3
+ ---
4
+ Bar bar bar.
@@ -0,0 +1 @@
1
+ title: "FooFoo"
@@ -0,0 +1,4 @@
1
+ ---
2
+ title: BarBar Bar
3
+ ---
4
+ Bar bar bar.
@@ -0,0 +1,4 @@
1
+ ---
2
+ title: BarBar Baz
3
+ ---
4
+ Baz baz baz.
@@ -0,0 +1 @@
1
+ title: "BarBar"
@@ -0,0 +1,16 @@
1
+ @pages = collection('_pages', url_base: @url)
2
+
3
+ export ->(req) do
4
+ if req.path == @url
5
+ list = @articles.list.map { it[:ref] }
6
+ req.respond_json(list)
7
+ elsif (page = @pages.get(req.path))
8
+ if page[:type] == :directory
9
+ req.respond_json(page.slice(:type, :title))
10
+ else
11
+ req.respond_json(page.slice(:type, :title, :body))
12
+ end
13
+ else
14
+ raise Error.not_found if !article
15
+ end
16
+ end
data/test/test_app.rb CHANGED
@@ -56,28 +56,28 @@ class AppTest < Minitest::Test
56
56
  assert_equal HTTP::NOT_FOUND, req.response_status
57
57
 
58
58
  req = @test_harness.request(':method' => 'GET', ':path' => '/test/api?q=get')
59
- assert_equal({ 'status' => 'OK', 'response' => 0 }, req.response_json)
59
+ assert_equal({ status: 'OK', response: 0 }, req.response_json)
60
60
 
61
61
  req = @test_harness.request(':method' => 'POST', ':path' => '/test/api?q=get')
62
62
  assert_equal HTTP::METHOD_NOT_ALLOWED, req.response_status
63
- assert_equal({ 'status' => 'Error', 'message' => 'Method not allowed' }, req.response_json)
63
+ assert_equal({ status: 'Error', message: 'Method not allowed' }, req.response_json)
64
64
 
65
65
  req = @test_harness.request(':method' => 'GET', ':path' => '/test/api/foo?q=get')
66
- assert_equal({ 'status' => 'OK', 'response' => 0 }, req.response_json)
66
+ assert_equal({ status: 'OK', response: 0 }, req.response_json)
67
67
 
68
68
  req = @test_harness.request(':method' => 'POST', ':path' => '/test/api?q=incr')
69
- assert_equal({ 'status' => 'OK', 'response' => 1 }, req.response_json)
69
+ assert_equal({ status: 'OK', response: 1 }, req.response_json)
70
70
 
71
71
  req = @test_harness.request(':method' => 'GET', ':path' => '/test/api?q=incr')
72
72
  assert_equal HTTP::METHOD_NOT_ALLOWED, req.response_status
73
- assert_equal({ 'status' => 'Error', 'message' => 'Method not allowed' }, req.response_json)
73
+ assert_equal({ status: 'Error', message: 'Method not allowed' }, req.response_json)
74
74
 
75
75
  req = @test_harness.request(':method' => 'POST', ':path' => '/test/api/foo?q=incr')
76
- assert_equal({ 'status' => 'Error', 'message' => 'Teapot' }, req.response_json)
76
+ assert_equal({ status: 'Error', message: 'Teapot' }, req.response_json)
77
77
  assert_equal HTTP::TEAPOT, req.response_status
78
78
 
79
79
  req = @test_harness.request(':method' => 'POST', ':path' => '/test/api/foo/bar?q=incr')
80
- assert_equal({ 'status' => 'Error', 'message' => 'Teapot' }, req.response_json)
80
+ assert_equal({ status: 'Error', message: 'Teapot' }, req.response_json)
81
81
  assert_equal HTTP::TEAPOT, req.response_status
82
82
 
83
83
  req = @test_harness.request(':method' => 'GET', ':path' => '/test/bar')
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'helper'
4
+ require 'syntropy/test'
5
+
6
+ class CmdNewTemplateTest < Syntropy::Test
7
+ def env
8
+ {
9
+ mode: 'test',
10
+ app_root: File.join(__dir__, '../cmd/new/template/app'),
11
+ config_root: File.join(__dir__, '../cmd/new/template/config'),
12
+ mount_path: '/'
13
+ }
14
+ end
15
+
16
+ def test_cmd_new_template_server
17
+ req = get('/foo')
18
+ assert_equal HTTP::NOT_FOUND, req.response_status
19
+
20
+ req = get('/')
21
+ assert_equal HTTP::OK, req.response_status
22
+ assert_equal 'text/html', req.response_content_type
23
+ end
24
+
25
+ def test_cmd_new_template_storage
26
+ storage = load_module('_lib/storage')
27
+ refute_nil storage
28
+
29
+ cp = storage.connection_pool
30
+ assert_kind_of Syntropy::ConnectionPool, cp
31
+
32
+ schema = storage.schema
33
+ assert_kind_of Syntropy::Schema, schema
34
+
35
+ # migration already ran
36
+ refute_nil schema.current_version(cp)
37
+ end
38
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'helper'
4
+
5
+ class CollectionTest < Minitest::Test
6
+ COLLECTION_ROOT = File.join(__dir__, 'fixtures/collection/basic/_articles')
7
+
8
+ def setup
9
+ @machine = UM.new
10
+ @collection = Syntropy::Collection.new(
11
+ machine: @machine,
12
+ root: COLLECTION_ROOT,
13
+ url_base: '/apps/blog'
14
+ )
15
+ end
16
+
17
+ def test_collection_setup
18
+ assert_equal COLLECTION_ROOT, @collection.root_dir
19
+ assert_equal '/apps/blog', @collection.url_base
20
+ end
21
+
22
+ def test_collection_list
23
+ list = @collection.list
24
+ assert_kind_of Array, list
25
+ assert_equal 3, list.size
26
+
27
+ foo = list[0]
28
+ assert_kind_of Hash, foo
29
+ assert_equal({
30
+ fn: File.join(COLLECTION_ROOT, '2026-06-30-foo.md'),
31
+ ref: '2026-06-30-foo',
32
+ url: '/apps/blog/2026-06-30-foo',
33
+ type: :markdown,
34
+ attributes: {
35
+ date: Date.parse('2026-06-30'),
36
+ title: 'FooFoo',
37
+ category: ['a', 'b'],
38
+ author: 'Bar Baz',
39
+ },
40
+ body: 'Foo foo foo.'
41
+ }, foo.slice(:fn, :ref, :url, :type, :attributes, :body))
42
+
43
+ bar = list[1]
44
+ assert_kind_of Hash, foo
45
+ assert_equal({
46
+ fn: File.join(COLLECTION_ROOT, '2026-07-13-bar.md'),
47
+ ref: '2026-07-13-bar',
48
+ url: '/apps/blog/2026-07-13-bar',
49
+ type: :markdown,
50
+ attributes: {
51
+ date: Date.parse('2026-07-13'),
52
+ title: 'BarBar',
53
+ category: ['b', 'c'],
54
+ author: 'Baz Foo',
55
+ },
56
+ body: 'Bar bar bar.'
57
+ }, bar.slice(:fn, :ref, :url, :type, :attributes, :body))
58
+ end
59
+
60
+ def test_collection_get
61
+ assert_nil @collection.get('/')
62
+ assert_nil @collection.get('/apps/blog')
63
+
64
+ item = @collection.get('/apps/blog/2026-06-30-foo')
65
+ assert_kind_of Hash, item
66
+ assert_equal({
67
+ fn: File.join(COLLECTION_ROOT, '2026-06-30-foo.md'),
68
+ ref: '2026-06-30-foo',
69
+ url: '/apps/blog/2026-06-30-foo',
70
+ type: :markdown,
71
+ attributes: {
72
+ date: Date.parse('2026-06-30'),
73
+ title: 'FooFoo',
74
+ category: ['a', 'b'],
75
+ author: 'Bar Baz',
76
+ },
77
+ body: 'Foo foo foo.'
78
+ }, item.slice(:fn, :ref, :url, :type, :attributes, :body))
79
+ end
80
+ end
81
+
82
+ class CollectionAppTest < Syntropy::Test
83
+ self.env = {
84
+ app_root: File.join(__dir__, 'fixtures/collection/basic'),
85
+ mount_path: '/blog'
86
+ }
87
+
88
+ def test_collection_app_controller
89
+ req = get('/blog/2026-06-30-foo')
90
+ assert_equal HTTP::OK, req.response_status
91
+
92
+ assert_equal ['/index+'], app.module_loader.modules.keys
93
+
94
+ app.module_loader.invalidate_fn(File.join(env[:app_root], '_articles/2026-06-30-foo.md'))
95
+ assert_equal [], app.module_loader.modules.keys
96
+ end
97
+ end
98
+
99
+ class NestedCollectionTest < Syntropy::Test
100
+ self.env = {
101
+ app_root: File.join(__dir__, 'fixtures/collection/docs'),
102
+ mount_path: '/docs'
103
+ }
104
+
105
+ def setup
106
+ super
107
+ @collection = Syntropy::Collection.new(
108
+ machine: @machine,
109
+ root: File.join(env[:app_root], '_pages'),
110
+ url_base: '/docs'
111
+ )
112
+ end
113
+
114
+ def test_nested_collection_list
115
+ list = @collection.list
116
+ assert_equal 4, list.size
117
+
118
+ assert_equal 'FooFoo Foo', list[0][:attributes][:title]
119
+ assert_equal 'FooFoo Bar', list[1][:attributes][:title]
120
+ assert_equal 'BarBar Bar', list[2][:attributes][:title]
121
+ assert_equal 'BarBar Baz', list[3][:attributes][:title]
122
+
123
+ assert_nil list[0][:prev]
124
+ assert_equal list[1], list[0][:next]
125
+
126
+ assert_equal list[0], list[1][:prev]
127
+ assert_equal list[2], list[1][:next]
128
+
129
+ assert_equal list[1], list[2][:prev]
130
+ assert_equal list[3], list[2][:next]
131
+
132
+ assert_equal list[2], list[3][:prev]
133
+ assert_nil list[3][:next]
134
+ end
135
+
136
+ def test_nested_collection_dirs
137
+ root_dir = File.join(env[:app_root], '_pages')
138
+ root = @collection.root
139
+ assert_kind_of Hash, root
140
+
141
+ assert_equal({
142
+ fn: nil,
143
+ ref: '',
144
+ url: '/docs',
145
+ type: :directory,
146
+ }, root.slice(:fn, :ref, :url, :type))
147
+
148
+ assert_equal 2, root[:items].size
149
+
150
+ assert_equal({
151
+ fn: File.join(root_dir, '01-foo/_index.yml'),
152
+ ref: '01-foo',
153
+ url: '/docs/01-foo',
154
+ type: :directory
155
+ }, root[:items][0].slice(:fn, :ref, :url, :type))
156
+
157
+ assert_equal({
158
+ fn: File.join(root_dir, '02-bar/_index.yml'),
159
+ ref: '02-bar',
160
+ url: '/docs/02-bar',
161
+ type: :directory
162
+ }, root[:items][1].slice(:fn, :ref, :url, :type))
163
+ end
164
+ end