Skip to content

Commit ec77242

Browse files
committed
Test updates
1 parent f334829 commit ec77242

4 files changed

Lines changed: 190 additions & 18 deletions

File tree

‎.github/workflows/generate.yml‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@ jobs:
1818
- uses: actions/checkout@v2
1919

2020
- name: Install Python dependencies
21-
run: pip install pyyaml
21+
run: pip install pyyaml Pillow
2222

2323
- name: Run Python tests
24-
run: python -m unittest test_insert_quote test_yaml_to_formats -v
24+
run: python -m unittest test_insert_quote test_yaml_to_formats test_quote_to_image -v
2525

2626
generate-data:
2727
runs-on: ubuntu-latest
@@ -61,6 +61,6 @@ jobs:
6161
6262
- uses: stefanzweifel/git-auto-commit-action@v4
6363
with:
64-
file_pattern: 'images/*'
64+
file_pattern: 'images/**'
6565
skip_dirty_check: true
6666
commit_message: Update images dir

‎.github/workflows/main.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ jobs:
1414
- uses: actions/checkout@v2
1515

1616
- name: Install Python dependencies
17-
run: pip install pyyaml
17+
run: pip install pyyaml Pillow
1818

1919
- name: Run Python tests
20-
run: python -m unittest test_insert_quote test_yaml_to_formats -v
20+
run: python -m unittest test_insert_quote test_yaml_to_formats test_quote_to_image -v
2121

‎quote_to_image.py‎

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -109,22 +109,24 @@ def render_image(words, width, height, font_size, time_start, time_count, margin
109109
return img
110110

111111

112-
def fit_text(words, width, height, font_size, time_start, time_count, margin,
112+
def fit_text(words, width, height, time_start, time_count, margin,
113113
regular_path, bold_path):
114114
"""
115115
Find the largest font size where text fits within height - 100.
116116
Returns a rendered PIL Image, or None if the minimum size doesn't fit.
117117
"""
118+
low, high = 18, 300
118119
best = None
119-
size = font_size
120120

121-
while True:
122-
h = measure_layout(words, width, size, time_start, time_count, margin,
121+
while low <= high:
122+
mid = (low + high) // 2
123+
h = measure_layout(words, width, mid, time_start, time_count, margin,
123124
regular_path, bold_path)
124-
if h is None or h >= height - 100:
125-
break
126-
best = size
127-
size += 1
125+
if h is not None and h < height - 100:
126+
best = mid
127+
low = mid + 1
128+
else:
129+
high = mid - 1
128130

129131
if best is None:
130132
return None
@@ -168,7 +170,7 @@ def _draw_credits(img, width, height, margin, credit_path, source, author):
168170
full_text, font=credit_font, fill=0, anchor='ls')
169171

170172

171-
def render_entry(entry, width, height, font_paths):
173+
def render_entry(entry, width, height, font_paths, save_without_credits=False):
172174
"""
173175
Render quote and credits images for one entry.
174176
Top-level function (required for multiprocessing pickling).
@@ -181,7 +183,9 @@ def render_entry(entry, width, height, font_paths):
181183
quote_out = os.path.join('images', f'quote_{time_key}_{idx}.png')
182184
credits_out = os.path.join('images', 'metadata', f'quote_{time_key}_{idx}_credits.png')
183185

184-
if os.path.exists(quote_out) and os.path.exists(credits_out):
186+
credits_done = os.path.exists(credits_out)
187+
quote_done = os.path.exists(quote_out) if save_without_credits else True
188+
if credits_done and quote_done:
185189
return
186190

187191
margin = 26
@@ -207,14 +211,15 @@ def render_entry(entry, width, height, font_paths):
207211

208212
print(f'Making image for {time_key}_{idx}')
209213

210-
img = fit_text(words, width, height, 18, time_start, time_count, margin,
214+
img = fit_text(words, width, height, time_start, time_count, margin,
211215
regular_path, bold_path)
212216
if img is None:
213217
print(f'WARNING: could not fit text for {time_key}_{idx}, skipping',
214218
file=sys.stderr)
215219
return
216220

217-
img.save(quote_out)
221+
if save_without_credits:
222+
img.save(quote_out)
218223

219224
_draw_credits(img, width, height, margin, credit_path,
220225
entry['source'].strip(), entry['author'].strip())
@@ -234,6 +239,8 @@ def main():
234239
help='Include NSFW quotes (omitted by default)')
235240
parser.add_argument('--workers', type=int, default=cpu_count(),
236241
help='Parallel worker processes (default: CPU count)')
242+
parser.add_argument('--without-credits', action='store_true', dest='save_without_credits',
243+
help='Also save quote images without credits')
237244
args = parser.parse_args()
238245

239246
device = args.device.lower()
@@ -275,7 +282,8 @@ def main():
275282
tasks.append({**entry, 'idx': idx})
276283

277284
font_paths = (regular_path, bold_path, credit_path)
278-
worker = partial(render_entry, width=width, height=height, font_paths=font_paths)
285+
worker = partial(render_entry, width=width, height=height, font_paths=font_paths,
286+
save_without_credits=args.save_without_credits)
279287

280288
with Pool(args.workers) as pool:
281289
pool.map(worker, tasks)

‎test_quote_to_image.py‎

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
#!/usr/bin/env python3
2+
import os
3+
import tempfile
4+
import unittest
5+
6+
from quote_to_image import fit_text, measure_layout, render_entry
7+
8+
_HERE = os.path.dirname(os.path.abspath(__file__))
9+
REGULAR = os.path.join(_HERE, 'LinLibertine_RZ.ttf')
10+
BOLD = os.path.join(_HERE, 'LinLibertine_RB.ttf')
11+
CREDIT = os.path.join(_HERE, 'LinLibertine_RZI.ttf')
12+
13+
FONTS_AVAILABLE = all(os.path.exists(p) for p in (REGULAR, BOLD, CREDIT))
14+
15+
SHORT_QUOTE = "It was half past three in the afternoon."
16+
TIME_NAME = "half past three"
17+
TIME_START = 3 # word index of "half"
18+
TIME_COUNT = 2 # "half past three" = 3 words → count = 2
19+
20+
SAMPLE_ENTRY = {
21+
'time': '15:30',
22+
'time_name': TIME_NAME,
23+
'quote': SHORT_QUOTE,
24+
'source': 'Test Book',
25+
'author': 'Test Author',
26+
'idx': 0,
27+
}
28+
29+
30+
@unittest.skipUnless(FONTS_AVAILABLE, 'LinLibertine fonts not found')
31+
class TestMeasureLayout(unittest.TestCase):
32+
33+
def test_returns_positive_height_for_short_quote(self):
34+
h = measure_layout(SHORT_QUOTE.split(), 600, 40,
35+
TIME_START, TIME_COUNT, 26, REGULAR, BOLD)
36+
self.assertIsNotNone(h)
37+
self.assertGreater(h, 0)
38+
39+
def test_returns_none_when_font_too_large_for_narrow_width(self):
40+
# Width of 10px — any word at size 300 won't fit
41+
h = measure_layout(SHORT_QUOTE.split(), 10, 300,
42+
TIME_START, TIME_COUNT, 0, REGULAR, BOLD)
43+
self.assertIsNone(h)
44+
45+
def test_larger_font_produces_greater_height(self):
46+
words = SHORT_QUOTE.split()
47+
kwargs = dict(time_start=TIME_START, time_count=TIME_COUNT,
48+
margin=26, regular_path=REGULAR, bold_path=BOLD)
49+
h_small = measure_layout(words, 600, 20, **kwargs)
50+
h_large = measure_layout(words, 600, 60, **kwargs)
51+
self.assertIsNotNone(h_small)
52+
self.assertIsNotNone(h_large)
53+
self.assertGreater(h_large, h_small)
54+
55+
56+
@unittest.skipUnless(FONTS_AVAILABLE, 'LinLibertine fonts not found')
57+
class TestFitText(unittest.TestCase):
58+
59+
def test_returns_image_for_normal_quote(self):
60+
img = fit_text(SHORT_QUOTE.split(), 600, 800,
61+
TIME_START, TIME_COUNT, 26, REGULAR, BOLD)
62+
self.assertIsNotNone(img)
63+
64+
def test_returned_image_has_correct_dimensions(self):
65+
img = fit_text(SHORT_QUOTE.split(), 600, 800,
66+
TIME_START, TIME_COUNT, 26, REGULAR, BOLD)
67+
self.assertEqual(img.size, (600, 800))
68+
69+
def test_returns_none_for_unrenderable_quote(self):
70+
# A single extremely long word that can never fit at size 18
71+
words = ['A' * 500]
72+
img = fit_text(words, 100, 100, 0, 0, 0, REGULAR, BOLD)
73+
self.assertIsNone(img)
74+
75+
def test_chosen_size_fits_within_height(self):
76+
"""The rendered image should require height < height - 100."""
77+
words = SHORT_QUOTE.split()
78+
margin = 26
79+
width, height = 600, 800
80+
81+
img = fit_text(words, width, height, TIME_START, TIME_COUNT,
82+
margin, REGULAR, BOLD)
83+
self.assertIsNotNone(img)
84+
85+
# Verify the winning size actually fits
86+
from quote_to_image import measure_layout as ml
87+
# Find what size fit_text chose by checking sizes around the result
88+
# We trust binary search is correct if size+1 overflows
89+
for size in range(18, 301):
90+
h = ml(words, width, size, TIME_START, TIME_COUNT, margin, REGULAR, BOLD)
91+
if h is None or h >= height - 100:
92+
winning_size = size - 1
93+
break
94+
else:
95+
winning_size = 300
96+
97+
h_winner = ml(words, width, winning_size, TIME_START, TIME_COUNT,
98+
margin, REGULAR, BOLD)
99+
self.assertIsNotNone(h_winner)
100+
self.assertLess(h_winner, height - 100)
101+
102+
103+
@unittest.skipUnless(FONTS_AVAILABLE, 'LinLibertine fonts not found')
104+
class TestRenderEntrySaveWithoutCredits(unittest.TestCase):
105+
106+
def setUp(self):
107+
self.tmpdir = tempfile.TemporaryDirectory()
108+
self._orig_dir = os.getcwd()
109+
os.chdir(self.tmpdir.name)
110+
os.makedirs(os.path.join('images', 'metadata'), exist_ok=True)
111+
112+
def tearDown(self):
113+
os.chdir(self._orig_dir)
114+
self.tmpdir.cleanup()
115+
116+
def _credits_path(self):
117+
return os.path.join('images', 'metadata', 'quote_1530_0_credits.png')
118+
119+
def _quote_path(self):
120+
return os.path.join('images', 'quote_1530_0.png')
121+
122+
def test_default_only_writes_credits(self):
123+
render_entry(SAMPLE_ENTRY, 600, 800,
124+
(REGULAR, BOLD, CREDIT), save_without_credits=False)
125+
self.assertTrue(os.path.exists(self._credits_path()))
126+
self.assertFalse(os.path.exists(self._quote_path()))
127+
128+
def test_flag_writes_both_files(self):
129+
render_entry(SAMPLE_ENTRY, 600, 800,
130+
(REGULAR, BOLD, CREDIT), save_without_credits=True)
131+
self.assertTrue(os.path.exists(self._credits_path()))
132+
self.assertTrue(os.path.exists(self._quote_path()))
133+
134+
def test_skip_if_credits_already_exists(self):
135+
# Pre-create the credits file
136+
credits_path = self._credits_path()
137+
with open(credits_path, 'wb') as f:
138+
f.write(b'placeholder')
139+
140+
render_entry(SAMPLE_ENTRY, 600, 800,
141+
(REGULAR, BOLD, CREDIT), save_without_credits=False)
142+
143+
# File should be unchanged (still our placeholder)
144+
with open(credits_path, 'rb') as f:
145+
self.assertEqual(f.read(), b'placeholder')
146+
147+
def test_skip_if_both_files_exist_with_flag(self):
148+
quote_path = self._quote_path()
149+
credits_path = self._credits_path()
150+
151+
for path in (quote_path, credits_path):
152+
with open(path, 'wb') as f:
153+
f.write(b'placeholder')
154+
155+
render_entry(SAMPLE_ENTRY, 600, 800,
156+
(REGULAR, BOLD, CREDIT), save_without_credits=True)
157+
158+
for path in (quote_path, credits_path):
159+
with open(path, 'rb') as f:
160+
self.assertEqual(f.read(), b'placeholder')
161+
162+
163+
if __name__ == '__main__':
164+
unittest.main()

0 commit comments

Comments
 (0)